For the complete and most up-to-date text, visit here.
This proposal is focused on a few corner-cases in the language surrounding functions as values when using concurrency. The goal is to improve flexibility, simplicity, and ergonomics without major changes to Swift.
Motivation
The partial application of methods and other first-class uses of functions have a few rough edges when combined with concurrency. For example, today you can create a function-value representing an actor's method by writing an expression that only accesses (but does not call) a method using one of its instances. More precisely, this access is referred to as a "partial application" of a method to one of its (curried) arguments, the object instance. One can think of StatefulTransformer.transform's type signature as being (isolated StatefulTransformer) -> ((Data) -> Data) to support partial applications:
typealias Data = Int
extension StatefulTransformer: AnyActor {
func transform(_ x: Data) -> Data { /* ... */ }
func insideIsolationExample() -> [Data] {
let f: (Data) -> Data = self.transform
// ^~~~~~~~~~~~~~
// a partial-application of `self` to `transform`
return [4, 8, 15, 16, 23, 42].map(f)
}
}
Partial-applications of object methods are allowed almost anywhere, but this is no ordinary method. As of today, the partial-application of an actor-isolated method is not allowed if it appears in a context outside of that actor's isolation:
extension StatefulTransformer {
// a nonisolated method trying to partially-apply an isolated method
nonisolated func outsideIsolationExample() -> [Data] {
let _: (Data) -> Data = self.transform
// ^~~~~~~~~~~~~~
// error: actor-isolated instance method 'transform' can not be partially applied
}
}
Part of the need for this limitation is that knowledge of the isolation is effectively erased from the type signature when partially-applying the actor instance. That knowledge is required for non-async isolated methods, as their runtime representation cannot encode isolation enforcement alone. But, the limitation is not a fundamental barrier. It is feasible to create a @Sendable version of a partially-applied non-async actor method, by representing it with an async function value.
Conceptually, a similar limitation comes up when performing a conversion that removes the @MainActor from a function type. Since it is generally unsafe to do so, the compiler emits a warning, such as in this example:
@MainActor func dropMainActor(_ f: @MainActor (Data) -> Data) -> [Data] {
return [4, 8, 15, 16, 23, 42].map(f)
// warning: converting function value of type '@MainActor (Data) -> Data' to '(Data) throws -> Data' loses global actor 'MainActor'
}
But not all situations that drop the global-actor from a function value are unsafe. In the example above, the conversion that loses the global-actor happens while already on the same actor. By the same logic as our actor-method example, this should be safe if we do not allow that casted function to later leave the MainActor's isolation.
These and other aspects of how Sendable and actor isolation interact with function values are the focus of this proposal.
Proposed solution
This section provides a summary of the solutions and changes proposed for Swift. For complete details and caveats, see the Detailed design.
Inferring @Sendable for methods
In SE-302, the @Sendable attribute was introduced for both closures and named functions/methods. Beyond allowing the function value to cross actor boundaries, the attribute primarily influences the kinds of values that can be captured by the function. But methods of a nominal type cannot capture anything but the object instance itself. Furthermore, a non-local (i.e., a global or static) function cannot capture anything, because a reference to a global declaration is not considered a capture of that value. Thus, the proposed simplifications are:
- the inference of
@Sendableon all methods of a type that conforms toSendable. - the inference of
@Sendableon all non-local functions. - the prohibition of marking a method
@Sendableif the object type does not conform toSendable.
Introducing @isolated function types
After SE-338, an async function that has no actor isolation is said to be nonisolated and within a distinct isolation domain. That distinct domain has an effect on whether a non-Sendable value can be passed to the function. For example, Swift marks the following function call as invalid because it would break the isolation of ref:
@Sendable func inspect(_ r: MutableRef) async { /* ex: creates a concurrent task to update r */ }
actor MyActor {
var ref: MutableRef = MutableRef() // MutableRef is not Sendable
func check() async {
await inspect(ref) // warning: non-sendable type 'MutableRef' exiting actor-isolated context in call to non-isolated global function 'inspect' cannot cross actor boundary
}
}
When used as a first-class value, the type of inspect will be @Sendable (MutableRef) async -> (). If inspect were bound in a let, we can still accurately determine whether to reject the call based on the type. But as discussed previously, the true isolation of an actor instance cannot be accurately represented in a function's type, as it is dependent upon a dynamic value in the program. Without any way to distinguish these kinds of values, type confusions can happen:
extension MyActor {
func update(_ ref: MutableRef) { /* ... */}
func confusion(_ g: @Sendable (MutableRef) async -> ()) async {
let f: (MutableRef) async -> () = self.update
let ref = MutableRef()
await f(ref) // Want this to be OK,
await g(ref) // but this to be rejected.
}
}
func pass(_ a: MyActor) async {
await a.confusion(inspect)
}
In the example above, the call to g should raise an error about passing a non-Sendable value from an actor-isolated domain into a non-isolated one. That fact is inferred purely based on the async in the type of g, which has no other isolation listed. But if we raise that error based on the types, then f would also be an error, despite not actually crossing actors! As of today, this example raises no diagnostics in Swift.
To solve this type confusion, a new type-level attribute @isolated is proposed to distinguish functions that are isolated to the actor whose context in which the value resides. Any uses of an @isolated function will rely on the location in which that value is bound to determine its isolation.
Let's see how this can be helpful for our example. The partial-application self.update will now yield a value of type @isolated (MutableRef) -> (), which can then be converted to @isolated (MutableRef) async -> () by the usual subtyping rules. Now the type checker can then correctly distinguish the two calls when performing Sendable checking on the argument:
extension MyActor {
func update(_ ref: MutableRef) { /* ... */}
func distinction(_ g: @Sendable (MutableRef) async -> ()) async {
let f: @isolated (MutableRef) async -> () = self.update
let ref = MutableRef()
await f(ref) // OK because `@isolated`
await g(ref) // rejected.
}
}
Notice that g is bound as a parameter of distinction, which is isolated to the actor instance. That means the @isolated in g's type refers to the actor instance in that method. When checking whether it is safe to pass ref, we now know f is safe and g is not. We could also store an @isolated function in the instance's isolated storage, like this:
actor Responder {
private var takeAction : @isolated (Request) -> Response
func denyAll(_ r: Request) -> Response { /* ... */ }
func changeState() {
// ...
takeAction = denyAll
}
func handle(_ r: Request) -> Response {
return takeAction(r)
}
func handleAll(_ rs: [Request]) -> Response {
return rs.map(takeAction)
}
}
Because the declaration takeAction is isolated and itself serves as the location of a value of type @isolated, any values stored there have the same isolation as the declaration. The rules about @isolated are as follows:
- The isolation of a value of type
@isolatedmatches the isolation of the declaration in which it resides. A value bound to an identifier is said to reside in the context of that binding, otherwise, an expression producing a value resides in the context that evaluates the expression. - A function value with
@isolatedtype is produced when a function is used as a first-class value and all of the following apply:- The function is isolated to an actor.
- The isolation of the function matches the evaluation context of the first-class use.
- An function type that is
@isolatedis mutually exclusive with the following type attributes:@Sendable- any global-actor
- A function value whose type contains global-actor
@G, and appears inG's isolation domain can be cast to or from@isolated. - An
@isolatedtype cannot appear on a binding that is not in an isolated context. - An
@isolatedvalue cannot cross isolation domains without performing that removes the attribute.
Rationale: The purpose of
@isolatedis to track first-class functions that are isolated to the same actor as the context in which the value appears. It ensures the function has never left the isolation. That is why@Sendablemust be mutually-exclusive with@isolated. Once an@isolatedfunction tries to leave the isolation domain, it must lose the@isolatedattribute and cannot gain it back.
Going further, @isolated provides the needed capability to share partially-applied methods and isolated closures across actors, when it is safe to do so:
func okSendable(_ y: @Sendable (V) async -> ()) {}
func badSendable(_ x: @Sendable (MutableRef) async -> ()) {}
func asPlainValueFunc(_ y: (V) -> ()) {}
func asPlainMutableRefFunc(_ y: (MutableRef) -> ()) {}
extension MyActor {
func shareThem(_ takeValue: @isolated (V) -> (), _ changeRef: @isolated (MutableRef) -> ()) {
okSendable(takeValue)
badSendable(changeRef) // error: cannot cast '@isolated (MutableRef) -> ()' to '@Sendable (MutableRef) async -> ()' because 'MutableRef' is not Sendable.
asPlainValueFunc(takeValue)
asPlainMutableRefFunc(changeRef)
}
func packageIt(_ changeRef: @isolated (MutableRef) -> ()) -> (@Sendable () async -> ()) {
let ref = MutableRef()
let package: @isolated (MutableRef) -> () = {
changeRef(ref)
}
return package
}
}
Here, we know both takeValue and changeRef are isolated to the actor-instance because of MyActor.shareThem's isolation. In fact, the compiler can determine specifically which instance of that type the functions must be isolated: it's the implicit parameter (_ self: isolated MyActor) currently in scope! The example above shows a number of ways we share first-class isolated functions with contexts that are not isolated to that same actor. Whenever we share an @isolated value with a context that is not isolated to the same actor, the @isolated attribute must be dropped according to a set of rules.
Removing @isolated from a function's type
The primary function of @isolated is to track function values from their origin point, which is a context isolated to some actor, until it reaches a boundary of that isolation. That is when @isolated must be removed. These boundaries are not only cross-actor. A plain function not-otherwise-isolated is considered a boundary:
func process(_ e: Element, _ f: (Element) -> ()) { /* ... */ }
func crunchNumbers(_ a: isolated MyActor,
_ es: [Element],
_ cruncher: @isolated (Element) -> ()) {
for e in es {
process(e, es, cruncher)
}
}
Here, because arguments passed to process do not cross actors, there's no need for it to become @Sendable. Since it is not @Sendable, the cruncher does not need any additional conversions and the @isolated can simply be dropped.
Now, had cruncher been async, an additional requirement that the Element conforms to Sendable. The reason is subtle: without specifying the isolation of an async function, it can only be inferred to be nonisolated, which is a distinct isolation domain. Thus, whenever we drop isolation from an async function, we must enforce Sendable conformance for the input and output types, even if the function is not @Sendable. Thus, we have the following rule:
Rule for Dropping @isolated:
- If the function is
async, then its argument and return types must conform toSendable. - Otherwise, the attribute can be dropped.
Once an attempt is made to pass an @isolated method across isolation domains, it must become @Sendable. This follows directly from rules about actor-isolation: whether an argument passed to an actor-isolated function is required to be Sendable depends on the isolation of the context in which the call appears. Since we cannot statically reason about the context in which a @Sendable function will be invoked, we have the following rule:
Rule for Exchanging @isolated for @Sendable:
- The function becomes
async. - If the function is isolated to a distributed actor, then it also gains
throws. - The function's argument and return types must conform to
Sendable.
Rationale: For a normal call to a non-async method of an actor from a different isolation domain, you must
awaitas it is implicitly treated as anasynccall (andthrowsfor distributed actors) because actors are non-blocking. To maintain isolation principles once isolation information is lost, the argument and return types of an actor-isolated function must also conform toSendable.
Global-actors and function types
For methods that are isolated to a global-actor, partial applications of those methods can more simply state their exact isolation in a way that is independent of the context in which the value resides. A @Sendable partially-applied method that is isolated to a global-actor is not required to be async. Nor is it required to have Sendable input and output types.
But, as discussed in the Motivation, there are still situations where losing or dropping the global-actor isolation from a function's type is useful. In addition, there are scenarios where adding a global actor is unsafe or misleading.
Dropping global actors from function types
When there is a desire to drop a global-actor from a function type, then the same exact rules behind dropping @isolated apply. That is, whenever the function value qualifies for being converted to an @isolated function, and then the rules for dropping or exchanging the @isolated are used.
Adding global actors to function types
On the other side, there are some issues with casts that add a global actor to an async function type. Consider this function, which attempts to run an async function on the MainActor:
@MainActor func callOnMainActor(_ f: @escaping (Data) async -> Data) async -> Data {
let d = Data()
let withMainActor: @MainActor (Data) async -> Data = f
return await withMainActor(d)
}
If Data is not a Sendable type, then the conversion is unsafe and must be prohibited. Otherwise, it would allow non-Sendable values to leave the MainActor into a nonisolated domain. The only safe conversions of async functions that add a global actor are those whose argument and return types are Sendable. But then, that conversion serves no purpose: the executor or underlying actor of an async function cannot be changed through casts due to SE-338.
This proposal aims to clear up this confusion and make it safe by proposing two rules that, when combined with the rest of this proposal, yield an overall safety improvement and simplification of the language:
- Casts that add a global actor to an
asyncfunction type are prohibited. - Given an
asyncfunction type where its argument and return types areSendable, writing a global-actor on the type is considered redundant.
Keep in mind that async functions with Sendable argument/return types are still allowed to be isolated to a global actor. The isolation attribute can simply be dropped from its type whenever it is desired. Take this for example:
extension SendableData: Sendable {}
@MainActor func mainActorFetcher() -> SendableData async { /* ... */ }
func processRequests(withFetcher fetchData: @MainActor () async -> SendableData) async {
// ^~~~~~~~~~
// proposed change: the global-actor in this type is not required because SendableData is Sendable
let next = await fetchData()
}
func doWork() async {
// this cast to drop the MainActor is now allowed by this proposal,
// and is only explicitly written here for clarity.
let obtainer: () async -> SendableData = mainActorFetcher
await processRequests(withFetcher: obtainer)
}
Swift will suggest removing the @MainActor in the type of the withFetcher parameter, since its argument and return types are Sendable. Any function such as mainActorFetcher passed to it will be implicitly cast to drop its @MainActor, despite not being in a context isolated to that actor (i.e., @isolated does not apply).