Conditionally conform to Sendable when closure property is Sendable

My understanding is that closures are treated a little differently and can't conditionally be Sendable.

Let's say I have a type that holds onto a closure:

public struct Effect<Input, Output> {
    public typealias Closure = (Input) -> Output
    
    public let closure: Closure
    
    public init(closure: @escaping Closure) {
        self.closure = closure
    }
}

This will work until I need it to be Sendable. It's easy enough to conditionally conform the type to Sendable when Input and Output are Sendable like so:

extension Effect: Sendable where Closure: Input: Sendable, Output: Sendable {}

However, this will correctly result in a warning because Closure isn't Sendable. What if we tried to conditionally conform Closure to Sendable to see what happens...

extension Effect: Sendable where Closure: Sendable, Input: Sendable, Output: Sendable {}

This will compile but the warning remains because I guess a function requires the @Sendable annotation instead of the protocol conformance.

Is there any way to handle cases like this without making a separate type like below?

public struct SendableEffect<Input, Output>: Sendable {
    
    public let closure: @Sendable (Input) -> Output
    
    public init(closure: @escaping @Sendable (Input) -> Output) {
        self.closure = closure
    }
}
1 Like

This will work until I need it to be Sendable .

Can you provide an example snippet that requires Sendability

Yes, but I won't be able to get to it for a few days

:+1:

This just isn't how function types naturally interact with Sendable. The sendability of a function depends on its captures and is orthogonal to its parameter and result types. There's no reason a function that takes Sendable arguments can't be non-Sendable or vice-versa.

1 Like

If you could write T: (Foo) -> Bar you’d be able to make this conditional though. So it’s something Swift doesn’t expose in its surface language today (“no, there’s no way to do this”) but it’s not impossible to represent.

3 Likes

Oops, I am now seeing that a function can be sendable while its parameters or return are not. Did not realize that.

I don't think dependently-Sendable function types would be a useful feature. Generic code that just wants to pass function values around doesn't need to know that they're function values, and the sendability of the functions will be preserved by opaque generics in the usual way. Constraining the code to know that the values are functions is only useful if the code wants to call those values or produce new values from (say) a closure expression. In either case, not knowing whether the function type is Sendable would just force the code to be maximally conservative on both sides: you'd have to create Sendable closures but immediately treat them as non-Sendable.

In general, when you're working with a known function, you know how you plan to use it and whether those uses require it to be Sendable, so you should just take either a Sendable or a non-Sendable function as appropriate, with no need to be generic over its sendability.

2 Likes

I'm a bit confused. How would a conditional conformance to Sendable based on whether a type parameter representing a function type is Sendable force the code to be maximally conservative / force you to create @Sendable function values? For example, pretend you could write this:

struct S<Arg, Result, Func> where Func == (Arg) -> Result {
  let fn: Func

  init(fn: Func) {
    self.fn = fn
  }

  func call(arg: Arg) -> Result {
    return fn(arg)
  }
}

extension S: Sendable where Func: Sendable {}

IMO this is a reasonable thing to want to do. We already let @Sendable function types be passed as generic arguments to a type parameter T where T: Sendable. That said, I'm not sure if it'd be tricky to teach the constraint system to infer closure types as @Sendable based on captures because today the constraint system doesn't reason about closure captures at all for the purpose of type inference; it only infers a closure type to be @Sendable if it's written contextually.

1 Like

If Func were actually constrained to “any function with this shape”, and you were in an extension of S, and you tried to turn a closure into a Func, you’d have to use a Sendable closure because Func might be a sendable function type. You don’t get to assume that Func isn’t Sendable because Func isn’t statically constrained to Sendable; that’s not sound.

You’re right that there isn’t a similar constraint on calls, though. So yes, if you just want an opaque function value that you can pass around and call, you could reinvent it with this feature. Of course, you could also just use a function type.

1 Like

I see, thanks! Also, my example can be expressed today like this:

// Compiled with -swift-version 6

struct Effect<Fn> {
  let fn: Fn

  init<Input, Result>(fn: Fn) where Fn == (Input) -> Result {
    self.fn = fn
  }

  func call<Input, Result>(arg: Input) -> Result where Fn == (Input) -> Result {
    fn(arg)
  }
}

extension Effect: Sendable where Fn: Sendable {}

@MainActor func use(effect: Effect<@Sendable (Int) -> Void>) {
  Task.detached {
    print(effect)
  }
}

@MainActor func bad(effect: Effect<(Int) -> Void>) {
  Task.detached {
    print(effect) // error: Capture of 'effect' with non-sendable type 'Effect<(Int) -> Void>' in a `@Sendable` closure
  }
}

Probably not worth it to actually write the code this way, but it does accomplish what I was after in my example.

5 Likes

Thank you for the detailed response!