Swift Digest
SE-0216 | Swift Evolution

Introduce user-defined dynamically “callable” types

Proposal
SE-0216
Authors
Chris Lattner, Dan Zheng
Review Manager
John McCall
Status
Implemented (Swift 5.0)

01 何が問題だったのか

Swift は C / Objective-C との相互運用にきわめて強力ですが、Python・JavaScript・Ruby などの動的言語とのブリッジもますます重要になっています。こうしたブリッジを自然に書くための第一歩として Swift 4.2 では SE-0195 による @dynamicMemberLookup が導入され、foo.bar のようなメンバーアクセスをブリッジ側で動的に解釈できるようになりました。

しかしこれだけでは、動的言語側の「オブジェクトをそのまま呼び出す」パターンを Swift で素直に書くことができません。たとえば Python の Dog クラスを Swift 側から使う場合、@dynamicMemberLookup だけに頼ると次のような書き方になってしまいます。

// import DogModule.Dog as Dog
let Dog = Python.import.call(with: "DogModule.Dog")

// dog = Dog("Brianna")
let dog = Dog.call(with: "Brianna")

// dog.add_trick("Roll over")
dog.add_trick.call(with: "Roll over")

Python 側では単に Dog("Brianna") と書くだけで済む箇所が、Swift では毎回 .call(with:) のようなメソッドを挟む必要があります。構文上の重みが大きいだけでなく、ブリッジであることがコード中に漏れ出してしまい、Swift が大切にしている「読みやすいコード」という価値を損ないます。

Swift の通常の呼び出し構文 foo(bar, baz) が使える相手は、関数型の値(関数・メソッド・クロージャ)とメタタイプ(String(42) のようなイニシャライザ式)に限られており、それ以外の通常の型のインスタンスを「呼び出す」ことはエラーでした。動的言語ブリッジの PythonObject のような型を、あたかも関数のように呼び出す手段が言語側に無かったことが、この問題の本質です。

02 どのように解決されるのか

新しい属性 @dynamicCallable を導入し、struct・class・enum・プロトコルに付与できるようにします。この属性が付いた型のインスタンスは、通常の関数呼び出し構文で呼び出せるようになり、その呼び出しは型が実装する dynamicallyCall メソッドへの呼び出しへと、コンパイラがシンタックスシュガーとして書き換えます。

意味論的なモデルは変わりません。あくまで構文上の糖衣であり、呼び出し結果の型・throws@discardableResult などの挙動は、dynamicallyCall メソッド側の宣言にそのまま従います。

2つの dynamicallyCall メソッド

@dynamicCallable が付いた型は、次のいずれか一方、あるいは両方を実装します。

func dynamicallyCall(withArguments args: <Arguments>) -> <R1>
// <Arguments> は ExpressibleByArrayLiteral に適合する任意の型。
// <Arguments>.ArrayLiteralElement や戻り値 <R1> は任意。

func dynamicallyCall(withKeywordArguments args: <KeywordArguments>) -> <R2>
// <KeywordArguments> は ExpressibleByDictionaryLiteral に適合する任意の型。
// Key は ExpressibleByStringLiteral に適合する型である必要がある。
// Value と戻り値 <R2> は任意。

withArguments: 版は位置引数のみを受け取るシンプルな呼び出しに対応します。withKeywordArguments: 版はラベル付き引数を扱え、ラベルの無い位置引数は空文字列 "" をキーとして渡されます。

重複するキーや位置引数との混在を扱うには Dictionary ではキーが重複できないため、KeyValuePairs を使うのが実用的です。

呼び出しの脱糖

@dynamicCallable 型のインスタンスを関数のように呼び出すと、コンパイラは次のように書き換えます。

a = someValue(keyword1: 42, "foo", keyword2: 19)
// ↓ 次と等価
a = someValue.dynamicallyCall(withKeywordArguments: [
    "keyword1": 42, "": "foo", "keyword2": 19
])

位置引数はキー "" として並べられ、ラベル付き引数はそのラベルをキーとして並べられます。

あいまい性の解決(最も具体的なマッチ)

withArguments:withKeywordArguments: の両方を実装している型では、呼び出しの構文形に応じてコンパイラがより具体的な側を選びます。

  • withArguments: を実装していて、ラベル付き引数が一つも無い呼び出しなら withArguments: を使う。
  • それ以外は withKeywordArguments: を使う。
  • withKeywordArguments: が実装されていないのにラベル付き引数で呼び出すとコンパイルエラー。
@dynamicCallable
struct Callable {
  func dynamicallyCall(withArguments args: [Int]) -> Int { return args.count }
}
let c1 = Callable()
c1()      // c1.dynamicallyCall(withArguments: [])
c1(1, 2)  // c1.dynamicallyCall(withArguments: [1, 2])
c1(a: 1, 2) // error: 'withKeywordArguments:' が無い

@dynamicCallable
struct KeywordCallable {
  func dynamicallyCall(withKeywordArguments args: KeyValuePairs<String, Int>) -> Int {
    return args.count
  }
}
let c2 = KeywordCallable()
c2()         // withKeywordArguments: [:]
c2(1, 2)     // withKeywordArguments: ["": 1, "": 2]
c2(a: 1, 2)  // withKeywordArguments: ["a": 1, "": 2]

@dynamicCallable
struct BothCallable {
  func dynamicallyCall(withArguments args: [Int]) -> Int { return args.count }
  func dynamicallyCall(withKeywordArguments args: KeyValuePairs<String, Int>) -> Int {
    return args.count
  }
}
let c3 = BothCallable()
c3()         // withArguments: []
c3(1, 2)     // withArguments: [1, 2]
c3(a: 1, 2)  // withKeywordArguments: ["a": 1, "": 2]

動的言語ブリッジでの使用例

@dynamicMemberLookup と組み合わせることで、Python や JavaScript のブリッジ型が自然な呼び出し構文を提供できるようになります。

@dynamicCallable @dynamicMemberLookup
struct PythonObject {
  @discardableResult
  func dynamicallyCall(
    withKeywordArguments: KeyValuePairs<String, PythonObject>
  ) -> PythonObject { /* ... */ }

  // 位置引数のみの呼び出しを最適化するために両方実装してもよい。
  @discardableResult
  func dynamicallyCall(withArguments: [PythonObject]) -> PythonObject { /* ... */ }

  subscript(dynamicMember member: String) -> PythonObject { /* ... */ }
}

この型があれば、Python のコードを Swift からほとんど同じ見た目で書けます。

// import DogModule.Dog as Dog
let Dog = Python.import("DogModule.Dog")

// dog = Dog("Brianna")
let dog = Dog("Brianna")

// dog.add_trick("Roll over")
dog.add_trick("Roll over")

// dog2 = Dog("Kaylee").add_trick("snore")
let dog2 = Dog("Kaylee").add_trick("snore")

キーワード引数を持たない JavaScript のブリッジでは withArguments: のみ実装する、逆にラベルを辞書で受け取る Python 風のパターンを模したい場合は withKeywordArguments: を使う、といった選択がブリッジ作者に委ねられます。

制限事項

@dynamicCallable は型の主たる宣言(extension ではない側)にのみ付けられます。これは @dynamicMemberLookup と同じ方針です。

また、インスタンスの呼び出しのみが対象で、static / class メンバの動的呼び出しはスコープ外です(メタタイプの呼び出し構文はイニシャライザとしての意味がすでにあるため)。Smalltalk 系言語のメソッドカリー化もこの proposal には含まれません。

Future Directions

Proposal では、今後の発展の可能性としていくつかの方向が示されています。いずれもこの proposal のスコープ外で、実現が約束されているものではありません。

  • @dynamicMemberCallable: Ruby のようにメソッド名とキーワード引数をまとめて解決する言語向けに、メンバ呼び出しを動的にフックする属性を追加する案。
  • 一般的な callable の糖衣: C++ の operator() のように、固定数・異なる型の引数を受け取る「普通の callable」を Swift でも糖衣として書けるようにする案。variadic generics の整備と合わせて検討される見込みです。