Swift Digest
SE-0385 | Swift Evolution

Custom Reflection Metadata

Proposal
SE-0385
Authors
Pavel Yaskevich, Holly Borla, Alejandro Alonso, Stuart Montgomery
Review Manager
Doug Gregor
Status
Returned for revision

01 何が問題だったのか

ライブラリの中には、クライアントコードの中の特定の宣言(型・関数・プロパティなど)にライブラリ側でマークを付けてもらい、それを実行時に発見して使いたい、というものがあります。代表例がテストです。XCTestでは、テストメソッドを NSObject サブクラス上に定義し、名前に test プレフィックスを付けるという暗黙の約束でテストを自動発見しています。このアプローチには次のような弱点があります。

  • 命名規則に縛られるため冗長で、うっかり別のメソッドに test プレフィックスを付けると意図せずテスト扱いになってしまいます。
  • 個々のテストに対して「有効/無効」や要件などのメタデータを添える方法がありません。
  • 実行時の発見手段が言語側に無いため、Swift Package Managerのようなツールが独自の発見ロジックを持たねばならず、別のテストライブラリへの対応が難しくなっています。

同じ「宣言を登録して後で発見する」というパターンは、プラグインアーキテクチャなど、テスト以外の領域でも現れます。クライアントに「全プラグイン型の一覧」を明示的に渡してもらう従来のやり方は、書き忘れやすく、ボイラープレートが増えがちです。

また、宣言に宣言固有のメタデータを付ける用途では、property wrapper が近い役割を担ってきました。たとえばRealmの @Persisted property wrapper は、宣言ごとに固定の情報(たとえば @Persisted(named: "CustomName") のようなカラム名)を持たせるのに使えますが、property wrapper はインスタンスごとにバッキングストレージを確保するため、宣言ごとに固定の値を保持する用途にはオーバーヘッドが大きすぎます。値は宣言ごとに一定なのに、格納型のインスタンスごとに何度も評価・保持されてしまうのです。

これらに共通するのは、「宣言に対してライブラリ定義のメタデータを付け、後からまとめて問い合わせたい」というニーズが言語機能として用意されていないことでした。

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

新しい組み込み属性 @reflectionMetadata を導入し、任意の nominal type(struct / enum / class / アクター)をカスタム属性として使える reflection metadata 型に昇格できるようにします。あわせてReflectionモジュールに、同じメタデータ型が付与された宣言を実行時にまとめて取得するAPIを追加します。

メタデータ型の宣言

@reflectionMetadata を付けた型は、init(attachedTo:) という特別な形のイニシャライザを1つ以上持ちます。この attachedTo: 引数の型が、その属性を付けられる宣言の種類を決めます。対応する宣言の種類と、コンパイラが合成する第1引数は次の通りです。

  • 型: メタタイプ(T.Type
  • グローバル関数: 未適用の関数参照
  • 静的メソッド: (T.Type, Args) -> Result 形の関数(メタタイプを受け取って呼ぶクロージャ)
  • インスタンスメソッド: (T, Args) -> Resultmutating の場合は (inout T, Args) -> Result
  • インスタンスプロパティ: key-path(KeyPath<T, V>
@reflectionMetadata
struct Flag {
  init<T>(attachedTo: T.Type) { /* ... */ }
  init<Args, Result>(attachedTo: (Args) -> Result) { /* ... */ }
  init<T, Args, Result>(attachedTo: (T.Type, Args) -> Result) { /* ... */ }
  init<T, Args, Result>(attachedTo: (T, Args) -> Result) { /* ... */ }
  init<T, Args, Result>(attachedTo: (inout T, Args) -> Result) { /* ... */ }
  init<T, V>(attachedTo: KeyPath<T, V>, custom: Int) { /* ... */ }
}

使う側は、この Flag をそのまま属性として書きます。コンパイラが宣言の種別に応じたオーバーロードを選び、第1引数を自動で渡します。追加の引数があれば、属性の引数としてそのまま書けます。

// Flag.init(attachedTo: doSomething)
@Flag func doSomething(_: Int, other: String) {}

// Flag.init(attachedTo: Test.self)
@Flag
struct Test {
  // Flag.init(attachedTo: { metatype in metatype.computeStateless() })
  @Flag static func computeStateless() {}

  // Flag.init(attachedTo: { instance, values in instance.compute(values: values) })
  @Flag func compute(values: [Int]) {}

  var state = 1

  // Flag.init(attachedTo: { (instance: inout Test) in instance.incrementState() })
  @Flag mutating func incrementState() { state += 1 }

  // Flag.init(attachedTo: \Test.answer, custom: 42)
  @Flag(custom: 42) var answer: Int = 42
}

適用のルール

1つの宣言に複数の reflection metadata 属性を付けることはできますが、同じ型の属性を重ねて付けることはできません@Flag @Flag はエラー)。

属性は、対象型の主宣言か、同一モジュール内の unavailable かつ無制約な extension のいずれかで付与する必要があります。別モジュールの extension や制約付き extension での付与は、同じ型に対して複数のメタデータが付くのを防ぐため禁止されます。unavailable extension を許すのは、APIの実装者が特定の属性を明示的にオプトアウトできるようにするためです。

@available(*, unavailable)
@Flag extension MyType { } // 同一モジュール内ならOK

また、属性の対象となる宣言は完全に具体化されている必要があります。ジェネリックなプロパティや、ジェネリックな型の中にある(型パラメータに依存する)宣言には付けられません。具体化された extension の中であればOKです。これは、ジェネリックな値は実行時に必ず具体化された状態で存在し、属性のクエリで「型パラメータのないまま」列挙する手段が無いためです。

struct GenericType<T> {
  @Flag var genericValue: T // エラー
}

extension GenericType where T == Int {
  @Flag var concreteValue: Int // OK
}

プロトコルへの付与と推論

reflection metadata 属性はプロトコルにも付けられます。この場合、適合型の主宣言に属性が自動的に推論されます。

@EditorCommandRecord
protocol EditorCommand { /* ... */ }

// @EditorCommandRecord が推論される
struct SelectWordCommand: EditorCommand { /* ... */ }

ただし、プロトコル適合が extension で書かれている場合は推論されません(プロトコルに属性が付いている以上、適合は実質的に要件の一部とみなされるため、主宣言に明示的な属性が無ければエラーになります)。プロトコルに付けた属性には追加の引数を書けず、追加値が必要なら適合側で明示的に書き直します。

// 推論された @EditorCommandRecord を上書きして引数を渡す
@EditorCommandRecord(keyboardShortcut: "j", modifier: .command)
struct SelectWordCommand: EditorCommand { /* ... */ }

実行時の取得

Reflectionモジュールに、属性のインスタンスを全モジュール横断で列挙する Attribute.allInstances(of:) が追加されます。

public enum Attribute {
  public static func allInstances<T>(of type: T.Type) -> AttributeInstances<T>
}

public struct AttributeInstances<T>: Sequence, IteratorProtocol {
  public mutating func next() -> T?
}

この問い合わせが呼ばれて初めて、各 init(attachedTo:) が遅延評価され、インスタンスが生成されます。property wrapper のように格納型のインスタンスごとに評価されるわけではないため、宣言ごとに固定のメタデータを安価に扱えます。

Magic literals の扱い

init(attachedTo:) のデフォルト引数に #function / #file / #line / #column を書いた場合、これらは属性が書かれた位置(またはプロトコルから推論された場合は適合型の宣言位置)を指すように特別扱いされます。#function はイニシャライザではなく「属性が付けられた宣言」の名前になります。これにより、ライブラリ側でエラーメッセージやログに元の宣言位置をそのまま埋め込めます。

Availabilityとの連携

@available で制限された宣言に属性が付いている場合、Attribute.allInstances(of:) が返すシーケンスからは実行環境で利用できないインスタンスが自動的に除外されます。内部的には、各インスタンス生成が次のようなコードに包まれて実行されるイメージです。

if #available(macOS 12, *) {
  return Flag(attachedTo: NewType.self)
} else {
  return nil
}

活用イメージ

たとえばRealmの @Persisted property wrapper は、attached macro と組み合わせて次のように書き換えられます。スキーマのカスタマイズ情報は @reflectionMetadata 型として別に持たせ、property wrapper のインスタンスごとのストレージコストを無くせます。

@reflectionMetadata
struct Named {
  let name: String
  init<T: _Persistable>(attachedTo: T.Type, _ name: String) {
    self.name = name
  }
}

@Persisted
class Dog: Object {
  var name: String
  @Named("CustomName") var age: Int
}

現在のステータス

本Proposalはレビューの結果 Returned for revision となっており、現時点ではそのままの形で採用されていません。機能の方向性や活用イメージはここに記した通りですが、最終的な仕様は今後の改訂でさらに変わる可能性があります。