Conditional compilation for attributes
01 何が問題だったのか
Swiftには時代とともに新しい属性(attribute)が追加されていきます。@preconcurrency のようにすでに書かれているコードに後から付けてコンパイル時チェックの精度を上げる、といった使い方もでき、古いコードを新しいコンパイラに合わせて更新していく上で重要な役割を担っています。
しかし、新しい属性を既存コードに取り入れると、その属性を知らない古いコンパイラではコードがビルドできなくなります。従来はこれを回避するために、#if compiler(>=...) で条件コンパイルし、属性あり・なしの宣言を両方書くという手段が取られてきました。たとえば @preconcurrency をサポートしないコンパイラにも対応するには次のように書くことになります。
#if compiler(>=5.6)
@preconcurrency protocol P: Sendable {
func f()
func g()
}
#else
protocol P: Sendable {
func f()
func g()
}
#endif
このやり方には二つの問題があります。ひとつは、属性を付け替えたいだけなのにプロトコル P 全体を丸ごと複製する必要があり、冗長で保守しにくいこと。もうひとつは、compiler(>=5.6) というチェックがその属性の有無と本来は独立した情報であることです。@preconcurrency は偶然 Swift 5.6 から使えるようになっただけで、コンパイラフラグで有効化される可能性も、開発途中で仕様が変わる可能性もあります。また @objc のように、バージョンではなくプラットフォームやビルド構成によって使えるかどうかが変わる属性もあり、コンパイラバージョンによる分岐では正確に表現できません。
02 どのように解決されるのか
宣言に付く属性まわりについて、次の二つの機能を導入します。
- 宣言に付く属性そのものを
#if/#elseif/#else/#endifで囲めるようにします。属性リストの一部だけを条件コンパイルできるため、宣言本体を複製する必要がなくなります。 hasAttribute(AttributeName)というコンパイル条件を追加します。現在の言語モードにおいて、そのコンパイラがAttributeNameという名前の属性を認識するかどうかをtrue/falseで返します。
この二つを組み合わせると、先ほどの @preconcurrency の例は次のように書けます。宣言は一度しか書く必要がなく、「@preconcurrency が使えるなら付ける」という意図もそのまま読み取れます。
#if hasAttribute(preconcurrency)
@preconcurrency
#endif
protocol P: Sendable {
func f()
func g()
}
hasAttribute が対象にするもの
hasAttribute は、コンパイラが言語機能として組み込みで認識している属性を対象にします。property wrapper、result builder、グローバルアクターなどの仕組みによってユーザーが定義したカスタム属性はここには含まれません。
たとえば @propertyWrapper を付けた MyWrapper 型を用意すると、@MyWrapper という属性として使えますが、
hasAttribute(propertyWrapper)はtrue(組み込み属性なので認識される)hasAttribute(MyWrapper)はfalse(ユーザー定義のカスタム属性は対象外)
となります。
採用されない側のブランチも構文解析される
#if による条件コンパイルでは、条件が偽のブランチも構文解析自体は行われます。属性の文法はカスタム属性も受け入れられるように汎用的に定義されていて、おおむね次の形です。
@ 属性名 ( 任意のトークン列 )
このため、古いコンパイラが知らない属性名が書かれていても、条件が偽であればパース自体は通り、宣言には適用されないだけで済みます。
#if hasAttribute(UnknownAttributeName)
@UnknownAttributeName(something we do not understand) // パースは通るが適用はされない
#endif
func f()
結果として、将来追加される属性についても、それを知らない古いコンパイラで「hasAttribute が false になるので何もしない」という形で安全に無視させることができます。