Hey everyone!
The following is a post asking for feedback from the community on the manner in
which we should allow for partial consumption of noncopyable fields of
noncopyable types. Looking forward to everyone's thoughts.
Introduction
Currently given a var like construct (e.x.: var, inout), Swift does not allow
for a stored field of the type to be partially consumed:
struct E : ~Copyable {}
struct S : ~Copyable {
var first: E
var second: Klass
}
var s = S()
let _ = s.e // Error! Cannot partially consume s
Since this applies to inouts, this also applies to stored fields of self in
mutating methods. E.x.:
extension S {
mutating func doSomething() {
let _ = self.e // Error! Cannot partially consume self
}
}
That being said, one can still of course pass the field inout to take the field
out by using the consume
operator:
extension S {
mutating func doSomething() {
let _ = (consume self).e
self = S()
}
}
while this works, it is a significiant reduction in expressivity since one has
to consume /all/ of self causing one to be unable to access the rest of the
fields of self later in the function. Given this reduction in expressivity it is
natural to ask... can we improve this situation by allowing for partial
consumption of self:
extension S {
mutating func doSomething() {
let _ = e
print(k) // I can still print k!
e = E() // Reinitialize e so self is fully initialized at end of
// doSomething()
}
}
We can do this and in fact, the Swift compiler already has support for this,
albeit turned off! The reason that it is turned off is that we realized that
there is design space here that we wanted to explore and that when the
noncopyable proposal went through evolution we labeled partial consumption
explicitly as an extension of the proposal. That being said, lets now go through
the design space together.
Partial Consumption of NonCopyable Types
We consider below a few different axes in the design space:
- On types with deinits
- When Library Evolution is enabled
- When Library Evolution is disabled
- In either case, whether or not we should allow for partial consumption only
in methods.
Partial Consumption on types with Deinits
We ban partial consumption of noncopyable types with deinits since when a field
is partially consumed, we are allowing for the type to be destroyed in
parts. For example:
struct E : ~Copyable
struct S : ~Copyable {
var first: E
var second: E
deinit {}
}
var s = S()
let _ = s.first // s.first is destroyed here
doSomething()
// s.second is destroyed here
Since s
here has been partially consumed, we never destroy it all together
implying that we never would call its own deinit implying that we must not allow
it. Note that even though we do not allow this, we still allow for authors in
consuming methods to use the discard
operator to turn off the deinit of the
value and then partially deconstruct the value.
Partial Consumption when Library Evolution is enabled
The clear invariant that partial consumption of noncopyable types relies upon is
that all stored fields of the noncopyable type must be accessible in the module
where the partial consumption occurs. Naturally this means that in library
evolution our ability to partially consume types is significantly
limited. Specifically:
-
Frozen types regardless of access control level can always be partially
consumed. This includes even frozen types with private fields since even
though the private field is not available to be used it is still exposed at
the ABI level. -
Public and usableFromInline types can never be partially consumed outside of
the resilience domain where the type is defined. Since resilience domains are
today limited to the current module, this means that one could not partially
consume outside of the current module. -
Internal types that are not usableFromInline, private, and fileprivate
noncopyable types can always have their stored properties partially consumed.
Partial Consumption when Library Evolution is disabled
When we compile without library evolution, from an ABI perspective we have
everything that we need to always partially consume even public types since when
library evolution is disabled all types have a frozen ABI. But we have
additional Source Compatibility constraints to consider: we have always allowed
for authors to convert fields from being stored to computed and back. This
creates source stability issues since:
-
When we convert a stored property to a computed property, we will be
replacing a partial liveness use of just one of the value's stored fields to
a use of the entire value since a computed property takes self as a fully
live value. E.x.:// Library public struct E : ~Copyable {} public struct S : ~Copyable { var first: E var second: E } // Executable let _ = s.first // Invalidates s.first let _ = s.second // Invalidates s.second -> // Library struct S : ~Copyable { var first: E var second: E { E() } } // Executable let _ = s.first // Invalidates s.first. let _ = s.second // Uses all of s when calling the getter s.second. Use after free!
-
When we convert a computed property to a stored property, we introduce a new
partial invalidation potentially causing later code to stop compiling. E.x.:// Library struct E : ~Copyable {} struct S : ~Copyable { var first: E { E () } func doSomething() { } } // Executable let _ = s.first // We call the s.first the getter. s.doSomething() // Call s.doSomething() -> // Library public struct S : ~Copyable { var first: E func doSomething() { } } // Executable let _ = s.first // Invalidate s.first s.doSomething() // Error! s is not completely initialized.
These source compatibility concerns imply that even in non-ABI stable libraries
we do not want to allow for public noncopyable types to be partially consumed by
default. This suggests that we may want to add the ability for a library author
to explicitly notate such public types that they are giving up these source
compatibility properties. A natural way to do this is to endow @frozen
with
this meaning when applied to public noncopyable types in libraries without
library evolution enabled. As an additional benefit, by extending the meaning of
frozen in this manner, we prevent an additional difference in between Swift when
compiled with/without library evolution enabled: in both language modes, public
noncopyable types can only be partially consumed outside of their current module
if they have @frozen
attached.
Partial Consumption outside of Methods
The final axis to consider is whether or not we should be even more restrictive
and only allow for partial consumption of noncopyable types inside methods. The
argument in favor of this approach is that the author of a type has the greatest
understanding of the invariants of the type and the impact of a value being
consumed and thus self being invalid. The argument against this is that the move
checker will prevent any such misuses, e.x.: if one were to call any method on
the partially consumed noncopyable type, we would get an error. So even if a
user of a type made such a mistake, it would never actually result in a valid
program. So we would be giving up expressivity without any real gain.
I would love everyone's thoughts here on the design above and as well any use
cases where one thinks that this may be useful.