Any型のオブジェクトをProtocolにキャストしようとすると失敗する場合の対策と原因


TL;DR

  • Any型(Objective-Cで言うところのid型)にProtocolに準拠した値型オブジェクトをas AnyObjectを行って格納し、さらにそのAny型のオブジェクトをProtocolにキャストしようとすると失敗する。

  • 格納したAny型のオブジェクトを一度as AnyObjectしてから、Protocolへキャストすると成功する。

  • これはObjective-CとSwiftの間でのブリッジの挙動によって起こる。

  • Swift3の頃からIssueに上げられているが現在(Xcode10.2, Swift5)でも修正されていない。

何が起こったか

Any型のオブジェクトからプロトコルにキャストしようとしたところ、プロトコルに準拠した値が入っているのにも関わらず、キャストに失敗してしまいました。

以下に簡単に再現できるソースを貼るのでPlayground等で試してみてください。

import Foundation

protocol Animal {}

struct Dog: Animal {}

let anyAnimal: Any = Dog() as AnyObject

print(anyAnimal as? Animal) // nil

anyAnimalには、Animalに準拠しているDogの値が入っているにも関わらず、キャストに失敗してnilが出力されてしまいます。

SR-3871

これはSwift側のバグなのかと思い、調べてみると以下のIssueを発見しました。
[SR-3871]Protocol passing via objective-c / Any can't be cast back to protocol type

このIssueは、Objective-CのAny型に格納されたProtocolに準拠した値オブジェクトをProtocolにキャストしてAny型から戻そうとすると失敗するという内容に関してのIssueです。

これは現在(Xcode10.2, Swift5)でも解決されていません。

コメントを見てみるとSwiftの値型オブジェクトをObjective-Cで扱うためにas AnyObjectでキャストをした状態でAny型プロパティに格納すると、Protocolへの準拠の判定ができなくなるために、キャストできるかどうかの判定で弾かれるということのようです。

解決策

一度as AnyObjectを挟んでからキャストすると、Protocolへのキャストも成功します。
上記のコードであれば以下のような対応を行えば正しくProtocolにキャストされます。

print((anyAnimal as AnyObject) as! Animal) // __lldb_expr_11.Dog

もしくは、Dogをclassに変更することでも、正しくキャストする事ができるようになります。

import Foundation

protocol Animal {}

class Dog: Animal {} // struct → class

let dog = Dog()
let anyAnimal: Any = dog as AnyObject
print(anyAnimal as! Animal) // __lldb_expr_11.Dog

次はどうして、AnyObjectにキャストしたり、Dogをstructからclassに変更するとProtocolへのキャストが成功するのかを探っていきましょう。

原因

原因にたどり着くためには、まずas AnyObjectの挙動について理解する必要があります。

as AnyObjectの挙動

as AnyObjectは、Swiftの値型オブジェクトをObjective-Cで扱うために、クラスのインスタンスに変換する機能を持ちます。

クラスのインスタンスに変換するということを詳細に説明すると、

  • クラスの型に行った場合は何もしない

  • Bridged value typesに行った場合は、_ObjectiveCBridgeableプロトコルで指定された型に自動で変換される

    • Bridged value typesであるDictionary、Arrayなどの型は、_ObjectiveCBridgeableによってObjective-Cで扱う際の型を指定しているので、NSDictionaryやNSArrayなどの型として扱う事できるといった感じです。
  • それ以外の場合に行った場合は、Immutableのクラスとして変換する

    • その際 _SwiftValue というクラスに変換される

as AnyObjectに関しては、以前に@takasekさんが「as AnyObject で何が起こるのか」という素晴らしい記事を書かれているので、それを読めば、より詳細に理解できるかと思います。

_SwiftValue in Any

as AnyObjectの挙動が理解できたところで、最初に挙げたコードを見ていきましょう。

import Foundation

protocol Animal {}

struct Dog: Animal {}

let anyAnimal: Any = Dog() as AnyObject

print(type(of: anyAnimal)) // _SwiftValue

Dog()は値型であり、_ObjectiveCBridgeableに準拠しているわけでもないのでas AnyObjectした時点で、_SwiftValueに変換されて、Objective-Cの領域であるAnyに格納されます。

もちろんprint(type(of: anyAnimal))では、_SwiftValueが出力されます。

// Any(_SwiftValue) → Protocol
print(anyAnimal as? Animal) // nil

// Any(_SwiftValue) → Struct
print(anyAnimal as! Dog) // __lldb_expr_11.Dog

// Any(_SwiftValue) → AnyObject(_SwiftValue) → Protocol
print((anyAnimal as AnyObject) as! Animal) // __lldb_expr_11.Dog

以上のキャストの結果を見ると、Objective-Cの領域のAny(id)型に格納されている_SwiftValueに対してProtocolに準拠しているかどうかを判定できないがゆえに、キャストもできないという事のようです。

よって、解決法で挙げたようにSwiftの領域にあるAnyObject型に一度変換する事で、それを回避する事ができるようになります。

class

as AnyObjectはclassに対しては何も行わないというのは先ほど述べた通りです。

import Foundation

protocol Animal {}

class Dog: Animal {}

let dog = Dog()
let anyAnimal: Any = dog as AnyObject
print(type(of: anyAnimal)) // Dog
print(anyAnimal as! Animal) // __lldb_expr_11.Dog

as AnyObjectをDogに対して行っても、何も行われないため、_SwiftValueに変換されず、Any型に格納されていてもProtocolへのキャストに失敗する事がなくなります。

どういった場合に起こり得るか

今日では、Swiftが浸透しているため、Objective-Cとのブリッジを特に意識する必要はあまりなく、今回述べたような状況に陥ることはほとんどないのではないかと思います。

しかし、実際のところFoundationやOSSのライブラリがObjective-Cで作成されていることで、それを意識していないと、思わぬ落とし穴にはまる事があります。

Notification がその最たる例でしょう。

例:Notification

以下が、サンプルです。

import Foundation

protocol Animal {}

struct Dog: Animal {}

final class HogeClass {

    init() {
        // Notificationを登録する
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(self.didReceiveNotification(_:)),
                                               name: .init("Hoge"),
                                               object: nil)
    }

    // Notificationを受け取る
    @objc
    private func didReceiveNotification(_ sender: Notification) {
        print(type(of: sender.object!)) // _SwiftValue
        print(sender.object as? Animal) // nil
    }
}

let hoge = HogeClass()
// Notificationを投げる
NotificationCenter.default.post(name: .init("Hoge"), object: Dog())

NotificationCenterがpostする際の引数のobject(Any型)にDogのインスタンスを指定します。

実際に受け取ったHogeClassのprivate func didReceiveNotification(_ sender: Notification)sender.objectは、内部でas AnyObjectが行われているためか、_SwiftValueとして受け取らざるを得なくなり、Animalにキャストしようとすると失敗し、先ほどの解決法を使用することになります。

個人的感想

こういったことから、開発中のアプリのソースコードにSwiftとObjective-Cが共存していないとしても、フレームワークやライブラリにObjective-Cが存在している限りは、それらとSwiftとのブリッジの意識は頭の片隅に置いておいたほうが良いでしょう。