[Pitch #2] Called-once function types

Hello Swift Evolution!

This is the second pitch for a language feature that lets you annotate function types as executing exactly once or at most once:

func callOnce(_ c: @once () -> Void) {
  c()
  c() // error: 'c' is consumed more than once
}

Read the full proposal in this pull request.

The motivation for this feature is to give the compiler enough information to let closures passed to certain APIs consume values of noncopyable types and re-send values of non-Sendable types. Today, code that attempts this is rejected, because it would not be safe if the closure ran more than once.

Since the first pitch, we have updated the proposal to incorporate feedback from that thread. The most significant change is the addition of the @once attribute, which models closures that execute exactly once rather than at most once. Exactly-once semantics are much more common in APIs than at-most-once semantics and exactly-once closures could enable future features, such as user-defined linear types. Introducing @once now spares the ecosystem a second costly migration later.

Other changes include:

  • Renamed the attribute for functions that may be called at most once from @called(once) to @callable(once).
  • Added the compiler-internal Deinitable protocol to model the constraints of @once types.
  • Added withoutActuallyDeiniting(_:do:) to convert @once functions to @callable(once) functions.
  • Narrowed sending inference for non-escaping closures to the captures that the closure body sends.
  • Specified the rules for overrides, top-level protocol witness mismatches, overloads that differ only in execution semantics, and storage of these function values.
  • Designed C, C++, and Objective-C interoperability, including dynamic enforcement for imported called_once parameters.
  • Described the plan for adopting these attributes in the standard library.

See the Alternatives considered and Future directions sections for discussion of many of the topics from the first pitch thread.

Please let us know what you think, and thanks for your time!

17 Likes

Really great proposal, so many questions I had at the start were comprehensively answered by the end. I even more-or-less came around to the naming :rofl:

The only major question I was left with was the idea that @once closures can escape. As discussed in other threads, ~Deinitable isn't linear on its own; ~Deinitable & ~Escapable is linear. It seems to me that (within the proposed syntax**), we must make an arbitrary choice as to which one of those two possibilities to pick for @once, with ~Deinitable (as proposed) providing less safety but somewhat more flexibility than ~Deinitable & ~Escapable.

The proposal text says

A @once value is not called if:

  • ...
  • ownership of the value is transferred to a context that is never destroyed, such as a closure that is kept alive by a reference cycle or by a global variable.

The proposal expressly excludes globals of ~Deinitable types and a non-@once closure can't capture a @once closure (because that would store a ~Deinitable type in a Deinitable one). So I guess the only way to achieve this is to wrap the @once closure with withoutActuallyDeiniting.

Speaking of which, the design of withoutActuallyDeiniting is strange to me — given that the argument is allowed to escape, the with form makes little sense (particularly since it won't work with async closures). But then it's also this escaping that causes the above weaknesses in @once. It seems like maybe we should keep the with form (with async overload) and make the resultant closure non- escaping (noting that the programmer can still use withoutActuallyEscaping to break out of that situation).

If we allow escaping, including by changing the function to simply return the wrapped closure rather than keeping the with form, then I think it should at least be marked & named as being unsafe, in that the @once closure may be being used to uphold an arbitrary safety guarantee, and this function allows anyone to break that guarantee, whatever it may be.


** that is, if instead of @once we wrote @Nondeinitable @Noncopyable, we could allow both these options. I am generally in agreement with the proposal that a single attribute is better, though.

Are you factoring in that function parameters are non escaping by default? If the only annotation you write on a function parameter is @once, it is linear[1] afaik.

Like ordinary function types in parameter positions, @once and @callable(once) types are not escaping unless the @escaping attribute is specified.


  1. for certain definitions of linear... ↩︎

I disagree, linear logic does not have any notion of "escapability": Linear logic - Wikipedia

To put it another way, what invariants are broken by allowing a ~Deinitable & Escapable function type?

4 Likes

Looks from the examples like the @escaping-ness of the withoutActuallyDeiniting parameter matches its argument, rather than having that @escaping removed.

I think that this thread says everything about the interaction of ~Deinitable with Escapable that I want to say, and better than I can: How might linear types work in swift?. But basically if we allow any "safe" language or library construct that allows a ~Deinitable type to be leaked, or stored in a ~Deinitable type, then we no longer have linear types. That's relevant because this proposal is proposing withoutActuallyDeiniting, which does (as proposed) allow that to happen. As in my original reply, restricting the Deinitable wrapper created by withoutActuallyDeiniting to be ~Escapable closes that hole.

In attempting to compose this reply, I noticed that this proposal needs to ban @discardableResult for ~Deinitable types.

But you can already leak such a type even if it doesn’t escape:

let x = createNonDeinitableValue()
while true {}
// nothing discards x

This would be a nice ergonomic improvement, but I imagine the move-only checker would catch such a call at the SIL level.

What led you to pick such different names for these two attributes? Naively I would expect them to read pretty similarly. (@called(once) or @called(exactlyOnce) and @called(atMostOnce), for example).

Overall, I think this pitch is pretty great, but the asymmetry really sticks out to me.

I do see a little text in alternatives considered about this, but I'm not sure "the @called(exactlyOnce) spelling is significantly more verbose than @once, though, which is undesirable for such a common attribute." really holds water. It's really not verbose, compared to common Swift APIs and attributes, and that rationale directly contradicts the rationale offered for defining both attributes. I'm less looking for iron-clad logic, and more for "can you expand on the thinking that led you here?"

6 Likes

This is undeniably true, but I still don't find it convincing :slight_smile:

I think the difference in my mind is along the lines of, we tied the lifetime of x to the lexical scope, and the lexical scope still exists (until the app is killed), so x is still "in use", so not deiniting it is correct. And if someone later adds a break statement to that loop, the code will be correct. Whereas if we leak it entirely, we've lost track of a value we promised to deinit, and can no longer fulfill that promise in any circumstance.

Fully aware this is more of an emotional argument than a logical one. Though I do think the "will a future maintainer of the code automatically uphold safety guarantees they don't fully understand" angle is important.

Can you include the declarations of the Copyable, Escapable, and Deinitable protocols in the pitch? It wasn’t clear to me which ones inherit from each other now.

Did you consider importing nullable called_once blocks as @callable(once) types so that they’re allowed to be optional? (This could always be reversed when Optional: ~Deinitable in the future).

Similarly, can you include a discussion of the source compatibility impacts of moving from @once to @callable(once) in an API (or vice versa)?

Do you envision updating any protocols in the standard library to use called-once function types?

In the “ Only offer @once without @callable(once)” section, you say “this would forfeit the ability to improve noncopyable and non-Sendable type ergonomics for many widely used APIs.” Could you elaborate on the kinds of APIs that would need these semantics?

The spelling of withoutActuallyDeiniting feels very awkward to me. Have you considered alternative spellings? I agree with Keith that making it a with-style function seems unnecessary. Some names to consider: guaranteeCall(to:), guaranteeConsumption(of:).

In the future, the author of a C or Objective-C API could be given a way to assert that the implementation honors called_once, allowing Swift to pass arguments to it without incurring overhead from dynamic checks.

This is also an issue with NS_NOESCAPE (where an escapable closure is constructed anyway). It would be really great to have some sort of NS_I_PROMISE_TO_FOLLOW_CLOSURE_RULES attribute that eliminates both of these sources of overhead, for cases when you need help from C or Objective-C to provide scoped access to a resource.

So, the thing is, if you want to tie the lifetime of a value to a scope, that’s what ~Escapable is for. I don’t think anybody is arguing that it’s okay for a non-escaping exactly-once function to not have been called by the end of its scope. If we ever find a corner case in the language that allows that, we should close it.

But I don’t think it makes any sense to use this argument to tie ourselves in knots and say that escaping exactly-once functions are logically Wrong unless we combine them with some completely unworkable system of proving that call sites will always be reached. Leaks of the kind you’re worried about are, basically, combinations of latent local obligations that happen to be dynamically impossible to satisfy. The difference between possibility and impossibility in these cases is essentially unknowable (even dynamically) without running a cycle-detecting collector, which Swift long ago decided against as part of its standard runtime.

The argument that that makes the feature less useful is rooted in the statement that you cannot know that the function is called by a specific point in the program. And, of course, if that’s what you’re trying to prove, you should’ve made the function non-escaping within a scope that ends before that point. The point of the function being escaping is to not have such limitations.

2 Likes

I think this is equivalent to saying "Safe code must not use the at-least-@once-ness of a closure to enforce safety" (the at-most-once-ness is fine).

Given that standpoint, then, what's the point in having @once at all? I think only the difference in the body of a function which accepts an @once closure, where control flow analysis can generate compilation errors if an execution path misses a call. Outside of lexical contexts, we're effectively throwing up our hands in despair anyway.

It makes me wonder if the correct design is not to introduce @once at all, and instead introduce an @mustConsume attribute that can be applied to any parameter or local variable of any noncopyable type, that provides the flow-sensitive analysis that @once is providing.

[edit] I guess the "no storing it in Deinitable" is very helpful too. Though the easy escape hatch is still making me uncomfortable.

I am not aware of any kind of safety that depends on code running at an unspecified point in the future as long as it’s before process exit, and there had better not be one, because we absolutely do not make that promise on any level. Total progress is a very, very hard thing for a general purpose programming language to prove.

4 Likes

I originally implemented these attributes as @called(exactlyOnce) and @called(atMostOnce) and after I spent enough time reading them repeatedly I came away feeling that they just looked very clunky. That led me to the conclusion that @once is the most obvious, elegant spelling for a type that only executes once, and it's available; we should use it!

The asymmetry is admittedly a bit unsatisfying but I want us to seriously consider using the best available spelling for the most common execution semantic. Would a more satisfying counterpart to @once be just @atMostOnce? I'd be fine with that too.

I also wonder if symmetry in these attributes would matter to us as much if we spread out the introduction of these attributes over many years. Is sharing a common design amongst the exactly-once, at-most-once, and sequentially attributes really important, or do they just need to be clear on their own?

2 Likes

Non-escaping @once does make the guarantee you want if the end of the scope is actually reached, and escaping @once still offers a very practical level of verification that the programmer hasn’t forgotten about the value on some path.

1 Like

They currently look like this:

@_marker public protocol Copyable: Deinitable/*, ~Escapable*/ {}
@_marker public protocol Escapable/*: ~Copyable*/ {}
@_marker public protocol Deinitable/*: ~Copyable, ~Escapable*/ {}

The choice to make Copyable refine Deinitable was an attempt to mitigate the type checking performance impact of introducing a third invertible protocol. The refinement is not designed to be load-bearing for anything and could be removed in the future with a corresponding update to the compiler if we decide to make Deinitable a user-facing protocol in the future and want to make them independent.

That's an option, but it would involve multiple iterations of churn since each time we update the import rules existing code that implements those imported interfaces will get new warnings about no longer matching. It's probably worth it, though.

Similarly, can you include a discussion of the source compatibility impacts of moving from @once to @callable(once) in an API (or vice versa)?

I feel like this is already covered by the function conversion rules section. I can try to generalize the text of the source compatibility section to make it clear that the effects of narrowing or widening the parameters of an API can include moving from @callable(once) to @once and vice versa, though.

Do you envision updating any protocols in the standard library to use called-once function types?

That's a good question. There don't seem to be many protocol requirements that would benefit. The closure passed to Sequence.withContiguousStorageIfAvailable(_:) ought to be @callable(once) and the closures passed to StringProtocol.withCString(_:) and StringProtocol.withCString(encodedAs:_:) ought to be @once. If anyone thinks there are more, let me know. Evolving protocol requirements would be tricky, so it's probably a good thing that we don't have many examples. I suspect we should leave these alone.

In the “ Only offer @once without @callable(once)” section, you say “this would forfeit the ability to improve noncopyable and non-Sendable type ergonomics for many widely used APIs.” Could you elaborate on the kinds of APIs that would need these semantics?

Sure, a few good examples from the standard library APIs that could adopt @callable(once) are:

  • the autoclosures operands of && and ||,
  • Optional.map(_:), Optional.flatMap(_:), Result.map(_:), etc.
  • the default value autoclosure of Dictionary.subscript(key:default:),
  • Mutex.withLockIfAvailable(_:),
  • The cancellation handler of withTaskCancellationHandler(operation:onCancel:)
1 Like

Yeah, and it’s worth mentioning specifically that resource leaks, while potentially very inconvenient for the developer, do not violate what we think of as memory safety.

The latest iteration of this pitch looks great.

Since calling a @once or @callable(once) function always consumes it, parameters with these function types are implicitly consuming

and later

This restriction could be lifted in the future if borrowing these function values becomes useful, such as to pass them to generic functions that inspect them without consuming them.

Is it the right choice to default the implicit to consuming if we believe there might be value further down the road to lift the borrowing restriction? It would add yet another case of where something behaves differently than the "norm". I think I would personally rather write the consuming modifier out.

withoutActuallyDeiniting

It is not directly spelled out so just making sure, there will also be an async variant of this method which is nonisolated(nonsending) and where the closure is also async + nonisolated(nonsending) right?

Thanks for sharing the hierarchy here! It makes a lot of sense to me that Copyable refines Deinitable — if you’re able to create a new copy of a value out of thin air, it should follow that you are also able to get rid of that copy once you are done with it. Otherwise it would never be clear whether calling a consuming func on such a value is intended to end its lifetime or not.

It probably is, but I think being explicit here would be helpful for folks who are considering updating their APIs or making new APIs in the future, to help them understand what would happen if they chose the wrong annotation.

Yeah, that makes sense to me too. It’s a little unfortunate that these won’t get the safety improvements, but all three of these feel like things that could be better implemented with spans these days anyway.

And thanks for the stdlib examples! I have personally been bitten by &&/|| before so I’m looking forward to seeing the change there. Another area that comes to mind after reading your list are the parameters to assert and friends, where there’s a similar issue with non-copyables as `&&.

Can we have this diagnostic specifically on the branch that exits without calling f? Maybe one error per function with a note per branch.

func dropsConditionally(_ f: @once () -> Void, _ condition: Bool) { // error: 'f' is not called on all 
  if condition {
    return // note: 'f' is not called on this path
  } 
  f()
}

func borrowOnce(_ c: borrowing @once () -> Void) { } // error: '@once' cannot be used together with 'borrowing'

IMO, that's redundant. Attempt to call the function will diagnose the error anyway. This error won't be applied to other ~Copyable or ~Deinitable types, right? I'd prefer to avoid introducing extra special cases for closure types:

func borrowOnce(_ c: borrowing @once () -> Void) { } // error

struct Box<T: ~Deinitable> { var value: T }
func borrowOnce(_ c: borrowing Box<@once () -> Void>) { } // no error???

Overrides of class members may not vary in their use of @once and @callable(once). An overriding method, property, or subscript must declare @once and @callable(once) in exactly the same positions as the declaration it overrides, even where the function conversion rules would allow the difference:

Why? Wouldn't allowing this help with source-compatibility?


Overloads may not differ only in whether a parameter is @once or @callable(once), or only in the @once and @callable(once) attributes of their results:

func produce() -> () -> Void { ... }
func produce() -> @once () -> Void { ... } // error: invalid redeclaration

I guess the first overload of produce Is missing @callable(once) in the result type.


Since Optional requires its wrapped type to be Deinitable (see Storage), optional @once function types cannot be formed, so called_once parameters that are _Nullable or _Null_unspecified are imported as ordinary optional function types:

Why not optional @callable(once)?