SwiftUI で @State を使っていると、
@State var name = ""
と書いただけなのに、name / $name / _name が自然に使えることに少し不思議さを感じる。
この記事では、@State の挙動をSwift コンパイラの出力(SIL)から確認していく。
この記事でわかること
-
@Stateの$と_の役割 -
@Stateで参照できるプロパティがコンパイラによってどのように生成されているか
基本のおさらい: @Stateとは
SwiftUI における @State は、View が内部で保持する状態を表すための Property Wrapper である。
@State var name = ""
上記の name はただの変数ではなく、値が変わるとSwiftUI が自動的にView を再描画するという特別な性質を持つ。
また、@State を使うと、1つの状態に対して次の3つの形が使えるようになる。
name$name_name
import SwiftUI
struct ExampleView: View {
@State var name: String
init(name: String) {
_name = State(initialValue: name)
}
var body: some View {
VStack() {
Text("Hello, \(name)")
TextField("Name", text: $name)
}
}
}
このコードでは、1つの @State に対して
-
nameは値として使われる -
$nameは Binding として渡されている -
_nameは init で初期化に使われる
この章では、@Stateの基本的な動作を確認してきた。次の章では、この $ や _ が何を意味しているのかを、コードを見ながら順番に深掘りしていく。
@State の挙動をコンパイラ出力から確認する
前の章で、@State と定義することで
name$name_name
という3つの形に実際にアクセスできることを確認した。
この章では、Swift コンパイラの出力を直接確認することで、これらについて理解を深めていく。
コンパイル結果を確認する
Swift では、コンパイル過程で生成される中間表現(SIL: Swift Intermediate Language)を出力することができる。
SILでは、コンパイラが実際に生成したコードの構造や呼び出し関係を確認できる。
ここでは、@State を含む View がコンパイラによってどのような形に展開されているかを見ていく。
まず、次のコマンドを実行する。
swiftc -emit-sil ExampleView.swift
name, $name, _nameが生成されていることの確認
出力結果の中から、ExampleView に関する部分を抜粋すると、次のような定義が確認できる(一部簡略)。
@MainActor @preconcurrency struct ExampleView : View {
@State @_projectedValueProperty($name)
var name: String {
get
nonmutating set
nonmutating _modify
}
var $name: Binding<String> { get }
@_hasStorage @_hasInitialValue
var _name: State<String> { get set }
var body: some View { get }
}
この出力は、Swift ソースコードをそのまま写したものではなく、コンパイラが型チェック後に認識している構造を表している。
ここから分かる点
この構造を見ると、@State var name に対して、次の3つが明確に別のプロパティとして生成されていることが分かる。
name
@State @_projectedValueProperty($name) var name: String
@State が付いたStringプロパティとして存在している
_projectedValueProperty($name) という属性が付与されており、name と $name がコンパイラレベルで関連付けられていることが分かる
$name
var $name: Binding<String> { get }
$name は単なる記法ではなく、Binding<String> を返す独立したcomputed property として扱われ、name とは別のプロパティとして生成されている
_name
var _name: State<String> { get set }
_name は State<String> 型で、@_hasStorage が付いており、実際のストレージを持つプロパティとなっている。@_hasInitialValueとあることから、init 内で初期化できる理由がここに表れている。
この時点で言えること
このコンパイル結果から、name、$name、_name はコンパイラによって生成された実在するプロパティを参照しているということがわかる。
name と _name の関係
出力結果の中には、以下のような箇所も確認できる。
// ExampleView.name.getter
// Isolation: global_actor. type: MainActor
sil hidden @$s10SampleView07ExampleB0V4nameSSvg : $@convention(method) (@guaranteed ExampleView) -> @owned String {
bb0(%0 : $ExampleView):
debug_value %0, let, name "self", argno 1
%2 = struct_extract %0, #ExampleView._name
retain_value %2
%4 = alloc_stack $State<String>
store %2 to %4
%6 = alloc_stack $String
// function_ref State.wrappedValue.getter
%7 = function_ref @$s7SwiftUI5StateV12wrappedValuexvg : $@convention(method) <τ_0_0> (@in_guaranteed State<τ_0_0>) -> @out τ_0_0
%8 = apply %7<String>(%6, %4) : $@convention(method) <τ_0_0> (@in_guaranteed State<τ_0_0>) -> @out τ_0_0
%9 = load %6
...
return %9
}
最初に_name を取り出してる。その後 _name.wrappedValue を参照し、それが nameのgetterの戻り値となっている。
つまり name は _name.wrappedValue の値の String を参照していることがSIL上で確認できる。
$name と _name の関係
// ExampleView.$name.getter
// Isolation: global_actor. type: MainActor
sil hidden @$s10SampleView07ExampleB0V5$name7SwiftUI7BindingVySSGvg : $@convention(method) (@guaranteed ExampleView) -> @owned Binding<String> {
bb0(%0 : $ExampleView):
debug_value %0, let, name "self", argno 1
%2 = struct_extract %0, #ExampleView._name
retain_value %2
%4 = alloc_stack $State<String>
store %2 to %4
%6 = alloc_stack $Binding<String>
// function_ref State.projectedValue.getter
%7 = function_ref @$s7SwiftUI5StateV14projectedValueAA7BindingVyxGvg : $@convention(method) <τ_0_0> (@in_guaranteed State<τ_0_0>) -> @out Binding<τ_0_0>
%8 = apply %7<String>(%6, %4) : $@convention(method) <τ_0_0> (@in_guaranteed State<τ_0_0>) -> @out Binding<τ_0_0>
%9 = load %6
...
return %9
}
ここでも最初に _name を取り出している。参照しているのは State.projectedValueで
戻り値は Binding<String> となっている。
これより $nameは_name.projectedValue の値の Binding<String> を参照していることがわかる。
コンパイル結果からわかること
上記の結果より以下のことがわかる
| 記法 | 内容 | 型 |
|---|---|---|
_name |
値を保持 | State<String> |
name |
_name.wrappedValue |
String |
$name |
_name.projectedValue |
Binding<String> |
まとめ:@State の _ と $ をコンパイラから理解する
この記事では、@State に対して使える
name_name$name
という3つの形について、Swift コンパイラの生成物として追ってきた。
コンパイラ視点で分かったこと
コンパイルの生成物を追うことで、@State が Property Wrapper として実際にどのようなコードに展開されているかを確認できた。
具体的には、次の点が明らかになった。
-
_nameはState<String>型のストレージ -
nameは_nameを経由してState.wrappedValueを参照する -
$nameは_nameを経由してState.projectedValueを参照する
これらはコンパイラが生成したプロパティと関数呼び出しとして存在している。
最後に
@State が Property Wrapper であること自体は、公式ドキュメントを見れば分かる。
しかし本記事では、「仕様としてそう定義されている」という説明ではなく、その仕様が、実際にどのようなコードとして生成されているかを、SIL を通して確認してきた。
上記を通じて、なぜ _name が init で必要になるのか、なぜ $name が Binding<String> になるのかといった疑問も、仕様ではなく構造として理解できたと思う。