map() の使い方がわからない
一緒に仕事してる新人の子が、Kotlin の Listオブジェクトでよく使う map()メソッドの使い方がいまいち理解しきれてない、ということを問題点として提起してくれました。
そういえば昔はよく理解できてなかったよなー、俺もなー、と昔の感覚を思い出したんですが、今の自分はコレクション操作関数を使うのは何の抵抗もなくなってしまっていて、何がわからなかったんだっけ?というのがよく思い出せないんですよね。
「俺はどうやって身につけたんだっけ?慣れ?慣れな気がする。慣れたら使い方わかるよ」とアドバイスになってないことを伝えてましたが… なんかいい方法ないかなー、と考えてたんですが、自分で map() を実装してみたらイメージがちょっとはつかめるのでは?と思いつきました。
そこで、課題として map() を一緒に実装してみました。自分としても、たぶん関数(クロージャ)を受け取ってそれを実行すればいけるだろう、と考えてたんですが、変数に型がついてる言語で実装したことはなかった気がするので、自分にとっても練習になりそうだと思いました。
map() を実装してみる
最初に Kotlin での map() 関数の使い方を再確認しておきます。以下のような感じですね。
val list = listOf("1", "2", "3", "4")
println(
list.map {it + it}
)
//=> [11, 22, 33, 44]
まずは map() の本質を学ぶために、
- 拡張関数などを使わず、Listオブジェクトと変換関数を受け取る
- String に限定して実装する
という制限で map() を実装してみました。
以下の関数が最初の実装です。
fun myMap1(src: List<String>, cb: (String) -> String): List<String> {
val result = mutableListOf<String>() // [A]
for (s in src) {
result.add(cb(s)) // [B]
}
return result // [C]
}
変換関数を引数で受け取って、リストの各要素に対して変換関数を適用し、結果のリストを返します。
これは以下のように呼び出せます。
fun doubleStr(str: String): String {
return str + str // [D]
}
println( // [E]
myMap1(list, ::doubleStr) // [F]
)
//=> [11, 22, 33, 44]
リストと変換関数を引数として myMap1() を呼び出します。(Kotlinで関数を渡す時は、頭に ::をつける必要があります)
このコードを実行すると以下の順番で実行され、結果の文字列が表示されます。
[F] -> [A] -> [B] -> [D] -> [B] -> [D] -> [B] -> [D] -> [B] -> [D] -> [C] -> [E]
各行にブレークポイントを仕掛けて、呼び出し順と、このコードが正しく動くことを確認してもらいました。
このように関数を渡して機能を拡張する機能を 高階関数 と呼び、関数型プログラミング言語でよく利用される手法です。
さらにKotlinには、最後の引数が関数であれば、関数宣言自体を以下のようにブロックを使って記述することができます。
println(
myMap1(list) { it + it }
)
これでいつも使ってる map() と同じように呼び出すことができました!
つまりブロックを使った記述は、変換関数を簡単に書くための仕組みにすぎなくて、mapの実態は、関数を引数で受け取って処理をする関数なわけですね。
ここまで実装してみて、実際に動きを確かめてみたところ、新人君からは理解が進んだとフィードバックをもらえました。ヨカッタ!
map() のように、集合に対して処理をする関数を使いこなすと、複雑な処理を簡潔に記述できるケースがよくあるので、いろいろな言語でこういう「集合に対して処理をするメソッド」が定義されています。こういうメソッドは コレクション操作関数 と呼ばれます。
せっかくなのでほかのコレクション操作関数も実装してみよう、ということでいくつかメソッドを実装してみました。
まずは filter() メソッドです。
fun myFilter1(src: List<String>, cb: (String) -> Boolean): List<String> {
val result = mutableListOf<String>()
for (s in src) {
if (cb(s)) {
result.add(s)
}
}
return result
}
println(
myFilter1(list) { it.toInt() % 2 == 0 }
)
//=> [2, 4]
簡潔に実装できて良い感じですねー。
次は maxBy() です。
fun myMaxBy(src: List<Int>, cb: (Int) -> Int): Int? {
var result : Int? = null
for (s in src) {
if (result == null) {
result = s
} else {
if (cb(result) < cb(s)) {
result = s
}
}
}
return result
}
val numList = listOf(1, 2, 3, 4)
println(
myMaxBy(numList) { - it }
)
//=> 1
すばらしい。
ここまで理解できたらコレクション操作関数について、だいぶ理解が進んだと思ってよいのではないかと思います。おつかれさまでした!
ジェネリクスを使う
Kotlinのジェネリクスを使って map() を書き直してみます。
fun <T, R> myMap2(src: List<T>, cb: (T) -> R): List<R> {
val result = mutableListOf<R>()
for (s in src) {
result.add(cb(s))
}
return result
}
println(
myMap2(list) { it + it }
)
//=> [11, 22, 33, 44]
これでいいんですね。すごく簡単にジェネリクスにできる。ありがたやー。
拡張関数を使う
Kotlinには拡張関数(Extension Functions)という仕組みがあって、既存の型に拡張して関数を定義することができます。Rubyのオープンクラスのように拡張できるわけですが、Kotlinの場合は元の型を変換するわけではなくてスコープ限定で継承を作ってくれて、どういう実装になってるのかは理解してないけど便利。
この拡張関数を使って map() を書き直してみます。
fun <T, R> List<T>.myMap3(cb: (T) -> R): List<R> {
val result = mutableListOf<R>()
for (s in this) {
result.add(cb(s))
}
return result
}
println(
list.myMap3 { it + it + it }
)
//=> [111, 222, 333, 444]
ファッ!? これだけでいいんですか??? ほとんど何も苦労せず既存型に自分のメソッドが生やせるとは!
で、Kotlinのmapの実装を見てみると、Iterable という interface に定義されてました。なので Iterable に生やしてみます。
fun <T, R> Iterable<T>.myMap4(cb: (T) -> R): List<R> {
val result = mutableListOf<R>()
for (s in this) {
result.add(cb(s))
}
return result
}
println(
list.myMap4 { it + "ite" }
)
//=> [1ite, 2ite, 3ite, 4ite]
List interface は Collection を継承していて、 Collection はさらに Iterable を継承している。
なので普通にListのオブジェクトからも使えるわけですね。
Iterableはiterator() のみを提供している interface で、 Kotlin 組み込みの map() メソッドも、 Iterable への拡張関数として定義されています。
組み込みのコレクション操作関数も拡張関数として定義されてるわけですね!
拡張関数はオーバーヘッドがあるから組み込み関数は直に定義されてるとかいうことはなく、普通に組み込み関数でも拡張関数が使われてる。
これでいいんだ!これでいいんだとしたら... もう拡張関数は俺のおもちゃじゃないか!
Rubyのコレクション操作関数を移植する
Rubyには便利なコレクション操作関数がいろいろありますので、これらを Kotlin にも移植してみました。
reject()
fun <T> Iterable<T>.reject(cb: (T) -> Boolean): List<T> {
return filter {
!cb(it)
}
}
たまに使いたいメソッド。
index_by()
fun <T, R> Iterable<T>.indexBy(cb: (T) -> R): Map<R, T> {
return this.map { cb(it) to it }.toMap()
}
いいっすねー。
uniq()
fun <T> Collection<T>.uniq(): Iterable<T> {
return this.toSet()
}
これは普通に toSet() 使えばいいっすね。
each_slice()
fun <T> Iterable<T>.eachSlice(countInSlice: Int, cb: (List<T>) -> Unit) {
var slice = mutableListOf<T>()
for (s in this) {
slice.add(s)
if (slice.size == countInSlice) {
cb(slice)
slice = mutableListOf<T>()
}
}
if (slice.size > 0) {
cb(slice)
}
}
val list10 = (1..10).toList()
list10.eachSlice(3) {
println(it)
}
//=> [1, 2, 3]
//=> [4, 5, 6]
//=> [7, 8, 9]
//=> [10]
楽しい!
まとめ
map()などのコレクション操作関数の仕組みについて説明しました。
コレクション操作関数は高階関数の手法を利用した便利メソッドで、多くの言語で実装されています。
また、拡張関数を使ってコレクション操作関数を生やすのは、簡単かつ便利ということを学べました。
さらに楽しい!
Rubyでenumerableにオレオレコレクション操作関数を生やしていた時代を思い出します。この手軽さで既存クラスを拡張できるのなら、Kotlinでもいろいろできそう。混乱しないようなルールは必要でしょうが、コレクション操作関数は便利に色々定義していいのではないでしょうか。
あとは、Kotlin には組み込みでもコレクション操作関数がいろいろ定義されているということが、調査していてわかりました。たとえば groupBy() とか partition() とか sum() とか take() とか。
これらの組み込みの関数も知っておいた方がいいなと思いました。 https://qiita.com/opengl-8080/items/36351dca891b6d9c9687 でまとめていただいる資料が便利でした。
以上です。