Callable values of user-defined nominal types
01 何が問題だったのか
Swift では、関数呼び出し構文 f(...) を使える値は限られていました。具体的には、関数型の値、型名(T(...) は T.init(...) への糖衣)、そして SE-0216 で導入された @dynamicCallable が付いた型の値です。それ以外の、たとえば「数学的な関数を表す値」や「一つの主要な処理を持つ値」は、いくら本質的に関数として振る舞っていても、専用のメソッドを介してしか呼び出せませんでした。
関数を表す値
たとえば、多項式 2 + 3x + 4x² のような関数そのものを表す型を考えます。
struct Polynomial {
let coefficients: [Float]
}
extension Polynomial {
func evaluated(at input: Float) -> Float {
var result: Float = 0
for (i, c) in coefficients.enumerated() {
result += c * pow(input, Float(i))
}
return result
}
}
let f = Polynomial(coefficients: [2, 3, 4])
print(f.evaluated(at: 2)) // => 24
数学での関数適用は f(x) と書くのが自然ですが、Swift では f.evaluated(at: 2) のように毎回メソッド名を添える必要があります。subscript で f[2] のように書くこともできますが、角かっこは「コレクションへのインデックスアクセス」を連想させ、関数適用の意味とは合いません。
一つの主要な処理を持つ型
ニューラルネットワークのレイヤのように、「入力を受け取って出力を返す」という一つの主要な処理を持つ型もあります。
struct Perceptron {
var weight: Vector<Float>
var bias: Float
func applied(to input: Vector<Float>) -> Float {
return weight • input + bias
}
}
レイヤを組み合わせてモデルを定義する際、applied(to:) を何度も重ねて呼び出すことになり、コードが冗長で読みにくくなります。
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))))
}
}
パーサーコンビネータのような DSL でも、parser.applied(to: text) のような適用を繰り返すことが多く、同じ問題が起こります。これらは本質的に関数なのだから、関数呼び出し構文そのもので書けたほうが自然です。
@dynamicCallable では埋まらない隙間
SE-0216 の @dynamicCallable は、動的言語(たとえば Python)との相互運用のために、引数のアリティ・ラベル・型をすべて実行時に解決する 仕組みです。Python のオブジェクトを Swift から呼び出すといった用途には適しますが、アリティや引数ラベル、型が静的に決まっているユーザ定義型に使うには粒度が合いません。静的に型付けされた「関数的な値」を関数呼び出し構文で呼べるようにする、@dynamicCallable の静的版に相当する仕組みが求められていました。
02 どのように解決されるのか
ベース名が callAsFunction のインスタンスメソッドを持つ値は、関数呼び出し構文 value(...) で呼び出せるようになります。型側に特別な属性やプロトコルを付ける必要はなく、単に callAsFunction という名前のメソッドを定義するだけで、そのメソッドが「関数呼び出しを受けるメソッド」として扱われます。
struct Adder {
var base: Int
func callAsFunction(_ x: Int) -> Int {
return base + x
}
}
let add3 = Adder(base: 3)
add3(10) // => 13
型チェック時に add3(10) は add3.callAsFunction(10) へと書き換えられます。従来、呼び出し式の callee として解決されるのは「関数型の値」「型名」「@dynamicCallable 型の値」の 3 種類でしたが、そこに 4 つ目の種類として「callAsFunction メソッドを持つ値」が加わる、という位置づけです。
オーバーロードと通常のメソッドとしての性質
callAsFunction は特別な宣言種別ではなく、ただの名前付きメソッド です。そのため、オーバーロード、ジェネリクス、throws、引数ラベルなど、通常のメソッドと同じ機能がそのまま使えます。
struct Adder {
var base: Int
func callAsFunction(_ x: Int) -> Int {
return base + x
}
func callAsFunction(_ x: Float) -> Float {
return Float(base) + x
}
func callAsFunction<T>(_ x: T, bang: Bool) throws -> T where T: BinaryInteger {
if bang {
return T(Int(exactly: x)! + base)
} else {
return T(Int(truncatingIfNeeded: x) + base)
}
}
}
let add1 = Adder(base: 1)
add1(2) // => 3
try add1(4, bang: true) // => 5
引数が合わないときのエラーメッセージは通常の関数呼び出しと同じ形式で表示され、曖昧な場合はオーバーロード候補が示されます。
通常のメソッドなので、メソッド参照の仕組みもそのまま使えます。callAsFunction を直接参照すれば、self をキャプチャしたクロージャとして取り出すことができます。
let add1 = Adder(base: 1)
let f1: (Int) -> Int = add1.callAsFunction
let f2: (Float) -> Float = add1.callAsFunction(_:)
let f3: (Int, Bool) throws -> Int = add1.callAsFunction(_:bang:)
callAsFunction メソッドは型の本体だけでなく extension でも追加できるため、既存の型にあとから呼び出し可能性を与えることもできます。
モデルやパーサーでの使い方
冒頭で挙げたニューラルネットワークのモデル定義は、applied(to:) を callAsFunction に置き換えるだけで、レイヤ適用の連鎖がそのまま関数合成のように書けるようになります。
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 callAsFunction(_ input: Tensor<Float>) -> Tensor<Float> {
return dense(flatten(maxPool(conv(input))))
}
}
let model: Model = ...
let ŷ = model(x)
パーサーの場合も同様で、parser("(+ 1 2)") のように関数そのものを呼び出す感覚で書けます。
@dynamicCallable との併用
ひとつの型が callAsFunction メソッドと @dynamicCallable 属性の両方を持つこともできます。呼び出し式の解決は次の優先順で行われます。
- 通常の関数呼び出し・イニシャライザ呼び出し
callAsFunctionメソッドの呼び出し@dynamicCallableによる動的呼び出し
静的に解決できるものが優先され、そこで解決できない場合のみ動的呼び出しにフォールバックします。
関数型への暗黙変換は含まれない
callAsFunction メソッドを持つ値は、関数呼び出し構文で呼べるだけであり、関数型の値へ暗黙に変換されるわけではありません。たとえば次のような代入はできません。
let h: (Int) -> Int = add1 // NG: 暗黙変換はされない
関数として取り回したい場合は、前述のとおり add1.callAsFunction のようにメソッド参照を使って明示的にクロージャを取り出します。関数型への暗黙変換や、as (Int) -> Int による明示変換、関数型を制約として使うといった拡張は Future Directions として言及されていますが、このProposalのスコープ外であり、将来に向けた見通しに留まります。