Skip to content

Instantly share code, notes, and snippets.

@rxwei
Last active March 20, 2019 06:06
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rxwei/5986ab3c9d705f0560e8532d0650c7db to your computer and use it in GitHub Desktop.
Save rxwei/5986ab3c9d705f0560e8532d0650c7db to your computer and use it in GitHub Desktop.
Callable

Hi all, @dan-zheng and I wrote a proposal to introduce static callables to Swift. This proposal is also available as a gist here. We'd love to hear your feedback.

Introduce callables

  • Proposal: SE-NNNN
  • Authors: Richard Wei, Dan Zheng
  • Review Manager: TBD
  • Status: Implementation in progress

Introduction

This proposal introduces callables to Swift. Values that behave like functions can take advantage of this syntactic sugar, to look like a function call when they are called.

In a nutshell, we propose to introduce a new declaration syntax with the keyword call:

struct Adder {
    var base: Int
    call(_ x: Int) -> Int {
        return base + x
    }
}

Values that have a call member can be applied like a function, forwarding arguments to the call member.

let add3 = Adder(base: 3)
add3(10) // => 13

Motivation

Functions in programming languages take various forms: named ones, anonymous ones, ones that capture variables, etc. In Swift, the function declaration syntax and the closure expression syntax are flexible enough for many use cases. However, values of a non-function type that behaves like a function are sometimes hard to design or use in the following ways:

  • When values of a type behave like a function, it is not easy to name their "apply" method.
  • When "applying" a value of a non-function type that behaves like a function, we need to call a method, which does not feel first-class given the function-like role of the value.

Here are some concrete sources of motivation.

Parameterized functions

Machine learning models often represent a function that contains an internal state called "parameters", and the function takes an input and predicts the output. In code, models are often represented as a data structure that stores parameters, and a method that defines the transformation from an input to an output using stored parameters. Here’s an example:

struct Perceptron {
    var weight: Vector<Float>
    var bias: Float

    func applied(to input: Vector<Float>) -> Float {
        return weight  input + bias
    }
}

Stored properties weight and bias are considered as model parameters, and are used to define the transformation from model inputs to model outputs. Models can be trained, during which parameters like weight are updated, thus changing the behavior of applied(to:). When a model is used, the call site looks just like a function call.

let model: Perceptron = ...
let ŷ = model.applied(to: x)

Many deep learning models are composed of layers, or layers of layers. In the definition of those modules, repeated calls to applied(to:) significantly complicates the look of the program.

struct Model {
    var conv = Conv2D<Float>(filterShape: (5, 5, 3, 6))
    var maxPool = MaxPool2D<Float>(poolSize: (2, 2), strides: (2, 2))
    var flatten = Flatten<Float>()
    var dense = Dense<Float>(inputSize: 36 * 6, outputSize: 10)

    func applied(to input: Tensor<Float>) -> Tensor<Float> {
        return dense.applied(to: flatten.applied(to: maxPool.applied(to: conv.applied(to: input))))
    }
}

Repeated calls to applied(to:) harms clarity and makes code less readable. If model could be called like a function, like what it represents mathematically, the definition of Model becomes much shorter and more concise. The proposed feature makes it possible to promote clear usage by omitting needless words.

struct Model {
    var conv = Conv2D<Float>(filterShape: (5, 5, 3, 6))
    var maxPool = MaxPool2D<Float>(poolSize: (2, 2), strides: (2, 2))
    var flatten = Flatten<Float>()
    var dense = Dense<Float>(inputSize: 36 * 6, outputSize: 10)

    call(_ input: Tensor<Float>) -> Tensor<Float> {
        return dense(flatten(maxPool(conv(input))))
    }
}

let model: Model = ...
let ŷ = model(x)

There are a lot more ways to simplify the model definition even further, but making a model callable like a function is a great first step.

Domain specific languages

DSL constructs like string parsers represent a function from inputs to outputs. Parser combinators are often implemented as a higher-order function that operate on parser values, which are themselves a data structure—some implementations store a closure, while some other efficient implementations store an expression tree. They all have an “apply”-like method that performs an application of the parser (i.e. parsing).

struct Parser<Output> {
    // Stored state...

    func applied(to input: String) throws -> Output {
        // Using the stored state...
    }

    func many() -> Parser<[Output]> { ... }
    func many<T>(separatedBy separator: Parser<T>) -> Parser<[Output]> { ... }
}

When using a parser, one would need to explicitly call applied(to:), but this is a bit cumbersome—the naming this API often repeats the type. Since parsers are like functions, it would be cleaner if the parser itself were callable.

call(_ input: String) throws -> Output {
    // Using the stored state...
}
let sexpParser: Parser<Expression> = ...
sexpParser("(+ 1 2)")

A static counterpart to @dynamicCallable

SE-0216 introduced user-defined dynamically callable values. In its alternatives considered section, it was requested that we design and implement the 'static callable' version of this proposal in conjunction with the dynamic version proposed. See its pitch thread for discussions about “static callables”.

Prior art

Many languages offer the “callable” syntactic sugar:

Unifying structural types and nominal types

The long term goal with the type system is to unify structural types (functions, tuples, etc) and nominal types, to allow structural types to conform to protocols and have members. When function types can have members, it will be most natural for them to have a call member, which can help unify the compiler's type checking rules for call expressions.

Proposed design

We propose to introduce a new keyword call and a new declaration syntax–the call declaration syntax.

struct Adder {
    var base: Int
    call(_ x: Int) -> Int {
        return base + x
    }
}

Values that have a call member can be called like a function, forwarding arguments to the call member.

let add3 = Adder(base: 3)
add3(10) // => 13

Detailed design

call member declarations

call members can be declared in structure types, enumeration types, class types, protocols, and extensions.

A call member declaration is similar to subscript in these ways:

  • It does not take a name.
  • It must be an instance member of a type.

But it is more similar to a func declaration in that:

  • It does not allow get and set declarations inside the body.
  • When a parameter has a name, it is treated as the argument label.
  • It can throw.

The rest of the call declaration grammar and semantics is identical to that of function declarations–same syntax for access level, generics, argument labels, return types, throwing, mutating, where clause, etc. They can be overloaded based on argument types.

When the call keyword is escaped, it can be used as a normal identifier such as a function name. This is the same as init.

call-declaration → call-head generic-parameter-clause? function-signature generic-where-clause? function-body?
call-head → attributes? declaration-modifiers? 'call' generic-parameter-clause 

Examples

struct Adder {
    var base: Int

    call(_ x: Int) -> Int {
        return base + x
    }

    call(_ x: Float) -> Float {
        return Float(base) + x
    }

    call<T>(_ x: T, bang: Bool) throws -> T where T: BinaryInteger {
        if bang {
            return Int(exactly: x)! + base
        } else {
            return Int(truncatingIfNeeded: x) + base
        }
    }
   
    // This is a normal function, not a `call` member.
    func `call`(x: Int) {}
}

Call expressions

When type-checking a call expression, the type checker will try to resolve the callee. Currently, the callee can be a function, a type name, or a value of a @dynamicCallable type. This proposal adds a fourth kind of a callee: a value with a matching call member.

let add1 = Adder(base: 1)
add1(2) // => 3
try add1(4, bang: true) // => 5

When type-checking fails, the error message looks like function calls’. When there’s ambiguity, the compiler will show relevant call member candidates.

add1("foo") // error: cannot convert value of type 'String' to expected argument type 'Int'
add1(1, 2, 3) // error: `add1` does not have a `call` member that takes an argument list of type (Int, Int, Int)

When the type is also @dynamicCallable

A type can both have call members and be declared with @dynamicCallable. When type-checking a call expression, the type checker will first try to resolve the call to a function or initializer call, then a call member call, and finally a dynamic call.

Direct reference to a call member

Like methods and initializers, a call member can be directly referenced, either through the base name and the contextual type, or through the full name.

let add1 = Adder(base: 1)
let f: (Int) -> Int = add1.call
f(2) // => 3
let g: (Int, Bool) -> Int = add1.call(_:bang:)
g(4, true) // => 5

A value cannot be implicitly converted to a function when the destination function type matches the type of the call member.

let h: (Int) -> Int = add1 // error: cannot convert value of type `Adder` to expected type `(Int) -> Int`

Implicit conversions are generally problematic in Swift, and as such we would like to get some experience with this base proposal before even considering adding such capability.

Source compatibility

The proposed feature adds a call keyword, which will require existing identifiers named "call" to be escaped. The compiler will produce a warning and a fix-it to help migrate the code.

test.swift:3:6: warning: keyword 'call' should be used as an identifier here
func call() {}
     ^~~~
test.swift:3:6: note: if this name is unavoidable, use backticks to escape it
func call() {}
     ^~~~
     `call`

Effect on ABI stability

This proposal is about a syntactic sugar and has no ABI breaking changes.

Effect on API resilience

This proposal is about a syntactic sugar and has no API breaking changes.

Alternatives considered

Use an attribute to mark callable methods

struct Adder {
    var base: Int
    @callableMethod
    func addWithBase(_ x: Int) -> Int {
        return base + x
    }
}

This approach achieves a similar effect as call declarations, except that methods can have a custom name and be directly referenced by that name. However, we feel that this customization introduces more noise than usefulness. It is also not consistent with prior art in other languages, where callable methods are expected to have a particular name (e.g. def __call__ in Python).

Use a type attribute to mark types with callable methods

@staticCallable // similar to `@dynamicCallable`
struct Adder {
    var base: Int
    // Informal rule: all methods with a particular name (e.g. `func call`) are deemed callable methods.
    func call(_ x: Int) -> Int {
        return base + x
    }
}

We feel this approach is not ideal because:

  • A marker type attribute is not particularly meaningful. Callable methods are what make callable types “callable” - the attribute means nothing by itself. In fact, there’s an edge case that needs to be explicitly handled: if a @staticCallable type defines no call methods, an error must be emitted.
  • The name for callable methods (e.g. func call) is not first-class in the language, while their call site syntax is.

Use a Callable protocol to represent callable types

// Compiler-known `Callable` marker protocol.
// This is similar to `StringInterpolationProtocol`.
struct Adder: Callable {
    var base: Int
    // Informal rule: all methods with a particular name (e.g. `func call`) are deemed callable methods.
    func call(_ x: Int) -> Int {
        return base + x
    }
}

We feel this approach is not ideal for the same reasons as the marker type attribute. A marker protocol by itself is not meaningful and the name for callable methods is informal. Additionally, protocols should represent particular semantics, but “callable” behavior has no inherent semantics.

In comparison, call declarations have a formal representation in the language and exactly indicate callable behavior (unlike a marker attribute or protocol).

Property-like call with getter and setter

In C++, operator() can return a reference, which can be used on the left hand side of an assignment expression. This is used by some DSLs such as Halide:

Halide::Func foo;
Halide::Var x, y;
foo(x, y) = x + y;

This can be achieved via Swift’s subscripts, which can have a getter and a setter.

foo[x, y] = x + y

Since the proposed call declaration syntax is like subscript in many ways, it’s in theory possible to allow get and set in a call declaration’s body.

call(x: T) -> U {
    get {
        ...
    }
    set {
        ...
    }
}

However, we do not believe call should behave like a storage accessor like subscript. Instead, call’s appearance should be as close to function calls as possible. Function call expressions today are not assignable because they can't return an l-value reference, so a call to a call member should not be assignable either.

Static call members

Static call members could in theory look like initializers at the call site.

extension Adder {
    static call(base: Int) -> Int {
        ...
    }
    static call(_ x: Int) -> Int {
        ...
    }
}
Adder(base: 3) // error: ambiguous static member; do you mean `init(base:)` or `call(base:)`?
Adder(3) // okay, but looks really like an initializer.

We believe that the initializer call syntax in Swift is baked tightly into programmers' mental model, and thus do not think overloading that is a good idea.

We could also make it so that static call members can only be called via call expressions on metatypes.

Adder.self(base: 3) // okay

But since this would be an additive feature on top of this proposal and that subscript cannot be static yet, we'd like to defer this feature to future discussions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment