Partial reinitialization of noncopyable values

In SE-0429, we introduced the ability to partially consume noncopyable structs in some situations. However, there isn't yet an easy way to make a value whole again by reassigning the properties that have been consumed. I'd like to propose filling in that gap:

Here is a copy of the initial draft of the proposal:


Partial reinitialization of noncopyable values

  • Proposal: SE-XYZW
  • Authors: Joe Groff
  • Review Manager: TBD
  • Status: Awaiting implementation
  • Implementation: TBD
  • Review: (pitch)

Introduction

This proposal introduces the ability to reinitialize noncopyable
values that have been partially consumed.

Motivation

SE-0429 introduced partial consumption for noncopyable values. This gives programmers the ability to apply consuming operations individually to fields of a noncopyable struct, tuple, or enum:

struct File: ~Copyable {
  consuming func close() { ... }
}

struct Buffer: ~Copyable { ... }

struct BufferedFile: ~Copyable {
  var file: File
  var buffer: Buffer

  consuming func takeBufferAndCloseFile() -> Buffer {
    // Consume the file by closing it:
    file.close()

    // And then consume the buffer by returning it to the caller:
    return buffer
  }
}

However, prior to this proposal, as soon as any part of an aggregate is consumed, there is no way to make the value whole again, other than to initialize an entirely new value:

extension BufferedFile {
  mutating func switchFile(to: File) -> Buffer {
    // Consume the old file...
    file.close()

    // We'd like to do this, but it's an error today:
    /*ERROR*/ file = to

    // So we have to do something awkward like this instead:
    self = BufferedFile(file: to, buffer: self.buffer)
  }
}

This makes it awkward to perform operations that require replacing noncopyable components of an aggregate.

Proposed solution

We propose allowing partial reinitialization, giving mutable stored properties of noncopyable aggregates the ability to be reinitialized after they've been consumed. When a partially-consumed aggregate has all of its consumed components reinitialized, the entire value becomes valid again:

extension BufferedFile {
  mutating func switchFile(to: File) -> Buffer {
    // Consume the old file...
    file.close()

    // This should become valid:
    file = to
    // Reinitializing `file` makes `self` whole again, allowing us to
    // safely return from this `mutating` method
  }
}

Detailed design

Maintaining API integrity

As with partial consumption, it is important that partial reinitialization does not allow clients of an API to bypass invariants set up by the API author. The author of a noncopyable type can define initializers to ensure something occurs whenever a new value of the type is constructed, implement a deinitializer and consuming methods to ensure something occurs when those values' lifetimes end, and use let properties or private setters to limit how the value's state can be changed by client code. Types also expect to be able to add or remove stored properties from their representation as long as they maintain their public API. It is important that partial reinitialization does not give clients the ability to bypass the API boundary or create new implicit library evolution constraints. Partial consumption was already designed with API integrity in mind, and some of the considerations for that proposal also need to apply to partial reinitialization.

Restrictions on non-@frozen public types

For code to be able to either partially consume or reinitialize a property, the code in question needs to know that the properties being consumed and reinitialized are stored properties, so like partial consumption, partial reinitialization is limited to values of types defined in the current module, or public types from other modules which have been explicitly marked @frozen:

// Module A
public struct NC: ~Copyable {}
func consume(_: consuming NC)

@frozen
public struct Frozen: ~Copyable {
  public var a: NC, b: NC
}

public struct Nonfrozen: ~Copyable {
  public var a: NC, b: NC
}
// Module B
import A

struct SameModule: ~Copyable {
  var a: NC, b: NC
}

func test(f: inout Frozen, nf: inout Nonfrozen, sm: inout SameModule) {
  // OK, f is explicitly frozen
  consume(f.a)
  f.a = NC()

  // ERROR, nf is not frozen and is from a different module
  consume(nf.a)
  nf.a = NC()

  // OK, sm's type is defined in the same module as us
  consume(sm.a)
  sm.a = NC()
}

Correspondence between partial reinitialization and assignment

Partial reinitialization should not allow for clients to bypass a type's initializers and construct values in a state that the public API would otherwise prevent. With no restrictions at all, partial consumption combined with reinitialization would allow for a client to consume all of a struct's field and then reinitialize them, effectively creating a new instance bypassing any initializers or deinitializer provided by the type:

func replace(_ value: consuming Frozen) -> Frozen {
  // Consume all of the fields of the original value...
  consume(value.a)
  consume(value.b)

  // ...then replace them with new values
  value.a = NC()
  value.b = NC()

  // `value` has now been changed to have the same state as if we had
  // called `SameModule(a: NC(), b: NC())`, but without going through
  // any of SameModule's inits or deinit
  return value
}

However, given the nature of structs, it is already possible to completely replace an instance if you are able to reassign each of its fields:

func replace(_ value: consuming Frozen) -> Frozen {
  // We can simply reassign each field in turn
  value.a = NC()
  value.b = NC()

  // `value` has now been changed to have the same state as if we had
  // called `SameModule(a: NC(), b: NC())`, but without going through
  // any of SameModule's inits or deinit
  return value
}

So, in the case of a simple struct like Frozen above, partial reinitialization does not create new possibilities that mere assignment couldn't already achieve. To prevent this sort of total replacement by reassignment, the author of a struct can restrict what mutations client code can perform by using immutability (with let properties) and access control (with private setters on properties):

// Module A

@frozen
public struct FrozenSemiImmutable: ~Copyable {
  public let var a: NC
  public private(set) var b: NC

  public init(a: NC, b: NC) {
    print("very important initialization behavior here")
    self.a = a
    self.b = b
  }
}
// Module B
import A

func attemptReplace(_ value: consuming FrozenSemiImmutable)
  -> FrozenSemiImmutable
{
  // We can't reassign either field, so we can't fully replace the value
  // without going through the initializer.
  value.a = NC() // ERROR: `a` is immutable
  value.b = NC() // ERROR: `b` has a private setter

  return value
}

One can think of a partial consumption followed by reinitialization of the same field as being a decomposed reassignment of that field, with the erasing of the old value and moving of the new value separated into two stages. Therefore, we restrict partial reinitialization of a property so that it is only allowed in contexts that would allow ordinary assignment of that property:

func attemptReplace(_ value: consuming FrozenSemiImmutable)
  -> FrozenSemiImmutable
{
  consume(value.a)
  consume(value.b)
  
  // We can't reassign either field, so we aren't allowed to reinitialize
  // them either:
  value.a = NC() // ERROR: `let` property `a` cannot be reinitialized
  value.b = NC() // ERROR: `b` has a private setter so cannot be reinitialized

  return value
}

This ensures that partial reinitialization cannot violate API boundaries and mutate values in ways that would not otherwise be allowed.

Partial consumption and reinitialization of types with deinit

Partial consumption has, prior to this proposal, been disallowed for types with a user-defined deinit, since deinit requires a complete value to tear down, and being able to consume a value by consuming each of its individual properties would give client code the ability to destroy that value while bypassing the deinit. However, since this proposal adds the ability to reinitialize the properties of a partially-consumed value, we can now allow for partial consumption of values with deinits, but only when the partially-consumed properties are reinitialized before the end of the value's lifetime:

var instanceCount = 0

struct Counted: ~Copyable {
  var a = NC(), b = NC()
  
  init() { instanceCount += 1 }
  deinit { instanceCount -= 1 }
}

func test1(_ x: consuming Counted) {
  // OK, x is fully reinitialized before its lifetime ends
  consume(x.a)

  x.a = NC()
}

func test2(_ x: consuming Counted) {
  consume(x.a)
  
  // ERROR: x is not reinitialized before the end of its lifetime
}

Source compatibility

This proposal introduces new capabilities for noncopyable types without changing the behavior of any existing syntax, so is fully compatible with existing Swift source.

ABI compatibility

This functionality can be added to the compiler with no changes to the Swift runtime or type layout, so this proposal has no impact on ABI.

Implications on adoption

This proposal has been designed to improve the ergonomics of working with noncopyable values without creating new API design concerns, so that API authors do not need to be concerned about their clients adopting this feature, nor do clients need anything from API authors to take advantage of the feature.

Future directions

Noncopyable tuples

It would be reasonable to support noncopyable tuples. When we do, it should be possible to partially consume and reinitialize them. Since tuples are always a straightforward combination of their elements, with no API abstraction or nontrivial initialization/deinitialization behavior, it should be possible to consume and reinitialize their elements without restriction.

Alternatives considered

Do nothing

As noted in the detailed design, this proposal does not allow clients of an API to do things they could not already do with noncopyable types. Developers today can, with enough effort, use regular assignment, inout parameters, or initialization of new values to express the same things as partial reassignment. Nonetheless, it is often awkward or nonobvious to do so, and we think the ergonomic improvement provided by this proposal is worth it.

21 Likes

I’m very happy to see this. Just a few days ago I ran into the need for this for the first time, and had to resort to the Optional workaround, which is indeed awkward and error prone. In fact, just minutes ago I finished finding and fixing a bug I had introduced in that code where in one situation I forgot to insert a new non-nil value into the optional after consuming the previous wrapped value.

The only part that I’m not totally clear about is the restriction on non-@frozen public types. Do I understand correctly that enabling this for @frozen public types is just the best we can allow at the moment, but that conceivably (even if we don’t plan to ever do this) the language could provide a way to expose the fact that a particular public property is in fact a stored property, and that this would be the true minimum requirement for allowing reinitialization of that property, even if the whole type is not @frozen?

3 Likes

Thanks @Joe_Groff, this looks very useful. We also could have used this during development of swift-subprocess.

2 Likes

I'm very much in favor of this, solving the only problem I've encountered when working with noncopyable values.

1 Like

I don't think we have any immediate plans to do so, but that would be a possibility. If a type made an explicit promise to maintain a property as an independent stored property, then we could consume and reinitialize its value from outside the module. However, in such a situation, reinitializing the property would be required, since there are no public guarantees about the layout of the rest of the type.

3 Likes

Big +1 from me. I have used ~Copyable extensively over the past months and this has been one of the most common lacking features.

3 Likes

I believe both instances of mutating func switchFile(to: File) -> Buffer should be mutating func switchFile(to: File) instead.

1 Like

That is great! The only thing I’m a bit worried about is that this adds another feature to stored properties that aren’t available to computed properties. Have you thought about ways to make this also work with computed properties?

Relatedly, I would also want partial reinitialization (and consumption) of Copyable structs. Would the proposal as is also work in theory with Copyable structs?

Supporting computed properties would require that their accessors expose detailed information about what underlying stored properties they use, and what effect the accessor has on the state of those properties. The closest thing we have today might be the way init accessors have to indicate which stored properties they initialize. However, initialization is already tied to the implementation in a type that most properties don't need to be, and it seems like supporting nonoverlapping consumption through computed properties would impose a lot of API complexity. Do you have a use case in mind?

Yes, partial consumption already works with noncopyable bindings of Copyable types, and partial reinitialization ought to as well.

I assume this should say to: consuming File?

I am in favour of this change as a natural ergonomic feature of the language. If the compiler can correctly reason that the resulting value has been made whole, who are we to stop it?

2 Likes

I think we are talking about different things. I was thinking about something like this:


struct Foo {
    var a: Int
    var b: Int

    mutating func partialConsume() {
        // error: 'consume' can only be used to partially consume storage of a noncopyable type
        _ = consume a
        a = 1
    }
}

Which fails to compile with the nightly toolchain.

1 Like

Would a fully consumed value be equivalent to a partially consumed value whose stored properties happen to all be consumed? That is, would it be possible to partially reinitialize values that were fully consumed?

extension BufferedFile {
  mutating func switchFile(to: File) {
    let buffer = self.takeBufferAndCloseFile()
    // `self` is fully uninitialized
    self.file = to
    self.buffer = buffer
    // `self` is fully initialized again
  }
}

If so, a future direction I think might be useful would be to additionally allow values that were never initialized in the first place to be partially reinitialized (like self in initializers).

3 Likes

I see. Yeah, it would make sense to extend the consume operator to allow for explicit partial consumption of known-stored properties of values that themselves can be consume-d.

I was intending to add language to explicitly forbid this. Being able to initialize a value by parts from nothing would bypass that type's initializers. If we were to allow it, it would at most be in situations where we know a purely memberwise initializer exists and is visible to the caller context, which would require giving the implicit memberwise initializer special treatment we don't currently.

4 Likes

I see.

I suppose that if partially consumed values whose stored properties are all consumed are treated the same as fully consumed values, some other related questions would be:

  • Would all stored properties be consumed, or only the noncopyable ones?
  • If a noncopyable struct is known to have no stored properties (or no noncopyable ones), would it be reinitialized immediately after it is consumed, since vacuously, all its stored properties would be reinitialized?

It seems like if we had a way to statically mark a function (or initializer) as side-effect-less (pure), then the rule could just be that if there is at least one visible initializer without side effects then it is fine to initialize it by setting its properties (assuming that all stored properties are settable), because one could always have started with the pure initializer and then altered the properties

Copyable properties can be in a consumed or initialized state just like noncopyable ones.

If a type has no stored properties, there is no way to put it into a partially-initialized state. You can already fully consume and reinitialize a variable of the empty non-copyable type, though.

A pure initializer could still enforce invariants between the values of the fields, validate that values are within a certain range, etc. If the type does not provide public setters for its stored properties, then code outside of the original definition can only change the values via the type's public methods.

This is why I added “(assuming that all stored properties are settable)”. My point is that the only requirement for a type to be piece-wise-initializable in principle is that it has at least one pure initializer. Then in practice, in order to actually end up with a fully initialized value that you can do something useful with you will need to have access to the setters of all stored properties, which you would not for a type that enforces additional invariants.

1 Like

That's a fair point. There is however still the case of an initializer that always fails:

public struct Foo: ~Copyable {
  public var a: Bar, b: Baz
  public init?(bad: ()) { return nil }
  public init(worse: ()) throws { throw SomeError() }
  public init(worst: ()) { fatalError() }
}

All of the initializers on Foo could be considered "pure", but none of them will ever actually give a caller an initial instance of Foo back, so allowing an instance of Foo to be elementwise initialized does technically bypass the API boundary. This is admittedly a bit of a silly case.

More subtly, though, one could also use type inhabitability as a way of limiting who can call an initializer.

public struct Foo2: ~Copyable {
  public init(x: consuming Bar)
}

In order to call Foo2.init(x:), the caller needs to be able to get access to a Bar they have ownership of, and Bar could be a type with no public initializers that is only obtainable through specific interfaces, in order for a library to enforce an order of operations or limit what contexts have access to Foo.

1 Like

Thanks for the feedback so far! I've updated the proposal to correct the examples and capture some of the discussions from this thread.

2 Likes