SE-0253: Callable values of user-defined nominal types

Overall response:

+0.5

I like the idea and the direction, but have specific feedback. I think that with some tweaks, this could be a nice addition to Swift, but I do not agree with its present form.


  • There are a few situations where this is a pretty “nice” feature to have. The Polynomial example given is a good one. Personally, I’ve come across others such as an Expression, a Filter, a Sorter, and a Mapper.

  • The BoundClosure example is a bit weird, mainly because the property is called value. I would expect the value of the closure to be what it evaluates to, not what the parameter is. It took me several readings to understand what this would really buy me.

  • I am glad that this is rejecting the idea of callable static members. Thank you for keeping initialization syntax mostly unambiguous

Specific Criticism:

I do not think this should be a method. In all of the cases, it is better from an API perspective to offer both an “evaluate” method (or calculate or apply or parse, as the document correctly identifies) and the call functionality. There are definitely times where contextual readability would prefer one over the other.

From an API-maintainability perspective then, it is unfortunate to have to implement bothevaluate” and call, and to implement one in terms of the other. This increases API surface area, which increases maintenance effort, and increases the likelihood of bugs or incomplete features. This is especially evident in the Adder example. The more complete and developer-friendly API of Adder, under the current proposal, would have that API be:

struct Adder {
    var base: Int
    
    func adding(_ value: Int) -> Int { 
        return base + value 
    }
    
    func adding(_ value: Float) -> Float { 
        return Float(base) + value 
    }
    
    func adding<T>(_ value: T, bang: Bool) -> T where T: BinaryInteger {
        if bang {
            return T(Int(exactly: x)! + base)
        } else {
            return T(Int(truncatingIfNeeded: x) + base)
        }
    }
    
    func call(_ value: Int) -> Int { 
        return adding(value)
    }
    
    func call(_ value: Float) -> Float { 
        return adding(value)
    }
    
    func call<T>(_ value: T, bang: Bool) -> T where T: BinaryInteger {
        return adding(value, bang: bang)
    }
}

If I, as the maintainer of Adder, decide to add a new adding method to support adding a new BigInt, then I have to remember to also add a corresponding call() method. This is needlessly verbose and is a very error-prone experience. Look at how many situations we have here in the forums where we have to go back and add in this little extension here or there because we added a feature in one place, but didn't account for all of the other situations where we'd want something similar. Directly callable methods will compound that problem.

Instead, it is far simpler to implement the functionality once, and then decorate the method with a @callable attribute. There's far less concern for incomplete API, as we only have to implement the functionality once and not have to also provide a shadow call() version.

Using a compiler attribute also eliminates any extra work needed to support a direct reference to the call method, because you would just use a bound instance method, like we already do:

let add1 = Adder(base: 1)
let f1: (Int) -> Int = add1.adding(_:)

I realize that the compiler attribute is discussed and dismissed under the "alternatives considered" section, for two reasons, which I'll reproduce here:

  1. First, we feel that using a @callableMethod method attribute is more noisy, as many callable values do not need a special name for its call-syntax delegate methods.

  2. Custom names often involve argument labels that form a phrase with the base name in order to be idiomatic. The grammaticality will be lost in the call syntax when the base name disappears.

The rationale for the first one is directly contradicted by discussion previously in the document, where pretty much every situation where this would be really useful has been shown to have a usefully "named" counterpart: evaluate, apply, parse, calculate, and so on. Proper and considerate API design would dictate that, as much as possible, an API should provide enough flexibility to allow clients to use it in their own, readable without without compromising maintainability. Thus, we should expect that all implementations of this feature should offer both versions: a named implementation and a callable implementation. If we accept that premise, then we accept making this feature be a call() method is asking API maintainers to duplicate their work.

The second reason is a little bit more interesting, but I also believe it to be a red herring. Like with @dynamicMemberLookup and @dynamicCallable, this is not a feature we expect many developers to be implementing haphazardly in codebases. Because this is a targeted feature, the "grammaticality" seems like a non-issue. If a situation truly arises where a @callable method is grammatically awkward, then a conscientious and considerate API producer would either choose to make the method not @callable, or expose a @callable version that is more grammatically correct.

On top of that, the contrived example of a callable Layer is so alien to the rest of the document that it seems entirely out-of-place. Of course it sounds awkward, because "applying a layer to an int" is itself completely non-sensical without a much larger context.

Of all the reasonable examples given in the document (expressions, polynomials, parsers, etc), it is easy to imagine what a method on those would look like that would be unambiguous. In many cases, the presence of the labels would increase readability.

The Polynomial example is a great one:

struct Polynomial {
    var coefficients: [Float]
    
    @callable
    func evaluate(at x: Float) -> Float { ... }
}

let curve = Polynomial(...)
let value = curve(at: 42)

Thus, I believe the dismissal of the @callable (or @callableMethod or whatever it is bikeshedded to be) is premature.

I do recognize that the core team expressed that they:

[do] not want to support user-defined unique names for callable declarations (e.g. allow call func myCallable(){}): this would complicate the internals of the compiler (including overload resolution) and thus the core team would like there to be a single (language defined) name.

To this, my only response is:

Swift exists for developers. Developers do not exist for Swift.

I believe that making this be a separate method from all existing functionality on a type is an overly burdensome requirement to place on all developers wanting to implement this feature that outweighs the complexity these additions would add to the internals of the compiler.

8 Likes