I've been talking more with @hborla and @Douglas_Gregor about this, and I think we can structure this into a series of three proposals.
Expanded parameters
The first proposal allows a subsequence of call arguments to be implicitly collected together at compiler time to initialize a single aggregate argument. That is, one could write a function signature something like this:
func addOrbit(options: expanded OrbitOptions)
where OrbitOptions
has one or more accessible initializers, like so:
struct OrbitOptions {
init(distance: Distance<Double>,
eccentricity: Double = 0,
inclination: Angle<Double> = 0)
}
Callers could then supply the arguments to build an OrbitOptions
directly when calling addOrbit
:
addOrbit(distance: .gigameters(778.57), eccentricity: 0.0489)
or they could simply provide the aggregate directly:
addOrbit(options: opts)
This is a relatively straightforward proposal that would be much more broadly useful. I think the main subtlety here is that we would need to carefully define the argument-matching rules (and place restrictions on parameter lists with expanded parameters) so that the type-checker can unambiguously decide which arguments are meant to be collected into the expansion without doing a type analysis.
It is in keeping with Swift's general emphasis on keeping control over call sites in the hands of the API author that this feature would be opt-in, rather than something you could do with any argument. That also helps to keep the build-time impact in check.
To apply it to my proposed lowering, we could change the part of the transformation that extracts a Shared
type and instead just expect the wrapper type to define that explicitly. Non-shared initializers would be expected to take a typically-expanded parameter that initializes that storage. That parameter would still need to be marked as shared in some way to get the desired effect; see the next section.
Static parameters
The second proposal is that any parameter could be declared to be static. (I think I prefer the spelling static
over shared
. This adds a new use of static
, but I think it's generally consistent with the existing use in Swift of meaning "global rather than instance/local". If people hate it, we can have that discussion.)
func buildOrbit(options: static OrbitOptions)
A static
argument is always passed as a pointer to global immutable memory. It's unclear how best to expose this in the callee; maybe we just allow code to safely take the address of it with &
, or maybe we make the parameter implicitly an UnsafePointer
(or a new StaticPointer
type, or something similar).
On the caller side, a static
parameter can be satisfied with a pointer (when using the &
operator, maybe?). Otherwise, the argument expression must be independent of context (e.g. no references to local variables), and global storage will be lazily initialized with that expression prior to the call.
As someone with quite a bit of implementation experience with this kind of static-local-initializer feature, I think we want to be careful to avoid making too strong a guarantee about either execution or uniqueness of address. The compiler will make a best effort to evaluate the initializer expression as few times as possible and use as little storage as it can; however, the expression may be evaluated either more or less than a strict model of "once per unique context" might imply, and storage is only guaranteed to be unique to the extent that different memory is required to hold the value. Accordingly, initializer expressions should not be side-effectful, and code should not rely on the storage being 1-1 with calls. For example, if you are in a generic function over <T, U>
, and you use MemoryLayout<T>.alignment
as a static initializer, the storage address might be different for all pairs of <T,U>
, or it might be different only for different values of T
, or it might be different only for distinct alignment values, or it might even be shared with completely different initializers that happen to evaluate to the same result. Similarly, if you use UUID()
as a static initializer in the same context, you might get different UUIDs for all pairs of <T,U>
, or you might get one UUID for all calls from that context, or you might even get one UUID that is shared across different calls that happen to use UUID()
as an initializing expression.
While the expanded-parameter feature is largely just build-time work, this feature requires runtime support. It's possible that this could be made to back-deploy, but we can't really guarantee that in advance. This same work could be leveraged to allow static
stored properties in generic types.
Applying this to my proposed lowering is straightforward: non-shared initializers would take a static
parameter to their shared storage (which would often be a static expanded
parameter, combining this feature and the previous), and this same value would be used when reconstituting the composite type.
Separating static
storage from property wrappers
Finally, the third proposal is to take advantage of static
parameters to reduce the inline memory overhead of certain kinds of property wrappers, which is most of what we've been talking about in this thread, and so I won't go over it again. This could be done either by passing the static argument pointer to a wrappedValue:
subscript during accesses, as Joe suggests, or by reconstituting the property wrapper during accesses from a combination of the static argument pointer and the instance storage, as I suggest. In either case, I think this builds naturally on the previous two language features, which are independently useful.