Currently, the only way to get an unsafe pointer to an existing value is by using a closure-based API such as withUnsafePointer, where the lifetime of the pointer ends when the closure finishes running. These APIs guarantee that any unsafe pointer to an existing value has a bounded lifetime that the compiler knows about.
As far as I know, there are two reasons for this guarantee to exist:
- If the value isn't directly addressable in memory (such as if it's a computed property), then the compiler needs to know how long to materialize a temporary value in memory.
- Even if the value is already directly addressable in memory, the compiler wants to know that if a value isn't currently being borrowed or mutated, then there are no unsafe pointers that can access it. This allows for aliasing-related optimizations that would otherwise be impossible.
But the closure-based APIs are restrictive: they make it impossible to do things like have the lifetime span an await, and can create "pyramid of doom" situations. These were the same problems caused by withExtendedLifetime, and the solution was to introduce extendLifetime as an alternative.
Non-escapable types give us the ability to express non-lexical lifetime dependencies. What I think would be nice, then, is a set of non-escapable types to more flexibly use unsafe pointers without compromising the benefits of the closure-based APIs. They could look like this:
struct UnsafePointerManager<Pointee> where
Self: ~Escapable,
Pointee: ~Copyable & ~Escapable
{
/// Create an instance by borrowing the given pointee.
@lifetime(self: borrow pointee)
init(_ pointee: borrowing Pointee)
/// This pointer is only valid for the lifetime of the instance.
/// If the lifetime of the instance isn't otherwise guaranteed,
/// use `extendLifetime` on the instance after using the pointer.
/// Storing the instance in a local variable or parameter
/// is not sufficient to guarantee its lifetime.
var pointer: UnsafePointer<Pointee> { get }
}
struct UnsafeMutablePointerManager<Pointee> where
Self: ~Escapable,
Pointee: ~Copyable & ~Escapable
{
/// Create an instance by mutating the given pointee.
@lifetime(self: &pointee)
init(_ pointee: inout Pointee)
/// This pointer is only valid for the lifetime of the instance.
/// If the lifetime of the instance isn't otherwise guaranteed,
/// use `extendLifetime` on the instance after using the pointer.
/// Storing the instance in a local variable or parameter
/// is not sufficient to guarantee its lifetime.
var pointer: UnsafeMutablePointer<Pointee> { get }
}
Then withUnsafePointer and withUnsafeMutablePointer could be implemented using these types:
func withUnsafePointer<T, E, Result>(
to value: borrowing T,
_ body: (UnsafePointer<T>) throws(E) -> Result
) throws(E) -> Result where E: Error, T: ~Copyable, Result: ~Copyable {
let pointerManager = UnsafePointerManager(value)
defer { extendLifetime(pointerManager) }
return try body(pointerManager.pointer)
}
func withUnsafePointer<T, E, Result>(
to value: inout T,
_ body: (UnsafeMutablePointer<T>) throws(E) -> Result
) throws(E) -> Result where E: Error, T: ~Copyable, Result: ~Copyable {
let pointerManager = UnsafeMutablePointerManager(&value)
defer { extendLifetime(pointerManager) }
return try body(pointerManager.pointer)
}
It becomes very important to use extendLifetime to prevent the pointer from being invalidated too early, similar to how extendLifetime prevents weak and unowned object references from being invalidated too early. For example:
func f(s: S) {
let pointerManager = UnsafeMutablePointerManager(&s.x)
let pointer = pointerManager.pointer
pointer.pointee += 1
extendLifetime(pointerManager)
}
If s.x is a computed property, then the compiler needs to know when to call the setter. Without the call to extendLifetime, it may call the setter too early. This would be a non-issue if the compiler just extended the lifetime of an UnsafePointerManager instance as long as possible. But that would make it easier to have a conflicting access to memory (which is especially problematic if exclusivity is unenforced such as when using UnsafeMutablePointer). If the compiler implicitly shortens lifetimes when it detects a conflicting access to memory at compile-time, then subtle errors become possible again, such as in the following example:
func f(s: S) {
let pointerManager = UnsafeMutablePointerManager(&s.x)
let pointer = pointerManager.pointer
print(s)
pointer.pointee += 1
}
This example has undefined behavior because print(s) forces the lifetime of pointerManager to end, making pointer invalid. Using extendLifetime catches the issue at compile time:
func f(s: S) {
let pointerManager = UnsafeMutablePointerManager(&s.x)
let pointer = pointerManager.pointer
print(s) // Error: `s` is used while `pointerManager` is still mutating it
pointer.pointee += 1
extendLifetime(pointerManager) // `pointerManager` is used here
}
Unfortunately, extendLifetime is easy to forget. One way to prevent users from using UnsafePointerManager incorrectly is to make it noncopyable, make it a "must-consume" type (also called a "linear type"), and add a consuming endLifetime() method to be used in place of extendLifetime. That would require "must-consume" types to be introduced, and it would prevent UnsafePointerManager from being used to implement copyable wrapper types such as a hypothetical Borrowed<T> type.
There is another caveat to address: a borrow doesn't necessarily point to memory. In that case, a temporary value in memory must somehow be materialized. A possible solution is to have the initializer of UnsafePointerManager take a new kind of borrow that's guaranteed to point to memory, so the compiler knows to materialize a value in memory if necessary.
I would appreciate hearing any thoughts on these ideas, or other ideas for improving on the withUnsafePointer family of APIs.