Swift Digest
SE-0030 | Swift Evolution

Property Behaviors

Proposal
SE-0030
Authors
Joe Groff
Review Manager
Doug Gregor
Status
Withdrawn

01 何が問題だったのか

Swift のプロパティには、lazywillSet / didSet@NSCopying のように、「特定のパターンのふるまいを持たせたい」という要求がたびたび出てきます。Swift 1 / 2 時点ではこうしたパターンをコンパイラに直接組み込んで対応していましたが、このやり方には以下のような限界がありました。

言語組み込みの特別扱いが増えすぎる

lazy は「はじめてアクセスされた時点で初期化し、その後は保持した値を返す」という典型的なパターンです。言語機能として入れなければ、次のような定型コードを毎回書くことになります。

class Foo {
  // lazy var foo = 1738
  private var _foo: Int?
  var foo: Int {
    get {
      if let value = _foo { return value }
      let initialValue = 1738
      _foo = initialValue
      return initialValue
    }
    set {
      _foo = newValue
    }
  }
}

ボイラープレート削減のために lazy を組み込んだものの、同じ種類の要求は他にもたくさんあります。willSet / didSet による観測、@NSCopying によるコピー、スレッド同期つきのアクセス(Objective-C の atomic 相当)、プロキシ的なプロパティ——こうしたパターンを一つずつ言語へ追加していくと、言語と処理系が肥大化し、直交性も失われていきます。

組み込み lazy 自体にも不満が多い

言語に直接組み込まれた lazy は、柔軟性の面でも問題を抱えていました。

  • シングルスレッド前提で、同期版の lazy が欲しい場面では使えない。
  • 値型で使うと getter が mutating になってしまい、イミュータブルな値からアクセスできない。
  • 記憶域がインラインなので、値型のコピー間でキャッシュを共有できず、メモ化用途には不向き。

これらはすべて、「言語に一つだけ組み込まれた lazy」では吸収しきれないバリエーションです。

多段階初期化のための「遅延初期化の let」も欲しい

もう一つよくあるのが、「一度だけ代入できて、その後はイミュータブルとして扱いたいが、初期化タイミングは init 内でなくてよい」というプロパティです。いわゆる多段階初期化です。

class Foo {
  let immediatelyInitialized = "foo"
  var _initializedLater: String?

  // ユーザコードからは non-optional の let のように見えてほしい。
  // 代入は1回限り、未代入での読み出しは禁止したい。
  var initializedLater: String {
    get { return _initializedLater! }
    set {
      assert(_initializedLater == nil)
      _initializedLater = newValue
    }
  }
}

implicitly-unwrapped optional(String!)を使えば一応書けますが、「一度しか代入できない」という制約も、nil 安全性も失ってしまいます。専用のパターンとして表現できるほうが安全です。

ライブラリ化したいが、当時の Swift にはその手段がなかった

lazy にせよ willSet / didSet にせよ @NSCopying にせよ、共通しているのは「プロパティの記憶域・初期化・アクセスを、定型パターンとして再利用したい」という点です。これらはコンパイラに埋め込まれた特別扱いではなく、ライブラリとして定義できる仕組み が本来欲しいところでした。しかし Swift にはそのための抽象化機構が存在せず、結果として新しいパターンが必要になるたびに言語本体へ手を入れる状況が続いていました。この提案は、その状況を解決するための「プロパティ向けの汎用的な拡張ポイント」を導入しようとするものです。

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

この提案は Withdrawn(取り下げ) となりました。ここで示された「プロパティ向けの拡張ポイント」という発想自体は継続して検討され、最終的には別形式の提案として SE-0258 Property Wrappers が Swift 5.1 で採択・実装されました。そのため、この提案で提示された構文は Swift には入っていません。以下では、「もし採択されていたら何がどう書けるはずだったのか」をダイジェストとして整理します。

中心にあるアイデア: property behavior declaration

lazy のようなパターンを、コンパイラ組み込みではなくライブラリ として定義できるようにします。そのための新しい宣言が property behavior declaration で、var behavior という予約ワードの組み合わせで導入します。behavior は、プロパティの記憶域・初期化・アクセサ(get / set)をまとめて定義したテンプレートのような存在です。

// lazy をライブラリとして再実装する例
public var behavior lazy<Value>: Value {
  // behavior 固有の記憶域
  private var value: Value?

  // この behavior を使うプロパティには初期値の指定を要求する宣言。
  // 宣言側の初期値式が initialValue として参照できるようになる。
  initialValue

  mutating get {
    if let value = value {
      return value
    }
    let initial = initialValue
    value = initial
    return initial
  }
  set {
    value = newValue
  }
}

behavior を使う側の書き方

プロパティ宣言では、var の直後に角括弧 [...] で behavior 名を指定します。

var [lazy] x = 1738  // 内部的に Int? の記憶域が確保され、nil で初期化される
print(x)             // ここではじめて 1738 が評価される
x = 679              // setter で値を上書き

behavior 側で宣言された記憶域は、プロパティを宣言したスコープに展開されます。たとえば var [lazy] a: Int は概念的に次のように展開されるイメージです。

var `a.[lazy].value`: Int?
var a: Int { ... }  // lazy の get / set が実装される

initialValue requirement

behavior 宣言中に initialValue と書くと、「この behavior を使うプロパティは初期値式を書かなければならない」という要件になります。初期値式は initialValue という識別子を通して behavior 側から getter のように参照でき、参照するたびに再評価されます。初期値を要求しない behavior(たとえば後述の delayedImmutable)では、この宣言を省略します。

accessor requirement による「使う側に実装してもらうアクセサ」

behavior は、「使う側のプロパティ宣言で独自のアクセサを書いてもらう」タイプのパターンにも対応できます。willSet / didSet に相当する observed な behavior は、次のように accessor requirement を宣言して実現します。

public var behavior observed<Value>: Value {
  initialValue
  var value = initialValue

  // デフォルト実装付きのアクセサ要件。省略可能。
  mutating accessor willSet(newValue: Value) { }
  mutating accessor didSet(oldValue: Value) { }

  get { return value }
  set {
    willSet(newValue)
    let oldValue = value
    value = newValue
    didSet(oldValue)
  }
}

使う側は、通常のプロパティと同じ感覚でアクセサを書き並べます。

var [observed] counter: Int = 0 {
  didSet { print("\(oldValue) -> \(counter)") }
}

アクセサ要件にデフォルト実装があれば省略可能、なければ必須というルールです。また、標準の didSet / willSet では値が変わらなくても呼ばれてしまうのが不満だ、という声に応える形で、Equatable 制約を使って「本当に変わったときだけ通知する」 changeObserved のような behavior を自作することもできます。

public var behavior changeObserved<Value: Equatable>: Value {
  initialValue
  var value = initialValue
  mutating accessor didChange(oldValue: Value) { }

  get { return value }
  set {
    let oldValue = value
    value = newValue
    if oldValue != newValue {
      didChange(oldValue)
    }
  }
}

var [changeObserved] x: Int = 1 {
  didChange { print("\(oldValue) => \(x)") }
}

x = 1  // 何も出力されない
x = 2  // 1 => 2

多段階初期化向けの delayed behavior

implicitly-unwrapped optional に頼らずに多段階初期化を扱うための behavior も、同じ枠組みで書けます。可変版 delayedMutable と「一度だけ代入可能」な delayedImmutable を、それぞれ別の behavior として提供します。

public var behavior delayedImmutable<Value>: Value {
  private var value: Value? = nil

  get {
    guard let value = value else {
      fatalError("property accessed before being initialized")
    }
    return value
  }
  set {
    if let _ = value {
      fatalError("property initialized twice")
    }
    value = newValue
  }
}

class Foo {
  var [delayedImmutable] x: Int

  init() {
    // ここで x を初期化しなくてよい
  }

  func initializeX(x: Int) {
    self.x = x  // すでに初期化済みならクラッシュ
  }
}

self への参照と型制約

behavior 宣言の内側では、self は「この behavior を使うプロパティを持つ値」を指します。型としては暗黙の型パラメータ Self として扱われ、where 句で制約をかけられます。たとえば Objective-C の atomic に相当する同期アクセスを、「ロックを提供するクラスに属するプロパティにだけ適用可能な behavior」として表現できます。

public protocol Synchronizable: class {
  func withLock<R>(body: () -> R) -> R
}

public var behavior synchronized<Value where Self: Synchronizable>: Value {
  initialValue
  var value: Value = initialValue

  get { return self.withLock { return value } }
  set { self.withLock { value = newValue } }
}

behavior 内では self のメンバへの参照は必ず明示的に書く必要があります(self.withLock のように)。behavior 自身のメンバ名と衝突しないようにするためです。self は通常イミュータブルで、mutating なメソッドやアクセサの中だけで inout として扱われます。また、記憶域の初期化式や init の中からは self を参照できません(containing value の初期化中に走るため)。

今回のスコープから外れていたもの

提案は意図的にスコープを絞っており、次のような項目は将来の拡張として別立てにされていました。

  • let プロパティへの behavior 適用。当時 Swift には効果システムが無く、イミュータブル性の保証ができないため見送り。
  • 初期値式からの型推論var [uint16only] x = 1738 のように、behavior の制約を推論に反映するかどうかは別途議論が必要。当面は behavior 付きプロパティには明示的な型注釈を必須にする方針でした。
  • behavior の合成lazysynchronizedobserved を重ねがけするような合成は、順序によって意味が変わり、誤った組み合わせが容易に作れてしまうため、別提案として検討する整理。
  • 初期化式の遅延評価lazy のように「実際の初期化後まで初期値式を評価したくない」ケースのための「deferred な initializer」は、意味論設計が大きいため切り出し。
  • プロパティの out-of-line 初期化init の中で後から代入するスタイルを behavior 付きプロパティでも可能にするのは、別途取り組む課題とされていました。
  • プロパティ名を文字列として参照する仕組み や、behavior のオーバーロード も、有用だが独立した設計が必要な話題として将来課題扱いでした。

現在の Swift での立ち位置

この提案自体は取り下げとなりましたが、動機となっていた「プロパティのパターンをライブラリ化したい」という課題は、Swift 5.1 で導入された Property Wrappers(SE-0258)として別の設計で解決されています。Property Wrappers では、behavior 宣言のような専用構文ではなく、属性として付与できるジェネリックな型@propertyWrapper を付けた構造体・列挙型・クラス)で同種のパターンを表現します。使い方は @Lazy var foo = 1738 のようになり、この提案で示されたユースケース(lazy、NSCopying 相当、observed、値の範囲制約 など)はいずれも Property Wrappers の側で実現できます。

そのため現在の Swift では、本提案の var behavior / var [behavior] という構文そのものは存在しません。同じ目的を達成したい場合は Property Wrappers を使います。本ダイジェストで紹介した var behavior の仕組みは、あくまで「Property Wrappers 以前に検討されていた別の設計案」として参照してください。