SwiftProgrammingSwift開発者

Swiftコンパイラーは、結果ビルダークロージャーをデザガーするときにどのような構文変換を適用し、このメカニズムは異種戻り値を持つ条件分岐において型安全性をどのように維持するのか?

Hintsage AIアシスタントで面接を突破

質問への回答

質問の歴史

Swiftは、バージョン5.1で結果ビルダー(当初は関数ビルダーと呼ばれていた)を導入し、SwiftUIのようなライブラリに宣言的な構文を可能にしました。これ以前は、階層的データ構造を作成するために深くネストされた初期化子呼び出しが必要で、視覚的に煩雑で維持が困難でした。この機能は、パーサーコンビネータライブラリや関数型プログラミングモナドにインスパイアされ、Swiftの静的型システムに適応させて、命令型構文の親しみやすさを保つように設計されました。

問題

開発者は、Swiftのコンパイル時型安全性を損なうことなく、またランタイムオーバーヘッドを導入せずに複雑な値を構築するために、逐次文を書く方法を必要としました。主な課題は、異なるブランチが異なる型を生成する可能性がある制御フロー構文(例えばif文やforループ)をこれらの構造内でサポートすることでした。単に存在型の配列を使用すると、具体的な型情報が失われ、動的ディスパッチを強いられ、パフォーマンスクリティカルなコードパスが損なわれることになります。

解決策

Swiftコンパイラーは、意味解析フェーズ中にソースからソースへの変換を行い、結果ビルダークロージャーのボディをビルダー型に対する一連の静的メソッド呼び出しに書き換えます。逐次文はbuildBlockの引数になり、条件文はbuildEither(first:)buildEither(second:)への呼び出しに変換され、オプショナルブランチはbuildOptionalを使用します。この変換は型チェックの前に行われ、コンパイラーが合成された型が期待される戻り値の型と一致していることを確認できるようにし、手動のネストされた呼び出しに相当する効率的なインラインコードを生成します。

@resultBuilder struct MyBuilder { static func buildBlock<T1, T2>(_ t1: T1, _ t2: T2) -> (T1, T2) { (t1, t2) } static func buildOptional<T>(_ component: T?) -> T? { component } static func buildEither<T>(first: T) -> T { first } static func buildEither<T>(second: T) -> T { second } } @MyBuilder func build() -> (Int, String?) { 42 if Bool.random() { "hello" } }

実生活の状況

バックエンドチームは、流暢なインターフェースを使用してデータベースクエリパイプラインを構築する必要がありました。彼らは、開発者がドットでメソッドをチェーンするのではなく、操作を縦にリストできる構文を求めており、スキーマの互換性に対するコンパイル時の検証を維持したいと考えていました。

彼らは最初に、各操作が変更されたQueryオブジェクトを返す従来のメソッドチェイニングを使用することを検討しました。このアプローチは単純なリニアパイプラインには適していましたが、条件付きでフィルタやジョインを追加する必要があるときには不格好になり、一時変数や複雑な三項演算を必要としました。また、すべての中間型が同じでなければならず、ステージ別の最適化を妨げました。

別のオプションとして、クロージャーベースの修飾子の配列[(Query) -> Query]を受け入れることが考えられました。これにより、希望する縦の構文を実現できましたが、各ステップでの型情報が完全に消去され、列の存在や型の不一致に対するコンパイル時の検証ができなくなりました。ベンチマークでは、変換クロージャーをインライン化できないため、15%のランタイムオーバーヘッドが生じることが確認されました。

チームは、カスタム@QueryBuilder結果ビルダーを実装しました。彼らは異種のパイプラインステージを受け入れ、それらを型付きのタプルに組み合わせるためのオーバーロードされたbuildBlockメソッド、型を消去せずに条件付きのWHERE句を処理するためのbuildEither、およびforループ生成のJOIN操作のためのbuildArrayを定義しました。これにより、縦の宣言的な構文が維持され、ゼロコストの抽象化が可能になり、LLVMオプティマイザがパイプライン構築全体をインライン化できるようになりました。クエリ定義コードは50%短縮され、スキーマの不一致は統合テスト中ではなくコンパイル時に検出されました。

候補者がしばしば見逃すこと

異なるケースが異なる具体的な型を返す場合、結果ビルダー内でコンパイラーはどのようにswitch文をデザガーするのか?

コンパイラーはswitchをネストされたbuildEither呼び出しのバイナリツリーに変換し、型チェッカーにすべてのブランチを単一の型に統一させる必要がります。ケースが異なる型(例えば、SwiftUIでのTextImage)を返すと、ビルダーが型消去を提供しない限り、コンパイルは失敗します。候補者はしばしばswitchが特別な多方向ディスパッチ処理を受けるという前提を持っていますが、実際にはバイナリの決定(最初のケースと残り)の下でカスケードするだけです。この解決策は、すべてのケースが同じ具体的な型を返すことを確認するか、値をAnyViewのような存在型コンテナでラップするためにbuildExpressionを実装する必要がありますが、これは静的最適化の機会を犠牲にします。

結果ビルダー内に@availableチェックを追加することが、buildLimitedAvailabilityを介して特別な処理を必要とするのはなぜか?

結果ビルダーが可用性チェック(例えば、if #available(iOS 15, *))でラップされたコードを含むとき、コンパイラーは保護されたブロック内のコンポーネントがすべてのデプロイメントターゲットに存在することを保証できません。buildLimitedAvailabilityがなければ、型チェッカーは最低デプロイメントターゲットに対して可用性で保護されたコードを検証しようとするため、失敗します。このメソッドはコンパイル時のフィルターとして機能し、古いOSバージョンを対象とする際にビルダーがプレースホルダーまたは空の値に置き換えることを許可します。候補者は、これがバイナリ生成前に利用できないコードパスが完全に型消去または置き換えられることによって「シンボルが見つかりません」リンク時エラーを防ぐことを理解していません。

buildExpressionbuildBlockの正確な違いは何で、型安全性のためにbuildExpressionを実装する必要があるのはいつか?

buildBlockはすでに変換された複数のコンポーネントを最終的な結果に結合しますが、buildExpressionは、個々の式がbuildBlockに渡される前に変換されるオプションのフックです。候補者はしばしば、buildExpressionが式レベルでの早期の型消去を可能にし、異種の型を結合する前に統一できることを見逃します。例えば、SwiftUIViewBuilderは、必要な場合にのみビューをAnyViewで暗黙的にラップするためにbuildExpressionを使用します。この違いを理解しないと、開発者は逐次文の間で型の不一致を優雅に処理するビルダーを実装できず、ユーザーにすべての式を手動でキャストさせることを強いることになります。