[Pitch] Introduce a way to annotate functions that are called at most once

Thanks for working on this and pitching it. I agree that this a missing feature that will remove a lot of the current ergonomic problems when using ~Copyable types in the language. Most of those ergonomic wins will be unlocked with adoption across the standard library and ecosystem.

While we can hope that adoption is fast I expect that developers will always run into some old potentially unmaintained APIs that haven't adopted @call(once) yet. Could we create a similar escape hatch to withoutActuallyEscaping to allow developers to pass @call(once) closures to APIs that expect regular closures. Something like this

// Legacy API
func foo(operation: () -> Void)

// My modern code
func modern(operation: @call(once) () -> Void) {
  actuallyCalledOnce(operation) { regularClosure
    foo(regularClosure)
  }
}

actuallyCalledOnce would probably require an allocation to store the call-once closure, on first call it would then nil out the stored closure, and on any subsequent call it would trap.

A non-Copyable value consumed by the closure is destroyed exactly once: if the closure is called, the body's consuming use destroys it; if the closure is never called, it is destroyed when the closure itself is destroyed. This holds for both non-escaping and escaping closures.

I think this is not entirely correct. If the closure is called the body can but doesn't have to destroy the ~Copyable consumed value. The body might as well just pass the value somewhere e.g.:

func foo(operation: @called(once) () -> Void) { operation() }

var opt: Optional<NonCopyableValue> = nil
var nonCopy = NonCopyableValue()
foo {
  opt = nonCopy
}

This makes the attribute a suitable replacement for @_implicitSelfCapture in general and in Task creation APIs in particular.

Is this saying that all @call(once) closures will always implicitly capture self?

  • sending @called(once) closures infer all of their non-Sendable captures to be jointly sent by default.

This makes sense but while reading this I was thinking that we should disallow a @Sendable @call(once) closure. Since the closure is called at most once it can never be called concurrently across isolation domains. So only sending @call(once) closures are valid.

I share the same concern. It is already hard to implement generic types that store closures where the closure can be @Sendable/~Sendable and the type itself wants to conditionally become Sendable. This pitch would add another layer to this where a generic type might want to be conditionally Copyable based on the closure.

6 Likes

Would it be possible to discard @called(once) closure? Is there a workaround to have a guarantee that closure will be called not less that once (a guaranteed call)?

1 Like

As part of the review of the overarching feature, the language steering group explicitly recognized that typed throws could be adopted (where obvious) through the stdlib without separate proposals, but it's an exception and not a general rule. We have been reviewing ~Copyable and ~Escapable adoption.

It ought to be stated explicitly when the plan is to adopt a feature throughout the stdlib, if that is indeed the plan, because as you say there can be 'adventures' in adoption and those are not always just implementation details.

5 Likes

Sounds good, I'll mention that in the proposal!

2 Likes

Yes, I think that's doable, we'd have to work on the name but it does seem helpful for adoption...

I'll clarify what happens with consumed captured when the function is called. I tried to make a point about what happens when it isn't but I see how it make it more confusing now.

Not always, only when there are operations that require self to be captured implicitly i.e. method calls in the body.

Yeah, I'm still thinking about this myself, it does seem like both have the same effect really.

2 Likes

Yes, closure doesn't have to be called and can be discarded. I have a section on in "Proposed solution" that talks about why it was chosen and why not "exactly once" instead.

But how is this implicit anymore then? If there are method calls on self in the body then self is already captured. My understanding (which might be wrong) was that @_implicitSelfCapture captured self even if nothing in the body captured self i.e. no method calls on self. Edit: My understanding was wrong and @_implicitSelfCapture only seems to enable to drop the self. when referencing properties or methods in escaping closures.

@_implicitSelfCapture is just a check that allows to omit use of self. in references and that's precisely what @called(once) does as well.

2 Likes

My one nit about the naming here is that the root is "called": for a feature that pointedly permits the annotated function to be not called, this is quite unfortunate.

In general, Swift takes pains not to name a thing that might be not foo, "foo." We do all sorts of contortions to avoid it: fooable, maybe fooed, foo-providing, etc. Almost any alternative name here—@callable(once), @once, etc.—does not have this issue (at least to the same extent).

6 Likes

I understand the mechanic, but I want to clarify a specific parallel. Since ~Copyable types can have a deinit, the compiler is already capable of tracking ownership to ensure deinit is called on all execution paths when an instance is destroyed.

It seems a very similar lifetime-tracking logic could be applied to @called(once) closures. The primary difference would be the compiler behavior at the end of the lifetime: instead of automatically injecting a deinit call, the compiler would emit a diagnostic error if the closure hasn't been explicitly called.

Yeah, maybe @callable(once) is a better name but I feel like it might not fit "exactly" semantics if it was decided to support that in some form in the future, on the other hand we could just make that @once...

2 Likes

Without an inter-procedural analysis I don't think that's possible to do statically as I mentioned in the paragraph I wrote.

What I find useful is a behavior where a @called(once) closure (or a ~Copyable type) must be explicitly consumed (e.g. passed to another function) or explicitly called (if it is a closure) / destroyed via _ = consume arg (~Copyable type). If no explicit consuming action is found on an execution path, it should result in a compiler error rather than a silent implicit deinit.

This might be out of scope of this proposal, but clarifications would be helpful.

I think this is separate from this proposal which is explicitly "at most" once semantics based on how regular non-Copyable values behave. What you are suggesting seems to be more in a realm of "exactly once" instead.

It is written in proposal "@called(once) is a new type attribute that applies only to function types".

Are there plans to relax this limitation for callAsFunction types?

You can already do something like this today if you declare the type as ~Copyable and callAsFunction as consuming:

struct S: ~Copyable {
    var x: Int

    consuming func callAsFunction() {}
}

func m() {
  let s = S(x: 10)
  s()
  s()  // error: 's' consumed more than once
}

That actually has a stronger guarantee; s can't be used at all after it's been called, so it's not quite the same thing. But is there a use case for a type that implements callAsFunction where you want it to be callable once but otherwise not consumed afterwards? (I guess you could imagine a situation where you'd want to inspect properties of S after the call if it was mutating, but you could just make that outcome the return type of callAsFunction instead and I think the flow of data would be clearer.)

8 Likes

A few minor comments.

Exactly once semantics would need a new flow-sensitive analysis proving that a function value is invoked on every path through its caller, similar to definite-initialization analysis for stored properties. That kind of analysis breaks down as soon as the function value can escape its original context — e.g. by being stored into a Task, handed to a completion-handler API, or returned from a function — because the compiler can no longer see all of the paths that lead to it being called, or whether it's called at all before the enclosing scope exits.

Not important, but I think linear types, something like ~Deinitable, would be a more "conventional" approach to implementing this, and it would take up less space to just say that here.

This is accomplished through non-copyability. A @called(once) function type is a ~Copyable type,

Nitpick: to avoid confusion about the role of ~ in the surface syntax, it might be better say "unlike other function types, a @called(once) function type does not conform to the Copyable protocol".

Also, should we add support for unapplied method references to consuming methods on non-Copyable types? Today, this produces a diagnostic, but it could be modeled by giving the curry thunk a @called(once) type:

slava@Mac swift % cat unapplied.swift 
struct S: ~Copyable {
  consuming func f() {}
}

func g() {
  let s = S()
  let fn = s.f
}
<unknown>:0: error: noncopyable 'self' cannot be consumed when captured by an escaping closure or borrowed by a non-Escapable type
7 Likes

I'd personally rather not add any more curried unapplied methods, but since we seem to have given up on uncurrying them it probably can't hurt to do so.

I think this is well worth adding. On naming, I'd prefer @xwu's @callable(once) spelling, since the root carries semantic content beyond the "might never be called" problem.

To my ear, called asserts what happens, so once attaches to an assertion and reads as "exactly once". On the other hand, callable states a capability, and a capability naturally reads as an upper bound ("usable once", "redeemable once") with no implication that it's exercised at all. @callable(once) conveys what @tevelee's @called(atMostOnce) makes explicit, but without the extra length.

I wouldn't want to shorten this to a bare @once. On its own, @once leaves the reader to supply the verb, and "called once" isn't the only candidate.

On exactlyOnce not fitting with callable: is the concern that a capability spelling can't express an obligation? "Callable exactly once" would still only grant permission without requiring the call. If that's the concern, I'd expect a future exactly-once feature to want a different root regardless.

4 Likes

I'd think that the feature there would be - it's required to be called once.

1 Like