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

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

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

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

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

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

```swift
[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` を取り除くことです。

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

 

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

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

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

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

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

```swift
["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)と同じ処理になるからです。

```swift
[[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)の `flatMap` を `compactMap` にリネームするという変更がなされました。

 

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

 

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

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

 

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

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

 

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

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

 

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

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

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

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

 

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

 

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

 

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

 

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

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

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

 

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

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

 

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

 

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

```swift
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)の `flatMap` を `compactMap` にリネームすることによりオーバーロードを解消しようとしています。

 

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

```swift
// 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 が必要になります。

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

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

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