Option を使いこなすと、より関数型言語らしいコードが書けるようになります。

どんなときに使うのか?

  • nullの代わりに使う
    • 関数の結果が得られない場合など (例:Map[K,V]#get(key)の返り値は Option[V])
  • モナド(monad)として使い、エラー値を扱うコードの流れをスムーズにする
    • for-comprehensionと共に使うと良い

モナドと聞いて怖じ気付く必要はありません。これから詳しく説明していきます。

パターンマッチでOptionの値を取得

Option[A]は値が存在するか、しないかを表すクラスで、Some(a)とNoneの二種類の値があります。パターンマッチでこの二種類の値を処理するのが基本です。

 val m = Map("A" -> "Apple", "B" -> "Banana")
 def lookup(symbol:String) = m.get(symbol) match { // m.get でOption[String]が返る
  	   case Some(name) => name + " is found!"
  	   case None => "No name is found for " + symbol
 }
 println(lookup("A")) // Apple is found
 println(lookup("C")) // No name is found for C

パターンマッチをせずにOptionを処理する

Optionの値がSome(a)の時だけ続きの処理を行うにはmapを使います。

 val m = Map("A" -> "Apple", "B" -> "Banana")
 m.get("A") map { f => println(f + "is found") } // Apple is found
 m.get("C") map { f => println(f + "is found") } // (何も表示されない)

その他便利な関数

getOrElse - Optionの値がSome(v)ならvを、それ以外にはdefault値を返す

def getOrElse[B >: A](default: ⇒ B): B

使い方

val v = List(Some("A"), None)
for(each <- v) yield { each.getOrElse("empty") } // List(A, empty)

Optionには、map, filter, foreachなどcollectionでよく使う関数も定義されており、これらもやはり値がSome(x)の場合のみ処理が実行されるように定義されています。詳しくはOptionのAPIを参照してください。

エラー処理にOptionを使う

まず例題を示します。

例:入力がnullかどうかをチェックする

例えば、ユーザー名とパスワードを受け取ってログインする関数loginを作ることを考えます。ユーザー名とパスワードの情報が揃っていないと次の処理ができないので、nullかどうかのチェックが入りますが、以下のように、コードが入り組んでしまいます。

def login(name:String, password:String) : Boolean = {
    if(name != null) {
	  if(password != null) 
	     database.isValidPassword(name, password)
      else
	    false
    }
	else 
      false
}

Early exitを利用したコード

あまりScala的ではないですが、事前にnullかどうかをチェックしてデータに不備があれば早々にreturn(early exit)しる書き方も、C, Javaなどのプログラミング言語でよく使われています。

def login(name:String, password:String) : Boolean = {
    if(name == null) 
	 return false
	if(password == null) 
	  return false
    return database.isValidPassword(name, password)
}

ただし、不備のあった場合の処理(falseを返すコード)が重複して現れてしまうので無駄があります。

パターンマッチを利用したコード

事前にOption(v)として値をラップしておくと、vがnullの場合はNone, それ以外の場合はSome(v)に変換してくれます。

def login(name:String, password:String) : Boolean = (Option(name), Option(password)) match {
    case (Some(u), Some(p)) => database.isValidPassword(u, p)
    case _ => false
}

ここでパターンマッチを利用するのもありです。

大分コードがすっきりしてきましたが、ユーザー名が与えられていない場合、そこで処理を終了してほしいのですが、上記のコードではパスワードの方も常にSome(p)かどうかを判定しているので無駄がありそうです。

モナド(monad)とは

コードをきれいにしつつ、処理の無駄も省くのに登場するのがモナドです。

ScalaのOptionはモナドになっています。モナドはとりあえずmap, flatMapの二種類の関数が定義されているものと理解すればよいでしょう。少なくともこの理解だけですぐに使い始めることができます。

trait Monad[A] {
  // Monad[A]の中身Aを取り出し、fを適用して、その結果BをMonad[B]でwrapする
  def map[B](f: A => B) : Monad[B]   
  // Monad[A]の中身Aを取り出し、Monadを返すfを適用する
  def flatMap[B](f: A => Monad[B]) : Monad[B]
}

(実際にこのような単体のMonad traitがあるわけではありませんが、同等のものがScala標準ライブラリの中には存在します)

Monadは値をくるむ毛皮のようなもので、map, flatMapはその毛皮を剥がしてから何かの操作を行い、その結果に対してまた毛皮を着せる操作に対応しています。Monadを使ったコードでは、中に含まれている値に対して何らかの操作を行っても、Monad[A] からMonad[Monad[B]]のようなネストした型に変換するのではなく、Monad[A] -> Monad[B]と毛皮を一枚で済ませるようにするのが特徴です。

OptionはMonad

ScalaのOptionがmonadになっていると言いましたが、では実際にOptionの実装の一部を簡単にしたものを取り出して見てみましょう。

sealed trait Option[A] {
  def map[B](f: A => B) : Option[B] 
  def flatMap[B](f: A => Monad[B]) : Option[B]
}

case class Some[A](a: A) extends Option[A] {
  def map[B](f: A => B) : Option[B] = Some(f(a))
  def flatMap[B](f: A => Monad[B]) : Option[B] = f(a)
}

case class None[A] extends Option[A] {
  def map[B](f: A => B) : Option[B] = None
  def flatMap[B](f: A => Monad[B]) : Option[B] = None
}

map, flatMapは、Someの中身の値aに対して実行されますが、Noneの場合、中身がないので、map, flatMapは共に実行結果としてNoneが返ります。ここで注目しておきたいのは、Noneに対してもmap, flatMapが定義されているので、map, flatMapの操作は、Optionの値がSomeであろうとNoneであろうと連続して適用していけるという点です。

  • 上記のNoneクラスは型情報を簡略化した定義になっています。Noneの厳密な定義については、共変 covariantな型を使うを参考に。

Monadのmap, flatMapを使う

if文やパターンマッチを使ったnull(またはNone)の値のチェックは、ログインをするためのコードの本質的な部分ではないので、本来はエラー処理の部分を気にせずプログラミングできることが好ましいはずです。ここでmonadのmap, flatMapを使うとコードの流れを妨げずに、必要な処理に的を絞ってコードを書けるようになります。以下はその例です。

def login(name:Option[String], password:Option[String]) : Boolean = 
    name flatMap { u => password map { p => database.isValidPassword(u, p) } } getOrElse false

nameやpasswordがNoneの場合、map, flatMapの適用結果はNoneになるだけなので、コード中に出てくるエラー処理は最後のgetOrElseの部分のみになり、残りはエラー処理を気にせずに一本道で書けます。

コメントを挟んで、コードの中身をより詳しく説明すると以下のようになります。

def login(name:Option[String], password:Option[String]) : Boolean = {
   val r = name flatMap { u => // name monadの中身がとりだされる
     password map { p => // password monadの中身が取り出される
	   database.isValidPassword(u, p) 
	 } // mapなので、monadを外したBooleanが返る
   } 
   // rの型はbooleanをmonadでくるんだ Option[Boolean]
   r getOrElse false  // getOrElseもmonadを剥がす。

ただし、map, flatMapを活用すると、コードは一行に収まるもののやや面倒な書き方になってしまうのが玉に瑕です。

for-comprehensionを使って簡潔に

そこでScalaのfor文による網羅(for-comprehension)を使うと、上記のコードを手短に書けるようになります。

def login(name:Option[String], password:Option[String]) : Boolean = 
   val r = for(u <- name; p <- password) yield database.isValidPassword(u, p) 
   r getOrElse false  // user, passの情報が揃って無い場合にはfalseが返る

これがどうmonadなのか不思議に思うのは当然ですが、for-comprehensionが具体的に何をしているのかを知れば納得できるはずです。

for-comprehensionの定義

Scalaのfor-comprehensionは、map, flatMapなどmonadによる操作を簡潔に使うためのsyntax sugarとなっています。以下に置き換えの定義を示します。

for内のパラメータが1つの場合

for { p0 <- e0 } yield e  

は、mapを使って以下に置き換えられます。

e0 map { p0 => e }

複数パラメータがある場合

for { 
 p0 <- e0
 p1 <- e1
 ... 
 pn <- en } yield e

一番外側のパラメータがflatMapに置き換えられます。これが再帰的に繰り返され、最後に上記のパラメータが1つの場合のルールによりmapが適用されます。

 e0.flatMap { p0 => 
   for { 
     p1 <- e1
	 p2 <- e2
	 ...
	 pn <- en 
   } yield e
 }

これらの変換を適用すると、先に述べたfor文によるlogin関数のコードがmap, flatMapで置き換えたものと同等になることを確認してください。

参考

comments powered by Disqus