Swift
swtws

Swift 4.1で導入されたcompactMapとその背景について

この記事は 2018/4/14に行われた Swift Tweets 2018 Spring で私 @amarillons が発表した内容と同じです。https://togetter.com/li/1218153

Swift 4.1で導入された compactMap について、その内容と背景を紹介します。

これまでは、2種類の flatMap がオーバーロードされていました。

Sequence.flatMap<S>(_: (Element) -> S) -> [S.Element]
    where S : Sequence                         // (A)
Sequence.flatMap<U>(_: (Element) -> U?) -> [U] // (B)

まずは二つのはたらきについて簡単に説明します。(A)は普通の flatMap です。map に似ていますが、2重にネストした Sequence が1重に展開されます。

[1, 2, 3].map { [Int](repeating: $0, count: $0) } // [[1], [2, 2], [3, 3, 3]]
[1, 2, 3].flatMap { [Int](repeating: $0, count: $0) } // [1, 2, 2, 3, 3, 3]

(B)は特殊な flatMap です。これの主な使い道の一つは Sequence から nil を取り除くことです。

[1, 2, nil, 4, 5].flatMap { $0 } // [1, 2, 4, 5]

["1", "2", "three", "4", "5"] のような Array を、各要素を整数に変換して [1, 2, 4, 5] を取得したいようなときにも使えます。

["1", "2", "three", "4", "5"].flatMap { Int($0) } // [1, 2, 4, 5]

flatMap ではなく map でこれをやろうとしても、 String から Int への変換は Int("1")! のように行えますが、 Int("three")! では変換に失敗してクラッシュしてしまいます。

["1", "2", "three", "4", "5"].map { Int($0)! } // クラッシュ

mapfilter を組み合わせて書くこともできますが、あまりスマートではありません。!が出てくるのも避けたいです。(B)の flatMap を使えば先程見たように簡単に書けます。

["1", "2", "three", "4", "5"]
    .map { Int($0) }
    .filter { $0 != nil }
    .map { $0! } // [1, 2, 4, 5]

(B)の flatMap が便利なのはわかりましたが、(A)と(B)は一見関係ない処理に見えます。どうしてどちらも flatMap という名前だったのでしょうか。

その理由は、 Optional を要素が0個か1個の Sequence だと考えれば(B)は(A)と同じ処理になるからです。

[[1], [], [3]].flatMap { $0 }              // (A) [1, 3]
[.some(1), .none, .some(3)].flatMap { $0 } // (B) [1, 3]

Swift 4.1で実装されたswift-evolutionのSE-0187 https://github.com/apple/swift-evolution/blob/master/proposals/0187-introduce-filtermap.md では、(B)の flatMapcompactMap にリネームするという変更がなされました。

これは、(B)の flatMap には問題があったからです。Proposalに挙げられている例を使って説明します。

次のような Person 型を考えます。

struct Person {
    var age: Int     // 年齢
    var name: String // 名前
}

PersonArray である people から、年齢の Array を作る操作をしたいとします。

let people: [Person] = ...
let ages: [Int] = ???

これは map を使って簡単にできます。

let people: [Person] = ...
let ages: [Int] = people.map { $0.age }

しかし、 flatMap を使って書いても特にエラーにはなりません。

let people: [Person] = ...
let ages = people.flatMap { $0.age }

エラーにはなりませんが、ここでは無駄なラッピング・アンラッピングが行われています。先程のコードは、実際には次のコードと同じです。

let people: [Person] = ...
let ages = people.flatMap { .some($0.age) }

{ $0.age }ageSequence ではないので、 (Element)->U? をとる(B)の flatMap を使おうとしていると解釈されます。
したがって、 $0.age.some にラップされてしまいます。

しかし age はもともと Int で決して nil にならないので、結局すべての要素が残されることになり、このラッピングは無駄な操作になります。

このような処理では flatMap ではなく map を使うべきですが、 flatMap を使ってもコンパイルエラーにならないため、誤った使い方をしていることに気づかないことになります。これが(B)の flatMap の問題です。

Proposalでは別のケースにも言及されています。 peopleageArray に変換する代わりに nameArray に変換する場合を考えてみます。結果が [Int] になるか [String] になるかだけの違いです。

mapflatMap を間違えた場合、Swift 3では age のときと同じように(B)の flatMap が使われて [String] が得られていました。

let people: [Person] = ...
let names = people.flatMap { $0.name } // (B)が使われてnamesは[String]

しかし、Swift 4では StringSequence に適合したため、暗黙の型変換をともなわない(A)の flatMap と解釈されてしまいます。

let people: [Person] = ...
let names = people.flatMap { $0.name } // (A)が使われてnamesは[Character]

結果的に、コードを書いた時は(B)の flatMap を適用しようとしていたのに、(A)の flatMap が適用されてしまうことになります。

names の型が明記されていればコンパイルエラーとなって気付くことができますが、型推論したままJSONに書き出していたりすると問題が起こるまで気付くことができません。

let people: [Person] = ...
let names = people.flatMap { $0.name } // (A)か(B)でnamesの型が変わる
let jsonData = try JSONEncoder().encode(names) // (A)でも(B)でもOK

このような問題が起こるのは Optional への暗黙の型変換が行われていることが原因です。しかし、暗黙の型変換をなくすのは現実的でないので、SE-0187では(B)の flatMapcompactMap にリネームすることによりオーバーロードを解消しようとしています。

もう一点、Proposalで言及されている compact メソッドについても紹介します。 Sequence に含まれる nil を取り除きたいときは、今回導入された compactMap を使って次のように書けます。

// nilを取り除いて[Int?]から[Int]に変換
let optionalArray: [Int?] = ...
let nonOptionalArray: [Int] = optionalArray.compactMap { $0 }

これはよく使う操作なので、もし compact というメソッドがあって optionalArray.compact() と書ければより便利になると考えられます。しかし、Swift 4.1ではそのような compact メソッドを実装することはできません。

実装にはGenerics Manifestoに書かれているParameterized Extensions https://github.com/apple/swift/blob/master/docs/GenericsManifesto.md#parameterized-extensions が必要になります。

extension<U> Sequence where Element == Optional<U> {
    func compact() -> [U] {
        return compactMap { $0 }
    }
}

将来的には compactも標準ライブラリに追加されることが期待されます。

以上、Swift 4.1で導入された compactMap について紹介しました。