Introduce User-defined “Dynamic Member Lookup” Types
01 何が問題だったのか
Swift は C や Objective-C との相互運用では多大な労力(Clang Importer など)によって「Swift らしい」使い心地を提供してきました。一方で、Python・Ruby・JavaScript など本質的に動的な言語との相互運用は貧弱な状態にありました。
これらの動的言語は、そもそも Swift の静的な型システムとは構造が大きく異なります。たとえば Python には stored property の宣言が存在しないため、任意のメンバを実行時に追加・参照できます。この性質を Swift に持ち込もうとすると、次のような書き方しかできませんでした。
// Swift 側から Python を呼ぶ架空のラッパー(この提案より前)
let pickle = Python.get(member: "import")("pickle")
let file = Python.get(member: "open")(filename)
let blob = file.get(member: "read")()
let result = pickle.get(member: "loads")(blob)
obj.someMember のような Swift ネイティブのドット構文は、その型に someMember が静的に宣言されていない限り使えません。そのため、動的言語のメンバアクセスをラップしようとすると get(member: "...") のような冗長な呼び出しが至るところに現れてしまいます。これは、動的言語のエコシステムが持つ膨大な資産(データサイエンスの Python ライブラリ群など)を Swift から自然に利用するうえで大きな障害でした。
同じ問題は、JSON のように実行時に構造が決まる動的なデータを扱う API でも発生します。たとえば JSON の中を json[0]?["name"]?["first"]?.stringValue のように添字で掘り下げるコードは書けますが、これを json[0]?.name?.first?.stringValue のようにドット構文で書くには、型ごとに個別のメンバ宣言を用意するしかなく、汎用的な仕組みがありませんでした。
動的言語との相互運用のために Clang Importer に相当する仕組みをそれぞれの言語ごとに用意する案も検討されましたが、次のような問題があります。
- Python のようにメンバが宣言されない言語では、結局「未知のメンバに対するドット構文」を受理する仕組みが言語側に必要になる。
- 各動的言語ごとに専用の取り込み機構をコンパイラに組み込むと、Swift コンパイラが際限なく肥大化する。
- 「AnyObject dispatch」のように、すべてを
ImplicitlyUnwrappedOptional経由にする既存のアプローチは型安全性を大きく損なう。
こうした背景から、動的言語や動的データのための最小限かつ型安全な構文拡張として、@dynamicMemberLookup が必要とされました。
02 どのように解決されるのか
型に @dynamicMemberLookup 属性を付け、subscript(dynamicMember:) を定義できるようにします。これにより、その型の値に対するドット構文のメンバアクセスが、静的に宣言されたメンバで解決できなかった場合に、自動的にこの subscript 呼び出しへと書き換えられます。
基本的な仕組み
@dynamicMemberLookup を付けた型では、value.foo のような記述が通常のメンバ解決に失敗したときに、次のように変換されます。
// 書くコード
a = value.someMember
value.someMember = a
mutate(&value.someMember)
// コンパイラが生成する形
a = value[dynamicMember: "someMember"]
value[dynamicMember: "someMember"] = a
mutate(&value[dynamicMember: "someMember"])
ポイントは次の通りです。
- 静的に宣言されたメンバ(プロパティやメソッド)が優先されます。静的に見つからなかったときだけ
subscript(dynamicMember:)が使われます。したがって、型に@dynamicMemberLookupを付けても、既存のドット構文の意味は壊れません。 - 結果が mutable な l-value になるかどうかは、subscript が set を持つかどうかで決まります。set があれば
inout渡しや代入もでき、get のみならイミュータブルなアクセスになります。 @dynamicMemberLookupを付けた型にはsubscript(dynamicMember:)を1つ以上宣言する必要があり、無い場合はコンパイルエラーです。
subscript の書き方
メンバ名の受け取り方は ExpressibleByStringLiteral に適合する任意の型でよく、String のほか StaticString などにもできます。戻り値の型にも制約はなく、通常の値・Optional・ImplicitlyUnwrappedOptional など、API のニーズに応じて選べます。
たとえば Python の値をラップする型を書くと、次のようになります。
@dynamicMemberLookup
struct PyVal {
// ...(Python オブジェクトを保持する実装)
subscript(dynamicMember member: String) -> PyVal {
get {
let result = PyObject_GetAttrString(borrowedPyObject, member)!
return PyVal(owned: result)
}
set {
PyObject_SetAttrString(borrowedPyObject, member,
newValue.borrowedPyObject)
}
}
}
こうしておけば、Swift 側から dog.add_trick("Roll over") のように Python の任意の属性・メソッドへアクセスできます。
JSON のような動的データ構造への応用
用途は動的言語に限りません。たとえば JSON を enum で表現している型に対し、次のように Optional を返す subscript を足すだけで、ドット構文で辿れるようになります。
@dynamicMemberLookup
enum JSON {
case intValue(Int)
case stringValue(String)
case arrayValue([JSON])
case dictionaryValue([String: JSON])
subscript(dynamicMember member: String) -> JSON? {
if case .dictionaryValue(let dict) = self {
return dict[member]
}
return nil
}
}
// 使う側
let name = json[0]?.name?.first?.stringValue
失敗時の扱い方は API 作者が選べます。
Optionalを返す(JSON の例のように、呼び出し側に失敗の処理を強制する)。ImplicitlyUnwrappedOptionalを返す(失敗の可能性を型で示しつつ、呼び出し側に書き下しを強いない)。- 非 Optional を返し、見つからなければ実行時トラップする(Python ラッパーが採る方針の一つ)。
API 進化への注意
@dynamicMemberLookup を持つ型では、ドット構文のメンバアクセスは必ずコンパイルが通ります。そのため、あとから型に静的なメンバを追加すると、それまで動的解決されていた名前が静的メンバに「奪われる」可能性があります。意味のある挙動変化を伴うため、@dynamicMemberLookup は、大量の API を持つ型や API が頻繁に変わる型、名前衝突が起きやすい型には使わない方がよい、とされています。
Future Directions
Python 向けのコード補完は、この提案の範囲外とされています。良好な補完体験には制御フロー解析やヒューリスティクスといった Swift の静的型システムには馴染まない工夫が必要で、SourceKit 側の特別な対応として実現するのが妥当だ、という整理です。あくまで展望であり、この提案で補完体験が保証されるわけではありません。