Do struct properties have the same lifetimes as the struct value?

suppose i have a struct that looks like:

struct ManagedBytes
{
    let buffer:UnsafeRawBufferPointer
    let object:AnyObject?
}

where the buffer pointer points to some memory that is part of (or managed by) the object pointer’s allocation.

if i have an instance let bytes:ManagedBytes, is it ever possible that bytes.object will get deinitialized while bytes.buffer can still be accessed? (at this point, i’ve given up on interoperability with Array, so let’s just assume that all participating buffer types, e.g. ByteBuffer, can speak AnyObject.)

i feel like i am driving myself nuts with this “bag of bytes” nonsense, surely there must be an easier way??

(or just give up on “safety” entirely, i imagine spending weeks on this problem would be quite hilarious to C++ developers.)

If buffer is extracted out of the structure then it can most definitely out live the object.

func unsafelyExtractBuffer() -> UnsafeRawBufferPointer {
  let managed: ManagedBytes = createManagedBytes()
  return managed.buffer
}

However if by construction or convention buffer is not able to escape the storage of ManagedBytes then no it should live as long as the structure does. This is why the with* APIs work - it enforces a convention to not escape the passed-in parameter of the closure unless you are VERY careful. Construction could work by making buffer private and only letting out values derived from the buffer out.

This is of course the impetus for the non-copyable types - where if you knew that the buffer wasn't able to be copied you could then know that that cannot escape. Consequently having a definite lifetime.

To be fair this problem exists strongly for C/C++ but takes the form of use-after-free bugs.

2 Likes

yes, i'm totally aware of this, what i'm more worried about is the compiler doing some kind of rust-style propertywise deinitialization of struct fields while the struct binding itself is still being used (to access self.buffer).

the point of having buffer visible (either by making it public or usable from inline in a frozen layout) is precisely to make UnsafeRawBufferPointer's API available for doing things like loading unaligned integers, etc. if we try to preempt early deallocations by hiding behind resilience, then we are back to our old problem of slow RandomAccessCollection<UInt8> abstractions.

The paranoid, safe-under-all-conditions way to do this is with an inlinable method that calls withExtendedLifetime(self.object). There are conditions under which the compiler keeps self alive, in its entirety, within a method call. And access to an unsafe pointer is one of those conditions. But that doesn't really help you--other than making withExtendedLifetime somewhat over-conservative. Either way, you'll need to provide a closure-taking withUnsafe API to expose the unsafe pointer interface. That's because you can't return that unsafe pointer from a method and expect self to be kept alive under all conditions that might occur in the caller.

  @inlinable
  public func withUnsafeBuffer<ResultType>(
    _ body: (UnsafeRawBufferPointer) throws -> ResultType
  ) rethrows -> ResultType {
    defer { withExtendedLifetime(self.object){} }
    return try body(buffer)
  }

The "better" way would be a new lifetime-safe bag-of-bytes type that formally depends on the lifetime of its container, but has an efficient concrete representation (not a protocol). It's achievable with some language support, but will have limitations when it comes to existing generic code.

2 Likes

Is this a formal guarantee?

I was under the impression that the compiler was allowed to destructure the struct, and I assumed that it would be allowed to optimise the lifetime of object such that it might not be guaranteed during accesses to buffer.

Is this maybe one of the recent changes to lifetime optimisation?

Not yet at the language level. The compiler follows a set of formal rules that work well:

In general, yes. So if deinitializing one member invalidates another unsafe pointer member, then I would make that explicit using withExtendedLifetime whenever accessing the pointer rather than relying on some subtle optimizer rules (which are linked above).

Yes, the optimizer now keeps track of each variable's original lexical scope and considers self to be a lexically scoped over a method body. Lifetimes are still optimized, but only under reasonably safe conditions. All the optimizations that didn't follow the new rules were disabled.

1 Like