Concurrency Interoperability with Objective-C
01 何が問題だったのか
Swift の並行処理機能は async 関数やアクターを中心に据えていますが、Objective-C にはこれらに直接対応する言語機能がありません。一方で Objective-C の世界には、クロージャ(Objective-C ブロック)を用いたコンプリーションハンドラ形式の非同期 API が大量に存在します。iOS 14.0 SDK だけでも、コンプリーションハンドラを取るメソッドは 1,000 近くにのぼります。
これらは、Swift から直接呼び出すもの、Swift 側のサブクラスでオーバーライドするもの、Swift の型が適合するプロトコルの要件として現れるもの、といったあらゆる形で Swift コードと接触します。そのまま Swift にインポートしても、コンプリーションハンドラ方式のまま呼び出すしかなく、せっかく SE-0296(Async/await)で導入される async / await の恩恵を受けられません。
たとえば CloudKit の次のような Objective-C API を考えます。
- (void)fetchShareParticipantWithUserRecordID:(CKRecordID *)userRecordID
completionHandler:(void (^)(CKShareParticipant * _Nullable, NSError * _Nullable))completionHandler;
素直にインポートすると、Swift でも次のようなコンプリーションハンドラ形式の関数になり、呼び出し側はクロージャを渡して結果を受け取る必要があります。
func fetchShareParticipant(
withUserRecordID userRecordID: CKRecord.ID,
completionHandler: @escaping (CKShare.Participant?, Error?) -> Void
)
これをそのまま await で呼べれば自然ですが、Objective-C 側の宣言は変えられませんし、Swift 側で新たに async 版を書き足していくのもアプリ・ライブラリ全体には現実的ではありません。逆方向、Swift で定義した async メソッドを Objective-C から使いたいケース(@objc で公開するケース)でも、Objective-C 側には async がないためそのままでは表現できません。
加えて、アクターを @objc で公開しようとすると、アクターのスーパークラスはアクターでなければならないというルールが NSObject ベースのクラス階層とぶつかります。Objective-C のプロトコル(たとえば NSObjectProtocol)への適合も、Swift だけでは満たせません。
まとめると、次の 3 点を同時に解決する仕組みが必要でした。
- 既存の Objective-C のコンプリーションハンドラ API を、Swift からそのまま
async/awaitで呼べるようにする - Swift の
asyncメソッドを@objcで公開し、Objective-C からは従来どおりコンプリーションハンドラ形式で呼べるようにする - アクターを
@objcとして扱えるようにし、Objective-C のクラス階層・プロトコルと共存させる
02 どのように解決されるのか
Objective-C のコンプリーションハンドラ形式の非同期 API を Swift 側で async 関数として自動インポートし、逆に Swift の async メソッドを @objc で公開するときは自動的にコンプリーションハンドラ形式のメソッドとして生成します。アクターについても @objc との組み合わせを可能にし、Objective-C のクラス階層・プロトコルと共存できるようにします。
Objective-C のコンプリーションハンドラメソッドを async としてインポートする
次の条件を満たす Objective-C メソッドを「コンプリーションハンドラメソッド」とみなします。
- メソッド自身の戻り値が
void - 引数のうち 1 つが、戻り値
voidのブロック(コンプリーションハンドラ)であり、実装内のすべてのパスでちょうど 1 回呼ばれる - エラーを返せるメソッドでは、そのブロックの引数に
_NonnullでないNSError *型のパラメータが含まれる
どの引数がコンプリーションハンドラかは、明示的な swift_async 属性でも指定できますし、次のようなヒューリスティクスで自動推論されます。
- 引数が 1 つだけで、最初のセレクタ名の末尾が
WithCompletion/WithCompletionHandler/WithCompletionBlock/WithReplyTo/WithReplyのいずれかならば、その引数がコンプリーションハンドラ。末尾の句はインポート時の関数名から取り除かれる - 引数が複数あり、最後の引数のセレクタ名または仮引数名が
completion/completionHandler/completionBlock/replyTo/replyなどであれば、それがコンプリーションハンドラ - 引数が複数あり、最後の引数のセレクタ名が上の末尾句のいずれかで終わっていれば、それがコンプリーションハンドラ。末尾句の前の部分は関数のベース名に付け足される
コンプリーションハンドラだと判定された引数は、次のルールで async 関数の戻り値に変換されます。
- コンプリーションハンドラ引数は消える
- ブロック引数にエラーを示す
NSError *が含まれていれば、その関数はasync throwsになり、NSError *引数は戻り値からは消える - 残った引数が 1 つなら戻り値はその型、複数ならタプル型
- エラーを送出しうる関数で、ブロック引数が
_Nullable_resultで修飾されていればオプショナルとしてインポートし、そうでなければ非オプショナルとしてインポートする
たとえば PassKit の次の API は、
- (void)signData:(NSData *)signData
withSecureElementPass:(PKSecureElementPass *)secureElementPass
completion:(void (^)(NSData *signedData, NSData *signature, NSError *error))completion;
従来のコンプリーションハンドラ形式でのインポートに加えて、次の async 形式としてもインポートされます。
@objc func sign(
_ signData: Data,
using secureElementPass: PKSecureElementPass
) async throws -> (Data, Data)
呼び出し側は await で自然に使えます。
let (signedValue, signature) = try await passLibrary.sign(signData, using: pass)
コンパイラは内部的に、このような async 呼び出しをコンプリーションハンドラを使ったコードへと展開します。疑似コードで書けば次のようなイメージです。
try withUnsafeContinuation { continuation in
passLibrary.sign(
signData, using: pass,
completionHandler: { (signedValue, signature, error) in
if let error = error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: (signedValue!, signature!))
}
}
)
}
名前の整形も少し加わります。ベース名が get で始まる場合は get が取り除かれて先頭の頭字語は小文字化され、ベース名が Asynchronously で終わる場合はその語が取り除かれます。また、コンプリーションハンドラ引数が nullable で、async 版の戻り値が非 Void の場合は、結果を使わずに fire-and-forget で呼ぶユースケースに合わせて @discardableResult が付与されます。
Objective-C 側の制御用アノテーション
自動インポートでは意図どおりにならない API のために、Objective-C 側で指定できる属性も用意されます。
_Nullable_result:_Nullableと同様にブロック引数がnilになりうることを示しますが、コンプリーションハンドラブロックの引数に限り 働きを持ち、その引数はasync版の戻り値で optional になる__attribute__((swift_async(none))): この API をasync化しない__attribute__((swift_async(not_swift_private, C)))/__attribute__((swift_async(swift_private, C))): 明示的に C 番目の引数をコンプリーションハンドラとしてasync化する。swift_private版は Swift でラッパを書く用途向けに「private」なasyncとしてインポート__attribute__((swift_async_name("method(param1:param2:)"))):async版につける Swift 名を明示的に指定する(コンプリーションハンドラ引数のラベルは含めない)__attribute__((swift_async_error(none))):NSError *を普通の引数として扱い、throwsにしない__attribute__((swift_async_error(zero_argument, N)))/__attribute__((swift_async_error(nonzero_argument, N))): N 番目の整数引数が 0 / 非 0 のときthrowsさせ、その引数はasync版の戻り値から消す__attribute__((swift_attr("...")): 任意の Swift 属性を付ける汎用属性。並行処理の文脈では、Objective-C API に@MainActorのようなグローバルアクター属性を付けるのに使える
Swift 側で @objc な async メソッドを定義する
逆向きの変換も同じ考え方で行われます。Swift で定義した async メソッドに @objc を付けると、コンパイラは対応する Objective-C メソッドにコンプリーションハンドラ引数を追加した形の宣言を自動生成します。
@objc func perform(operation: String) async -> Int { ... }
に対しては、次の Objective-C メソッドが公開されます。
- (void)performWithOperation:(NSString * _Nonnull)operation
completionHandler:(void (^ _Nullable)(NSInteger))completionHandler;
コンパイラが合成する実装は、Task を分離して走らせ、結果が出たらコンプリーションハンドラに渡す、という流れです(コンプリーションハンドラが nil の場合は単に呼び出しません)。
async throws のメソッドでは、コンプリーションハンドラに NSError * 引数が追加され、Swift 側で非オプショナルなポインタ引数は Objective-C 側では _Nullable に、Swift 側で optional な引数は _Nullable_result に対応します。
@objc func performDangerousTrick(operation: String) async throws -> String { ... }
- (void)performDangerousTrickWithOperation:(NSString * _Nonnull)operation
completionHandler:(void (^ _Nullable)(NSString * _Nullable, NSError * _Nullable))completionHandler;
メソッドが正常終了した場合は第 1 引数に値、第 2 引数の NSError * に nil が渡されます。例外を送出した場合は第 1 引数に nil、第 2 引数にエラーが渡されます。非ポインタの引数は、エラーパスではゼロ初期化された値が渡されます。
合成される実装を Swift の疑似コードで表現すると次のようになります。
// コンパイラが合成する
@objc func performDangerousTrick(
operation: String,
completionHandler: ((String?, Error?) -> Void)?
) {
runDetached {
do {
let value = try await performDangerousTrick(operation: operation)
completionHandler?(value, nil)
} catch {
completionHandler?(nil, error)
}
}
}
アクターと @objc
アクタークラスも @objc にできます。通常、アクタークラスのスーパークラスはアクタークラスでなければなりませんが、例外として NSObject を直接のスーパークラスにすることは認められます。NSObject は状態を持たず、レイアウトも事実上固定されているため、アクターの状態分離とは干渉しません。これにより、@objc にしたアクターは自動的に NSObjectProtocol への適合も満たせるようになり、Objective-C の多くのプロトコル(NSObjectProtocol を要求するもの)に適合させられます。
アクタークラスのメンバを @objc にできるのは、そのメンバが async である場合か、アクターの isolation の外にある場合(後にキーワードは nonisolated に整理されます)に限ります。同期的なメンバがアクターの isolation の中にあると、self 上でしか呼び出せないという制約を Objective-C 側に伝えられず、データ競合安全性を守れないためです。
actor class MyActor {
@objc func synchronous() { } // error: アクターの isolation 内
@objc func asynchronous() async { } // OK: 非同期
@objc @actorIndependent func independent() { } // OK: isolation 外
}
コンプリーションハンドラの 1 回呼び出しをランタイムで検証
async 関数は必ず「中断する」「値を返す」「エラーを送出する」のいずれかで終わります。この対応をコンプリーションハンドラ側に翻訳するとき、コンプリーションハンドラがちょうど 1 回だけ呼ばれることが前提になります。Objective-C 側の実装が誤って複数回呼んだり、1 回も呼ばなかったりすると、呼び出し元の await が再開されなかったり、複数回再開されてしまったりして壊れます。
コンパイラはコンプリーションハンドラ本体を自前で合成しているので、そのブロック内に「すでに呼ばれたか」を表すフラグを仕込み、呼ばれずに破棄された場合や 2 回目に呼ばれた場合をランタイムで検出します。根本解決ではないものの、問題の発見はしやすくなります。
ソース互換性と既存 API の両立
同じ Objective-C API を「コンプリーションハンドラ形式」と「async 形式」の両方として Swift にインポートすることでソース互換性を保ちます。既存の Swift コードは従来どおりコンプリーションハンドラ形式で呼び続けられ、新しく書くコードから順に async 形式へ乗り換えていけます。
両形式が共存する結果、次のようなオーバーロードが発生しえます。
@objc func lookupName() -> String
@objc func lookupName(withCompletionHandler: @escaping (String) -> Void)
@objc func lookupName() async -> String
async の有無だけが違う 2 つの関数は通常は書き分けられませんが、インポート時に限り、呼び出しコンテキストが同期か非同期かに応じてコンパイラが自動で選び分けます。
プロトコル要件も同じメソッドが両形式でインポートされますが、適合する側は片方だけ実装すれば足りるように、プロトコル適合の検査が緩められます。サブクラスによるオーバーライドについては、サブクラス側で async / コンプリーションハンドラのどちらか一方しか書けないため、もう一方経由の呼び出しが基底クラス実装に落ちてしまう可能性があります。Swift は新しく書かれる async オーバーライドに対しては @objc dynamic を推論してこの問題を避けますが、既存のコンプリーションハンドラ版オーバーライドには自動推論せず、警告にとどめて互換性への影響を抑えます。
Future Directions
NSProgress を返しつつコンプリーションハンドラも取る Objective-C API(戻り値が void でないため本 Proposal の async 化対象外)については、Task 側に NSProgress を紐付ける仕組みを用意すれば将来的に async 化できる余地があります。具体的な設計は今後の提案に委ねられています。