リモート開発メインのソフトウェア開発企業のエンジニアブログです

(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)

無事できました。
以上、長くなりましたが解説を終わります。

← 前の投稿

Mac から Windows に乗り換え

次の投稿 →

Option(Scala)の実用的な使い方 − データのマージ処理

コメントを残す