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
@Sendable
on all methods of a type that conforms toSendable
. - the inference of
@Sendable
on all non-local functions. - the prohibition of marking a method
@Sendable
if 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
@isolated
matches 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
@isolated
type 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
@isolated
is 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
@isolated
type cannot appear on a binding that is not in an isolated context. - An
@isolated
value cannot cross isolation domains without performing that removes the attribute.
Rationale: The purpose of
@isolated
is 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@Sendable
must be mutually-exclusive with@isolated
. Once an@isolated
function tries to leave the isolation domain, it must lose the@isolated
attribute 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
await
as it is implicitly treated as anasync
call (andthrows
for 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
async
function type are prohibited. - Given an
async
function 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).