(Scala) List[Option[A]]#flattenで起きる事
先日、kojiがOption(scala)の実用的な使い方 − データのマージ処理と言うナイスな記事を書いてくれたので、ScalaのOptionを包括した List#flatten
についてもう少し掘り下げて見たいと思い、今回筆(キーボード)を取りました。
かなり基本的な内容なのでScala初心者向けの記事となっています。
まず以下に、kojiが書いたflattenの記述例を記載します:
val dataSetA: Dataset[Row] = ??? // データの欠落は許容されない=欠落時は例外で落としてOK
val optDataSetB: Option[Dataset[Row]] = ??? // データの欠落を許容している為、Option
val optDataSetC: Option[Dataset[Row]] = ??? // データの欠落を許容している為、Option
// 同じスキーマを持ったデータA,B,Cをマージ
val mergedDataSet = List(optDataSetB, optDataSetC)
.flatten
.foldLeft(dataSetA)(_ union _)
※わかりやすいように一部内容を変えてます
さて、Dataset[Row]
やそのメソッドである union
(foldLeft
内で呼んでる)はApache Spark SQLのAPIに関連する機能なので、今回は分かりやすくScala標準の文字列リストを使う事にします。
まずはお馴染み、Scala REPLを開いて適当に文字列リストを2つ初期化します:
scala> val listA = List("Alice", "Bob", "Charlie")
listA: List[String] = List(Alice, Bob, Charlie)
scala> val listB = List("Dave", "Eric", "Frank")
listB: List[String] = List(Dave, Eric, Frank)
この2つを結合する場合、通常 ++
を使います:
scala> val merged = listA ++ listB
merged: List[String] = List(Alice, Bob, Charlie, Dave, Eric, Frank)
これが、Spark SQLの例で言う union
に相当しています。
さて、それではもう少し例に近づけて、リストが取得できないケースを考えてみます。この例ではリストの型は List[String]
ですが、失敗した時は null
を返すとします。(Scalaでは null
は基本使いませんが)
scala> val listC = null // リストの取得に失敗した想定。例えば読み取り元が存在しないファイルの為、IOエラーがあった時とか。
listC: Null = null
nullなので結合しようとすると、当然エラーが出ます:
val merged = listA ++ listB ++ listC
java.lang.NullPointerException
at scala.collection.immutable.List.$plus$plus(List.scala:209)
... 28 elided
先程も言いましたが、Scalaでは null
を使う事は基本無く、正常系/異常系を表現する場合は Option[A]
等を使います。kojiがやっていたプロジェクトでは Option[A]
を使っていたので、これを先程の3つのリストに当てはめるとこうなります:
scala> val optListA: Option[List[String]] = Option(List("Alice", "Bob", "Charlie"))
optListA: Option[List[String]] = Some(List(Alice, Bob, Charlie))
scala> val optListB: Option[List[String]] = Option(List("Dave", "Eric", "Frank"))
optListB: Option[List[String]] = Some(List(Dave, Eric, Frank))
scala> val optListC: Option[List[String]] = Option(null)
optListC: Option[List[String]] = None
※出力時の型の表記を揃えるため型アノテーションをあえてつけてます
以下、Option[A]
についてのおさらいになります。
まず、 Option()
でラップするだけで、値があれば Some[A]
に、無ければ None
にそれぞれ変換してくれます。どちらも Option[A]
の派生クラスです。
さて、先程のリストの結合ですが、リストは Option
にラップされてる為、このままでは演算ができません。なので、値を取り出す必要があります。取り出す方法はいくつかありますが、一番シンプルなのは get
を使う事です:
scala> optListA.get
resXX: List[String] = List(Alice, Bob, Charlie)
ただし、値が None
だった場合、例外が投げられます:
scala> optListC.get
java.util.NoSuchElementException: None.get
at scala.None$.get(Option.scala:366)
at scala.None$.get(Option.scala:364)
... 28 elided
と言う訳で、get
を安直に使う事はなく、 isEmpty
を使って None
でないかを判定するか、 getOrElse(default)
を使ったり、パターンマッチを使ったりで異常系の場合の例外処理を記載します。
以下は全部同じような意味になります:
scala> if (optListC.isEmpty) Nil else optListC.get
resXX: List[String] = List()
scala> optListC.getOrElse(Nil)
resXX: List[String] = List()
scala> optListC match { case Some(n) => n case None => Nil }
resXX: List[String] = List()
さて、今回はこの様に値が複数あって、 Some[A]
の物だけ集めて ++
で結合したいと言うのが要件でしたね。
単純に、先程の例で安全にリストを取り出して結合しても良いですが、今回kojiがそうした様にListで更にラップするのも有りです:
scala> List(optListA, optListB, optListC).filter(_.nonEmpty).map(_.get).reduce(_ ++ _)
resXX: List[String] = List(Alice, Bob, Charlie, Dave, Eric, Frank)
一応上記で何が起きているのか詳しく見てみましょう:
scala> val optLists = List(optListA, optListB, optListC) // List でOption[List[String]] をラップ
optLists: List[Option[List[String]]] = List(Some(List(Alice, Bob, Charlie)), Some(List(Dave, Eric, Frank)), None)
scala> val nonEmptyOptLists = optLists.filter(_.nonEmpty) // 各Option[List[String]]のうち、 `nonEmpty` が真の物だけピックアップ
nonEmptyOptLists: List[Option[List[String]]] = List(Some(List(Alice, Bob, Charlie)), Some(List(Dave, Eric, Frank)))
scala> val nonEmptyLists = nonEmptyOptLists.map(_.get) // 各Some[List[String]]から `get` で値を取り出し、リストに戻す
nonEmptyLists: List[List[String]] = List(List(Alice, Bob, Charlie), List(Dave, Eric, Frank))
scala> val mergedList = nonEmptyLists.reduceLeft(_ ++ _) // 各List[String]を `++` で繋いでいく。
mergedList: List[String] = List(Alice, Bob, Charlie, Dave, Eric, Frank)
これで安全にリストの結合ができました。しかし、実はこの処理、冒頭での例の通り、 .filter(_.nonEmpty).map(_.get)
の部分は単に .flatten
に置き換える事ができます:
scala> val nonEmptyLists = List(optListA, optListB, optListC).flatten
nonEmptyLists: List[List[String]] = List(List(Alice, Bob, Charlie), List(Dave, Eric, Frank))
scala> List(optListA, optListB, optListC).flatten == List(optListA, optListB, optListC).filter(_.nonEmpty).map(_.get)
resXX Boolean = true
ところで、List.flatten
と言うと、2次元配列 List[List[A]]
を List[A]
に平坦化すると言うイメージが有り、実際にScaladocにもそのような例があります:
scala> List(Set(1, 2, 3), Set(1, 2, 3)).flatten
resXX: List[Int] = List(1, 2, 3, 1, 2, 3)
scala> Set(List(1, 2, 3), List(1, 2, 3)).flatten // Setは重複なし
resXX: scala.collection.immutable.Set[Int] = Set(1, 2, 3)
では何故、 List[Option[A]]
をflattenすると、Noneを排除した上で中身を取り出したリストが作られるのでしょう?
前置きが長くなりましたが、本題に入ります。
List[Option[A]].flattenの動きをみてみる
話をシンプルにする為、実際に自分で Option[A]
を実装してみます。
機能はごくシンプルに、ひとまず get
と isEmpty
のみ実装しました:
sealed trait MyOption[+A] {
def isEmpty: Boolean
def get: A
}
final case class MySome[+A](value: A) extends MyOption[A] {
override def isEmpty: Boolean = false
override def get: A = value
}
case object MyNone extends MyOption[Nothing] {
override def isEmpty: Boolean = true
override def get: Nothing = throw new Exception("You cannot get any value from MyNone")
}
早速値を入れてみましょう:
scala> val optLists = List(MySome(List("Alice", "Bob", "Charlie")), MyNone, MySome(List("Dave", "Eric", "Frank")))
optLists: List[Product with Serializable with MyOption[List[String]]] = List(MySome(List(Alice, Bob, Charlie)), MyNone, MySome(List(Dave, Eric, Frank)))
これで準備完了、とはいきません。何故なら、 List#flatten
(厳密には GenericTraversableTemplate#flatten
) は第1引数に、 A => GenTraversableOnce[B]
型の関数を暗黙的に受け取る (Implicit Parameter) からです:
def flatten[B](implicit asTraversable: A => GenTraversableOnce[B]): List[B] // List#flatten の返り値は `List[B]`
※Implicit ParameterはドワンゴさんのScala研修テキストが詳しいです
この時点でのREPLのコンテキストでは暗黙的にパラメーターを渡せず、明示的に渡す必要があるので、早速作ってみましょう。
まず、現コンテキスト上では A
はListの要素である MyOption[List[String]]
なので、これをイテレーション可能な GenTraversableOnce[B]
互換の値 (例えば List[B]
とか) に変換する関数にする必要があります。 B
はA
がラップしている物、つまり List[String]
になります。つまり、 A => GenTraversableOnce[B]
はこの場合は MyOption[List[String]] => GenTraversableOnce[List[String]]
と言えます。
では、 MyOption
でイテレーション可能な表現とはどの様な物でしょうか?おそらく、 MySome("Alice")
であればそのまま中身の "Alice"
だけを反復する List("Alice")
のような値、 MyNone
であれば、1回も反復を行わない List() = Nil
のような値が妥当に思えます。 (繰り返しますが、GenTraversableOnce互換であれば必ずしもListである必要は無いです)
以上を踏まえると、今回渡す関数は次のような物で良さそうです:
MyOption[List[String]] => if (opt.isEmpty) Nil else List(opt.get)
実際にREPLで実行してみます:
scala> val nonEmptyLists = optLists.flatten((opt: MyOption[List[String]]) => if (opt.isEmpty) Nil else List(opt.get))
nonEmptyLists: List[List[String]] = List(List(Alice, Bob, Charlie), List(Dave, Eric, Frank))
scala> val merged = nonEmptyLists.reduceLeft(_ ++ _)
merged: List[String] = List(Alice, Bob, Charlie, Dave, Eric, Frank)
うまくいきました!
最後に、 flatten
の暗黙パラメーターをどう渡すかを考える必要があります。
ところで先程の MyOption
を GenTraversableOnce[B]
な表現にする、と言う処理ですが実は本家の Option
は既にこれを満たす toList
を持っています:
// scala.Option#toList
/** Returns a singleton list containing the $option's value
* if it is nonempty, or the empty list if the $option is empty.
*/
def toList: List[A] = if (isEmpty) List() else new ::(this.get, Nil)
なのでこれに習って、こちらでも先程作った関数を toList
として定義しておきます:
sealed trait MyOption[+A] {
def isEmpty: Boolean
def get: A
def toList: List[A] = if (isEmpty) Nil else List(get)
}
final case class MySome[+A](value: A) extends MyOption[A] {
override def isEmpty: Boolean = false
override def get: A = value
}
case object MyNone extends MyOption[Nothing] {
override def isEmpty: Boolean = true
override def get: Nothing = throw new Exception("You cannot get any value from MyNone")
}
REPLで実行するとこんな感じです:
scala> MySome("Alice").toList
resXX: List[String] = List(Alice)
scala> MyNone.toList
resXX: List[Nothing] = List()
ばっちりですね。後は、これを flatten
に暗黙的に渡すだけです。本家のOptionでは、コンパニオンオブジェクトである Option
オブジェクトにてImplicit Conversion (暗黙的な変換) を定義する事で実現しています:
object Option {
import scala.language.implicitConversions
/** An implicit conversion that converts an option to an iterable value
*/
implicit def option2Iterable[A](xo: Option[A]): Iterable[A] = xo.toList // IterableはGenTraversableOnceの派生
// 以下略
}
※Implicit ConversionについてもドワンゴさんのScala研修テキストを参照下さい
例によって我々もコンパニオンオブジェクトを用意して、Implicit Conversionを実装しましょう:
object MyOption {
import scala.language.implicitConversions
implicit def opt2List[A](opt: MyOption[A]): List[A] = opt.toList
// ついでに `apply` も実装して初期化しやすくする
def apply[A](value: A): MyOption[A] = if (value == null) MyNone else MySome(value)
}
sealed trait MyOption[+A] {
def isEmpty: Boolean
def get: A
def toList: List[A] = if (isEmpty) Nil else List(get)
}
final case class MySome[+A](value: A) extends MyOption[A] {
override def isEmpty: Boolean = false
override def get: A = value
}
case object MyNone extends MyOption[Nothing] {
override def isEmpty: Boolean = true
override def get: Nothing = throw new Exception("You cannot get any value from MyNone")
}
REPLで実行してみます:
scala> MyOption("Alice").toList
resXX: List[String] = List(Alice)
scala> MyOption(null).toList
resXX: List[Null] = List()
MyOptionはListに暗黙的に変換できます。なので、実際には定義されていない、 ++
等のListのメソッドを使う事ができます:
scala> MyOption("Alice") ++ List("Bob") ++ List("Charlie")
resXX: List[String] = List(Alice, Bob, Charlie)
scala> MyOption(null) ++ List("Bob") ++ List("Charlie")
resXX: List[String] = List(Bob, Charlie)
では最後に、今回のテーマである flatten
を交えて実行してみましょう:
scala> val optLists = List(MyOption(List("Alice", "Bob", "Charlie")), MyOption(null), MyOption(List("Dave", "Eric", "Frank")))
optLists: List[MyOption[List[String]]] = List(MySome(List(Alice, Bob, Charlie)), MyNone, MySome(List(Dave, Eric, Frank)))
scala> val nonEmptyLists = optLists.flatten
nonEmptyLists: List[List[String]] = List(List(Alice, Bob, Charlie), List(Dave, Eric, Frank))
scala> val merged = nonEmptyLists.reduceLeft(_ ++ _)
merged: List[String] = List(Alice, Bob, Charlie, Dave, Eric, Frank)
無事できました。
以上、長くなりましたが解説を終わります。
コメントを残す