しおメモ

雑多な技術系ブログです。ニッチな内容が多いです。

SwiftのOptionalの注意点とmap/flatMap

最初は、OptionalmapflatMapの話だけ書いていたのですが、だんだんOptionalそのものの話まで膨らんできたので、一緒に書くことにしました。

今回の話はコードレビューで見つけたら割と指摘したい部分ではあるものの、毎回この内容を書くのも無理があるので、一度まとめておく価値はあるかなと思いました。

Optional自体は、今では様々な言語に取り入れられているので、Swiftに限定しなくても良かったのですが、もろもろのシンタックスシュガーと歴史のためか、割と使う頻度が高い上に乱用も多いと思ったので、今回はSwift限定にしました。

Optionalの復習

他の言語でも多い実装ですが、Optionalの実装はただのenumです。
簡単に書き直すとこのような感じになります。

enum Optional<Wrapped> {
    case none
    case some(Wrapped)

    init(_ some: Wrapped) { self = .some(some) }
}

普段使っているT?Optional<T>の、nil.noneのシンタックスシュガーです。

これらのシンタックスシュガーに隠れているため、Optionalenumであることを意識することは少ないかもしれないですが、たとえば、

enum Hoge {
    case none
    case ok
}

という定義をすると、Hoge.noneを使おうとして、

var hoge: Hoge? = .none

と書くとOptional.noneが入ってしまうというミスが生じます。

このため、case noneを定義するのは危険で、Xcode 11から上のようなあいまいな.noneの使い方をすると警告が出るようになりました。(SR-2176)

おまけ: Xcode 11からOptionalにswitchが使えるようになった

知ってると良いことあるかもしれません。(SR-7799)

let hoge: Hoge?

// Before
if let hoge = hoge {
    switch hoge {
    case .ok:
        return 200
    default:
        return 400
    }
}

// After
switch hoge {
case .ok:
    return 200
default:
    return 400
}

可能な限りOptionalでない方が嬉しい

Optional自体は強力な機能ですが、使いすぎてしまうことでデメリットもあります。
とくにOptionalを公開するインターフェースに使う場合、不都合が生じることがあります。

デメリットが生じる一つの例として、プロパティや戻り値を不必要にOptionalにすることで、それを使う人毎回unwrapするコストが発生してしまうケースがあります。
後で具体的なコードが出てきますが、Optionalでなくても成り立つものは、Optionalにしない方がインターフェースとしては優れています。
(もちろん例外を握りつぶすとか、force unwrapしろということではないです。)

引数のOptionalについても、mapflatMapを知っていれば減らせる場合があります。

些細な差かもしれないですが、パフォーマンス的にも値をラップする分Optionalの方がオーバーヘッドが生じ不利になります。

Optionalを使う時の方針

基本的な方針はOptionalを引き回さないということと、不用意にOptionalを生じさせないということです。

引き回しそうになった場合は、unwrapの処理を他のメソッドに任せず、if letguard letOptional.mapなどを使ってなるべく外側で処理するようにします。

また、Optionalを発生させて戻り値に利用するメソッドや、failable initializer(init?)を使う際には注意が必要です。

mapとflatMapの使い方

OptionalmapflatMapを活用することで、コードがシンプルになるケースや、Optionalが減るケースがあります。

mapの実装

mapは与えられたクロージャで中身を変換して、Optionalでラップします

func map<U>(_ transform: (Wrapped) throws -> U) rethrows -> U? {
    switch self {
    case .some(let y):
        return .some(try transform(y))
    case .none:
        return .none
    }
}

中身(Wrapped)をUに変換するクロージャを渡すと、U?が返ってきます。

flatMapの実装

それに対してflatMapは、

func flatMap<U>(_ transform: (Wrapped) throws -> U?) rethrows -> U? {
    switch self {
    case .some(let y):
        return try transform(y)
    case .none:
        return .none
    }
}

中身(Wrapped)をOptional<U>に変換するクロージャを渡すと、Optional<U>が返ってきます。

mapとflatMapの使い分け

map(Wrapped) -> Uに対してU?を返すので、もしUHoge?だった場合、Hoge??が返ります。

let urlString: String? = "hoge"
let url = urlString.map(URL.init(string:)) // Hoge??

上のようなコードを書いた場合、urlの型はURL??と2重のOptionalになってしまいます。

多くの場合2重にする必要性はないため、Optionalを返すクロージャに対しては、flatMapを使うことが多いです。

let urlString: String? = "hoge"
let url = urlString.flatMap(URL.init(string:)) // Hoge?

urlString.flatMap(URL.init(string:))urlString.flatMap { URL(string: $0) }とほぼ同じです。

あまり良くないインターフェース

ありがちなケースを2つほど紹介します。

nilを伝播させるだけのインターフェースはアンチパターン

Optionalを引数にとって、nilをそのまま返り値のOptionalに伝播させるだけのインターフェースはアンチパターンです。

func generateMessage(count: Int?) -> String? {
    guard let count = count else { return nil }

    return "あなたは\(count)人目の訪問者です"
}

かなり極端な例ですが、上のようなメソッドは良いインターフェースとは言えません。
Optionalを渡すだけなら困りませんが、下のコードのように引数に、ただのIntを渡した際にもOptionalが返ってきてしまいます。

let message = generateMessage(count: 10) // String?

1引数の場合は、guard文のところはOptional.mapと全く同じことをやっているので、元のメソッドは単純に、

func generateMessage(count: Int) -> String {
    return "あなたは\(count)人目の訪問者です"
}

として、Int?を渡したいときは上のコードのようにすれば同じことができます。

こちらはIntを渡したときもStringが返ってくるので、インターフェースとしては最初のものよりも優れています。

let count: Int? = 10
let message = count.map(generateMessage(count:))

また、複数引数がある場合も、同じように不必要にOptionalを発生させるケースがあるので、nilを伝播させるためだけに引数や戻り値にOptionalを使うのは良いインターフェースとは言えません。

// Optionalだらけ
func generateMessage(first: String?, second: String?) -> String? {
    guard let first = first, let second = second else { return nil }

    return "\(first) feat. \(second)"
}

ただ、mapflatMapを使う場合は若干見づらくなるので、引数が多い場合はif letguard letを使うほうが良いと思います。

func generateMessage(first: String, second: String) -> String {
    return "\(first) feat. \(second)"
}

let first: String? = "John"
let second: String? = "Paul"

if let first = first, let second = second {
    let message = generateMessage(first: first, second: second)
    // ...
}

// or
let message = first.flatMap { str in second.map { generateMessage(first: str, second: $0) }}

このようにできるだけ外でunwrapするようにしておけば、引数がそれぞれnilだった際のハンドリングも個別に分けることができるため、メソッドの中でunwrapするよりも柔軟に対応できます。
(逆に、上のような書き方は2つのnilを1つにまとめてしまっているので、ある意味握りつぶしてしまっています)

failable intializer

failable initializerは、IUOやforce unwrapを使わない限り、必ずインスタンスをOptionalで持つことを強制します。
とくに、failable initializerしか用意されていないインターフェースにした場合、使う側にとってはインスタンスを作るごとに毎回unwrapしなくてはいけないため、非常にコストになります。

この点から、特定の状況以外では使わない方が良いと個人的には考えています。

具体的に例外のケースは、

  • super.initがfailable
  • イニシャライザの中で複数Optionalを使うメソッドやthrowするメソッドがある場合

くらいかと思います。

1つ目のケースはrequired init?(coder:)のように、スーパークラスがfailable initializerを持つクラスです。
2つ目のケースはJSONのデコードをイニシャライザの中で行うレスポンスのデータ型のような、複数Optionalが登場して、なおかつどれか失敗したら全体をnilとしたいクラスです。

特に、中でOptionaltry?を使っていないにも関わらず、init?を使っていたら危険信号です。

final class Hoge {
    init?(members: [Member]) {
        guard let !members.isEmpty else { return nil } // 余計なお世話じゃ

        // ...
    }
}

長いのでまとめ

上記のような、必要ない場面でOptionalを使ってしまうケースさえ気をつければ、非常に強力な機能なので、うまく付き合っていきましょう。