[Pitch] Conceptual Consistency for `callAsFunction`

Hello! I haven't participated on the Swift forums much, so please excuse me if I unwittingly break some unwritten rule or something.

Ever since the introduction of callAsFunction with SE-0253, I've felt that it breaks certain conceptual consistency in Swift's syntax. Specifically:

  • It adds a magic function name
  • It introduces inconsistency between the different "callables" in the language

Let's go into more depth for each of these issues.

Magic Function Name

Take a look at this hypothetical structure:

struct MyStruct {
    let myArray: [Int]
    init() {
        self.myArray = [1, 2, 3]
    }
    subscript(_ index: Int) -> Int {
        return self.myArray[index]
    }
    func callAsFunction() {
        print(myArray)
    }
}

MyStruct obviously doesn't do anything useful, but it illustrates well the magic-function-name issue. All three of init(), subscript(_:), and callAsFunction() are blocks of code at the top level of the structure that the compiler lets you call in special ways: init() is executed when you instantiate the structure with something like let myStruct = MyStruct(), subscript(_:) is executed when you use the subscript syntax on instance of the structure with something like let myValue = myStruct[1], and callAsFunction() is executed when you, well, call an instance of the structure as if it were a function with something like myStruct(). The invocation syntax for all three is different from the standard syntax for invoking a method on an instance (or on a type in the case of static methods). Yet, while the declaration syntax for init() and subscript(_:) clearly indicates that they're not used like regular functions by omitting the func keyword, callAsFunction() has no such indicator. Indeed, the only thing that makes callAsFunction()'s declaration different from that of any standard, non-magic function is the fact that it happens to use the identifier "callAsFunction". There's no grammatical indication that it's compiler magic.

I've thought of two possible solutions for this, one of which is, I think, clearly better than the other. I'll explain the less preferable one first.

Magic Protocol

One possible solution could be to introduce a compiler-magic protocol, perhaps CallableAsFunction. It's normal for protocol conformance to add extra "features" to a type, especially for protocols that come with predefined extensions. Here's how "conformance" to CallableAsFunction might look:

struct MyStruct: CallableAsFunction {
    func callAsFunction() { ... }
}

It's as if callAsFunction() were a named requirement of CallableAsFunction.

There are two main problems with this solution:

  • The protocol is still magic
  • It behaves differently than non-magic protocols with overloading

That second problem is particulaly, well, problematic. Today, you can overload callAsFunction() as much as you want with different parameter and generic combos. If CallableAsFunction weren't magic, then it would have to name infinitely many optional method requirements. This is obviously absurd, and that's without even getting into the fact that normal Swift protocols can't have optional requirements at all, let alone an infinite number of them. This is why I don't think that it's the right solution.

Syntax Unification

The better solution, in my opinion, is just to unify callAsFuntion with the init/subscript syntax. It could look something like this:

struct MyStruct {
    call(_ param1: Int, param2: String) { ... }
}
let myStruct = MyStruct()
myStruct(42, param2: "The meaning of life")

This makes it obvious that it's not any ordinary function, and the omission of the func keyword that indicates that fact is consistent with init and subscript.

Inconsistency between "Callables"

This is the more complex issue. Let's assume for the sake of clarity in this section that neither of the proposals in the "Magic Function Name" section have actually been implemented in the language. Take a look at this code:

struct MyFunctor {
    func callAsFunction() { ... }
}
typealias MyFunction = () -> Void
let myFunctor = MyFunctor()
let myFunction: MyFunction = { ... }
myFunctor.callAsFunction() // Works
myFunction.callAsFunction() // Compiler error

The fact that myFunctor.callAsFunction() is perfectly legal but myFunction.callAsFunction() isn't means that "under the hood", there are at least two distinct ways for a type to have its instances be callable. I think that this is a problem: calling something should be a singular concept, not two separate concepts. The most elegant way to resolve this issue would be to treat function types like () -> Void as functors themselves that provide a callAsFunction implementation that invokes the function's actual body. This could be—and probably would have to be—done entirely with compiler magic, but that's fine because it would mean that there's now only one conceptual way for something to be callable.

Conclusion

So, what do you think? Are these good ideas? Should I submit them as Swift Evolution proposals? If so, then should I combine them into one proposal or should I submit them separately? I would greatly appreciate any feedback or constructive criticism that you might have. Thanks for taking the time to read through this!

8 Likes

Your first points (before "Inconsistency between 'Callables'") were, I believe, thoroughly discussed in the original reviews -- and some even mentioned in the proposal doc you link. So it seems unlikely for those to change.

The idea of unifying "callable things", on the other hand, is definitely a nice future direction for this feature!

2 Likes

The call(...) declaration syntax was in fact our original pitch (Pitch: Introduce (static) callables). We took feedback from the Core Team and switched to callAsFunction(). Along with other alternatives it was discussed in this section of Alternatives Considered: Alternative ways to declare call-syntax delegate methods.

8 Likes

Thanks for the responses! I indeed saw that the call(...) syntax was discussed in the original pitch. I understand that that means that it likely won't change, but I personally still believe that it's better than the current syntax, so I wanted to take the opportunity to make the case for it again.

Maybe I just haven't come across it yet, but I haven't seen any other discussion about what I wrote in the "Inconsistency between 'Callables'" section. That, I believe, is a new idea that could make the language better. Let me know what you think!

1 Like

What concrete use case are you trying to solve?

Indeed; please do push this forward.