こんにちは、西 @jrsaruo_tech です。
iOSDC Japan 2025楽しかったですね!感想はまた別の記事で。
アンドパッドのブースでは最終日もクイズを出題していました。
遅くなってしまいましたが、本記事ではDay2 午前に出題したクイズ4題を解説します(Q4の解説執筆に苦戦しました)。
それぞれの解答と解説はセクションを閉じているので、まだ解かれていない方はぜひチャレンジしてみてください。
※ クイズで使用しているSwiftのバージョンは6.1.2、言語モードは6です。
% swift --version swift-driver version: 1.120.5 Apple Swift version 6.1.2 (swiftlang-6.1.2.1.2 clang-1700.0.13.5) Target: arm64-apple-macosx15.0
クイズ Day2 午前
Q1 ★☆☆☆☆
問題
/* ? */に当てはめて警告なしにコンパイルが通るものを選んでください。
func foo() async throws -> Int { 0 } func useFoo() async throws { let value = /* ? */ print(value) }
A
foo()B
async throws foo()C
try await foo()D
await try foo()
解答
C
解説
async throwsな関数は以下のいずれかの形で呼び出します。
// 頭にtry awaitをつける let value = try await foo() print(value) // async letで受け取る async let value = foo() print(try await value) // 受け取った返り値を参照する際にtry awaitをつける
今回はlet value = /* ? */に当てはまるものなので選択肢Cのtry await foo()が正解です。
選択肢Dのawait try foo()でもコンパイルは通りますが、try awaitの順にしてねという警告が出ます。
Q2 ★★☆☆☆
問題
次のコードはコンパイルが通る。○か×か?
func foo(isFoo: Bool, bar: Int) { let value: String if isFoo { value = "Foo" } else { switch bar { case ...0: value = "Bar <= 0" case 1: value = "Bar == 1" default: break } } print(value) }
解答
×
解説
もし問題のコードでfoo(isFoo: false, bar: 2)を呼ぶとどうなるでしょうか?if文のelse節に入ったのち、switch文のdefaultケースに入って分岐を抜け、変数valueが初期化されないまま最後のprint(value)に到達してしまいます。
Swiftではこのように変数が初期化されないまま参照され得るようなコードはコンパイルエラーとなります。初期化漏れを防いでくれるので安心ですね。
逆に、もし問題のコードのdefaultケースでvalueを初期化するよう修正すればコンパイルが通るようになります。valueをletで宣言できるのもポイントです。変数/定数の初期値を条件に応じて決めることができるので、うまく活用しましょう(最近はif/switch式で事足りるケースもありますが)。
Q3 ★★★☆☆
問題
次のなかでコンパイルが通るものをすべて選んでください。
A
private struct PrivateError: Error {} public func publicFunction() throws { throw PrivateError() }B
struct DismissAction { func callAsFunction(animated: Bool) { print("Dismiss") } } func useDismissAction(_ dismiss: DismissAction) { dismiss(animated: true) }C
struct FileDescriptor: !Copyable { var rawValue: Int32 }D
enum Color { case black static var app: AppColors { .init() } } struct AppColors { var primaryText: Color { .black } } let textColor: Color = .app.primaryText
解答
A, B, D
解説
A:OK
意外に感じる方もいると思いますが、このコードはコンパイルが通ります。privateな型として宣言したエラー型でも、publicなメソッドから投げることができます。
まずは返り値で考えると納得しやすいかもしれません。
public protocol FooProtocol {} private struct Foo: FooProtocol {} public func foo() -> any FooProtocol { return Foo() }
このコードはコンパイルが通ります。呼び出し側にはあくまでany FooProtocolにしか見えない、つまりFooという具体的な型は露出しないからです。
エラーも同様です。throws関数の呼び出し側では投げられたエラーをany Error型として受け取る、つまりprivateなエラー型自体は利用側には露出しないので、publicなメソッドからでもprivateなエラー型を投げることが許可されているのです。
B:OK
これもコンパイルが通ります。
callAsFunctionというインスタンスメソッドを持つ任意の型は、そのcallAsFunctionメソッドの呼び出しにおいて.callAsFunctionの部分を省略でき、あたかもインスタンスを関数として扱っているかのように書くことができます。
struct Foo { func callAsFunction() {} // オーバーロードもOK func callAsFunction(argument: Int) async throws -> String { ... } } let foo = Foo() // 以下は等価 foo() foo.callAsFunction() // try awaitする、引数を渡す、返り値を受け取るといったことも可能 let result: String = try await foo(argument: 10) // ただし関数型オブジェクトとして扱うことはできない // NG: let closure: () -> Void = foo
この機能はSwiftUIでも活用されています。
例)DismissAction
struct Foo: View { @Environment(\.dismiss) var dismiss // dismissの型はDismissActionというstruct var body: some View { Button("Dismiss") { dismiss() // あたかもdismissという関数を呼ぶかのように扱える } } }
C:NG
このコードはコンパイルが通りません。non-copyableな型を定義するには!ではなく~記法を使います。
struct FileDescriptor: ~Copyable { var rawValue: Int32 }
以下ではnon-copyableとは何かについて解説します。すでに理解されている方は選択肢Dまで読み飛ばしていただいて大丈夫です。
Swiftで型を宣言すると、その値はデフォルトでコピーできます。
struct Foo {} let foo = Foo() print(foo) // print関数にfooをコピーして渡している
コピーできることを表すプロトコルがCopyableです。通常、型やプロトコルを宣言すれば暗黙的にCopyableに適合します。
struct Foo {} // struct Foo: Copyable {} protocol FooProtocol {} // protocol FooProtocol: Copyable {}
コピーが発生することは大抵問題になりませんが、時に困る場面があります(詳細は割愛しますが、例えば単一リソースを扱いたいときやコピーコストが無視できないような場面です)。そこで、この暗黙のCopyable適合を抑止する、すなわち値をコピーできない型:non-copyableな型を宣言する方法が提供されています。
暗黙のCopyable適合を抑止するための記法が~Copyableです。
struct Bar: ~Copyable {} let bar = Bar() print(bar) // Error: print関数のbarをコピーして渡せない
ということで、選択肢Cは!Copyableという誤った記法を用いているためコンパイルが通りません。
ここからは補足ですが、なぜ否定の記号としてよく使われている!が採用されなかったのでしょうか?
ちょっとややこしいのですが、~Copyableは「Copyableではない」という意味ではなく「勝手にCopyableに適合させない」「Copyableであるとは限らない(コピーできるとは限らないので結果的にコピーができない)」というニュアンスです。structなどの具体的な型を~CopyableにしたときはCopyableに適合させることができなくなるので実質「Copyableではない」とも言えますが、~の本質はやはり「暗黙のプロトコル制約を外す」です。
Equatableプロトコルへの適合を外すことを考えてみると分かりやすいです。
まずはEquatableを継承したプロトコルPを考えてみましょう。
protocol P: Equatable {}
このPからEquatableプロトコルへの適合を外します。
protocol P {}
PのEquatable適合を外したからと言って、このPに適合した型がEquatableでないとは限りません。Equatableな型もPに適合できます。
struct Foo: P {} struct Bar: P, Equatable {} // OK
Pのみに適合した型はEquatableに適合しているとは限らないので、以下のコードでp同士の比較はできません。が、当然Equatableな値もpに渡すことができます。やはりEquatableでないとも限らないわけです。
func useP(_ p: some P) { print(p == p) // NG: 受け取ったpはEquatableであるとは限らないので比較できない } useP(Foo()) useP(Bar()) // EquatableなBarも渡せる
~Copyableも同じです。まずはCopyableを継承したプロトコルPを考えてみます。
protocol P: Copyable {}
このPからCopyableプロトコルへの適合を外してみましょう。単に: Copyableを消すだけでは暗黙的に適合してしまいます。そこで~Copyable記法を使います。
protocol P {} // protocol P: Copyable {} // ↓ Copyable適合を外す protocol P: ~Copyable {}
PのCopyable適合を外したからと言って、このPに適合した型がCopyableでないとは限りません。Copyableな型もPに適合できます。
struct Foo: P, ~Copyable {} struct Bar: P, Copyable {} // OK
Pに適合した型はCopyableに適合しているとは限らないので、以下のコードでpをコピーすることはできません。が、Copyableな値もpに渡すことができます。繰り返しになりますがCopyableでないとも限らないのです。
// some Pとだけ書くとsome P & Copyableと推論されてしまうので~Copyableも明記 func useP(_ p: consuming some P & ~Copyable) { print(p) // NG: 受け取ったpはCopyableであるとは限らないのでコピーできない } useP(Foo()) useP(Bar()) // CopyableなBarも渡せる
~Copyableが「Copyableでない」ではなく「Copyableとは限らない(Copyableかもしれないしそうでないかもしれない)」というニュアンスであることがお分かりいただけたでしょうか。もし!Copyableという書き方だと前者のように見えてしまうので避けられたわけですね。
なお、この~記法はnon-copyable専用の記法というわけではなく、一般に「プロトコル適合を抑止する」「プロトコル制約を外す」ためのものです。~BitwiseCopyableや~Escapableなどでも使われています。
D:OK
ここまで随分長くなってしまったのでここはサクッといきましょう。
選択肢Dのポイントはlet textColor: Color = .app.primaryTextが通るかどうかで、これはコンパイルが通ります。
T.foo.bar.bazがT型であるとき、この式全体がT型だと推論できるコンテキストであれば、T.fooやT.foo.barがどんな型であれ頭のTを省略できます。
ということでこのクイズの答えはA, B, Dでした。
Q4 ★★★★★
問題
以下のNonSendableクラスを用いたコードのなかで、コンパイルが通るものをすべて選んでください。
class NonSendable {}
A
nonisolated func a(_ nonSendable: NonSendable) { Task { print(nonSendable) } }B
@MainActor func b(_ nonSendable: NonSendable) { Task { print(nonSendable) } }C
nonisolated func c(_ nonSendable: sending NonSendable) { Task { print(nonSendable) } }D
nonisolated func d(_ nonSendable: NonSendable) { c(nonSendable) // 選択肢Cの関数c }
解答
B、C
解説の前に
Swift Concurrencyに関するクイズです。これを解くには多くの概念を理解する必要があり、かなり難易度が高いです。正直、ちゃんと理解していなくてもコードを書くことはできるでしょう。しかし、コンパイルエラーが出たときにその原因と適切な対処法が見えるかどうかはそれらへの理解にかかっています。ぜひ言語機能への理解を深め、やりたいと思った処理を思い通りに実現できる表現力を手に入れましょう*1。
とはいえすべて詳細に説明するとかなり長くなってしまうので、ここでは重要なポイントをかいつまんで説明します。Data race safetyに関する基本的な概念(actorへの隔離、isolation domain、Sendableなど)について理解されている方は「解説」セクションまで読み飛ばしてください。
しっかり学びたい方は以下のようなWWDCのセッションやドキュメントを参照してください。セッション動画の方が取っ掛かりやすいかと思います。
- WWDC2021: Protect mutable state with Swift actors
- WWDC2022: Eliminate data races using Swift Concurrency
- Documentation: Data Race Safety
では基本的な概念から見ていきます。
Swift Concurrencyにおける最大のテーマの1つはデータ競合の防止です。
データ競合とは「あるデータに対して複数スレッドから同時に読み書きすることでデータが壊れてしまう問題」のことです。例えば0という値に対して複数のスレッドから同時に10000回インクリメントしたら10000ではなく9928とか9845になった、みたいなことが起きます。
データ競合を防ぐには、同じ値に対する複数スレッドからの同時読み書きが発生しないようにしなければなりません。それをコンパイル時点で保証してくれるのがactorをはじめとするSwift Concurrencyのシステムです。データ競合が起きないことを保証できるコードはコンパイルが通り、データ競合のおそれがあるコードはコンパイルエラーになります*2。
そのための最も重要なコンセプトはactorへの データ隔離(isolation) です。
actorは可変データを「隔離」します。隔離されたデータへのアクセスはactorによってうまく制御され、複数スレッドから同時に読み書きされないよう保護してくれます。
actorのインスタンス1つ1つは自身の隔離領域を持ち、その中にデータを隔離します。この隔離領域をisolation domainと言います*3。
同じisolation domain内では複数スレッドから同時に読み書きされないことが保証されるためデータを自由に読み書きできますが、異なるisolation domainへ値を渡す(sendする)とデータ競合が起き得るので一定の制限がかかります。
異なるisolation domainへ渡してもデータ競合が起き得ない型はSendableプロトコルに適合させることができ、自由にsendできます。一方、Sendableでない値を異なるisolation domainへ渡すとデータ競合が起き得るため、基本的にはsendすることができません。ここで「基本的には」と書いたのは、データ競合が起きないことが保証できる場合に限りSendableでない値であっても異なるisolation domainへ渡せるからです。その手段の1つがsendingキーワードです。
それでは問題の解説に移ります。
解説
問題で示されたNonSendableクラスはSendableプロトコルに適合していないため、基本的には異なるisolation domainへ値を渡す(sendする)ことができません。しかし、sendingキーワードによってその制約を緩和することができます。
以下の例を見てみましょう。
class NonSendableCounter { var value = 0 } nonisolated func useCounter(_ counter: sending NonSendableCounter) { Task { @MainActor in // counterがnon-isolatedな環境からMainActor-isolatedな環境にsendされているがコンパイルが通る counter.value += 1 } }
Sendableでないcounterをnon-isolatedな環境からMainActor-isolatedな環境にsendしていますが、このコードはコンパイルが通ります。なぜなら、引数の型に付けられたsendingキーワードによって(呼び出し側に後述の制限がかかることで)安全にsendできるようになるからです。
sendingキーワードはざっくり言えば「値をsendする(かもしれない)のでもう使わないでね」と宣言するものです。例えば以下のようにSendableでない値をsendingな引数に渡すと、その後アクセスできなくなります。
func playSending() { let counter = NonSendableCounter() useCounter(counter) counter.value += 1 // Error }
もしこの制約がなかったらどうなるかというと、以下の流れでデータ競合が起き得ます。
counterをisolation domain A(ここではnon-isolated)からdomain B(ここではMainActor-isolated)にsendする- domain Aから再び
counterにアクセスする - 2と同時にdomain Bからも
counterにアクセスされ、データ競合が発生する
この2を禁止するという制約を課すことで上記の問題を回避でき、結果Sendableでない値を安全にsendできるようになるわけです。
以上を踏まえて選択肢を見ていきましょう。
A:NG
Sendableでない値nonSendableを外から受け取り、新たに作ったTaskに渡しています。
そのTask.initが受け取るクロージャoperationにはsendingキーワードが付いています。
https://developer.apple.com/documentation/swift/task/init(name:priority:operation:)-2dll5-2dll5)
init( ..., operation: sending /* 中略 */ () async -> Success )
sendingなクロージャは値をキャプチャできますが、その値にも同様の制約がかかります。つまり、sendingなクロージャにSendableでない値を渡すと、それ以降その値へのアクセスが禁止されます。ここでは引数nonSendableをTaskのクロージャに渡した後でnonSendableにアクセスしてはならないということです。
ところが、以下のように関数aの呼び出し側で(aを呼び出した後に)nonSendableにアクセスされる可能性があります。
let nonSendable = NonSendable() a(nonSendable) print(nonSendable) // 💣
これはsendingのルール(send済みの値にアクセスしてはならない)に反しており、データ競合の恐れがあります。これを防ぐため、引数で受け取ったSendableでない値はsendingクロージャに渡すことができないようになっています(これをできるようにする方法もあり、それが後述の選択肢Cです)。というわけで選択肢Aのコードはコンパイルが通りません。
B:OK
選択肢Aとの違いはnonisolatedが@MainActorに置き換わっている点です。
MainActor-isolatedな関数bにnonSendableが渡されている時点で、nonSendableはMainActorに隔離されています。またTask.initのoperationクロージャは呼び出し元のisolationを継承するので、operation内もMainActor-isolatedです。
すなわちMainActorに隔離されたnonSendableをMainActorに隔離された環境に渡しているだけでsendしていないので、このコードはコンパイルが通ります。
C:OK
選択肢Aとの違いは引数の型にsendingキーワードが付いている点です。
再度確認しておくと、選択肢Aの問題点は呼び出し側でnonSendableに再度アクセスされるかもしれないという点でした。
// 再掲 let nonSendable = NonSendable() a(nonSendable) print(nonSendable) // 💣
一方、関数cのようにnonSendable引数にsendingキーワードが付いていればそのルール(send後のアクセス禁止)がcの呼び出し側に適用され、関数呼び出し後にnonSendableにアクセスできなくなるので、上記の問題が起き得ません。
let nonSendable = NonSendable() c(nonSendable) print(nonSendable) // こっちがコンパイル時点で弾かれる
したがってデータ競合の恐れがなくコンパイルが通ります。
D:NG
これは選択肢AがNGなのと同じ理由でコンパイルが通りません。
以上からこのクイズの答えはB、Cでした。
おわりに
以上、Day2 午前のクイズ解説でした。午後の解説記事も追って公開します。
お読みいただきありがとうございました!