sequenceって何なんだ
Kotlinはcollectionに対する操作を関数的に記述することが可能です。
val shortLowerCaseNames = listOf("Takuya", "Yasuko", "Tom", "Jade", "Ted", "Juri")
// 4文字以下の名前のみ抽出
.filter { it.length < 4 }
// 名前を小文字に変換
.map { it.toLowerCase() }
// リストは4文字以下の小文字表記された名前のみ含む
println(shortLowerCaseNames)
// [tom, ted]
上記の例では変換と抽出をそれぞれmap
・filter
を呼び出すことで行っています。
このcollectioに対する操作を行う起点でasSequence
を呼び出すとsequenceとして操作を行うことができます。
val shortLowerCaseNames = listOf("Takuya", "Yasuko", "Tom", "Jade", "Ted", "Juri")
// sequenceとして操作を開始
.asSequence()
// 4文字以下の名前のみ抽出
.filter { it.length < 4 }
// 名前を小文字に変換
.map { it.toLowerCase() }
// sequenceをリストに変換
.toList()
// リストは4文字以下の小文字表記された名前のみ含む
println(shortLowerCaseNames)
// [tom, ted]
「確かデータが大量にあるときにはsequence使う方が早いんですよね。。。?」くらいにしか理解できていなかったので、collection操作とsequence操作の仕組みについて調べてみることにしました。
操作をlazyに行うのがsequence!
はい、意味が分かりません。少なくとも自分が最初に「sequenceはlazyに操作を進めることができるんだよ」と言われた際には全く意味が分かりませんでした。。
まず、上記のsequence利用例でtoList
を呼ばないで返却値を確認してみるところから始めてみましょう。
val shortLowerCaseNames = listOf("Takuya", "Yasuko", "Tom", "Jade", "Ted", "Juri")
// sequenceとして操作を開始
.asSequence()
// 4文字以下の名前のみ抽出
.filter { it.length < 4 }
// 名前を小文字に変換
.map { it.toLowerCase() }
// sequenceをリストに変換 ** しない **
// .toList()
// リストは4文字以下の小文字表記された名前のみ含む。。。?
println(shortLowerCaseNames)
// kotlin.sequences.TransformingSequence@4b5a5ed1
すると、kotlin.sequences.TransformingSequence@...
という期待するリスト値とは異なる内容の出力を確認できると思います。実は、この段階ではfilter
もmap
も実行されていません。これこそがsequenceの操作がlazyに行われているということなのです。
asSequence
メソッドを呼び出すことでリストはsequenceに変換されます。map
やfilter
などのsequenceに対する操作はIntermediate operation(中間操作)と呼ばれ、これらを呼び出すだけではsequenceは処理を始めてくれません。ではどうすれば処理を始めてくれるのかというと、上記の例で言うtoList
の様なメソッドを呼び出すことが必要です。これらのメソッドはTerminal operation(末端操作)と呼ばれ、これこそがsequence型に対して「はい、それじゃあ処理はじめ!」と合図する役割を果たしています。
つまり、"sequenceの演算がlazy"と言うのは"collectionへの処理と違い、sequenceへの処理はTerminal operationが適用されるまで行われない"と言うことだったのです。なるほど、equenceへの処理が遅延評価により実行されることは理解できました。それに加えてもう一つ、collection操作とsequence操作の違いとして重要なポイントがあります。それは、操作が適応される順番についてです。
操作を反復処理として行うのがsequence!
こちらも字面だけでは全く意味が分からないので、早速実例を見ていきます。例えば、下記の様な単純な操作を行うcollectionについて考えてみます。
val maybeTom = listOf("Takuya", "Yasuko", "Tom", "Jade", "Ted", "Juri")
// 名前を小文字に変換
.map { it.toLowerCase() }
// "tom"に一致する要素を検索
.find { it == "tom" }
// "tom"が存在すればその値が格納されている
println(maybeTom)
// tom
上記処理をsequenceとして記述すると、下記になります。
val maybeTom = listOf("Takuya", "Yasuko", "Tom", "Jade", "Ted", "Juri")
// sequenceとして操作を開始
.asSequence()
// 名前を小文字に変換
.map { it.toLowerCase() }
// "tom"に一致する要素を検索
.find { it == "tom" }
// "tom"が存在すればその値が格納されている
println(maybeTom)
// tom
今回の例ではmap
が中間操作、find
が末端操作になります。collection、sequence共に当然出力結果は同じなのですが、実は各要素に対して操作が適応される方法が両者で異なっています。collectionは全ての要素に対して各処理を適用していくのに対し、sequenceは各要素に対して全ての処理を反復的に適用していきます。言葉だけでは分かりづらいので、下記の図を見てみましょう。
collectionでは全ての要素に対してmap処理を行い新たなリストにその結果を格納している一方で、sequenceでは各要素に対してmap処理を行っていることが理解できます。そのため、もし中間操作で何らかの処理をリストとして実施したい場合にはsequenceを用いることはできません。sequenceでは末端操作終了時まで操作内容がリストとして扱われることがないためです。
簡単にではありますが、collectionとsequenceの際について学んできました。最後に、両者の特徴がパフォーマンスに対して与える影響について考えてみましょう。
collection vs sequence:パフォーマンスが優れているのは?
ここまで学んだことから、下記2パターンについてはsequenceを利用することでパフォーマンスの改善が期待できます。
###①操作対象データ量が膨大な場合
collectionで操作を記述すると、各処理ごとに新規のリストが作成されることになります。これは操作対象データ量が程々であれば問題になりませんが、その量が膨大であった場合には各処理結果に対していちいち新規のリストを作成していくのはあまり得策ではありません。その様な場合にはsequenceを利用することで、新規リストが生成される機会を末端操作時のみに限定することができます。膨大なデータを保持する余計なリストの作成を避けることができるので、パフォーマンスの向上が期待できます。
###②操作を早期に切り上げることできる場合
勘の良い方は先ほどcollectionとsequenceの操作処理方法の差異について確認した図で、"tom"がfind
で発見された後の各要素("Jade"・"Ted"・"Juri")についてはmap
による変換処理が行われず、処理が切り上げられていたことに気づいたかもしれません。この様にsequenceの末端操作によって処理を切り上げることができるケースであれば、collectionでは実行されてしまう不要な処理を省略することができます。当然、その分パフォーマンスについても向上が期待できます。
まとめ
この記事が「sequenceなんとなく使ってるけど、こいつ一体なんなんだろうなあ。。。」と思っていたエンジニアさんの一助になれば幸いです。あくまで今回まとめたのは概要のみであり、プロダクション環境などで利用する場合には考慮が必要な事柄も種々あると思います。もし補足などございましたら、コメント欄にてお待ちしております。
お読みいただきまして、ありがとうございました!