That makes sense, thanks for the explanation! I'm curious how an effect is rigorously defined, though. In some (arguably contorted) sense, the closure is depending on the external factor that it will be called at most once, which is outside of control in case the closure is accessible publicly.
As discussed upthread, just being ~Deinit doesn't make you exactly-once, you have to be ~Escapable as well — otherwise you can just be "forgotten".
What you’re saying isn’t wrong, but Slava is still correct.
All the guarantees of exactly-once functions, escaping or not, are subject to the limits of progress. A non-escaping exactly-once function only promises to have been called exactly once if control reaches the end of the scope the function is restricted to. An escaping exactly-once function makes an even weaker promise: it will have been called exactly once if no context still has a reference to it. But that is nonetheless useful, so in Slava’s idea (which I talked through before as “true linear” types) there would still be exactly-once functions that are ~Deinit but still Escapable.
Regardless, I don’t actually think we need to implement ~Deinit just to add exactly-once functions. They’ll just be limited in the first implementation.
While I agree that we do not strictly need to implement ~Deinitable as a requirement of implementing exactly once functions, I think we probably ought to. I've come around to this idea after discussing it with @Slava_Pestov and prototyping it to see how it would differ from the approach of special-casing the type system effects of exactly-once functions. With an invertible Deinitable protocol, many of the rules that must be applied to exactly-once functions fall naturally out of generic requirement checking, just like many of the requirements of at-most-once functions naturally fall out of their ~Copyable constraints. The alternative requires a pile of special-case rules that seem easy to fail to generalize completely.
In this proposal, the compiler's support for Deinitable and its inverse could be limited to just facilitating the implementation of exactly-once functions. Here's what I'm thinking:
- Introduce
Deinitableas a new invertible protocol. Ban references to it written in source code (no user defined types, no generic code written in terms ofDeinitable, no generalization of anything in the stdlib, etc.) This restriction could of course be lifted in the future. - The compiler diagnoses implicit deinitialization of
~Deinitablevalues, as described earlier in this thread. Copyableinherits fromDeinitable. This prevents all existing generic signatures from needing to expand implicitly and would therefore limit unnecessary type-checking performance impact. Existing~Copyablesignatures would gain an implicitDeinitrequirement. This seems like a defensible relationship between the protocols for Swift types, at least, since it's hard to imagine a type that should have an implicit copy constructor but no implicit destructor. It's unclear to me whether C++ interop might someday want to import a type asCopyable & ~Deinitable. However, I think it should be possible to remove the inheritance relationship later should we decide thatCopyableandDeinitableare independent, since at this stage none of the design ofDeinitableought to leak into ABI outside of the ABI for exactly-once function types.
In this proposal, the main way the Deinitable protocol would actually surface to users of the language would be in diagnostics about restrictions on what they can do with exactly-once functions. The rules about where exactly-once closures can be stored, captured, etc. naturally follow from more general rules about Deinitable types. A ~Deinitable value can only be:
- stored in a property of a type that is also
~Deinitable - captured by a closure that is itself
~Deinitable - transferred to a generic context that is also
~Deinitable(no such context would exist yet)
These are the same kinds of restrictions that already apply to at-most-once closures via ~Copyable and so they only need to be implemented once in terms of generic constraints.
I think we may potentially need to go down the road of more complete generics support for exactly-once closures somewhat soon anyways. It seems likely we'll want to be able to express Optional exactly-once function values for interop, since nullable closures can be common in Obj-C/C APIs and we'd therefore need to be able to generalize Optional to add ~Deinitable when its value is ~Deinitable. That is a project that should be tackled separately after this proposal, but it's nice to have some of the groundwork laid.
Finally, I want to call out that I named it Deinitable intentionally to match Copyable and Escapable. I'd be curious to know if folks think it should be just Deinit instead for some reason, or if that was just shorthand.
Sure, doing some of the internal modeling right away makes sense to me; I just don't want this proposal to have to wait for a whole major generics feature before it can move forward. Disallowing the protocol from being used for anything but compiler-internal purposes would work well.
We don't have to settle on the name now, but yes, it should have an -able suffix.
Deinitablewould be the direct option.Deinitializableis a real word (albeit a technical production that wouldn't appear in any dictionary).Discardablecould be justified, but I think on balance is the wrong word; "discarding" really means "throwing away without using", and these values are generally usable.- I'm sure there are other options.
~Deinitializable also has the benefit of matching UnsafeMutablePointer.deinitialize() which is the sort of operation that would not be available for such types.
The name Deinitable suggests that custom deinit logic exists, while another name like Discardable suggests that custom logic does not exist (required by e.g. BitwiseCopyable). It's not possible to write Discardable as ~Deinitable since it may still be satisfied by a type that does conform to Deinitable.
If the idea is to enforce that all deinitialization points be explicit, I prefer a name like ~ImplicitlyDiscardable.
I'm not sure this is defensible, we could say this combination has bad semantics but its a conceptually reasonable thing, used at least once.
I'd advocate for Discardable since these types can still be deinitialized, just not without an explicit operation. ~Copyable cannot be copied.
To me, T: Discardable just requires the ability to discard without using, it doesn't say the type cannot be used.
Fair enough! I think it is still a defensible decision to make in this proposal as a tactic for reducing churn related to introducing the protocol, though. Exposing a full suite of linear type capabilities is not a goal yet. If we choose to invest more in the expressivity of linear types after exactly-once functions are implemented, we can decide to remove the Copyable: Deinitable refinement at that point and if we're comfortable with the implications. This proposal should not lock us into maintaining the relationship since no one will be able to define their own ~Deinitable type yet.
Remember that if you really do have a type that can be copied but not destroyed, another way to express it is with a ~Copyable & ~Deinitable type that has an explicit copy() method. In my opinion the cost of making Deinitable independent from Copyable (every generic signature gets bigger) outweighs any marginal benefit in expressivity.
I think it's somewhat common for a function to take multiple callback parameters, and choose which callback to call once. For example, this is true for any function that takes two continuations, calling one if the function succeeds and another if the function fails. Only if the callbacks are known to be mutually exclusive with each other, is it possible for them to share the same captures even if some of the captured values are consumed and non-copyable:
func f(onSuccess: (Int) -> Void, onFailure: (any Error) -> Void) { ... }
var nc: NonCopyable()
f(onSuccess: { _ in consume nc }, onFailure: { _ in consume nc })
This limitation can technically be worked around, either by replacing the multiple callbacks with a single callback that takes an enum argument (such as Result in the example above), if each callback has the same return type, or by replacing the multiple callbacks with a noncopyable struct/protocol with multiple consuming methods. However, these workarounds seem to be inconvenient enough that, at least in Rust, there are convenience functions which take multiple callback parameters despite the limitations; for example, the Result.map_or_else method.
I don't think there's a generalized model that could allow mutually exclusive closures to consume the same captures. One of the closures is going to be executed and consume the value. The other closure would be implicitly destroyed, which normally also has to consume its captured values. In order to avoid over-consumption there would have to be some kind of analysis that guarantees both closures own exactly the same set of values and then ensures that the uncalled closure is just discarded instead of destroyed implicitly. I think the utility is too narrow to justify the complexity that would involve.