Swift Digest
SE-0293 | Swift Evolution

Extend Property Wrappers to Function and Closure Parameters

Proposal
SE-0293
Authors
Holly Borla, Filip Sakel
Review Manager
Chris Lattner
Status
Implemented (Swift 5.5)

01 何が問題だったのか

SE-0258 で導入された property wrapper は、プロパティにカスタムの振る舞い(バリデーション、変換、ロギング、履歴管理など)を付与する仕組みとして広く使われています。しかし、適用できる場所はローカル変数と型のプロパティに限られており、関数やクロージャのパラメータには @Wrapper を付けられない という制限がありました。

このため、関数のパラメータに対して「共通のルール」や「付随情報」を簡潔に付与したいケースで、property wrapper の利点が活かせませんでした。

共通の振る舞いを関数パラメータに適用したい

たとえば、引数に対する前提条件を表明する @Asserted や、呼び出しごとに値をログ出力する @Logged のような wrapper を、関数パラメータにそのまま書けると便利です。

@propertyWrapper
struct Logged<Value> {
  init(wrappedValue: Value) {
    print(wrappedValue)
    self.wrappedValue = wrappedValue
  }
  var wrappedValue: Value { didSet { print(wrappedValue) } }
}

// 本来こう書きたい
func runAnimation(@Logged withDuration duration: Double) { ... }

ところがこれまでは、関数の入り口で毎回手作業で wrapper をインスタンス化して展開する必要があり、せっかくの property wrapper の糖衣構文が途切れてしまっていました。

projection も併せて渡したい

property wrapper の中には、projectedValue を通じて「本体の値に付随する別の情報」を外部に公開するものがあります。たとえば、値の変更履歴を保持する @Traceable のような wrapper を考えます。

struct History<Value> { ... }

@propertyWrapper
struct Traceable<Value> {
  init(wrappedValue value: Value) { ... }
  init(projectedValue: History<Value>) { ... }

  var wrappedValue: Value { get { ... } set { ... } }
  var projectedValue: History<Value> { ... }
}

struct TextEditor {
  @Traceable var dataSource: String
}

TextEditor のメンバーワイズイニシャライザは String を受け取る形で合成されますが、呼び出し側が「既存の履歴を持つ値」で初期化したい場面では、History<String> を受け取る経路がありません。これを実現するには、オーバーロードを手書きで増やすか、実装詳細であるはずの Traceable 型を API に露出させるしかなく、どちらも不健全でした。

要するに

関数の利用側と実装側の双方にとって、property wrapper の糖衣構文をパラメータにも拡張したいという要求が強くありました。そしてその際、「ロギングのように実装詳細として wrapper を使うケース」と「projectedValue 経由で wrapper 固有の型を受け渡す API としてのケース」では必要な挙動が異なるため、両者を自然に扱えるモデルが必要でした。

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

関数およびクロージャのパラメータに property wrapper 属性を付けられるようにします。あわせて、パラメータに付く property wrapper を implementation-detail(実装詳細)API-level(API) の 2 種類に分類し、両者を整理します。どちらに分類されるかは、その property wrapper 型に init(projectedValue:) が宣言されているかどうかを見てコンパイラが自動的に判定します。

implementation-detail な property wrapper

デフォルトでは、property wrapper はすべて implementation-detail として扱われます。これは、関数の外から見える型は変わらず、wrapper の初期化は関数本体(callee)側で行われるという意味です。

func insert(@Logged text: String) { ... }

は次のように展開されます。

func insert(text: String) {
  let _text: Logged<String> = Logged(wrappedValue: text)
  var text: String { _text.wrappedValue }
  ...
}
  • 関数シグネチャ上の型は wrapped value の型(ここでは String)のまま。
  • 関数本体には、バッキングストレージを表す _textlet)と、元の名前の computed variable text が合成されます。
  • バッキングストレージがイミュータブルなため、text に setter は合成されません(SE-0003 で削除された var パラメータのような「呼び出し側から見えない書き換え」を持ち込まないための配慮です)。
  • wrapper の属性に引数がある(例: @Asserted(.greaterOrEqual(1)))場合は、必ず implementation-detail として扱われます。
  • implementation-detail な wrapper は、プロトコル要件の実装にもそのまま使えます(要件側は wrapped value の型のままです)。
protocol P {
  func requirement(value: Int)
}

struct S: P {
  func requirement(@Logged value: Int) { ... } // OK
}

API-level な property wrapper

property wrapper 型が init(projectedValue:) を直接宣言している場合、パラメータに付けられた wrapper は API-level とみなされます。この場合、wrapper は関数シグネチャの一部になり、初期化は呼び出し側(caller)で行われます。

@propertyWrapper
struct Traceable<Value> {
  init(wrappedValue value: Value) { ... }
  init(projectedValue: History<Value>) { ... }
  var wrappedValue: Value { get { ... } set { ... } }
  var projectedValue: History<Value> { ... }
}

func log<Value>(@Traceable value: Value) { ... }

log の実装側では、パラメータ名の先頭にアンダースコアが付いたバッキングストレージ(_value)と、元の名前・$ 付きの名前の computed variables が合成されます。

func log<Value>(value _value: Traceable<Value>) {
  var value: Value { get { _value.wrappedValue } }
  var $value: History<Value> { get { _value.projectedValue } }
  ...
}

呼び出し側の構文

呼び出し側では、引数ラベルの書き方で初期化方法を選べます。

let history: History<Int> = ...

log(value: 10)        // Traceable(wrappedValue: 10) が生成される
log($value: history)  // Traceable(projectedValue: history) が生成される

引数ラベルの前に $ を付けると init(projectedValue:) が呼ばれ、付けなければ init(wrappedValue:) が呼ばれます。ラベルなしパラメータに projection を渡したいときは $_: を使います。

func log<Value>(@Traceable _ value: Value) { ... }

log(10)
log($_: history)

未適用の関数参照(unapplied reference)

関数の未適用参照の型は、既定では wrapped value の型になります。コンパイラは wrapped value を受け取って内部で wrapper を生成するサンクを差し込みます。

func log<Value>(@Traceable value: Value) { ... }

let f: (Int) -> Void = log(value:)
f(10) // 内部で Traceable(wrappedValue: 10) が使われる

projection を受け取る形の参照がほしいときは、引数ラベルの前に $(またはラベルなしなら $_)を付けます。

let g: (History<Int>) -> Void = log($value:)
g(history)

クロージャ

クロージャのパラメータにも property wrapper を付けられます。クロージャでは型情報に wrapper 属性が乗らないため、wrapped value と projected value のどちらかを受け取る形のいずれかとして書きます。

// wrapped value を受け取る
let logWrapped: (Int) -> Void = { (@Traceable value) in ... }

// projected value を受け取る(パラメータ名の頭に $ を付ける)
let logProjected: (History<Int>) -> Void = { (@Traceable $value) in ... }

さらに、バッキング型と projection の型が同じ property wrapper(SwiftUI の Binding など、もし init(projectedValue:) を備えていれば)については、$ だけでクロージャパラメータを書けるようになる可能性があります。

// 例: Binding が init(projectedValue:) を備えていれば
let useBinding: (Binding<Int>) -> Void = { $value in ... }

その他の制約

  • パラメータに付ける property wrapper は、init(wrappedValue:)init(projectedValue:) の少なくとも一方を備えている必要があります。
  • 合成される wrappedValue の getter はミュータブルであってはいけません(バッキングがイミュータブルなため)。
  • @autoclosure 型のパラメータには付けられません。
  • API-level な property wrapper は、同じパラメータに結果ビルダーを併用することはできません。
  • enclosing self subscript を必要とする property wrapper は、インスタンスメソッド以外では使えません。
  • API-level な property wrapper をオーバーライドやプロトコル適合で使う場合、スーパークラスや要件側に同じ wrapper 属性が付いている必要があります。また、アクセスレベルは関数と一致させる必要があります。

Future Directions(見通し)

今回のスコープでは扱われませんが、今後の方向性として、init(projectedValue:) を通常のプロパティやローカル変数の初期化にも拡張する、@propertyWrapper(apiLevel) のような明示指定を導入する、プロトコル要件に API-level な property wrapper を書けるようにする、inout パラメータへの適用を可能にする、といったアイデアが示されています。いずれも speculative な展望であり、実現が約束されたものではありません。