🚨

[要注意] StrictConcurrencyを有効化するだけでprotocolの関数が呼ばれなくなることがある

2024/12/19に公開

はじめに

Swift 6が正式にリリースされ、みなさんのプロジェクトでも少しずつ対応が進み始めていることと思います。
今回この記事では、実際に自分が体験したStrictConcurrencyを有効化するだけで、protocolの関数が呼ばれなくなる怪現象を共有します。

再現環境

以下のようなprotocolとその実装を考えます。

@objc @MainActor public protocol FooDelegate: AnyObject {
    @objc optional func doSomething(_ completion: @escaping @MainActor () -> Void)
}

@MainActor
class FooImplement: NSObject, FooDelegate {
    var isCalled = false
    
    func doSomething(_ completion: @escaping () -> Void) {
        isCalled = true
        completion()
    }
}

問題

さて、このFooImplementのモジュールがStrict Concurrencyが無効であれば、FooDelegateのdoSomething関数は正しく呼ばれます。

let foo = FooImplement()
let delegate: any FooDelegate = foo
delegate.doSomething? {}
#expect(foo.isCalled) // 🟢成功

しかし、このFooImplmentのモジュールでStrict Concurrencyが有効になれば、コードは全く同じなのにFooDelegateのdoSomething関数は呼ばれなくなります。

let foo = FooImplement()
let delegate: any FooDelegate = foo
delegate.doSomething? {}
#expect(foo.isCalled) // ❌失敗

原因

この原因はprotocol側のdoSomething関数のcompletionがMainActorにisolationされているにも関わらず、実装側のdoSomething関数のcompletionはMainActorにisolationされていないことが原因です。

@objc @MainActor public protocol FooDelegate: AnyObject {
    // completionがMainActorにisolationされている
    @objc optional func doSomething(_ completion: @escaping @MainActor () -> Void) 
}

@MainActor class FooImplement: NSObject, FooDelegate {
    // completionがMainActorにisolationされていない
    func doSomething(_ completion: @escaping () -> Void) 
}

StrictConcurrencyを有効にするかどうかで、FooDelegateのインターフェースの見え方が変わり、MainActorの有無で別のインターフェースとして認識されるようになりました。
さらに不幸なことにoptiona funcなので、実装がなくてもビルドが通ってしまいます。
結果として、ビルドが通ったのに今まで呼ばれていた関数が呼ばれなくなるという現象が発生します。

現実的に起こりうるケース

さて、これが実際問題起こり得るシチュエーションの一つとして、WebKitWKNavigationDelegateなどです。
例えば、このprotocolには以下のような関数が定義されており、Xcode 16からしれっとdecisionHandlerにMainActorがつけられました。

@MainActor
optional func webView(
    _ webView: WKWebView,
    decidePolicyFor navigationAction: WKNavigationAction,
    decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void
)

この関数を昔から実装しているプロジェクトの場合、MainActorをつけてないことの方が多く、その場合StrictConcurrencyを有効化するだけで、この関数が呼ばれなくなるので注意が必要です。

一応警告は出る

ビルドは通ってしまいますが、コンパイラから全く指摘がないわけではありません。
一応コンパイラからoptional requirementの関数と似たインターフェースの関数だと指摘されます。

しかし、実際のプロダクトの場合、その規模にもよりますがStrictConcurrencyを有効化するだけで、警告が数百~数千出ることは珍しいことではなく、その全てを確認してなければ気づくことができない状況です。

どうすればいいか

幸い警告が出るので、ビルドしたログの中で nearly matchesなどで検索をかけて上記の警告が出ていないか確認することで気づくことができます。

extensionでデフォルトの実装がある場合はどうなるのか

ちなみに、ここまで読むと、optional funcではなくデフォルト実装がある関数の場合どうなるのか、と思う方がいると思います。

以下のようなデフォルト実装をもったprotocolを考えます。

@MainActor
public protocol BarDelegate: AnyObject {
    func doSomething(_ completion: @escaping @MainActor () -> Void)
}

extension BarDelegate {
    func doSomething(_ completion: @escaping @MainActor () -> Void) {
        completion()
    }
}

@MainActor
class BarImplement: NSObject, BarDelegate {
    var isCalled = false

    func doSomething(_ completion: @escaping () -> Void) {
        isCalled = true
        completion()
    }
}

この場合、StrictConcurrencyの有無で挙動が変わるということはなく、どの環境でもそれぞれ別の関数として認識されていました。
ただ、これ自体はそこまで違和感がある挙動ではなく、クロージャーのactor isolationであろうと、関数名であろうと、引数のラベルであろうと、定義とそのデフォルト実装を変えると等しく起こりうる可能性があることかと思います。

デフォルト実装のあるprotocolの定義はactor isoaltionであろうが軽率に変えるべきではありません。

おわりに

ビルド設定の有無でprotocolの関数が呼ばれたり呼ばれなくなったりする怪現象を紹介しました。
特にWebKitのシチュエーションは起こりやすいケースかと思いますので、みなさんのブロジェクトでもStrictConcurrencyを有効化して警告が大量に発生した場合は一度nearly matchesなどで検索をかけることをお勧めします。

Discussion