I need some time to think about it and inspect the Spaan implementation you have provided in godbolt. Thank you for your detailed insights and clarifications!
I think this is quite elegant and is probably easier for beginners to understand. I wonder how this would play out with MutableSpan, though. Thinking out loud here: it makes sense to me to expose Span as borrowing or yielding; however, it seems unintuitive to expose MutableSpan as inout (it's still a view into the base collection and we're not actually modifying the view itself).
What is escapability needed for apart from optimizing closure allocations? Also, while developing Hylo, did you encounter any corner cases that require ~Escapable (but don't justify the complexity of supporting it in the language).
Iâve been following this discussion to better understand the fundamental divergence in the models being compared here.
Maybe I've misunderstood, as very little code has been brought to the discussion so far to demonstrate the practical differences between the models. However, it seems to me that the core difference isn't whether lifetimes exist, as they are involved in both models, but rather where the language models them:
- The Yielding Model: Escapability and lifetime are properties of the binding. The lifetime is enforced by control flow (the structural scope of the yielded accessor). Essentially, we tie the lifetime of the resulting binding to the lifetime of all arguments; consequently, we don't need "lifetime" as a separate language construct.
- The "Returning a Reference" Model: The lifetime of the return value is explicitly declared. To support this, the returned type must make no assumptions about escapability, necessitating
~Escapable.
In the yielding model, we get safety "for free" because the stack frame implies the lifetime. In the returning model, we lose that stack context the moment we return a value, so we must reconstruct that safety using a more complex type system.
Both examples provided so far (the min subscript and the Span-yielding subscript) are covered by both models because, in both cases, the result depends on all arguments. This also aligns with the code I've been playing with after reading the Borrow/Inout types pitch (Compiler Explorer).
To identify a real point of divergence, I'll expand on the min subscript example. In the Yielding model, the "lifetime" of an access is the entire duration of the suspended coroutine. This leads to the "scope-locking" of disjoint arguments:
subscript sorted<T>(x: inout T, y: inout T, by comp: borrowing (T, T) -> Bool) inout -> (T, T) {
if comp(x, y) {
yield (&x, &y)
} else {
yield (&y, &x)
}
}
func main() {
var x = 1
var y = 2
let f: (Int, Int) -> Bool = { $0 < $1 }
inout (min, max) = &sorted[&x, &y, f]
// While 'min' and 'max' bindings are alive, 'f' is effectively locked because
// the 'sorted' accessor is still suspended. We cannot mutate or move 'f'.
&min += 1
}
In the Returning model, the function returns references and the stack frame is popped. The compiler can use named lifetimes to see that the result depends on x and y, but not on f. To achieve that same granularity in the Yielding model (releasing f while the binding is still active), we would have to introduce a way to annotate disjoint lifetimes of arguments within the signature, effectively reinventing named lifetimes.
@dabrahams @Alvae Do I understand correctly that the bet here is that these cases are so rare that the language can simply accept the "scope-locking" of disjoint arguments as a negligible cost in exchange for a much simpler overall system?
That is a very accurate description I think.
Yes, that is our position for now at least.
Iâll quickly note that we have discussed ways to avoid the âscope-lockingâ of disjoint arguments by annotating parameters rather than carrying escapability (or lifetimes) on return types. So even if this problem turns out to be more pervasive than we think, we would most likely go in that direction first.
Itâs not obvious to me why a second Spaan type would be needed. Am I missing something?
Oh I suppose if you want to get a Spaan of immutable elements from which you can drop elements off the ends from a mutable collection you need a second type (and a nonmutating _modify accessor). Exercise for the reader?
Thanks for the clarification. Regarding your point about avoiding "scope-locking" by annotating parameters, could you provide an example of what that might look like?
I imagine it would be something where we promise that an argument is only used in the prologue and is not "captured" by the yield. Something along these lines:
subscript sorted<T>(
x: inout T,
y: inout T,
// '@transient' promises that 'comp' is not needed after the yield starts.
by comp: @transient borrowing (T, T) -> Bool
) inout -> (T, T) {
if comp(x, y) {
yield (&x, &y)
} else {
yield (&y, &x)
}
}
If that's what you meant, it looks like "lifetimes but the other way around" - instead of declaring entangled arguments, we declare those which are not. I might be missing some important differences though.
Another question I have is about the composition of these accessors. In the "Returning a Reference" model we can write a closure returning a lifetime-bound result, then pass the closure to another function or store it somewhere (in the case of escapable closures).
How do you envision such composition working with subscripts? Do you plan to make subscripts first-class values? If so, would it involve a transformation into a Continuation Passing Style closure under the hood?
func incrementMin<T>(x: inout T, y: inout T, minProjector: borrowing [inout T, inout T] inout -> T) {
inout (min, max) = &minProjector[&x, &y]
&min += 1
}
func main() {
var x = 1
var y = 2
// Hypothetical syntax for a first-class yielding closure
let minProjector: [inout Int, inout Int] inout -> Int = { inout x, inout y in
if x < y {
yield &x
} else {
yield &y
}
}
// Is 'minProjector' transormed by the compiler into an equivalent of this?
// func minProjector(x: inout Int, y: inout Int, continuation: borrowing (inout Int) -> Void) {
// if x < y {
// continuation(&x)
// } else {
// continuation(&y)
// }
// }
incrementMin(&x, &y, minProjector)
}
The example you have offered illustrates what I meant already. The requirement for your @transient annotation is indeed that the argument should not be part of the projected value and not be used after the projection (what we call the âslideâ of the subscript).
Correct. Again, the advantage of this approach would be that it would let us avoid carrying information through the type of the returned (we say âprojectedâ) value.
Yes. We have formalized this feature in the core model of Hylo but havenât implemented it yet so I canât show syntax. However Iâll note that we can already emulate first-class citizenship using scoped conformances.
I can provide an example if necessary but I donât want to confuse people with an avalanche of Hylo-specific features that will be difficult to relate directly to Swift.
Not exactly. @dabrahams discussed about the compilation model we envision in here. I do not anticipate any issue applying it to first-class subscripts.
The thing about mobile devs is that many may never have needed the complexity the language brought productivity wise perhaps⌠then again SwiftUI is possible because of Swift and yet again many companies want custom UI and not just lightly skinned SwiftUI components so that means still dragging UIKit around or doing custom SwiftUI views which exposes a Lot of complexity in Swift.
There is also the company paying the mobile developers angle and LLMs lowering the cost of verbose code that should be considered, especially where more and more devs are moving to JavaScript / TS for mobile apps as RN and other multiplatform tech (like web based MFEs / Micro Front Ends) continue to expand (not to talk about KMP/CMP from the Android side or Flutter).
Devs are kind of walking with their feet / being walked or dragged into dynamic languages that prioritise fast iteration (talking about the dev iteration cycle: think, express, run and test, ⌠and repeat) which long compile times do not help with.
Anyways, I will stop here because I am ranting and I do not think all the above could be avoided if Swift compiled super mega fast, but it is annoying to see the reality on the field as Swift becomes, a tiny bit more and more every month, more like ASM for these multiplatform tools than something people can really code in anymore :/.
(Apologies for the rant, anyways)
Non-Escapability is needed for exposing notional parts of a value that donât exist in memory (like span) with minimal overhead. Itâs also useful for avoiding allocations with existential types (similar to the closure case, which has an any to represent the captures).
No we have not encountered any cases that require a first-class ~Escapable; as in my Span example non-escapability is a property of particular values not of types.
Wouldn't the second approach lead to a "pyramid of doom" if I need to deal with multiple references at once? (Not that a pyramid of doom is necessarily a bad thing.)
In Swift I think the pyramid of doom is a visual indicator of a worse problem â things like async, (typed) throws, sending, noncopyability, etc. don't compose well across these withX style APIs.
(I think the Haskellers are laughing at us in Monad now)
It's definitely a bad thing, but as @Alvae already pointed out this construct is just a substitute for the lack borrow/inout bindings. Her suggestion is to put up with the pyramid for the purposes of understanding what would be possible with yielding accessors and these two bindings.
Daveâs overall argument is especially applicable to large, long-lived projects. Large projects arenât like small ones that can ignore language complexity if they donât need those features. I spent 25 years on the Photoshop team and worked with other large app and library teams. With a large team and long-lived codebase, itâs inevitable that somebody will decide almost every obscure language feature is essential to something theyâre coding. It may even start out isolated to the interior of a small library but it rarely stays that way. And unlike with the standard library, app developers at large companies routinely have to debug into arbitrary company-owned libraries.
Over time, a large project and its libraries accumulate uses of esoteric language features. In the larger scheme of things, only a tiny percentage of them were truly necessary to achieve a significant difference in the performance of the overall system, but itâs organizationally impossible to prevent the accumulation. The end result is that all the engineers on the project have to learn the esoteric features because theyâll have to debug and/or develop in those modules.
As each update to C++ came out, we tried to educate developers about ânew features you shouldnât use but youâll have to know aboutâ and ânew features you should only use after consultation with a senior engineerâ - an ever-growing mental overhead. Swift is heading down the same path.
In my current life as a retired hobby developer, Swiftâs progressive disclosure works well. If I were concerned about Swiftâs usefulness in developing large systems, Iâd be pushing hard in the direction Dave is advocating - of shifting the balance between âwe can prove itâs sometimes useful so include itâ and âwe have to prove its usefulness is worth the costs it imposes on the people who donât need it but will have to learn about itâ.
Is there a playground for Hylo?
The best relevant resource is probably the subscript section of the language tour for now, but you can check out an experimental version of Hylo in compiler explorer.
There's one wrinkle here, and that's something like:
var first: Optional<Borrowing<T>> {
// ...
}
var first: Optional<Inout<T>> {
mutating get {
// ...
}
}
How do we do this with first class borrows? Do we add a facility to create a non-copyable stable safe pointer to a value, then yield an Optional of that?
We have thought about handling these cases with remote parts, but our current working theory is that APIs that return optional borrows are âwrongâ (for the overall balance of complexity/capability). If first had a precondition of !empty it could just project a T.
Also, we're exploring a syntactic approach that would allow the use of if let x = someProjection without ever actually forming an Optional, on the theory that the use cases ~never actually want to hang onto an Optional. That would save the extra test implied by the precondition.
I already had a couple use cases for Optional<Inout<T>>.
- subscript for collections that don't allow removal e.g. InlineArray.
extension InlineArray {
subscript(safe index: Int) -> Element?
// would be better described as (+ Borrowed version)
subscript(safe index: Int) -> Optional<Inout<Element>>
}
Todays version with runtime preconditions
extension InlineArray {
subscript(safe index: Int) -> Element? {
get {
if indices.contains(index) {
self[index]
} else {
nil
}
}
_modify {
if indices.contains(index) {
var optional: Optional = self[index]
defer {
if let optional {
self[index] = optional
} else {
// with Optional<Inout<Element>> this state would not be possible
fatalError("not allowed to be set to nil")
}
}
yield &optional
} else {
var optional = Element?.none
defer {
guard optional == nil else {
// with Optional<Inout<Element>> this state would not be possible
fatalError("can not set value at \(index)")
}
}
yield &optional
}
}
}
}
- Providing a property for an associated value of an enum that supports in-place mutation.
enum Foo {
case bar([Int])
case baz
var bar: [Int]? { ... }
// would be better described as (+ Borrowed version)
var bar: Optional<Inout<[Int]?> { ... }
}
Todays version with runtime preconditions
enum Foo {
case bar([Int])
case baz
var bar: [Int]? {
get {
switch self {
case .bar(let array):
array
case .baz:
nil
}
}
_modify {
switch consume self {
case .bar(let array):
var optional: Optional = array
yield &optional
if let optional {
self = .bar(optional)
} else {
// with Optional<Inout<Element>> this state would not be possible
fatalError("not allowed to be set to nil")
}
case .baz:
var optional = [Int]?.none
yield &optional
if let optional {
self = .bar(baz)
}
}
}
}
}
I think these are valuable constructs to support safely and efficiently.
I agree that safe and efficient mutations are the goal, and I might be wrong, but I am concerned that using Optional<Inout<T>> for these specific cases creates a misleading API contract (given the way Inout is currently proposed).
When a property yields Optional<Inout<T>> via mutating mutate, it effectively yields an inout Optional<...> to the caller. The caller is free to mutate the Optional itself, changing .none to .some or vice versa, and might expect those changes to be meaningful. And, yielding borrowing Optional<Inout<T>> does not give the caller exclusive access, rendering the referee read-only.
If we expand your InlineArray example, we run into both ambiguity and implementation issues:
extension InlineArray {
subscript(safe index: Int) -> Optional<Inout<Element>> {
borrow { ... } // useless (referee becomes read-only)
mutate {
guard indices.contains(index) else {
// We yield a disconnected 'none'
var t = Optional<Inout<Element>>.none
yield &t
// What happens if the caller writes to 't'?
// The caller might have written: `array[safe: 100] = Inout(&myVar)`
// We are forced to silently ignore the assignment or fatalError().
// But we can't express in the signature which one it is.
}
var t = Optional(Inout(&self[index]))
yield &t
// Similarly, what happens if the caller assigned `nil`?
}
}
}
The same ambiguity applies to the enum example. If the enum is in the .baz case, and the caller assigns a valid Inout to the projected optional, does the enum switch cases to .bar?
enum Foo {
case bar([Int])
case baz
var bar: Optional<Inout<[Int]>> {
borrow { ... } // useless
mutate {
switch self {
case .baz:
var t = Optional<Inout<[Int]>>.none
yield &t
// We can ignore mutation of 't' or assign `self = .bar(...)`
case .bar: ...
}
}
}
}
So inout Optional<Inout<T>> could represent either a "re-seatable reference" or "conditional access to existing storage," and we can't express which one it is.
For "conditional access to existing storage" there is an unambiguous solution - a CPS function with the "yield at most once" guarantee:
extension InlineArray {
mutating func safeWithElement<T>(
at index: Int,
_ f: (inout Element) -> T // ideally this closure should also convey "at most once" semantics
) -> T? {
guard indices.contains(index) else {
return nil
}
return f(&self[index])
}
}
Similarly, for your enum example, a switch with an inout binding would also be clearer, maintaining the same efficiency:
func process(_ foo: inout Foo) {
switch &foo {
case .bar(inout value):
value.append(...)
case .buz:
foo = .bar([])
}
}
So, IMO, being explicit about the control flow is better in both cases.
Properties that return Optional<Inout<T>> will most likely be mutating get similar to how getting MutableSpan from Array.mutableSpan is a mutating get.