This pitch introduces a @called(once) attribute to model at-most once execution of a function. Making function execution behavior visible to the compiler has two advantages. First, it makes it possible to diagnose incorrect usage of such functions. But, it also allows the compiler to understand how closure captures could affect variable lifetimes. This can improve compatibility with non-Copyable types.
My initial impression is that this looks pretty great and would be rather useful! I’ve only so far skimmed the proposal, so apologize in advance if this is mentioned somewhere:
Can this attribute (in its current shape and form, or perhaps as a future direction) be composed with property accessors and/or subscripts? Perhaps this runs into trouble somewhere in the ownership rules the attribute already builds on, but if so, it's not obvious to me.
Thanks for working on this! Making at-most-once invocation visible to the compiler seems very valuable, especially for ownership and non-Copyable captures.
I wonder whether @called(once) should instead be spelled @called(atMostOnce) or possibly @called(zeroOrOne). The proposed guarantee permits zero invocations, so once can read as stronger than the actual semantics.
A more explicit name would also preserve a natural spelling for possible future exactly-once semantics:
@called(atMostOnce)
@called(exactlyOnce)
Using explicit spellings would preserve a clear naming space for future evolution and avoid overloading once with two plausible meanings. @called(atMostOnce) and @called(exactlyOnce) describe the distinct contracts unambiguously, whereas @called(once) could reasonably be interpreted as either. Clang’s __attribute__((called_once)) also uses the term for an exactly-once guarantee, so the more precise spelling would reduce cross-language ambiguity.
Yes, that is a reasonable question whether once should be reserved for stronger guarantees as well. The question is though whether exactlyOnce is tractable because it requires inter-procedural analysis to work if such values are allowed to escape and it seems like in a lot of cases limiting that to non-escaping could be a significant limitation. I personally not against atMostOnce but I wanted to get this pitch discussed to see where the consensus is regards to naming.
Edit: I forgot to mention that the analysis is really only possible within a module so the value won't be allowed to be passed into an external API safely because there is no way to analyze the body.
This is similar to adding nonisolated(nonsending) annotation, I mentioned that in "Implications on adoption" section, old overload would have to be preserved for ABI and new code could be switched to use the one with @called(once).
Desperately needed, and the proposal looks basically how I hoped it would.
Whilst I agree with earlier commenters that @called(once) feels like it takes naming space away from a future "called exactly once" option, we discussed the actual possibility of truly linear types at length in the noncopyable continuation review, and the general sentiment of the language folks seemed to be that it was extraordinarily unlikely to be reasonable or possible to do so in future. So although @called(atMostOnce) and @called(exactlyOnce) feel like more of a pair, I think it's probably better to stick with @called(once) for the case we know we can do and that will be common, and pairing that with @called(exactlyOnce) is still fine if we do ever have linear types.
I wonder if we should have an explicit spelling for the default behavior, @called(many)?
My biggest concern with this proposal is that there is a mass of stdlib APIs that need to evolve in this direction. Not just Task constructors and task group addTask overloads, but also all the with functions (temporary allocations, cancellation handlers, etc.). I assume that these will all be covered by future proposals, but a lot of these are still waiting for work for noncopyable support that was promised a long time ago. I think without a comprehensive proposal now, there's a good chance that we're still unable to actually use this feature in all the stdlib places it's needed, a few years from now. (This is just how Swift evolution works — we get half a feature, half-baked stdlib changes, then everyone moves onto the new hotness without finishing the long tail of work for the previous one. See typed throws, variadic generics, sending, even async is still missing withoutActuallyEscaping, etc.).
Finally, I have a small concern that applies every time we add an attribute, which is that it disallows metaprogramming the language feature. So we can't (for example) create a general closure wrapper which accepts both @called(once) and regular closures and is e.g. conditionally-Copyable. (You can't do this in Rust either though, FWIW).
As far as adoption in the concurrency library this is definitely an attribute we'll work hard to adopt there because it unlocks a lot of usability. Rolling out adoption of other language features in the stdlib has at times been challenging, but we continue to adopt them as fast as reasonable/possible. The typed throws adoption on many of those APIs is still work in progress, but we did manage to do nonisolated(nonsending) in almost all APIs we cared about. The problem with adoption is often that we can't break existing code so sometimes it requires a song and dance to make it happen.
In the context of this review though, I did want to say that I fully expect all the concurrency APIs to adopt this rather quickly, because of the huge benefit and I don't expect many source compatibility issues.
How would you shim to an older ABI on previous OS versions? Would there be a withoutActuallyCallingMoreThanOnce helper, or would it just be that the called-once overloads would only be available if you have a new enough deployment target or otherwise restrict OS availability?
Are there any runtime implications for this new type, or will locally defined code using it work ok in back-deployment?
Would sending inference apply even if the capture is unable to be sent? If so, that would make adopting this attribute a source-breaking change.
I see you hedged "concurrency APIs" here, but the need for this language feature is much broader than just the concurrency APIs. For example, withoutActuallyEscaping is missing:
@callable(once) closure support
an async overload
typed throws support
noncopyable return type support
Due to those older features having been half-applied to the stdlib. So when you come to add @callable(once) support, you'll (reasonably!) be asked to backfill all that work that "should already have been done". Which in turn will make it easy to defer "hard" cases like that to "future proposals", whilst only actually addressing the low-hanging fruit (e.g. Task constructors where apparently there's not even an ABI concern). Which'll just leave us exactly where we are, except slightly worse.
I totally understand the desire to split these evolution proposals into manageable chunks, address the highest-impact pain points first, get features "into developers' hands" sooner, etc.. I'm sympathetic! But equally it's obvious from out here that the Swift project is accruing a mountain of technical debt in the form of half-implemented features, where the shiny new language features all collapse at the first interaction with a slightly-less-than-mainstream stdlib function.
The "obvious solution" is to require that this proposal name all the affected stdlib functions, and actually commit to adjusting them to adopt not only @callable(once) but also backfill any other recent language features they're missing. At least that way future proposals would get easier from here. But perhaps there are other options that are better than "do nothing and promise this time will be different".
I think you're conflating some things here - there wouldn't be "@callable(once) Task init" proposals, where such adoption is "obvious". There might be for the hard cases if they cause some further modifications to some language rules.
However plain "adoption" of features doesn't need independent proposals, at least historically we've not done so if the adoption is just "reasonable". Reasonably we want to adopt typed throws in many apis, reasonably we want to adopt @callable(once) in many APIs. This isn't tied up in the evolution process, it is tied up in the difficulties of rolling out the adoption in a source compatible way.
I do however agree that the half adopted state of many features is a really bad look and we should do much better about it. At least given the current evolution process though, it's somewhat of a separate problem. A hard requirement that developing the feature must also entirely land it "everywhere" is a bit too draconian, especially that a proposal at review time sometimes isn't even fully implemented, and may not have uncovered certain issues making the adoption more difficult.
So while I agree with the sentiment, I don't think this is where we'll solve the half-adopted features problem.
(edit: that quote is from the pre-edit version of Konrad's post, hopefully it's still a valid sentiment)
Interesting; does that mean that a simple PR to (for example) add typed throws & noncopyable return type support to withoutActuallyEscaping might be accepted today, without any evolution discussion?
Because if so that might be worth advertising; I think there are a bunch of such function+feature combos that would make "good first contributions" and be valuable to users.
Well we certainly have been adopting e.g. nonisolated(nonsending) and typed throws in APIs without doing a proposal about the fact. In general I believe the guiding sentiment here is that if some adoption is "natural" and falls out of the original proposal of a feature, we do just work on the adoption. It's somewhat case-by-case, but typed throws for example and noncopyable adoption I believe we'd be happy to handle like that.
I will say though that those are not good first contributions often because the source or ABI compatibility "adventures" along the way are really difficult sometimes and require weird tricks and expertise... I wouldn't prevent anyone from trying, but I also wouldn't go out of my way to advertise those as "easy first tasks" because they're actually really tricky to get right.
I'd think that new overload would be deployment target gated.
The same implications like other attributes e.g. @isolated(any) or nonisolated(nonsending), sending parameters/results etc. The runtime needs to support the bit to make sure that casts, Any, etc. works correctly. The code doesn't use any of dynamic features should have no implications on back-deployment.
You'd have to elaborate on that. There are no sending captures today, so if you trying to adopt @called(once) on a sending parameter, for example, then the inference should all more code to be accepted not less.
…and that package updates someSwiftPackageAPI to make its closure parameter @called(once). My reading of the pitch is that the closure would convert from an implicit { [nonSendable] in to { [sending nonSendable] in. That would make nonSendable.bar() illegal since nonSendable has been sent. Is that reading correct?
It won't just happen for any closure, it would only happen either for a non-escaping one and the rule here is the same as async let where if there are no sending operations in the closure you can still use the value afterwards (it's un-sent so to speak), if it's already sending then there should be no problem either since it already disconnects all the captures.
For what it's worth, withoutActuallyEscaping has had typed throws support since the addition of typed throws in SE-0413. It's true that the result type should be allowed to be ~Copyable (and ~Escapable) and the closure should be allowed to be async, though.