Can I specify that an associatedtype is a function?

I'm trying to create a container to hold a callback which may or may not be async to help avoid duplicating the logic that applies in either case.

Currently I'm storing it in an enum with associated values, but in many cases that forces me to treat it like too much of a "black box" because I know from the context which type it actually is, so I'm trying to find a more elegant solution.

I've started my new approach like this:

public protocol CallType: Sendable {
  associatedtype Callback: Sendable // really it's a function!
}

public enum SyncCall: CallType {
  public typealias Callback = @Sendable () throws -> Void
}
public enum AsyncCall: CallType {
  public typealias Callback = @Sendable () async throws -> Void
}

struct Container<Call: CallType> {
  let description: String
  let block: Call.Callback

  init(_ description: String, executing block: Call.Callback) {
    self.description = description
    self.block = block
  }
}

The catch is I need the executing parameter to be @escaping but the compiler doesn't know that Callback is supposed to be a function type. Is there a way to express this?

I could declare async and non-async versions of the initializer in extensions, so the compiler would know about the concrete function type, but I want to avoid that code duplication, especially since I actually have multiple initializers.

Not directly as there is no "function" protocol to constrain against, but you can do:

struct FBox<F, each A, E, R> where E: Error {
  private var f: F
}

extension FBox where F == @Sendable (repeat each A) throws(E) -> R {
  init(_ f: @escaping F) { ... }
}

extension FBox where F == @Sendable (repeat each A) async throws(E) -> R {
  init(_ f: @escaping F) { ... }
}

(Typed from my phone in a food court, so I have no idea if it'll compile!)

1 Like

Surprisingly I get this error:

@escaping attribute only applies to function types

even though in the extension it's clear that F is a function type.

It's weird because as I said I can duplicate my initializer in different extensions like this, and the compiler accepts it:

struct Container<Call: CallType> {
  let description: String
  let block: Call.Callback
}

extension Container where Call == SyncCall {
  // no problem with @escaping:
  init(_ description: String, executing block: @escaping Call.Callback) {
    self.description = description
    self.block = block
  }
}

extension Container where Call == AsyncCall {
  init(_ description: String, executing block: @escaping Call.Callback) {
    self.description = description
    self.block = block
  }
}

@escaping is unnecessary except when a function type is the immediate type of a function parameter. A function type in any other position is implicitly assumed to be @escaping.

6 Likes

Even if this could be expressed, there would be no way to call a function that is dynamically either sync or async, because their calling conventions are rather different. Since a sync function can be converted into an async function, do you really need both initializers?

If I modify my code above to take out @escaping, then I get an error that block is non-escaping.

I'm not trying to have the call work dynamically; I know that's not really doable. But there is some logic that I'm using in both async and non-async scenarios that I'm trying to avoid duplicating.

Specifically, I'm working on a testing library that gets used like this:

func testSomething()
{
  spec { // this takes a result builder
    beforeEach {
      // initialization
    }
    it("does the thing") {
      // verify
    }
    it("does another thing")  {
      // verify
    }
  }
}
func testAsyncThings() async
{
  await spec {
    it("does something asynchronously") {
      // you can await stuff in here
    }
  }
}

I want to use the same logic (like running the beforeEach block for each test element) without having to duplicate it all in async and non-async versions of the test runner function.

I don't believe either the compiler or Swift Testing would introduce a hang here. The two examples appear to be equivalent.

That's fair enough. I should have tested it before I replied. It does appear to be hanging with the Swift 6.1 toolchain, although it doesn't appear #expect() has anything to do with it. I don't see an obvious root cause.

Any chance you filed an issue?

Edit: We can simplify the failure case to just:

let function2 = Container { @Sendable () async in
  1
}
await function2()
1 Like

So it sounds like the answer is no, there is no way to specify that an associatedtype is a function type.

Would it make sense to add a built-in protocol that all function types conform to, to make this possible? Or would this be considered too obscure of a use case?

What requirements would such a protocol declare?

1 Like

For my purposes at least, I just need it to express that it's a function type, so that I can use it with @escaping.

This sounds like a misunderstanding unfortunately. Non-escaping function types are not “first-class” and they cannot be abstracted over by a type parameter in the language, so it is never necessary or even meaningful to apply @escaping to a type parameter.

As I mentioned above, what I have now is something like this:

public protocol CallType: Sendable {
  associatedtype Callback: Sendable
}

public enum SyncCall: CallType {
  public typealias Callback = @Sendable () throws -> Void
}
public enum AsyncCall: CallType {
  public typealias Callback = @Sendable () async throws -> Void
}

struct Container<Call: CallType> {
  let description: String
  let block: Call.Callback

  init(_ description: String, executing block: @escaping Call.Callback)
       where Call == SyncCall {
    self.description = description
    self.block = block
  }
  init(_ description: String, executing block: @escaping Call.Callback)
       where Call == AsyncCall {
    self.description = description
    self.block = block
  }
}

@escaping is required there since I'm storing the callback, but I can only use it if the type I'm talking about is known by the compiler to be a function type. Thus I have to have two copies of the initializer, differing only in the where clause, so the compiler knows I'm talking about SyncCall.Callback and AsyncCall.Callback which are function types.

But if we had a built-in protocol for that - let's pretend it's called FunctionType - then I'm thinking I could do it this way:

public protocol CallType: Sendable {
  associatedtype Callback: Sendable, FunctionType
}

// same SyncCall and AsyncCall as above

struct Container<Call: CallType> {
  let description: String
  let block: Call.Callback

  // Just one version of the initializer because Call.Callback conforms
  // to FunctionType and so @escaping can be applied.
  init(_ description: String, executing block: @escaping Call.Callback) {
    self.description = description
    self.block = block
  }
}
1 Like

You don’t need to write @escaping here. It doesn’t mean anything when applied to a type parameter.

And yet the compiler complains:

Passing non-escaping parameter 'block' to function expecting an @escaping closure

Can you show the exact signature of the function that's making that complaint?

Remove @escaping from my first code block above.

Oh wait, if I also remove the where clause the error goes away. So I guess you're right...

1 Like