Protocol ExtensionでOptional型を無闇に使ってハマったこと。


要約

この記事はダラダラと長いです。コメント欄の議論がよくまとまっているので、先にそちらを見ていただいたほうがいいかもしれません。
Protocolに宣言されたプロパティと同名でかつ型の違うプロパティをProtocol Extensionで記述すると、それを実装したクラスや構造体に同名で型の違う2種類のプロパティが存在するという状況が起こるというトラップのような仕様のお話です。

僕が実際に体験したこと

最近Protocol Oriented Programmingを勉強して、さっそく業務でProtocol Extensionを意識したコードを書き始めています。ですが、単純な罠にハマって1時間ほど何が原因なのかがわからず困ってしまったので戒めの意味を込めて記事を書いてみました。

今回は例としてCallableというものを考えます。構造体に実装することで、名前を呼ぶことができる構造体を作ることができます。
特に指定がない場合を考えて、デフォルトの名前として、"You"を利用するようにしたいので、Protocol Extensionにて実装しました。

protocol Callable {
    var name: String { get }
}

extension Callable {
    var name: String {
        "You"
    }
}

使い方としてはこんな感じをイメージしておきます。

struct Taroh: Callable {
    let name = "Taroh"
}
let person = Taroh()
print(person.name) // Taroh
struct NoNamePerson: Callable {
}
let person = NoNamePerson()
print(person.name) // You

ここまでは問題がないのですが、ここからが問題です。
nameプロパティはgetアクセサしか持たないので、構造体を作ったあとにはその値を変更することができません。
そこで僕は思いつきました。「Optionalにして、後で入れればよいのでは」と。

struct Taroh: Callable {
    var name: String? = nil
}

そうすると、

var mutableTaroh = Taroh()
mutableTaroh.name = "new man"

このように、値を入れることができるようになります。
では、さっそくプロトコルに仕事をしてもらおうと、ジェネリクス関数を作成しました。
実行してみるとこうなるんです。

func callName<T> (_ sample: T) where T: Callable {
    print(sample.name)
}
callName(mutableTaroh) // You

さっきnew manを入れたばかりなのに、デフォルトの実装のままになってしまっています。
実はこの状態で構造体のプロパティの内容をプリントすると、

print(mutableTaroh.name) // Optional("new man")

正しく値は入っているのですが、なぜかOptional型になっています。

事実としてわかっていること

完璧な原因までは特定できてないので、コメント欄にてこの問題をどのように解釈すればいいのか教えていただきたいです。
ここからは自分なりの解釈を書きます。

Protocol Extensionをやめると、そもそもオプショナル型のプロパティを実装できない

struct Taroh: Callable {
    var name: String? = nil
}

この上記の実装は、デフォルトの名前をProtocol ExtensionでYouになるようにするのをやめると、エラーになります。
つまり、Protocol Extensionをしたことによって、(今思えば)謎の実装が可能になったわけです。

Optionalが悪いのかと思い、試しにnameプロパティをIntInt?の別の型にしてみると、エラーになります。
まずイニシャライザが変わるということと、新しくnew manと代入したところが型の不一致でエラーになります。

ひとまずここでわかったことは、ここでエラーになっていればProtocol Extension周りで何かがおかしいことに気付けるということです。逆に言うと、StringからString?に型をオプショナルにするだけの変更に変えたことによってこの問題に気づくことができなくなったということがハマった一つの原因として挙げられそうです。

Optionalで上書きしたことによって、対象のオブジェクトをプロトコルとして見るか、構造体として見るかで値が変わるようになってしまった。

簡単にいうとこういうことなんですが、、

let taroh = Taroh()
print(taroh.name) // Optional("new name")
let tarohCallable: Callable = Taroh()
print(tarohCallable.name) // You

これは納得行きそうな気はしますが、

struct Taroh: Callable {
    let name = "Taroh"
}
let person = Taroh()
print(person.name) // Taroh

上記のケースだと、Callableプロトコルにしても値は変わりません。むしろこっちも直感的な挙動ですよね。

setアクセサをプロトコルに宣言するとOptionalで上書きできなくなる

protocol Callable {
    var name: String { get set }
}

問題が起きてる状態で、上記に変更するとProtocolに準拠していないとエラーで怒られます。

雑感

結果的になぜこうなるかはここまででわからずじまいですが、とりあえず何も考えずにOptionalで上書きするのは直感的な挙動から大きく外れるのでやめたほうがいいということです。
特に僕がハマったときは、デフォルトの値が空配列だったので、余計に何が起きてるかを特定するのに時間がかかりました。。

そもそも、Protocol Extensionの実態が何なのかということと、アクセサの実態が具体的にどうなっているのかがわかっていない可能性があると感じました。(そこまでわからなくても良さそうな気はするのですが・・・)
おそらく何らかの原因でProtocol Extensionのプロパティと構造体のプロパティが別物として扱われているのだと思っています。

protocol Callable {
    var name: String { get }
}

extension Callable {
    var name: String {
        "You"
    }
}

struct Taroh: Callable {
    var name: String? = nil
}

struct NoNamePerson: Callable {
}

func callName<T> (_ sample: T) where T: Callable {
    print(sample.name)
}

var mutableTaroh = Taroh()
mutableTaroh.name = "new"
print(mutableTaroh.name)
callName(mutableTaroh)

最後に、問題のコードを貼り付けておきます。

Apple Swift version 5.4.2 (swiftlang-1205.0.28.2 clang-1205.0.19.57)
Xcode: Version 12.5.1 (12E507)