😇

SwiftのOptionalサブタイピングが抱える闇

2021/04/18に公開

この記事はDiscordで行った議論の清書です。議論は以下のURLからどうぞ。
https://discord.com/channels/291054398077927425/306995750418513920/821347439117074453

SwiftのOptional型は様々な特性がありますが、この記事で議論するのは以下の3つの特性についてです。

  1. Optionalはtagged unionである。
  2. 任意の型Tに対して、TはT?のサブタイプである。
  3. 任意の型TとそのサブタイプUに対して、U?はT?のサブタイプである。

※ この記事ではポインタの扱いなどのランタイムにおける内部的な取り扱いは議論しません。

それぞれの特徴について詳述します。

1. Optionalはtagged unionである

tagged unionである事の特徴は、対極のuntagged unionと比べるとわかりやすいです。
例えばTSのunion型やkotlinのnullableは、untagged unionといえます。列挙した型の順番は関係ないので、例えばTSにおいては
string | undefined(string | undefined) | undefined は全くの同義で、区別する方法はありません。kotlinのnullableも String?? のように二重のnullableは表現できません。

一方でSwiftのOptionalはこれが可能であり、例えば二重のOptionalに対して以下のパターンマッチをすることが可能です。

switch nestedOptional {
  case .some(.some(let value)): print(value)
  case .some(.none): print("some(none)")
  case .none: print("none")
}

例えばこれがどう役に立つかというと、Optionalの配列、例えば [Int?] に対して、最初の要素を取りだそうとすることを考えます。
配列は当然空の場合もあるので、返り値はOptionalであることが妥当でしょう。
しかし、配列要素もOptionalなので、これが区別できない場合は要素がnilだったのか、配列自体が空だったのかの区別がつきません。
二重のOptionalが表現可能であれば、.none は配列が空であった .some(.none) は要素がnilであった、と区別がつきます。
この議論はGenerics一般に拡張可能です。言い換えると

  1. tagged unionはGenericsに利用するのに何ら制限を伴わない
  2. untagged unionをGenericsに利用する場合、一定の曖昧さを含むことになる

と言ったところでしょうか。SwiftはOptionalについて1を選択していると言えます。
より詳しい議論は以下が参考になりそうです。
https://qiita.com/koher/items/e4835bd429b88809ab33

2. 任意の型Tに対して、TはT?のサブタイプである。

let a: String? = "hoge"

最もシンプルな例が上記になります。ここで "hoge" は文字列リテラルから推論されたString型なのですが、String?型に代入することができています。
実行時には以下の挙動となっています。

let a: String? = .some("hoge")

例えばサブクラスの代入なんかでは、こういった実行時のキャストに伴う値の操作は必要ないですが、Optionalの場合は必要になります。
この記事ではこういったキャストに伴う値の操作を、 暗黙の型変換 と呼称することにします。
例えばあるdelegateにselfを渡す時、そのdelegate型はほぼ間違いなくOptional型を期待しているでしょう。ですが、 .some(self) と書いたことは無いはずです。

3. 任意の型TとそのサブタイプUに対して、U?はT?のサブタイプである。

let a: Cat? = Cat("tama")
let b: Animal? = a

Swiftではこのコードもコンパイルが通ります。
実行時には以下の挙動となっています(厳密にはCoWが挟まって違いますが、ここでは省略します)

let a: Cat? = Cat("tama")
let b: Animal? = a.map { $0 as Animal }

たとえば UIViewController.willMove に任意のViewControllerを渡す時に、それが UIViewController の型になってるか、というのは気にしたことがないと思います。
或いは、scrollView.delegateUITableViewDelegate? を渡しても平気ですね。


それぞれのルールは単体で見る限りは至極真っ当で、問題になるとは思えません。
しかし問題はこれらのルールを組み合わせることによって発生します。順番に見ていきましょう。

暗黙の型変換に優先順位が必要となる

let a: Cat? = .none
let b: Cat?? = a

この場合、bはどうなっているでしょうか?というのが問題になります。
Cat? から Cat?? への変換は、性質2と性質3のどちらを当てはめることも可能です。
CatCat? のサブタイプである、と考えることも可能ですし、Cat? のOptionalである、と考えることも可能です。
実際にこれを動作させると、 .some(.none) になり、性質2が優先されていることがわかります。
挙動について熟知していないと、バグを作ってしまう可能性が高いものです。キャストに伴う仕様を把握していないといけない、というのは現代の言語としては少し辛いところがあります。

ところで、この暗黙の型変換は過去のバージョンのSwiftでは、特定条件下において性質3が優先されてしまうバグが存在しました。(私が発見して報告しました)
https://bugs.swift.org/browse/SR-6126

今では修正されましたが、こういった言語レベルのバグを含んでしまうのも、曖昧な仕様を内包するリスクの一つと言えるでしょう。

Optionalが暗黙的にアンラップされるべきか否か、期待も結果もコンテキストによって異なる

先の問題は、優先順位を定義してしまえば一応は解決するものでした。しかしこちらはそうはいきません。
少し前にOptionalとAnyの絡みで以下の記事を書いたのですが、
https://qiita.com/tarunon/items/fcd3a24084ed953e0e46

今日では状況が変わっていて、

let s = "hoge"
let x = (s as Any) as? String
let y = ((s as String?) as Any) as? String

上記のコードは x, y共に値を取り出すことができます。
これはAnyを通したキャストは暗黙的にアンラップされるべきという結果になった、と言えるでしょう。
しかし一方でAnyHashableの場合は異なります。

let s = "hoge"
let x = (s as AnyHashable).hashValue
let y = ((s as String?) as AnyHashable).hashValue

print(x == y) // false

hashValueについては暗黙的にアンラップされるべきではない、となっているようです。

ではこれはどうでしょう。

let s: String? = .none
let t: String?? = s
let u: String?? = .none

let x = (s as AnyHashable).hashValue
let y = (t as AnyHashable).hashValue
let z = (u as AnyHashable).hashValue

print(s == t, x == y) // true, false
print(s == u, x == z) // false, true

どうしてこんなことに…

そもそもTとT?の間には値としては別である、暗黙の型変換の上にサブタイプ関係は成り立っているという前提があります。
そうしたものを別の型(AnyやAnyHashable)に変換したときに、採用するべき値がTとT?のどちらなのか?と言うことが、サブタイプ関係を起因としてあやふやになってしまう。
そして例えばTを採用するのであれば、今度は 暗黙的なアンラップ という概念が出現することになります。(※私の考えとしては暗黙的なアンラップはやるべきではないと考えています)
例えばAnyはasによるキャストにおいて暗黙的なアンラップを含んでいます。
例えばAnyHashableは.hashValueの計算において暗黙的なアンラップを含んでいません。(ただしasによるキャストにおいて暗黙的なアンラップを含んでいます。)
そしてこれは何もAnyとAnyHashableに限らず、あらゆる箇所に存在する議論になってきます。N通りの事象の全てについて、暗黙的なアンラップがあるべきかどうか、期待と結果が異なっている、ということになってしまいます。

余談ですがAnyHashableの取り扱いについては一時期Swiftのベータ版で一悶着あり、Discordでも話題になりました。
https://discord.com/channels/291054398077927425/375206337937801216/806409093417533462


以下はもしXXならどうなるかという思考実験です。上記の問題はもはや解決は望めないものですが、有り得たかもしれない仮想の言語仕様において、上記の問題を解決出来るストーリーが無かったかを考えてみます。

サブタイプ関係を許さなかった場合

サブタイプ関係を許さなかった場合は、ここで挙げた問題は発生しません。
暗黙の型変換が存在しなければ、暗黙のアンラップも存在しません。
しかし代わりに、Objective-Cとの互換性はボロボロになってしまいます。
Optionalがサブタイプ関係を有していることで、Objective-Cにnull safeが存在しなかったころからのAPIに互換性を持たせることが出来ているのです。

Optionalをuntagged unionとする

untagged unionとした場合も、ここで挙げた問題は発生しません。
しかし、Genericsの型パラにOptionalを指定できなくなるか、あるいはfirstの結果が曖昧になるなどの別の問題を抱えることになります。

サブタイプ関係の有無を明示し、その場合はuntagged unionとする

サブタイプ関係をObjective-C互換のためのものと割り切り、その場合はuntagged unionとする言語仕様を考えてみます。
例えば @objc var delegate: UITableViewDelegate? のように、 @objc が付与されている場合のみ、種々のサブタイプ関係を許可します。代わりに、二重Optionalは許可されません。
ここで挙げた問題は発生せず、Objective-Cの互換性も保てますが、そこそこややこしい言語仕様になります。(Swift単体で見た時はOptionalからサブタイプ関係が無くなるだけなのでシンプルではありますが)


いずれにしても、今日まで続いてきた言語仕様を後方互換性を破壊してまで入れれるものかというと、そうではないので妄想の域を出ることはないでしょう。

昔似たようなこと書いた気がするんだよな

やはり書いていた
https://qiita.com/tarunon/items/844958accc4391097d97

Discussion