☘️

@Observable Macro を構造体へ適用させる難しさについて

2023/12/04に公開

本記事はSwiftWednesday Advent Calendar 2023の4日目の記事です。
昨日は @uhooi さんでした。

はじめに

Swift 5.9の導入により、Observationフレームワークを活用してデータの追跡をより簡単かつ高パフォーマンスに行うことが可能となりました。現在、このフレームワークでは@Observable Macroをクラスに対してのみ適用することができます。一方で構造体に適用することはできません。

もともと、@Observable Macroを構造体にも適用する案が提案されていました。しかし、Swiftの現行設計から見た時、構造体に対して@Observable Macroを適応させることは難しいという判断に至り導入されませんでした。

この議論は[Second review] SE-0395: Observabilityで展開されていました。
本記事では、このスレッドでの議論を基に、@Observable Macroの構造体への適用に関する論点について解説します。

Swiftにおける「値」と「場所」について

Swiftのプログラミングモデルを理解するためには、「値」と「場所」という二つの概念が重要です。この2つはSwiftの意味論に深く関連しています。

値(Value)について

Swiftでの「値」は、関数から返されたり、引数として渡されたりするデータを指します。基本的なデータ型(例えば整数や文字列)や構造体は、これらの値を表現するために使われます。たとえば、以下の例で示すBall(diameter: .03, color: Color.orange)は構造体によって表される値で、そのプロパティによって構成されます。Swiftでは、これらの値はメモリとは独立して存在すると考えられます。

struct Ball {
    var diameter: Double
    var color: Color
}

let ball = Ball(diameter: 0.03, color: .orange)

このコードスニペットでは、Ball構造体が定義され、そのインスタンスballが作成されています。ballBallの型の値を持ち、diametercolorというプロパティを持つことでその値が構成されます。このようにSwiftでは、値としてのデータと、そのデータが格納されるメモリ上の場所を区別して考えることが重要です。

場所(Location)について

Swiftの抽象的なマシンモデルでは、場所とはメモリの一部を指し、特定の型の値を格納します。例えば、変更可能なローカル変数を宣言すると、その変数がスコープに入る際に新しい場所が動的に作成され、スコープを外れるとその場所は破棄されます。構造体では、各プロパティが個別の場所に割り当てられます。
例えば、変数xが整数型(Int)の値10を格納する場合、Swiftはメモリ上にxのための場所を確保し、そこに10という値を格納します。

var x = 10 // 'x'はInt型の値'10'を格納する場所(メモリの一部)を持つ

print("メモリアドレス: \(UnsafeMutablePointer(&x))") // 0x00000001000081d8

クラス型の値における観察のしやすさ

クラス型の値は実際にはオブジェクトへの参照です。オブジェクトがメモリ上に作成されると、その場所にクラスが定義するプロパティが保存されます。この参照を通じて、同じオブジェクト(およびそのプロパティ)にアクセスできます。このため、クラス型の値の変更は、その場所の変更として追跡できます。クラスオブジェクトは、その場所の独立した変更可能性により同一性を持ち、Swiftでは===演算子を通じてこの同一性を表現することができます。

class Person {
    let name: String
    let age: Int

    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }
}

let person1 = Person(name: "Alice", age: 30)
let person2 = person1
let person3 = Person(name: "Alice", age: 30)

// メモリアドレスの参照比較
print("person1 は person2 と同じオブジェクトか? \(person1 === person2)") // true
print("person1 は person3 と同じオブジェクトか? \(person1 === person3)") // false

// メモリアドレスの出力
print("person1のメモリアドレス: \(Unmanaged.passUnretained(person1).toOpaque())") // 0x0000600002bf0060
print("person2のメモリアドレス: \(Unmanaged.passUnretained(person2).toOpaque())") // 0x0000600002bf0060
print("person3のメモリアドレス: \(Unmanaged.passUnretained(person3).toOpaque())") // 0x0000600002bf0090

この例では、Personクラスのインスタンスperson1が作成され、person2person1の参照を保持します。一方、person3person1と同じプロパティ値を持ちますが、異なるオブジェクトです。===演算子を使用して、これらのオブジェクトが同じ場所(メモリアドレス)を参照しているかどうかをチェックします。この例では、person1person2が同じメモリアドレスを共有していることが示されていますが、person3は異なるメモリアドレスに存在します。このように、Swiftではクラス型のオブジェクトの同一性はメモリ上の場所(アドレス)によって決定されます。

構造体における観察の難しさ

構造体は値型であり、その値が変更されると、元の場所とは異なる新しい場所に新しい値が保存されます。これは、たとえば、struct Balldiametercolorというプロパティを持っている場合、これらのプロパティが変更されると、Ballの全体的な値が新しい場所に保存されることを意味します。
この動作により、Swiftにおける構造体の値の変更を追跡することが特に複雑になります。値型はその性質上、メモリ上の同じ場所に固定されず、変更ごとに新しいインスタンスが生成されるため、従来のクラス型のような観察メカニズムを適用することは困難です。したがって、Swiftの言語モデルでは、値型に対する変更の追跡を自然に行うメカニズムはまだ存在しないため、構造体の値を観察するには、言語の基本的な概念を再考し、より深い理論的な問題を解決する必要があります。

場所の再配置の必要性と複雑性

Swiftのライブラリでは、特に配列のようなコレクション型において、メモリ内でのデータの場所を再配置する必要が生じることがあります。例えば、配列に新しい要素が追加されたとき、既存の要素を含む配列のストレージがメモリ上で移動されるかもしれません。
この再配置プロセスは、観察されている値にとっては明確であるべきですが、その実装は複雑です。更に、ある変数から別の変数へ値が移動された場合、理論上は新しい場所での値として扱われ、観察情報はリセットされるべきです。
しかし、Swiftの最適化プロセスでは、効率のために物理的な値の移動を避けることがあるため、この要件は実装上の課題を生じさせます。例えば、大きなデータ構造がある変数から別の変数へコピーされるとき、パフォーマンスのために実際のデータの移動は発生せず、ただ参照が更新されるだけである場合があります。このような状況では、観察メカニズムが意図したとおりに動作しない可能性があります。

おわりに

Swift 5.9のObservationフレームワークにおける@Observable Macroはクラスに対して適応することは可能ですが、構造体には適用ができず、適応させるような変更を加えることも難しいです。構造体の値を観察するためには、Swiftの言語モデルの再考や概念上の問題の調査が必要になります。現在はクラスに基づく観察可能な実装をする必要がありますが、将来的には構造体に適したより柔軟な観察方法の開発が(現状だと角度は低いですが)期待されています。この分野の進展は、Swiftコミュニティにとって重要な関心事になると考えています。

明日も @uhooi さんです!

開発環境

  • Swift compiler version info:
    • Apple Swift version 5.9 (swiftlang-5.9.0.128.108 clang-1500.0.40.1)
  • Xcode version info:
    • Xcode 15.0 Build version 15A240d

参考引用文献

DeNA Engineers

Discussion