値がOptionalなDictionaryのnil値の取り扱い

今朝呟いた本件ですが、色々補足しつつ記事としてまとめました。

値がOptionalなDictionaryとは?

値がOptionalなDictionaryとは例えば次のようなものを指しています(値がOptionalなことが大事なのでString型以外でも何でも良いです)。

var d = [String: String?]()

以下の糖衣構文ですが、上の簡単な記述とするのが一般的なはずです。

var d = Dictionary<String, Optional<String>>()

前提として値がOptionalなDictionaryを扱うのはなるべくやめた方が良いと思っている

そもそも、例えば以下のように非Optionalでもsubscriptして取った結果は String? となります。

var d = [String: String]()
let x: String? = d["key"]

もし値がOptionalだと、次のように取得結果が String?? ( Optional<Optional<String>> )とOptionalのネストになってしまいます。

var d = [String: String?]()
let x: String?? = d["key"]

Dictionaryの各種処理の戻り値自体がこのようにOptionalを返すものが多いため、値の型自体がOptionalだとややこしくなります。大抵は値の型自体をOptionalにすることなくDictionaryが包含するOptionalだけでハンドリングできると思っています。

とはいえどうしても値をOptionalとしたいこともありえるかもしれませんし、色々いじってたら面白かった、という次第です( ´・‿・`)

ちょっと前置きが長くなりましたが、以下本題です。


では、次のように宣言された d インスタンスを色々弄っていきます。

var d = [String: String?]()

`nil` をセットすると値削除となる

次のように単純に nil をセットするとsubscriptで指定したキーに対応する値の削除となります。

d["key"] = "value"
d["key"] = nil // 削除操作となるので、空のDictionaryになる

以下と全く同じですが、普通に nil リテラルを用いるのが良いです。

d["key"] = .none

Optionalの定義にもそう記述されています。

public enum Optional<Wrapped> : ExpressibleByNilLiteral {
/// In code, the absence of a value is typically written using the `nil`
/// literal rather than the explicit `.none` enumeration case.
case none
/// The presence of a value, stored as `Wrapped`.
case some(Wrapped)
}

`nil` 値をセットしたい場合は `String?.none` をセットする

では、値削除ではなく、nil値自体をセットしたい時はどうすれば良いでしょうか?

その場合は、次のように String?.none とOptionalの型を明示する必要があります。

d["key"] = String?.none
// 以下でも全く同じだが上の書き方の方が良い
d["key"] = Optional<String>.none

この挙動の違いの本質は?

では、上で nil リテラルを用いて削除操作していた時の nil とは何者か?という疑問が生じます。

それは、おそらくですが次と等価だと思います。実際にコンパイルが通り、 nil リテラル代入時と同じく値の削除処理となりました。

d["key"] = String??.none
// 以下と同じ意味
d["key"] = Optional<Optional<String>>.none

nil リテラルで代入すると、左辺からの型推論によって、 String? ではなく String??none だと解釈されてそうです。入れ子なので、一見変な解釈に感じるかもしれませんが、それはすなわちValue 型(この例の場合 String? 型)のOptionalということを踏まえると僕は自然に思いました。

ちなみに、こちらがDictionaryのsubscript set処理のソースコードです。 Value 型のOptional型の none の場合、elseの削除処理にいくことがソース的にも読み取れます。

HashedCollections.swift.gyb

Dictionary抜きにOptional入れ子のnilについて解説

色々概念が混じって頭がこんがらがる場合は、Dictionary抜きに、以下を見ると分かりやすいかもしれません。

let a: String?? = String?.none // "Optional(nil)"
let b: String?? = .none // "nil"
if let a = a {
print("a: \(a)") // 到達
}
if let b = b {
print("b: \(b)") // 到達しない
}

左辺はともに String?? 型ですが、aへの代入では右辺で String?? ではなくString? 型であることを明示しているので、”Optional(nil)”という値が存在することになり、アンラップすると値があるという扱いになります。aは次のように書き換えるとさらに分かりやすいかもしれません(説明のためにOptionalのイニシャライザーを使っているだけで実コードでは省略するのが良いです)。

let a: String?? = Optional(String?.none)

この値の有無の扱いの差が、これまで述べてきた、値がOptionalなDictionaryにどういう型の none をセットするかによって削除かnil値をセットするかの挙動の差に繋がります。

実際には削除処理はメソッドを使うのがベターだと思っている

Dictionaryの値削除はnil代入以外にも removeValue(forKey:) というメソッドでもできます。subscriptは条件によって値の追加・値の上書き・削除にもなりますが、特に削除操作はせっかくメソッドが用意されているのでそれを使った方が意図が明確になり可読性上がるかなと思いました。

というわけで、値がOptionalなDictionaryのnil値の取り扱いの際は次のように書き分けるのが良いかなと思っています(冒頭に書いた通りそもそもOptionalな値を扱うこと自体なるべく避ける前提で)。

// 削除したい時
d.removeValue(forKey: "key")
// nil値を入れたい時
d["key"] = String?.none
// nil値を入れる時は、以下のように変数経由のパターンの方が実際には多そう
let v: String? = nil
d["key"] = v

最後にちょっとおまけネタです。

左辺からの型推論

これを考えている時に、そういえば普段紐づいている型については無意識に使っている nil リテラルですが、OptionalのWrappedという制約型が推論できない文脈では使えないんだなあと気付かされました。

ちなみに、以下などのように型を与えてあげればコンパイル通ります。

let _: String? = nil
_ = nil as String?
_ = String?.none

_ (アンダースコア)自体については、以下の記事もご参照ください💁

こちらの記事も関連してますね 👀