マクロがプロパティ初期化式に self アクセスを許可できるようにする
Enable Macros to Grant self Access for Property Initializers
このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら↗。
01 何が問題だったのか
Swift には古くから lazy キーワードによる遅延初期化があります。lazy var は、初期化コストが高く必要になるまで初期化を避けたい場合や、初期化が型の初期化「後」に確定する値(self へのアクセスを含む)に依存する場合に便利です。
コンパイラは lazy var foo = initExpr() を、おおよそ次のように変換します。
- 同じスコープに
T?型の private なバッキング変数を追加し、初期値をnilにする - 元のプロパティを computed property に変換する
getアクセサの中で、バッキング変数がnilのときに初期化式を評価する
このとき初期化式は、あたかもすでに get アクセサの中へ移動されたかのように型チェックされます。そのため、通常のプロパティ初期化式では使えない self やインスタンスメンバーに、lazy var の初期化式からはアクセスできます。
struct Earth {
let mice = 21
let noGood = mice * 2
// ^ 🛑 cannot use instance member 'mice' within property initializer; property initializers run before 'self' is available
// ✅ initializer expression has access to `self`!
lazy var theAnswer = mice * 2
}
同じような変換は、accessor マクロを書くことでも実現できます。たとえば初期化式を get アクセサへ移動する @Lazy マクロを考えられます。ところが現在のコンパイラは、マクロ展開後の実際の文脈に関係なく、プロパティ初期化式は eager に評価されると仮定します。そのため、self アクセスを必要とすると、lazy の場合とは違ってコンパイルエラーになってしまいます。
struct Earth {
let mice = 21
@Lazy
var theAnswer = mice * 2
// ^ 🛑 cannot use instance member 'mice' within property initializer; property initializers run before 'self' is available
}
@Lazy は初期化式を get アクセサへ移動し、そこでは self アクセスが許されるため、このエラーは不必要に制限的です。同様の場面は、たとえば Observable なオブジェクトの初期化時に SwiftUI の environment values へアクセスしたい場合などにも生じます。
02 どのように解決されるのか
accessor マクロのロール宣言に、任意の initialization: パラメータを追加します。指定できる値は selfAvailable または selfUnavailable の 2 つで、省略時のデフォルトは selfUnavailable です。
selfUnavailable: これまでどおりの挙動で、プロパティ初期化式からselfにアクセスできません。selfAvailable: マクロが初期化式をselfにアクセスできる文脈へ移動することを宣言します。
マクロ作者は、初期化式を self の使える文脈(get アクセサなど)へ再配置する場合に selfAvailable を指定します。デフォルトが selfUnavailable なので、既存のマクロの挙動は変わりません。
// `@Lazy` マクロの宣言
//
// 初期化式を self が使える文脈で用いることを約束する:
@attached(accessor, initialization: selfAvailable, names: named(get), named(set))
@attached(peer, names: prefixed(_))
public macro Lazy() = #externalMacro( ... )
// 利用:
struct Earth {
let mice = 21
// ✅ ここで self が使える:
@Lazy var theAnswer = self.mice * 2
}
initialization: は accessor マクロ専用です。body マクロなど他のロールに付けたり、selfAvailable / selfUnavailable 以外の値を渡したりすると、それぞれエラーと fix-it が示されます。
なぜマクロ作者による宣言が必要なのか
初期化式は 2 回型チェックされます。1 回目はマクロ展開「前」の元の文脈で、2 回目はマクロ展開「後」の新しい文脈です。1 回目のチェックは型推論のために欠かせません。推論された型はマクロに渡され、展開結果を組み立てる際にしばしば必要になるからです。
@Lazy var foo = 42
// ^ プロパティ初期化式を型チェックした結果、型は Int と推論される
@Lazy var foo: Int = 42
// ^ マクロは推論された型 Int を参照できる
このように、型推論はマクロ展開より先に行う必要があります。つまり、1 回目のチェックの時点では、マクロが初期化式を最終的にどう使うかはまだ分かりません。たとえば init アクセサの中で使われるなら、初期化式は型の初期化時に評価され、self は使えません。この場合は self アクセスを禁じる現在の挙動が正しいことになります。一方 @Lazy のように get アクセサへ移動するなら self アクセスは妥当です。この違いは 1 回目のチェックの時点では判別できないため、マクロ作者が initialization: で明示的に宣言する、という設計になっています。
型チェックへの影響
初期化式を元の文脈でチェックする際、型チェッカーは initialization: selfAvailable を宣言した accessor マクロが付いているかを調べます。付いていれば、既存の lazy キーワードと同じく self アクセスを許可します。それ以外の場合はこれまでどおり、self アクセスをエラーとして診断します。
マクロ作者が selfAvailable と宣言しておきながら、実際には self アクセスが許されない文脈(init アクセサなど)へ初期化式を再配置した場合、1 回目のチェックは通ってしまいますが、展開後のコードに対する 2 回目のチェックで期待どおりエラーが診断されます。
// マクロ宣言:
@attached(accessor, initialization: selfAvailable, names: named(get), named(init))
@attached(peer, names: prefixed(_))
public macro NotLazy() = #externalMacro( ... )
// 利用:
struct Earth {
let mice = 21
@NotLazy var theAnswer = mice * 2
}
// 展開結果:
struct Earth {
let mice = 21
private var _theAnswer: Int
var theAnswer: Int {
@storageRestrictions(initializes: _theAnswer)
init {
_theAnswer = mice * 2
// ^ 🛑 cannot use instance member 'mice' within property initializer; property initializers run before 'self' is available
}
get { _theAnswer }
}
}
なお、selfAvailable を既存のマクロに後から付けると、self を使うコードの意味が変わりうる(同名の static メンバーとインスタンスメンバーの取り違えなど)ソース非互換を招く可能性があります。このため、既存マクロを selfAvailable に切り替える際は、マクロの提供者が非互換の可能性を慎重に検討し、ドキュメント化することが求められます。