Edited at

[Swift]flatMap・compactMapの挙動はソースコードを読んで理解しよう〜型情報と実装から難関メソッドを突破する〜

More than 1 year has passed since last update.


動機

flatMapの振る舞いって難しいですよね...。特にArrayflatMap

どのような値を返すのか突き止めるべく、あらゆるパターンの呼び出しを試して個別にその挙動を確認し、

自分独自の概念もこねくり回しながら、どうにか法則を抽出しようと試みる。

だけど、どうしてもその挙動を言葉に落とし込むことができなくて、涙で枕をぬらす夜が続きました。

ところがある日、そのソースコードを読んでみると、

あれだけ内部で複雑な動きをしているかに見えたflatMapの振る舞いが、

(Arrayの場合は)2つのオーバーロードと、意外なほど簡潔なコードだけで実現されていたことがわかったのです。

個別の現象からその内部構造を推測するトップダウン型だけではなく、ソースコードに当たってその振る舞いを読み解くボトムアップ型のアプローチも取り入れた結果、

これらのメソッドの振る舞いを、より多角的に理解できた気がしました。

で、これらに関連してcompactMapなるメソッドが導入された新しいSwiftも正式リリースされて間もないことですし、そちらの話題もちょっぴり絡めつつ、まとめてみました。

この記事を読むことで、

Optionalだったり、複雑なArrayオブジェクトに対するmapflatMap呼び出し、おまけにcompactMapまでも、その挙動が手に取るように把握できるようになります。

その結果、これらのメソッドに対する自信が深まり、Swiftがさらに楽しくなります。

他方、そもそもmapflatMapとは何か、Swiftではどのように呼び出すのか...などといった事柄は、他の方の優れた解説に多くを任せようと思います。

この記事では、実際のソースコードを参照しながらflatMapの挙動を解剖していくことで、

これまでとは別の角度からこのメソッドの理解を促進することに挑戦したいと思います。

ArrayflatMapcompactMapが混在する世界線の話ができたら理想的なので、

よければSwift 4.1以降をご用意のうえ、お読みくださると嬉しいです。


環境

Xcode 9.3

Swift 4.1


用語・あるとよさげな知識

記事中で用いる用語の語義と、もし知っていればこの記事が遥かに読みやすくなると思われる知識について、あらかじめ共有させてください。


レシーバ

メソッドを呼び出す主体となるオブジェクトのこと。

メソッド呼び出しの記法 オブジェクト.メソッド()の、.(ドット)の左側

let str = "HELLO"

str.lowercased() // レシーバは `str`であり、"HELLO"

let dog = Dog()
dog.bark() // レシーバは `dog`であり、Dog

let array = [1,2,3,4,5]
array.map{ $0 * $0 } // レシーバは `array`であり、[1,2,3,4,5]


あるとよさげな知識

SwiftOptional四方山話

理論から入門するswift/lib/Sema - わいわいswiftc #1
などにあるように、

オプショナル型とその非オプショナル型の間には、サブタイプ関係がある(T is T?)

// T is T?

let optionalInt: Int? = 1 // `T?`型の変数に、`T`型のオブジェクトを代入できている

また、Swiftの型の深淵をのぞくなどにあるように、

オプショナル型を戻り値にした関数オブジェクトとその非オプショナル型を戻り値にした関数オブジェクトの間には、サブタイプ関係がある(() -> T is () -> T?)

// () -> T is () -> T?

func bar() -> Int { return 1 }
let barOptional: () -> Int? = bar // `() -> Int?`型の変数に、`() -> Int` 型の関数オブジェクトを代入できている

ということを知っていると、特にOptional#mapOptional#flatMap周りの説明などが読みやすくなると思います。

もうひとつ、SwiftのOptional型を極めるの中などで用いられている、「オプショナルとは箱である」というアナロジーを、この記事を通して借用しています。もしリンク先をまだ読んだことがない場合、ぜひ読んでみてください。

またこの記事全編に渡り、上記の記事を参照いたしました。


Optional#flatMap(_:)の挙動を追う

標準ライブラリで定義された型のうち、flatMapを持つ型はいくつかあると思いますが、

実際の開発などで最もよく使うものは、OptionalArrayのそれではないかと思います。

そのため、本記事ではこの2つの型に属するflatMapに絞って書いていきます。

では、まずはOptionalにおけるflatMapから。

Arrayが持つflatMapに比べると複雑度は低いと思われますが、

それでも深い理解を求めるのであれば、丁寧に見ていく必要があると考えます。


mapflatMapのきほん

そもそもmapって、flatMapってどんな感じの処理なんだ、ってお話を簡単に。


map

map・・・すなわちマッピング

一般的なIT用語としてのマッピングなら、1


ある集合の個々の構成要素に対して、別の集合の要素を規則に従って機械的に対応付けたり割り当てたりすること。また、何かの分布や配置などを地図に重ね合わせて図示すること。


とりわけ、プログラミングにおけるマッピングなら、


あるオブジェクトをもとに、指定した変換ルールに基づき、新しいオブジェクトを生成すること。


こちらは引用というより、いま私が考えました。

変換ルールをクロージャとして指定してmapに渡してあげることで、

その変換ルールに基づいて新たにオブジェクトを生成、返却してくれます。

たとえば、ある整数をもとに、その整数に1を足した整数を得たいなら、こんな感じ。

let num:    Int? = 42

let newNum: Int? = number.map{ $0 + 1 }
print(newNum) // Optional("43")


flatMap

さて、そんな感じで使えるmapメソッドなのですが、困ったこともあります。

例えばこんなケースだとどうでしょう。

標準ライブラリIntには、文字列から整数を生成するイニシャライザが定義されています。2


IntegerParsing.swift

public init?(_ description: String) {

self.init(description, radix: 10)
}

このイニシャライザはその定義にinit?とあるように、

文字列から整数を生成できた場合、その整数を返しますが、

反対に生成できない場合、nilを返します(失敗可能イニシャライザ: Failable Initializer)。

つまり、このイニシャライザが返す値の型は、Int?になります。

let num1: Int? = Int("11")     // Optional(11)

let num2: Int? = Int("VA-11") // nil

// オプショナル型を返す処理を、非オプショナル型の変数で受けることはできない
let num3: Int = Int("11") // コンパイルエラー

最後の行にあるように、

オプショナル型を返す処理(メソッドや失敗可能イニシャライザ、他にもOptional Chainingなど)の結果は、必ずオプショナル型の変数で受ける必要がある(非オプショナル型の変数で受けることはできない)

という点は、一見当たり前のようで、何気にこのあと重要になるので、ぜひ知っておくとハッピーです。

(なお、そのようなルールになっている理由として、

もしオプショナル型を返す処理を非オプショナル型の変数で受けられるとしたら、

仮にその処理がnilを返した場合、本来nilを入れられない変数にnilを格納できてしまい、

null安全が崩壊する...といった説明ができるのでは、と思います。)

話を戻して、このイニシャライザを用いてmapを呼び出してみましょう。

let a: String?     = "1"

let result: Int?? = a.map{ Int($0) }

print(result) // Optional(Optional(1))

ありゃ。mapを実行した結果、一重のOptionalではなく、二重のOptionalで返ってきてしまいました。

このように、mapに渡したいクロージャが、その返り値にオプショナル型の値を返すクロージャだった場合、

mapの代わりにflatMapを用いると、二重の箱をフラット(一重)にして結果を得られ、上手く対処できます。

let a: String?   = "1"

let result: Int? = a.flatMap{ Int($0) }
print(result) // Optional(1)

mapの処理に加えて、フラットにする(Optionalという「箱」を、1個分ぺしゃんこにする)処理まで施してくれるのがflatMapなのですね。


実装を見る

ではその実装を見てみましょう。

また、OptionalflatMapmapと対称性があり、比較しながら見ていくといっそう理解しやすくなるため、こちらも合わせて引用します(必要な箇所を抽出・編集しています)。


Optional.swift

public enum Optional<Wrapped> {

case none
case some(Wrapped)

public func map<U>(
_ transform: (Wrapped) throws -> U // ※1 ここ同士が違う
) rethrows -> U? {
switch self {
case .some(let y):
return .some(try transform(y)) // ※2 ここ同士も違う
case .none:
return .none
}
}

public func flatMap<U>(
_ transform: (Wrapped) throws -> U? // ※1 ここ同士が違う
) rethrows -> U? {
switch self {
case .some(let y):
return try transform(y) // ※2 ここ同士も違う
case .none:
return .none
}
}
}


mapflatMap、交互ににらめっこしていると、


  • 引数のクロージャの返り値 (※1)

  • switch-case文のcase .some(let y)内、return している箇所 (※2) ← 特に注目

だけが少し異なり、それ以外は全く同じつくりであることがわかります。

シグネチャこそ2つのジェネリクス型(Optionalに付随するジェネリクス型Wrappedと、メソッド内で使われるジェネリクス型U)が絡み、とっつきにくいのは確かなのですが、

より着目すべきはその本体({ }の中身)であり、そこでなされている処理は意外なほどシンプルであることが見て取れます。

ではそのflatMapの処理の中身を見てみましょう。


再掲

func flatMap<U>(

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


flatMapを呼び出した主体(レシーバ)の値がnilかどうかを判定し(switch self)、

もしnilであれば、nilを返す。

一方nilでなければ、値バインディングパターン(value-binding pattern)のパターンマッチを用い(case .some(let y))、

列挙体Optionalのアソシエイト値として設定されている値(=「オプショナルという箱の中身」)を取り出して(let y)、その値を引数で渡されたクロージャに適用する(try transform(y))。

最後に、クロージャの実行結果をflatMapの結果として返す。


また、mapは処理のほぼすべてがflatMapと同じなのですが、

1点だけ、クロージャの実行結果をオプショナルでラップした上で返す、という点だけが異なります。


再掲

func map<U>(

_ transform: (Wrapped) throws -> U
) rethrows -> U? {
switch self {
case .some(let y):
return .some(try transform(y)) // オプショナル型にラップした上で返す
case .none:
return .none
}
}

こうして見ると、「flatMapがなんらかのフラットにする処理をしている」というよりも、

mapが処理のあと、あえて値をオプショナルに包んでいる(.some(...))。flatMapの"flat"とは、その対としての"flat"。」と考えていたほうが、おさまりが良いかもしれません。

「値をオプショナルでラップする」という余計なひと手間をわざわざ施しているぶん、

なんだかflatMapよりもmapのほうが特殊なメソッドのように感じられてきますね。

この動作を知るためには、その実装を覗くことなく到達するのはなかなか難しかったのでは...と思います。


注意点

さて、ここまであえて触れずにきたものの、気に掛けておかないといけない点があります。

上で見たように、flatMapの場合mapと違って、

クロージャの実行結果がオプショナルに包まれることはありませんでした。


map

case .some(let y):

return .some(try transform(y)) // こちらは、クロージャの実行結果をオプショナル型でラップしているが...


flatMap

...

case .some(let y):
return try transform(y) // こちらは、クロージャの実行結果を素直に返しているだけ
...

そのため、例えばこのflatMap全体の戻り値の型は、非オプショナルな型となってもよさそうなものです。

let a: Int? = 1

let result = a.flatMap{ $0 + 1 } // 戻り値の型は `Int`、値は2かな...🤔

ですが、実際はその戻り値の型は、オプショナル型であるInt?になってしまいます。

また、これを無理やりInt型の変数で受けることはできません。

let a: Int? = 1

let result: Int? = a.flatMap{ $0 + 1 } // 実際は、型は `Int?`、値はOptional(2)になってしまった😯

let result: Int = a.flatMap{ $0 + 1 } // これはコンパイルエラー

どうしてこのような結果になってしまうのでしょう。

もう一度flatMapのシグネチャに着目してみると、

引数として取るクロージャの定義に_ transform: (Wrapped) throws -> U?とありますね。

public func flatMap<U>(

_ transform: (Wrapped) throws -> U? // ← ここに注目
) rethrows -> U? {
...
}

クロージャtransformの戻り値は「U?」

つまり、このクロージャを実行した結果返される値の型は、その定義にひきずられる形で、

オプショナル型であると解釈されてしまう(たとえ実際には「非」オプショナル型の値を返していたとしても)、というわけです。

そしてflatMapreturnするのはこのオプショナル型の値なので、そうなるとそれを受け取る変数もオプショナル型でなければいけないのですね。

let a: Int? = 1

// `flatMap`に渡されたクロージャが返している値の型は、
// { return $0 + 1 } ...すなわち実際には `Int` 型のオブジェクト(今回は2)なのだが、
// クロージャの戻り値が `U?` になっているせいで、
// このクロージャを実行したとき返される値の型は `Int?`であると解釈されてしまう
let result: Int? = a.flatMap{ $0 + 1 }

また、よしんばtransformの戻り値が「U?」ではなく、「U」、つまり非オプショナル型だったと仮定してみましょう。

public func flatMap<U>(

_ transform: (Wrapped) throws -> U // ← 実際はこうではなく、あくまで仮定です
) rethrows -> U? {
...
}

ですが仮にそうだったとしても、rethrows -> U? とあるように、そもそもflatMap全体の戻り値も U?、すなわちオプショナル型となっているため、結局はこのflatMapの実行結果はオプショナル型の変数で受け取らないといけません。

public func flatMap<U>(

_ transform: (Wrapped) throws -> U // ← この戻り値が仮に `U` だったとしても...
) rethrows -> U? { // ← そもそも`flatMap`全体の戻り値が `U?`
...
}

これは、このようなコードを書くと確かめることができます。

// クロージャを受け取って実行し、その結果を返す関数

func myFunc<U>(
_ completion: () throws -> U // クロージャの戻り値の型は、非オプショナル型の `U` だが...
) rethrows -> U? { // この関数全体の戻り値の型は、オプショナル型の `U?`。なので...
return try completion()
}

// クロージャの戻り値が非オプショナル型だろうが、
// `myFunc`の戻り値が `U?` とオプショナル型になっているので、
// 結局このメソッドの結果は、オプショナル型の変数で受けないといけなくなる
let result1: Int? = myFunc{ return 1 }
// 当然非オプショナル型の変数で受けることはできず、コンパイルエラーになる
let result2: Int = myFunc{ return 1 } // error

そのため、どのみちflatMap実行結果の戻り値はオプショナル型と解釈される、というわけなのでした。

flatMapmapの結果がOptionalで返ってくるのは、

簡単に言うと「Optionalという箱の中身(値)に対して、それを箱から取り出すことなく(入れたまま)演算を行う」ことができると都合がよいから。

これにより、箱の中身(値)を外の世界に触れさせることなく、後続の処理につなげたりできるようになっています。

となると当然、返される値はOptionalという箱に入ったまま、というわけですね。


挙動のまとめ

・・・存外長い説明となってしまいました。

いま一度、OptionalflatMapの挙動をまとめておきます。


Optional#flatMap

・レシーバがnilならば、nilを返す。

・レシーバがnilでないならば、レシーバの"箱の中身"に対して引数で渡したクロージャを適用し、

その結果を返す。

(・加えて、その型定義に引きずられる形で、その結果はオプショナル型の変数で受ける必要がある。)


ついでに、Optionalmapの挙動は、こう説明できます。


Optional#map

・レシーバがnilならば、nilを返す。

・レシーバがnilでないならば、レシーバの"箱の中身"に対して引数で渡したクロージャを適用し、

結果をオプショナルに包んで、その結果を返す。

(・加えて、その型定義に引きずられる形で、その結果はオプショナル型の変数で受ける必要がある。

すなわち、クロージャ適用の結果得た値がオプショナル型であれば、二重のオプショナル型になる。)


以上、ソースコードを参照しながら、OptionalflatMap(と、map)の仕組みについて考察しました。


(補足) Optional#mapOptional#flatMapに関する補足

...と言いつつ、この項ではOptional#mapOptional#flatMapにまつわる、

「さらに探求したかったので調べてみたけど、本筋からは外れる話題」について、補足としてまとめました。

次のArray#flatMap(_:)の挙動を追う(その1)までスキップでも一切支障ございません。


Q1. (Wrapped) throws -> U?に対し、なぜ(Wrapped) -> Uを渡せるのか?

Optional#flatMapが引数として取るクロージャの型は (Wrapped) -> U? です。

public func flatMap<U>(

_ transform: (Wrapped) throws -> U? // クロージャ`transform`の型は `(Wrapped) -> U?`
) rethrows -> U? {
...
}

にもかかわらず、このようなコードが書けてしまうのです。


問題なくflatMapを呼び出せているが、本来であればmapで十分な場面

let a: Int? = 1

let result: Int? = a.flatMap{ $0 + 1 } // `flatMap`に渡されたクロージャの返り値の型は`Int`
print(result) // Optional(2)


(Wrapped) -> U?、つまりオプショナル型の返り値を返すクロージャを渡せ、と要求されているにもかかわらず、

(Wrapped) -> U、すなわちオプショナル型の返り値を返すクロージャを渡せています。

(Wrapped) -> U?(Wrapped) -> U、2つの型はそれぞれ別のものだというのに、

なぜこのようなコードが許されるのでしょうか?


どうやらSwiftにおいて、() -> T型の関数オブジェクトは、() -> T?型のサブタイプとして振る舞うことが可能なルールになっています。


() -> T is () -> T?


そのため、() -> Int型の関数オブジェクトを、flatMapへ渡せていた、というわけです。


問題なくflatMapを呼び出せているが、本来であればmapで十分な場面(再掲)

let a: Int? = 1

// `() -> Int` is `() -> Int?`なので、{ $0 + 1 } を`flatMap`に渡せていた!
let result: Int? = a.flatMap{ $0 + 1 }


前に見たように、mapではなくあえてflatMapを使うべき場面とは、「クロージャの返り値の型がオプショナル型」の場合です。

そのため、「クロージャの返り値の型がオプショナル型」ではない場合、flatMapは本来使えるべきではなく、理想的にはコンパイルが通らないような仕組みになっているのが望ましいですが...。

このルールのせいで、実際にはコンパイルが通り、実行できてしまいます。

このことが後で触れるcompactMapが新設された遠因になっていたりもするようで、その経緯についてはSwift 4.1で導入されたcompactMapとその背景について、こちらの記事がProposalをベースに簡潔に解説されていて、たいへん勉強になりました。

なお、Swiftにおいて2つの型の間にサブタイプ関係がある場合に実現可能になることは、

本記事の終盤に、「サブタイプ関係がある場合にできること」という見出しで、補足としてまとめてあります。


Q2. (Wrapped) -> Uに対し、なぜ(Wrapped) -> U?を渡せる(ように見えてしまった)のか?

Optional#mapが引数として取るクロージャの型は (Wrapped) -> U です。

public func map<U>(

_ transform: (Wrapped) throws -> U // クロージャ`transform`の型は `(Wrapped) -> U`
) rethrows -> U? {
...
}

にもかかわらず、このようなコードが書けてしまうのです。

let a: String? = "1"

let result = a.map{ Int($0) } // ...これ、`map`に渡されたクロージャの型は `(Wrapped) -> U?`。なんで渡せるんだ...?🤨

これが許されるのはちょっと奇妙な気がします。

Q1.で既に見たように、引数の型 (Wrapped) -> Int? に対し (Wrapped) -> Int を渡せるのはわかりました。

が、ここで起こっていることはその反対で、引数の型 (Wrapped) -> Int に対して、 (Wrapped) -> Int? のクロージャを渡せているかのように見えます。

ある2つの型がサブタイプ関係にある場合において、サブタイプになっている型に対しスーパータイプの型を渡すことは、本来できないはずなのでは...?

なぜこのようなコードが許されるのでしょうか?

...結論から言えば、当然私の考え方が間違っていたのです。

私の思考の誤ちを開陳させていただきたく思います。

今回のミソは、クロージャの戻り値が( Int のような一般的な型ではなく)U というジェネリクス型であり、コンパイル時に具体的な型に置き換えられる、という点にありました。

上で見たように、今回mapに渡されたクロージャの型は(String) -> Int? です。

let a: String? = "1"

let result = a.map{ Int($0) } // クロージャの型は`(String) -> Int?`

するとその結果、mapが持つ型パラメータ UInt? に決定(特殊化)されることになります

(mapのシグネチャに出現するUの箇所に着目して型パズルを解くと、論理的にInt?に定まることがわかります)。

そのときの状態はこうなります。


`map`が持つ型パラメータ`U`が特殊化された後の、今回の`map`の状態

// もともと`U`があった場所を`Int?`で置換すると、こうなる。

// 論旨とは全く関係ないが、`Wrapped`も`String`で特殊化された。
public func map<Int?>(
_ transform: (String) throws -> Int?
) rethrows -> Int?? {
...
}

クロージャtransformが返す値の型はInt?、実際のコードでmapに渡されたクロージャが返す値の型もInt?。(Int($0))

よって、サブタイピング関係がどうのなんて話は初めから存在せず、mapに渡されるクロージャにおいて型の整合性は保たれていたので、問題なくコンパイルできていた、というわけだったんですね。

「クロージャが返す値の型は U って書いてあって、U? のように?が付いていないのだから、このジェネリクス型は絶対にオプショナル型にはならない!」と思い込んでしまったが故に陥ったミスでした。

また、U? = Optional<U> という定義を柔軟に取り出せなかったことも、ドツボにはまっていった原因でした。

ここまでで判明したように、実際にはジェネリクス型に?が付いていなくても、それがオプショナル型で特殊化されることはありえます。

今回Uが特殊化された型はIntではなく、Int?(Optional<Int>)だったのです。


Q3. U? を返すメソッドで、なぜ U?? を返せる(ように見えてしまった)のか?

Q2.の問題を考えていた際、同時に陥っていた問題がこちらです。

こちらもQ2.の解決と同時に解決しましたが、また忘れないように、文章にして残しておきます。

Optional#mapが返す値の型は U? です。

public func map<U>(

_ transform: (Wrapped) throws -> U
) rethrows -> U? { // `map`が返す値の型は `U?`
...
}

にもかかわらず、このようなコードが書けてしまうのです。

let a: String?    = "1"

// `map`が返す値の型は`Int??`.
// これって`map`の返す値の型は`U?`ということを考えると許容されないのでは...?🤨
let result: Int?? = a.map{ Int($0) }

print(result) // Optional(Optional(1))

mapの戻り値の型 U? にもかかわらず、Int?? という二重のオプショナルの値を返せている?

通常のサブタイプ関係を鑑みるなら、戻り値 U?? に対し、実際にはU?を返せるのはOKだけど、ここではその逆が成立している。

これはおかしいのではないか?と考えてしまいました。

...この勘違いも、Q2.の疑問が解けたと同時に払拭できました。

今回の場合、mapが持つジェネリクス型 U が、Int? で解決(特殊化)されているので、mapはこうなっていたのでした(上記コードの再掲)。


`map`が持つ型パラメータ`U`が特殊化された後の、今回のmapの状態(再掲)

// もともと`U`があった場所を`Int?`で置換すると、こうなる。

public func map<Int?>(
_ transform: (String) throws -> Int?
) rethrows -> Int?? {
...
}

mapの戻り値の型はInt??、実際の呼び出しの結果も Int??

型の整合性はなんの問題もなく保たれていましたね。

let a: String?    = "1"

let result: Int?? = a.map{ Int($0) } // 私の勘違い。実際は型の整合性は保たれていた😅

print(result) // Optional(Optional(1))

Q2Q3の教訓として、ジェネリクス型は当然Optional<Wrapped>型としても特殊化される場合がある。シグネチャの?の有無だけに捕らわれるな!と自身に言い聞かせておきます。これでもう間違えるまい。


Array#flatMap(_:)の挙動を追う(その1)

本当はこちらが本日のメインディッシュです。

Optionalに続き、ArrayflatMapについてもその挙動を追っていきます。


実装を見る

さっそくソースコードを確認してみましょう。3

ArrayflatMapArrayに直接書かれているのではなく、Sequenceのプロトコル拡張として定義されているようです。

(必要な箇所を抽出・編集しています。)


SequenceAlgorithms.swift(4.1)

extension Sequence {

...
public func flatMap<SegmentOfResult : Sequence>(
_ transform: (Element) throws -> SegmentOfResult
) rethrows -> [SegmentOfResult.Element] {
var result: [SegmentOfResult.Element] = []
for element in self {
result.append(contentsOf: try transform(element))
}
return result
}
...
}

extension Sequence {
...
// @available(swift, deprecated: 4.1, renamed: "compactMap(_:)",
// message: "Please use compactMap(_:) for the case where closure returns an optional value")

public func flatMap<ElementOfResult>(
_ transform: (Element) throws -> ElementOfResult?
) rethrows -> [ElementOfResult] {
return try _compactMap(transform)
}
...
}


ここからわかるように、ArrayのflatMapには2種類が存在し、

その呼び出され方によって、どちらが使われるかが決定される、というわけだったのですね。

flatMapの呼び出し方によって、使われているflatMapの種類がそもそも違っていたため、

自分にとってその挙動を見抜くことが困難を極めていたのも、1つにはこの点が原因だったのでした。

どのような場合に、どちらのflatMapが呼ばれるのか?

その決定方法については後で改めて触れるとして、

まずは前者のflatMap<SegmentOfResult : Sequence>の挙動を追ってみたいと思います。


flatMap・その1

public func flatMap<SegmentOfResult : Sequence>(

_ transform: (Element) throws -> SegmentOfResult
) rethrows -> [SegmentOfResult.Element] {
var result: [SegmentOfResult.Element] = []
for element in self {
result.append(contentsOf: try transform(element))
}
return result
}

Optional#flatMapと同様、こちらもシグネチャの圧がやや強めですが、

そちらは一旦無視で実装のみに着目すると、その中身はわずか5行、かつその意図は非常に明確です。

あれだけクールなメソッドがたった5行で実装されていたなんて・・・ますますクールだ。


中身だけに着目すると...

public func flatMap<SegmentOfResult : Sequence>( ... ) {

// その中身は、たった5行だけ
var result: [SegmentOfResult.Element] = []
for element in self {
result.append(contentsOf: try transform(element))
}
return result
}

その中身について読み解いていきましょう。


配列の各要素を取り出して(for element in self)、引数で渡されたクロージャに適用し(try transform(element))、その結果を一時変数に格納する(result.append(...))。

これをすべての要素に対して繰り返し、最後にその一時変数(新しい配列)を返す。


関数型プログラミングの香りを併せ持つとも称されるであろうこのメソッドですが、

その内部をひも解いていけば、実はバリバリ手続き型チックな記述で構成されていたこともわかりますね。

以下の例は、2次元配列([[Int]])をもとに、その中の要素([Int])をフラットにした新しい配列を返しています。

let result = [[1,2,3],[4,5]].flatMap{ $0 }

print(result) // [1,2,3,4,5]

以降、当記事ではこのflatMapを、便宜上「A) 配列をフラットにするflatMapと呼びます。


フラットにするのはappend(contentsOf:)の仕事

さて、このflatMapはその内部でappend(contentsOf:)というメソッドを使用していたのでした。


再掲

func flatMap<SegmentOfResult : Sequence>(

_ transform: (Element) throws -> SegmentOfResult
) rethrows -> [SegmentOfResult.Element] {
var result: [SegmentOfResult.Element] = []
for element in self {
result.append(contentsOf: try transform(element)) // ←ここ
}
return result
}

append(contentsOf:)は配列を引数に取り、その要素をレシーバに追加するメソッドです。4

ここでなにげに重要なのが、append(contentsOf:)の引数として渡された配列は、レシーバにそのまま加わるのではなく、

(配列という)その「箱」がつぶされた上で加わる、という点です。

var array = [1,2,3]

array.append(contentsOf: [4,5])

// [1,2,3,[4,5]] ではなく、[4,5]を格納する箱がつぶされ(フラットにされ)た上で、レシーバに追加される
print(array) // [1,2,3,4,5]

ひるがえって、このようなflatMap呼び出しを追ってみると・・・

let array  = [[1,2,3],[4,5]]      // このような二次元配列があったとき・・・

let result = array.flatMap{ $0 } // それをそのままフラットにした新しい値を得たい

print(result) // ???

レシーバarrayの要素は[1,2,3][4,5]の2つなので、flatMap内部のforループは2回転。

1回転めが終わった時点の状態をトレースすると、

append(contentsOf:)の働きにより、内部の一時変数resultはこんな感じで・・・


`flatMap`内部

func flatMap() {

var result: [SegmentOfResult.Element] // [1,2,3]
for element in self {
result.append(contentsOf: ...)
}
...
}

2回転めが終わった時点だと、こんな感じ。


`flatMap`内部

func flatMap() {

var result: [SegmentOfResult.Element] // [1,2,3,4,5]
for element in self {
result.append(contentsOf: ...)
}
...
}

よって、最終的な結果はこうなります。

let array  = [[1,2,3],[4,5]]      

let result = array.flatMap{ $0 }

print(result) // [1,2,3,4,5]

つまり、こちらのflatMapが持つ「要素をフラットにした上で結果を返す」という性質は、

このappend(contentsOf:)こそが担っていた、というわけですね。

Array#appendには、append(contentsOf:)に加え、append(_:)という同名のメソッドが存在します。5

そしてこちらのappend(_:)には、引数に取った配列をフラットにした上でレシーバに加えるという機能はありません。

var array = [[1,2,3]]

array.append([4,5]) // `append(contentsOf:)`ではなく、`append(_:)`を呼ぶ

print(array) // [[1,2,3],4,5] ではなく、[[1,2,3], [4,5]]

後述するもう一方のflatMapが内部で使用しているのは、このappend(_:)のほうです。

そしてこの違いこそが、2つのflatMapの挙動の違いを生んでいたのです。

また後ほどArray#flatMap、Array#compactMapの注意点で言及します。


注意点

後述したいと思います。


挙動のまとめ

改めて、こちらのflatMapの挙動をまとめます。


Array#flatMap<SegmentOfResult:Sequence>

配列の各要素を取り出して、引数で渡されたクロージャに適用し、その結果を一時変数に格納する。

これをすべての要素に対して繰り返し、最後にその一時変数(新しい配列)を返す。



Array#flatMap(_:)の挙動を追う(その2)

さて、Arrayが持つflatMapは、同名で別のものがありました。

なので、次はそちらのflatMap<ElementOfResult>の挙動を追ってみたいと思います。

(あらかじめ言ってしまうと、Swift 4.1でcompactMapにリネームされたflatMapのことです。)


実装を見る


flatMap・その2

// @available(swift, deprecated: 4.1, renamed: "compactMap(_:)",

// message: "Please use compactMap(_:) for the case where closure returns an optional value")
public func flatMap<ElementOfResult>(
_ transform: (Element) throws -> ElementOfResult?
) rethrows -> [ElementOfResult] {
return try _compactMap(transform)
}

中身はたった1行、(アンダーバー)compactMapという別のメソッドに、その仕事を完全に横流ししています。

気を取り直して、(アンダーバー)compactMapの定義を見てみると...


_compactMap

func _compactMap<ElementOfResult>(

_ transform: (Element) throws -> ElementOfResult?
) rethrows -> [ElementOfResult] {
var result: [ElementOfResult] = []
for element in self {
if let newElement = try transform(element) {
result.append(newElement)
}
}
return result
}

どうやらこちらが実質的な本体のようですね。

といっても、こちらも1つ前に見た「(A) フラットにするflatMap」と非常に似ています。

// 前回見たやつ

func flatMap<SegmentOfResult : Sequence>( ... ) {
var result: [SegmentOfResult.Element] = []
for element in self {
result.append(contentsOf: try transform(element))
}
return result
}

// 今回のやつ
public func flatMap<ElementOfResult>( ... ) {
return try _compactMap(transform)
}

func _compactMap<ElementOfResult>( ... ) {
var result: [ElementOfResult] = []
for element in self {
if let newElement = try transform(element) {
result.append(newElement)
}
}
return result
}

詳細を追ってみると、前回見たflatMapと異なる点は、


appendされるのは「クロージャを適用した結果得た値がnilでない場合のみ」という条件付きになったこと

appendの方法がappend(contentsOf:)ではなくappend(_:)になったこと


のみですね。とりあえず使ってみましょうか。

以下の例は、

「整数の文字列が格納された配列をもとに、それを整数に変換した新しい配列を取得したい。

ただし、整数への変換に失敗した場合は、その要素をドロップしたい(含めたくない)"。」

と考えた人が書いたコードとなります。

let result = ["1", "two", "3"].flatMap{ Int($0) }

print(result) // [1, 3]

またこちらの例は、

「名字が格納された配列をもとに、それぞれの名字に"Kun(くん)"が付加された新しい配列を取得したい。

ただし、名字が特定の文字から始まっていた場合は、その要素をドロップしたい(含めたくない)"。」

と思い立ち、書いたコードとなります。

let result = ["Amemiya", "Sakamoto", "Kitagawa"].flatMap{

guard !$0.hasPrefix("A") else { return nil }
return $0 + " Kun"
}

print(result) // ["Sakamoto Kun", "Kitagawa Kun"] ← "A"から始まる名字はドロップされている

これらの例から察するに、こちらのflatMapは、

クロージャ実行の結果がnilになった場合、それをドロップした上で新しい配列を返すflatMap、といった感じになっています。

またこれを別の角度から見ると、特定の条件下でドロップしたい要素がある場合は、

その条件のときにnilを返すようにクロージャ内を記述してあげるのが使い方のコツといえそうです。

以降、当記事ではこのflatMapを、便宜上「B) nilをドロップするflatMapと呼称します。

また、上記で引用したコード定義内にあるコメントや、その定義の中身で誘導されるように、

こちらのflatMapは、Swift 4.1で非推奨となり、compactMapにリネームされました。

"compact"とはすなわち、nilをドロップすることのようです。


注意点

後述したいと思います。


挙動のまとめ

というわけで、こちらのflatMap(compactMap)の挙動をまとめます。


Array#flatMap<ElementOfResult> (compactMap)

配列の各要素を取り出して、引数で渡されたクロージャに適用する。

その結果がnilではなかった場合に限り、その結果を一時変数に格納する。

(逆に言うと、結果がnilだった場合、これはドロップされる。ここがミソ)

これをすべての要素に対して繰り返し、最後にその一時変数(新しい配列)を返す。



Array#flatMapArray#compactMapの注意点


A) 配列をフラットにするflatMapの注意点

・「A) 配列をフラットにするflatMap」は、要素の要素がnilだった場合、それをドロップしてくれるわけではありません。

「(B)」の方の挙動が頭に残っている場合、それにひきずられないようご注意。

// (A)

let result = [[1, nil], [3]].flatMap{ $0 }
print(result) // [Optional(1), nil, Optional(3)] ← `nil`がドロップされていない


B) nilをドロップするflatMap(=compactMap)の注意点

・また反対に、「(B) nilをドロップするflatMap(= compactMap)」には、

「A) 配列をフラットにするflatMap」が持つ、要素をフラットにした上で返す という性質は持ち合わせていません。

「(A)」の方の挙動が頭に残っている場合、それにひきずられないようご注意。

// (B)

let result = [1, 2, [3]].compactMap{ $0 }
print(result) // [1,2,[3]] ← [3] という「箱」が、フラットになっていない

こちらについては前に触れたように、Array#appendはオーバーロードによって2種類が存在し、それぞれその挙動が違うから、ですね。

(A) のflatMapが内部で使用しているappend(contentsOf:)は、引数の値をフラットにした上で内部の一時変数に追加するのに対し、

(B) のflatMap(=compactMap)が内部で使用しているappend(_:)には、そのような効果はないのでした。

というわけで、ここまでソースコードを読んできた私たちにとっては、これらの挙動も実は知っての通りでしたね。

(ウソです。私はこの点しこたま混乱し倒した上、この先また忘れそうだから私のために書きました)


どのような場合に、どちらのflatMapが呼ばれるのか

Arrayオブジェクトに対しflatMapを呼び出した際、どちらのflatMapが呼び出されるのか、

その決定方法を考察してみましょう。

(Xcodeのサポートを受けられるため、開発中これらの事柄に思考を巡らす必要は一切ありません。)


「A) 配列をフラットにするflatMap」に決定されるための条件

まず、「A) 配列をフラットにするflatMap」になるための条件から考えてみましょう。

その定義に型パラメータ<SegmentOfResult : Sequence> とあるので、これがキーになってきそうです。

func flatMap<SegmentOfResult : Sequence>(   ここ

_ transform: (Element) throws -> SegmentOfResult
) rethrows -> [SegmentOfResult.Element] {
...
}

まず、SegmentOfResult : Sequence なので、SegmentOfResultになる型がSequenceに適合していないといけないことが確定します。

それに加え、この型パラメータがどこで出現してどう使われているのか、定義とにらめっこしてみると、

flatMapが取るクロージャtransformの返り値に、SegmentOfResult が出現していることがわかります。

func flatMap<SegmentOfResult : Sequence>(

_ transform: (Element) throws -> SegmentOfResult ここ
) rethrows -> [SegmentOfResult.Element] {
...
}

この2つを組み合わせて考えると、

flatMapが取るクロージャの戻り値がSequenceに適合している場合

こちらの「A) 配列をフラットにするflatMap」に決定する、ということになりますね。

例えば、これなんかがそうです。6

let numbers    = [1, 2, 3]

let flatMapped = numbers.flatMap { Array(repeating: $0, count: $0) }

print(flatMapped) // [1, 2, 2, 3, 3, 3]

クロージャの戻り値の型はArray、とうぜんSequenceに適合してるので、

「A) 配列をフラットにするflatMap」のほうが呼び出されています。

(コードを見ただけでは確証が持てない場合でも、

alt+クリックでヘルプを表示し、どちらが呼び出されているか確認すれば大丈夫です。)


「B) nilをドロップするflatMap(=compactMap)」に決定されるための条件

一方、「B) nilをドロップするflatMap(=compactMap)」になる場合ですが...こちらはここまで来たら簡単です。

すなわち、A)の場合と反対に、flatMap(=compactMap)が取るクロージャの戻り値がSequenceに適合していない場合、

こちらのflatMap(=compactMap)が呼ばれることになります。

例えば、これなんかがそうです。

// 'flatMap' is deprecated: Please use compactMap(_:) for the case where closure returns an optional value

let flatMapped = ["1", "two", "3"].flatMap{ Int($0) }
print(flatMapped) // [1, 3]

クロージャの戻り値の型はInt?Sequenceには適合していません。

そのため A) ではなく、「B) nilをドロップするflatMap(=compactMap)」のほうが呼び出されています。

もちろんこの場合、flatMapじゃなくてcompactMapを使え、と警告されます。

将来的にはエラーになりそうだし、素直に従っておきます。

let compactMapped = ["1", "two", "3"].compactMap{ Int($0) } // ←`compactMap`に変更

print(compactMapped) // [1, 3] ← 結果はもちろん同じです

以上、どんな場合に、どちらのflatMapが使われるか、を考察してきました。

最初にお伝えしたように、通常こんなことを毎回考える必要は全くなくて、

上の例にあるようにcompactMapを使うべきシーンでflatMapと書いた場合、

XcodecompactMapを使うように警告を出してくれて、簡単にリネームできるようになっています。


(補足) サブタイプ関係がある場合にできること

前項までで本筋の内容は終了しました。おわりにまでスキップでも一切支障ございません。

また、この項は後日、独立した記事としてくくり出したいと思います。

Swiftにおいて、2つの型のあいだにサブタイプ関係がある場合に可能になることをまとめてみました。

それぞれの「できること」について、

A) 以下のような継承関係を持つ独自型 (Dog is Animal)

class Animal {}

class Dog: Animal {}

print(Dog() is Animal) // true

と、

B) ある非オプショナル型は、そのオプショナル型のサブタイプ(T is T?)

print(1 is Int?)  // true

という、2つのサブタイプ関係を例にとって見ていきます。

1: スーパータイプの変数に、サブタイプのインスタンスを代入できる

// 【Animal - Dog】パターン

let animal: Animal = Dog()

// 【T? - T】パターン
let optionalInt: Int? = 1

2: 関数がスーパータイプの引数を要求するとき、サブタイプのインスタンスを渡せる

// 【Animal - Dog】パターン

func takeAnimal(_ animal: Animal) { }
takeAnimal(Dog())

// 【T? - T】パターン
func takeOptional(_ optional: Int?) { }
takeOptional(1)

3: 関数の戻り値の型がスーパータイプのとき、サブタイプのインスタンスを返せる

// 【Animal - Dog】パターン

func getAnimal() -> Animal {
// 関数の返り値は`Animal`だが、サブクラスである`Dog`オブジェクトを返せる
return Dog()
}
// 関数が返した`Dog`オブジェクトは、代入時に関数の戻り値の型へ型強制(暗黙的なアップキャスト)される
let animal: Animal = getAnimal()
// 変数に格納されたオブジェクトの型は`Dog`なので、もちろんダウンキャストには成功する
print(animal as! Dog) // 成功

//【T? - T】パターン
func getOptional() -> Int? {
// 関数の返り値は`Optional<Int>`だが、サブタイプである`Int`オブジェクトを返せる
return 1
}
// 関数が返した`Int`オブジェクトは、代入時に関数の戻り値の型へ型強制(暗黙的なアップキャスト)される
let optionalInt: Int? = getOptional()
// 変数に格納されたオブジェクトの型は`Int`なので、もちろんダウンキャストには成功する
print(optionalInt as! Int) // 成功

4: 親クラスのメソッドの「スーパータイプの戻り値」を、子クラスのメソッド定義内で「サブタイプの戻り値」を返すようにオーバーライドできる

// 【Animal - Dog】パターン

class SuperClass1 {
func foo() -> Animal { return Animal() }
var bar: Animal { return Animal() }
var baz: () -> Animal {
return { return Animal() }
}
}

class SubClass1: SuperClass1 {
// サブタイプの戻り値を返すようにオーバーライドできる(Dog is Animal)
override func foo() -> Dog { return Dog() }
// コンピューテッドプロパティでも成立する
override var bar: Dog { return Dog() }
// もしや、と思ったら...こう書ける。マジかw
override var baz: () -> Dog {
return { return Dog() }
}
}

// 【T? - T】パターン
class SuperClass2 {
func foo() -> Int? { return Optional.some(1) }
var bar: Int? { return Optional.some(1) }
var baz: () -> Int? {
return { return Optional.some(1) }
}
}

class SubClass2: SuperClass2 {
// サブクラスの型を返すようにオーバーライドできる(Int is Optional<Int>)
override func foo() -> Int { return 1 }
// コンピューテッドプロパティでも成立する
override var bar: Int { return 1 }
// もしや、と思ったら...こう書ける。マジかw
override var baz: () -> Int {
return { return 1 }
}
}

また、もし2つの型の間にサブタイピング関係があるなら...

if T   is Optional<T>

if Dog is Animal

それらの型を戻り値に置いた関数オブジェクトにおいても、サブクラス関係が成立する、といえます。

then () -> T   is () -> Optional<T>

then () -> Dog is () -> Animal

よって、以下のようなコードが成立します。

(もしかすると「類型5」としてではなく「類型1や2や3の亜種」と考えた方が自然に感じられるかもしれません。)

5-1: () -> スーパータイプの変数に、() -> サブタイプの関数オブジェクトを代入できる

(類型1の亜種とも考えられる)

// 【Animal - Dog】パターン

func foo() -> Dog { return Dog() }
let fooAnimal: () -> Animal = foo // `() -> Animal`型の変数に、`() -> Dog` 型の関数オブジェクトを代入できている

// 【T? - T】パターン
func bar() -> Int { return 1 }
let barOptional: () -> Int? = bar // `() -> Int?`型の変数に、`() -> Int` 型の関数オブジェクトを代入できている

5-2: () -> スーパータイプを要求する引数に対し、() -> サブタイプの関数オブジェクトを渡せる

(類型2の亜種とも考えられる)

// 【Animal - Dog】パターン

func takeClosure(_ completion: () -> Animal) { }

func getDog() -> Dog { return Dog() }
takeClosure(getDog) // `() -> Animal` 型の引数に対し、`() -> Dog` の関数オブジェクトを渡せている

takeClosure{ Dog() } // ↑の2行を手短に書くとこう。やってることは同じ

// 【T? - T】パターン
func takeClosure(_ completion: () -> Int?) { }

func getOne() -> Int { return 1 }
takeClosure(getOne) // `() -> Int?` 型の引数に対し、`() -> Int` の関数オブジェクトを渡せている

takeClosure{ 1 } // ↑の2行を手短に書くとこう。やってることは同じ

5-3: () -> スーパータイプの関数オブジェクトを返す関数で、() -> サブタイプの関数オブジェクトを返せる

(類型3の亜種とも考えられる)

// 【Animal - Dog】パターン

func returnClosure() -> () -> Animal {
return { Dog() } // `() -> Animal`の関数オブジェクトを返す関数で、`() -> Dog`の関数オブジェクトを返せている
}

// 【T? - T】パターン
func returnClosure() -> () -> Int? {
return { 1 } // `() -> Int?`の関数オブジェクトを返す関数で、`() -> Int`の関数オブジェクトを返せている
}

この他にもできることがあれば、ご教示いただけると嬉しいです。


おわりに

さまざまなflatMapの実体に、そのソースコードを読むことで迫ってきました。

もしもこの先、flatMapが一見不可解な値を返してきた場面に遭遇したとしても、

ソースコードという「ここに立ち返れば大丈夫!」という道しるべを手にできたのは私にとって大きかったです。

そしてそれは、心の平穏ももたらしてくれるはずです。

一行一行、論理の整合性を踏みしめながら進んでいきたい私のような俗物にとって、型にうるさめのSwiftは本当にぴったりな言語だと感じています。

「この挙動はなぜ?」という事象に出くわしても、これまで集めてきた知識を組み合わせ、粘り強く考えていくことで解答にたどり着ける確率が非常に高いのは、Swiftの抗いがたい魅力です。

ArrayflatMapの一部がcompactMapへと切り分けられ、ジェネリクスの機能も漸進していく中、残る関心事は・・・flatMapがプロトコルMonadで抽象されたり、そのために必要な記述力をSwiftが獲得するーそんな未来はいつの日か訪れるのでしょうか?

・・・それに思いを馳せるには、自分自身さらなる精進が必要なので、今日はこれにて。


links

mapとflatMapという便利メソッドを理解する





  1. IT用語辞典より引用いたしました。 



  2. ソースコードはここにあるようです。 



  3. なお、Array#mapに関してはこの記事では取り扱いません。思いのほか長尺になってしまったもののOptional#mapについてこれまで言及してきたのも、元を辿ればOptional#flatMapをより理解したいと思ったが故でした。決してArray#mapの実装の意図を把握しきれなかったので取り上げることを放棄したといった話ではないと思います。それはさておいても、やはりArrayの実装についても徐々に知識を深めていかないとなと考えています。 



  4. ソースコードはここにあるようです。 



  5. ソースコードはここにあるようです。 



  6. このflatMapの使用例はソースコード内の文書化コメントからただパクってきたものなのですが、この記事を書いている際、ふとそのヘルプメッセージ(コメント)が古いまま残っていたことに気づきました。「なぜflatMapともあろうメジャーなAPIのコメントが2年前から放置されているんや...」と訝しみながらもPRを出してみたところ、12時間ほどでマージされてしまい(ただのコメント修正だからここまで短かったに違いない)、図らずとも自身初めてのSwiftへのコミット体験となりました。また、どう見積もっても書くのに1分とかからなかった2行の修正コードが、結果としてこれまで書いてきた中で最もインパクトを持つコードになりました。Contributionなんて言うのも憚れるミクロな修正でしたが、当の本人にとってはありえん嬉しみが深かったです。世界で一番好きな技術のコミュニティに受容される、これほど自己承認欲が充足され、また幸せなことは他にありません。この気持ちを忘れずに、次こそはコアライブラリ、そして標準ライブラリ・・・と手を入れられる開発者になれるよう、成長していきたいと思います。