2012年5月7日月曜日

データフローDSL考 (2)

前回は、データフロー図の例として以下の図を挙げました。テキストDSLでデータフローを記述する場合、こういったモデルを記述できることが必要となります。




最後の手段

データフロー図はグラフ構造を取ります。つまり、テキストDSLで記述する際の最後の手段としてノード記述方式があります。

デーフローに登場する各ノードを個別に記述し、ノード間に存在するリンクを(1)リンクの集合として記述、あるいは(2)ノードの属性として記述、といった方式です。

このノード記述方式を採用すれば絶対に記述できるのは明らかです。グラフ処理のアルゴリズムは行列を使うものが多いですが、これも一種のノード記述方式といえます。

ノード記述方式による最後の手段は確立されているという前提の上で、さらによい記述方式があるのかどうかという点が論点となります。なければ、普通にノード記述方式を採用していけばよいわけです。

パイプライン

データフローをテキストDSLで記述する際の有力な候補がパイプラインです。UNIXのシェルや、このブログでもたびたび取り上げている関数型プログラミングのMonadicプログラミングもパイプラインの例です。パイプラインはセマンティクスの分かりやすさとテキスト記述との相性の良さから、テキストDSLの記述方式の有力な候補となります。

パイプラインの問題点は、木構造やグラフ構造をうまく記述できないことです。木構造については工夫でカバーできる点もありそうですが、グラフ構造については本質的に不可能です。

このため、応用がぴったりはまると非常に強力ですが、汎用的には利用できないという大きな弱点があります。

解決案

この問題を解決するために考えてみたのが、複数のパイプラインを組合わせてグラフ構造を記述する方法です。

たとえば以下のようなデータフロー図を考えてみます。




このデータフローは、上側のデータフローと下側のデータフローを2つ組合わせたものと考えることができます。

この観点で注釈をつけたものが以下の図になります。




上側のパイプラインと下側のパイプラインを分かりやすく枠で囲みました。

また、プロセスが複数入力する場合の多くが、メインの処理対象に補足情報を継ぎ足していきます。たとえば、仕訳伝票や製造指示書といったトランザクションデータに商品番号や部門といったマスターデータを結合するような処理です。こういった用途が限定された軽い枝は、特別な記法の導入で記述することができる可能性があります。これば可能であればパイプラインのセマンティクスを維持したまま、より広範囲のモデルの記述が可能になります。

そして、2つのパイプラインを結合する記述方式の問題が解決すれば、パイプラインを軸にデータフローを記述することが可能になります。

より複雑に

前述の例を少し拡張すると以下のデータフロー図になります。




拡張したポイントを追加した注釈は以下の図です。




この図は、一番最初のデータフロー図と全く同じものです。つまり、少し複雑に見えるデータフロー図でも、細かく見ていくと軸となるパイプラインを組み合わせたものであることが多々あるわけです。

実感的には、現実の応用はこのように軸となるパイプラインを決めることができて、これに小さな演算や小さなパイプラインを加えていく形に落ち着くことが多いのではないかと思います。

こういった性質を持つドメインのモデル化をする上では、パイプラインを軸にしたセマンティクスのテキストDSLが有効ではないかと思います。

具体例

データフロー図、データフローモデルをテキストDSL化する方式はDSL駆動開発の鍵となる重要な要素です。以前からパイプライン拡張方式が有力と考えており、色々と試行錯誤をしてきました。

その成果物としてメッセージングフレームワークg3と私家版Asakusa DSLがあります。次回は、データフローDSLという観点でこれらのDSLについてみていきます。

2012年5月2日水曜日

データフローDSL考

ニコニコ超会議 2012の超エンジニアミーティングのScalaユーザーグループのコマで「Scalaでプログラムを作りました」のセッションを行いました。昨日の「Scalaへのアプローチ」はそのまとめです。このセッションの主張の一つがScalaの最も重要な用途がDSLであるということです。

3月に要求開発アライアンスでオブジェクト指向モデリングと関数型の関係を検討したセッション『Object-Functional Analysis and Design: 次世代モデリングパラダイムへの道標』を行いました。その内容のまとめは「Object-Functional Analysis and Designまとめのまとめ」となります。ここでは、OOADと関数型の接点としてデータフローが重要というのが結論でした。データフローの実装技術についても「データフローの実装技術」として考察しました。

この2つのセッションで共通に使用しているスライドが以下の「SparkとScalding」です。

オブジェクト指向、関数型、クラウド・コンピューティングの交差する技術領域では、データフローをいかにテキストDSLで記述していくのかという点が、非常に重要な論点になっていると考えます。

SparkやScaldingは、関数型言語の普通の記法でそのままHadoopなどの大規模並列分散計算基盤上で動作するフレームワークAPIを提供しています。これはかなり強力で、もしかして決定版かも、と思わせます。

この点について考える前に、この分野の定番技術であるデータフロー図についてみてみましょう。

データフロー図

OOADでは、主流からフェードアウトしてしまったデータフロー図ですが、クラウド・コンピューティングの登場によって、再び第一級のモデルになってきそうです。

古(いにしえ)の基幹システムはバッチ処理の集りなので、バッチ処理をデータフローでモデル化したものをシステム全体の機能モデルとして扱っても十分に機能していました。

当初OOADにおいてもデータフロー図は、機能モデルを記述する目的を期待されていましたが、インタラクション、コラボレーションをうまく記述することはできないので、システム全体の機能モデルを記述するのには力不足で、最終的にはユースケース・モデルに取ってかわられたという成り行きだったと思います。

とはいえ、バッチで実行するような複雑なデータ処理演算のモデル記述するのにはデータフロー図は引き続き有効です。(ただ、最近は基幹系でもデータフロー図がロストテクノロジーになっているという噂も聞きます。)

クラウド・プラットフォームによって大規模データ、大規模演算が可能になったことによって、扱う計算過程も複雑になってきます。データフロー図は、システム全体の機能モデルを記述するには向きませんが、こういった計算過程を記述するのには有力な候補になります。そういう意味で、大規模データ、大規模演算の分野での第一級モデルとして再評価されることになるのではないかと思います。

簡単な例

まず、シンプルなデータフロー図は以下のようになります。



ここでは、外部実体としてメインのRDBMSに格納されているエンティティ、データストアとして中間データの格納庫(カラム指向のNoSQLでの運用をイメージ)、という運用を想定しています。

このように一直線のパイプラインの場合、前述のSparkやScaldingによる関数型プログラミング的な記法がぴったりはまります。

複雑な例

しかし、現実のバッチ処理はもっと複雑です。たとえば、以下のようなデータフロー図は普通にありそうです。



この図のモデルに対して、テキストDSL化のポイントとなりそうな点に注釈を入れたものが以下の図です。



この図では以下の問題を挙げています。

  • 複数エンティティの入力
  • 複数エンティティへの出力
  • データフロー中からの入力
  • データフロー中からの出力
  • データフローの分岐
  • データフローの合流
  • データストアの使い回し(メモ化)

関数型プログラミングでは:

  • (SparkやScaldingの例で挙げた)パイプライン的な記述
  • 引数N個の関数の入れ子

の2つの選択肢があります。

「パイプライン的な記述」は、データフローが「簡単な例」にあるようなシンプルな場合はよいのですが、「複雑な例」にあるような込み入った物なってくると記述できなくなるのは明らかです。

DSLとしてはちょっと煩雑な記述になりますが「引数N個の関数の入れ子」方式では、入力側を枝、出力側を根にした木構造を持つデータフローを記述することができます。さらに出力側にタプルを用いることで出力側を木構造に開いていくことも不可能ではありません。

しかし、この方式は「データフローの合流」が登場してモデルが木構造の範疇を超えて、グラフ構造になってくると記述ができなくなってしまいます。

つまり、データフローをテキストDSLで記述する方式もなかなか簡単にはいかないわけですね。この対策について、次回考えてみたいと思います。

2012年5月1日火曜日

Scalaへのアプローチ

4月29日(日)にニコニコ超会議 2012の超エンジニアミーティングのScalaユーザーグループのコマで「Scalaでプログラムを作りました」のタイトルでお話をさせていただきました。

当日使用したスライドは以下のものです。

セッションの目的は、JavaなどのOOPをやっていて、Scalaをこれから始めたい人向けの情報提供です。

大きく以下の情報を提供しています。

  • Scalaの使い所
  • Scalaプログラミングの魅力

ボク自身は最近はScalazでの型クラスベースのMonadicプログラミングに凝っているので、Scalaプログラミングの面白さというと、このあたりの紹介をしてみたくなるわけですが、これから関数型プログラミングを始める人には、かなりハードルが高い内容でセッションの趣旨と合いません。

これから始める人にとって、普通のOOP、つまりBetter Javaとして使う場合でも有効なScalaを使うメリットは何かというと:

  • DSL
  • 並行プログラミング

の2点だと思います。また並行プログラミングの旬はもう少し先になりそうですし、ボク自身もまだメリットを享受しているとはとてもいえない状態なので、DSLを中心とした話にしてみました。

実際のところ、これから始める人も現在活用している人もDSLがScalaの最大のメリットだと思うので、仮に汎用的な入門記事を書く場合でも、今回の内容を軸に他の要素を加える形になると思います。

Scalaへのアプローチ


これから始める人向けの情報提供ということで、Scalaへのアプローチを考えてみました。まとめの一つ前のスライドです。ここは参考にしてもらえそうなところなので、詳しく説明します。

  • 最初はBetter Java
  • map系、fold系のList処理を導入
  • Option、Eitherの導入
  • flatMap系の演算を導入
  • Scalaz導入

Scalaプログラミングをする層は大きく(1)フレームワークを作ってAPIをDSLで提供する、(2)DSLを使ってアプリケーションを書く、の2つに分けることができます。

(1)と(2)で求められるスキルは大きく異なります。

雑誌の記事やブログなので取り上げられやすいScalaプログラミングの華やかな所は、トレイト、モナド、型クラスといった新技術ですが、これらを駆使したプログラミングは(1)のフレームワーク開発では必須ですが、(2)のアプリケーション開発では、知っていた方がよいのは確かですが、知らなくてもほぼ困りません。

最初はBetter Java

そこで、最初の段階ではBetter Javaに徹して使用し、PlayやScaldingといったDSLを活用するというアプローチがよいのではないかと思います。DSLを使う上でモナドを使う必要があるケースが出てきますが、これは個々のDSLの使い方のイディオムとして丸暗記しておけば十分です。

たとえばScala的にはOptionを使ってnull値の使用は避けるのが普通ですが、この段階ではそういったことは気にせず普通のJavaプログラミングのつもりで望むのがよいでしょう。まずは、普通に使えるようになるのが大事です。

トレイトはOOPをやっている人なら理解するのは容易なので最初の段階から導入しても問題ないでしょう。

Scalaを業務に導入することを考える場合、最初の段階ではBetter Java + トレイトがよい落とし所です。Scalaを導入する最大のメリットは、これから続々と登場することが予想される便利DSL+フレームワークを利用することですが、これが目的と割り切れば、この段階で止めてしまってもよいでしょう。

map系、fold系のList処理を導入

関数型プログラミングはList処理が非常に便利なのが美点なので、次の段階ではこれを使うようにしていきます。

java.util.ArrayListに対応するScalaのオブジェクトは、scala.collection.mutable.ArrayBufferですが、この段階までは、ArrayBufferを中心としたプログラミングになっているはずです。

これを、最終的にはList(scala.collection.immutable.List)を中心としたプログラミングに転換していくのが目標です。

Listの個々の要素に対する変換処理を記述するmapメソッド、List全体の変換処理を記述するfold系メソッド(foldLeftやreduceLeftなど)を組合わせてロジックを記述するのが大事なので、できるだけそういった方向でプログラミングしていくようにします。

Option、Eitherの導入

List処理に慣れたらOptionやEitherを導入します。

nullを回避して頑健なプログラミングをするためにOptionを最初の段階で導入するほうがよいという意見もありますが、Monadicプログラミングができない状態でOptionを使うと、if式やmatch式の羅列による煩雑なプログラミングになってしまい、プログラミングの楽しさという観点ではデメリットが多いので、無理をして導入する必要はないかなと思います。

もちろんOptionの価値を理解した上で積極的に活用したいという人が最初の段階からトライするのは非常によいことです。

Either(あるいはScalazのValidationやLogger)は、エラー処理を関数型的に取り扱うのに必須のオブジェクトなので、OptionとList処理が慣れた段階で導入するとよいと思います。Either導入に伴ってエラー処理も関数型流にしていきます。関数型流のエラー処理は、将来並行プログラミングに移行したときに必須となると予想されるので、今のうちにできるだけ慣れておくとよいでしょう。

flatMap系の演算を導入

最近の関数型プログラミングではモナドを多用します。モナド中心のプログラミングといっても過言はないほどです。

その中心となるのがflatMapメソッドです。次の段階ではflatMapメソッドを使ったMonadicプログラミングをマスターしていきます。

ListやOptionもモナドなので、Monadicプログラミングを使うとより便利に操作できます。OptionもMonadicプログラミングで使っていかないと便利さが実感できないので、このあたりから本格活用していくとよいでしょう。

Scalaz導入

本格的な関数型プログラミング、Monadicプログラミングを行う場合にはScalazを使うのがとても便利です。このブログでもScala+Scalazの枠組み上でScalaプログラミングのイディオムを整理しています。

正直なところScalazはかなり難しいのですが、フレームワークのAPIをDSLで提供する場合には、Scalazを使いこなすスキルが必要になります。

2012年4月27日金曜日

Scala Tips / Validation (10) - applicative

ValidationはScalazが提供する成功/失敗の計算文脈を提供するモナドです。Validationを使ってOptionやEitherと同様の成功/失敗の計算文脈上でのMonadicプログラミングをすることができます。

今回はapplicative演算です。

課題

以下に示す2つの関数validateNameとvalidateAgeを用いてデータの検証を行います。

def validateName(a: String): ValidationNEL[Throwable, String] = {
  if (a == null) new IllegalArgumentException("null name").failNel
  else if (a.length >= 2) a.success
  else new IllegalArgumentException("bad name: " + a).failNel
}

def validateAge(a: Int): ValidationNEL[Throwable, Int] = {
  if (150 > a && a >= 0) a.success
  else new IllegalArgumentException("bad age: " + a).failNel
}
validateName
正しい名前であることを検証。ここでは2文字以上の文字列を判定。
validteAge
正しい年齢であることを検証。ここでは0以上、150未満のInt値。

どちらの検証にも成功した場合はSuccess[NonEmptyList[Throwable], (String, Int)]、一つでも検証に失敗した場合はFailure[NonEmptyList[Throwable], (String, Int)]を返します。

validateNameとvalidateAgeの両方が失敗の場合には、NonEmptyListに両方のエラー情報が入ります。

flatMapやfor式を使った場合は、最初に失敗したエラー情報のみが返されますが、今回のテーマであるapplicativeの場合は、エラー情報が累積されていくのが特徴です。

(分類の基準)

Java風

Validationから直接値を取り出すことができないためキャストを使っており、その分大変見にくいコーディングになっています。その点を差し引いてもif式での判定は冗長な感じがします。

def validate(name: String, age: Int): ValidationNEL[Throwable, (String, Int)] = {
  val a = validateName(name)
  val b = validateAge(age)
  if (a.isSuccess && b.isSuccess) {
    val a1 = a.asInstanceOf[Success[NonEmptyList[Throwable], String]].a
    val b1 = b.asInstanceOf[Success[NonEmptyList[Throwable], Int]].a
    Success((a1, b1))
  } else if (a.isSuccess) {
    b.asInstanceOf[Failure[NonEmptyList[Throwable], (String, Int)]]
  } else if (b.isSuccess) {
    a.asInstanceOf[Failure[NonEmptyList[Throwable], (String, Int)]]
  } else {
    val a1 = a.asInstanceOf[Failure[NonEmptyList[Throwable], String]].e
    val b1 = b.asInstanceOf[Failure[NonEmptyList[Throwable], Int]].e
    Failure(a1 |+| b1)
  }
}

Scala風

match式を使うとかなりすっきりしました。しかし、match式がネストするとプログラムが複雑化する感じです。この場合は判定対象が2つなのでネストはまだ浅いですが、判定対象が増えてくると大変なことになるのは明らかです。

def validate(name: String, age: Int): ValidationNEL[Throwable, (String, Int)] = {
  validateName(name) match {
    case Success(a) => validateAge(age) match {
      case Success(b) => Success((a, b))
      case Failure(e) => Failure(e)
    }
    case Failure(e1) => validateAge(age) match {
      case Success(b) => Failure(e1)
      case Failure(e2) => Failure(e1 |+| e2)
    }
  }
}

Scala

fold, map, flatMapを使ったよい記述方法はないと思います。「Scala風」のmatch式を使った書き方になります。

Scalaz

applicative演算を使うと以下のようになります。コーディングが簡単な|@|メソッドを使っています。

def validate(name: String, age: Int): ValidationNEL[Throwable, (String, Int)] = {
  (validateName(name) |@| validateAge(age))((_, _))
}

数学記号を使った「⊛」メソッドを使うこともできます。

def validate(name: String, age: Int): ValidationNEL[Throwable, (String, Int)] = {
  (validateName(name) ⊛ validateAge(age))((_, _))
}

applicative演算の引数の個数が2つなので<**>メソッドを使うことができます。

def validate(name: String, age: Int): ValidationNEL[Throwable, (String, Int)] = {
  (validateName(name) <**> validateAge(age))((_, _))
}

どの記述方法を選ぶのかは好みによりますが、いずれにしても「Java風」、「Scala風」と比べて劇的に簡潔になっています。

ノート

ScalazでApplicativeを使う場合の論点は3つあります。

  • Monad未満Functor以上でも一定の性質(Applicative演算が適用可能)を持つオブジェクトを(Applicative演算を用いて)簡潔に操作したい。
  • 複数のモナドを引数に取る演算を行いたい。
  • 複数のモナドから新しいモナドを生成する際の文脈の再構築に関して、定型処理を自動化したい。

今回のテーマであるValidationは、後ろ2つに当てはまります。

Monad未満Functor以上の性質は、Scalazの場合はZipper、ZipStreamが当てはまるようです。ただし、普通はMonadでないApplicativeはあまりないので、一般的にはモナドに対してApplicative演算を行うと考えておいてよいと思います。

「複数のモナドを引数に取る演算を行いたい」については、今回の課題では、Validationモナドの上で個々の要素に対するバリデーション結果から最終的なバリデーション結果を演算するため、Applicative演算が効果的となります。

3番目の「文脈の再構築」での「定型処理」の典型例はモノイドによる加法演算です。Validationの場合には、エラー情報をモノイドで蓄積していくようになっています。今回の例ではNonEmptyListがMonoidなので、ここに対してThrowableが蓄積されていきます。

別の書き方

「Either (12) - Applicativeの記述方法」でも取り上げましたが、以下のような記述方法も可能です。これがApplicative演算の本来的な意味に忠実な記法です。

def validate(name: String, age: Int): ValidationNEL[Throwable, (String, Int)] = {
  validateName(name) <*> (validateAge(age) <*> ((x: Int) => (y: String) => (y, x)).success)
}

また、mapメソッドを使って関数を持ち上げる処理を少し簡略化する方法もあります。

def validate(name: String, age: Int): ValidationNEL[Throwable, (String, Int)] = {
  validateName(name) <*> (validateAge(age).map(x => y => (y, x)))
}

いずれにしても、本文で紹介した「|@|」や「<**>」の方が便利なので、普通はそちらの方を使っておくのがよいでしょう。応用によってはこちらの記法で書いた方がよいこともあるので、引き出しにストックしておきたい所です。

タプル

Applicative演算が可能な場合<|*|>メソッドで直接タプルを生成することができます。今回の課題ではこれを使用することもできます。

def validate(name: String, age: Int): ValidationNEL[Throwable, (String, Int)] = {
  (validateName(name) <|*|> validateAge(age))
}
fold

参考に「Scala」的なfoldを使ったコーディングを考えてみました。演算結果がタプルになるとfoldとの相性がわるいので、Listにしています。Listの場合、タプルより型情報の記述力が落ちるので、本文の課題より脆弱なプログラムになります。

def validate(name: String, age: Int): ValidationNEL[Throwable, List[Any]] = {
  List(validateName(name), validateAge(age)).foldLeft((List[Any](), List[Throwable]())) {
    (a, x) => x match {
      case Success(s) => (a._1 :+ s, a._2)
      case Failure(e) => (a._1, a._2 ::: e.list)
    }
  } match {
    case (xs, Nil) => Success(xs)
    case (_, x :: xs) => Failure(nel(x, xs))
  }
}

この場合は、引数の数が2つなので、かなり冗長な感じがしますが、引数が多数になる場合、引数の個数が任意個になる場合には有効なアプローチです。

諸元

  • Scala 2.9.2
  • Scalaz 6.0.4

2012年4月26日木曜日

Scala Tips / Validation (9) - forで演算を連結2

ValidationはScalazが提供する成功/失敗の計算文脈を提供するモナドです。Validationを使ってOptionやEitherと同様の成功/失敗の計算文脈上でのMonadicプログラミングをすることができます。

前々回はmapメソッドを用いて行う成功/失敗の文脈を切り替えない演算、flatMapメソッドを用いて行う成功/失敗の文脈を切り替える演算をfor式で記述する方法について説明しました。また、前回はより複雑な例をflatMapメソッドとfor式で記述しました。

いずれの場合も、flatMapメソッドの方がfor式よりも簡潔に記述できました。

今回は、さらに問題を複雑にしてfor式の方が簡潔になるケースについて考えます。

課題

以下に示す3つの関数mul101、toText、toLabelを組合わせてValidationNEL[Throwable, Int]をValidationNEL[Throwable, String]に変換する演算を考えます。

def mul101(a: Int): ValidationNEL[Throwable, Int] = {
  if (a >= 0) (a * 101).success
  else new IllegalArgumentException("less than 0: " + a).failNel
}

def toText(a: Int): ValidationNEL[Throwable, String] = {
  if (a % 2 == 0) a.toString.success
  else new IllegalArgumentException("not even: " + a).failNel
}  

def toLabel(a: String): ValidationNEL[Throwable, String] = {
  if (a.length < 5) ("Success:" + a.toString).success
  else new IllegalArgumentException("large length: " + a).failNel
}
mul101
Intを101倍する。0未満の場合はエラー。
toText
IntをStringにする。奇数の場合はエラー。
toLabel
Stringを整形する。文字数が5以上の場合はエラー。

いずれの関数も、入力パラメタの値によってエラーになるところがポイントです。これらの関数を組合わせて、成功の文脈と失敗の文脈を切り替える処理行うわけですが、正常系のアルゴリズム(成功の文脈)を簡潔に分かりやすく記述することを目指します。

ここまでは前回と同じです。

今回は、これらの演算を組み合わせた上で、mul101関数の結果、toText関数の結果、toLabel関数の結果を一つの文字列にまとめる処理を行います。

(分類の基準)

Java風

中核のロジックは「"%s -> %s -> %s -> %s".format(b, d, f, h)」です。ボイラープレートのコードに埋もれてしまい、前回との違いがよく分かりません。

def f(a: ValidationNEL[Throwable, Int]): ValidationNEL[Throwable, String] = {
  if (a.isSuccess) {
    val b = a.asInstanceOf[Success[NonEmptyList[Throwable], Int]].a
    val c = mul101(b)
    if (c.isSuccess) {
      val d = c.asInstanceOf[Success[NonEmptyList[Throwable], Int]].a
      val e = toText(d)
      if (e.isSuccess) {
        val f = e.asInstanceOf[Success[NonEmptyList[Throwable], String]].a
        val g = toLabel(f)
        if (g.isSuccess) {
          val h = g.asInstanceOf[Success[NonEmptyList[Throwable], String]].a
          Success("%s -> %s -> %s -> %s".format(b, d, f, h))
        } else {
          g.asInstanceOf[ValidationNEL[Throwable, String]]
        }
      } else {
        e.asInstanceOf[ValidationNEL[Throwable, String]]
      }
    } else {
      c.asInstanceOf[ValidationNEL[Throwable, String]]
    }
  } else {
    a.asInstanceOf[ValidationNEL[Throwable, String]]
  }
}

Scala風

match式を使う場合も、中核ロジックがボイラープレートコードに埋もれてしまいます。

def f(a: ValidationNEL[Throwable, Int]): ValidationNEL[Throwable, String] = {
  a match {
    case Success(b) => {
      mul101(b) match {
        case Success(c) => {
          toText(c) match {
            case Success(d) => {
              toLabel(d) match {
                case Success(e) => Success("%s -> %s -> %s -> %s".format(b, c, d, e))
                case Failure(g) => Failure(g)
              }
            }
            case Failure(h) => Failure(h)
          }
        }
        case Failure(i) => Failure(i)
      }
    }
    case Failure(j) => Failure(j)
  }
}

Scala

flatMapメソッドを用いて書くと以下のようになります。

def f(a: ValidationNEL[Throwable, Int]): ValidationNEL[Throwable, String] = {
  a.flatMap(b => mul101(b).flatMap(c => toText(c).flatMap(d => toLabel(d).map(e =>
    "%s -> %s -> %s -> %s".format(b, c, d, e)))))
}

前回との違いは、 中核ロジックである「"%s -> %s -> %s -> %s".format(b, c, d, e)」が、最初の入力、mul101関数の結果、toText関数の結果、toLabel関数の結果を使用することです。前回は最後に実行するtoLabel関数の結果のみを使っていました。

このため、最初の入力、mul101関数の結果、toText関数の結果、toLabel関数の結果を変数に覚えておき、後ろの段で参照する必要があります。これを実現するためには、各関数の結果を変数に設定するようにすると同時に、flatMapメソッド内で実行する関数の中で次のflapMapメソッドを呼ぶというように、flatMapメソッドをネストして実行します。

flatMapの実行結果のみを次のflatMapに渡すだけであれば、前回のようにパイプライン的につないでいけばよいのですが、前段の処理結果を後段で参照する必要がある場合は、flatMapをネストさせていかなければならないわけです。変数を設定する処理、ネストしていること、を一行にまとめてしまうと可読性が悪いので、これを整形すると以下のようになります。

def f(a: ValidationNEL[Throwable, Int]): ValidationNEL[Throwable, String] = {
  a.flatMap(b =>
    mul101(b).flatMap(c =>
      toText(c).flatMap(d =>
        toLabel(d).map(e =>
          "%s -> %s -> %s -> %s".format(b, c, d, e)))))
}

また、以下のようにも書けます。

def f(a: ValidationNEL[Throwable, Int]): ValidationNEL[Throwable, String] = {
  a.flatMap { b =>
    mul101(b).flatMap { c =>
      toText(c).flatMap { d =>
        toLabel(d).map { e =>
          "%s -> %s -> %s -> %s".format(b, c, d, e)
        }
      }
    }
  }
}

このようになってくるとflatMapメソッドであっても、簡潔に記述という感じではなくなってきました。

Scalaz

Scalaz的に>>=メソッドを使うと以下のようになります。Validationに対して>>=メソッドを使う場合は「import Validation.Monad._」のおまじないをしないといけないのが難点です。(「Validation (5) - flatMap」参照)

def f(a: ValidationNEL[Throwable, Int]): ValidationNEL[Throwable, String] = {
  import Validation.Monad._

  a >>= (b => mul101(b) >>= (c => toText(c) >>= (d => toLabel(d).map(e =>
    "%s -> %s -> %s -> %s".format(b, c, d, e)))))
}
def f(a: ValidationNEL[Throwable, Int]): ValidationNEL[Throwable, String] = {
  import Validation.Monad._

  a >>= (b =>
    mul101(b) >>= (c =>
      toText(c) >>= (d =>
        toLabel(d).map(e =>
          "%s -> %s -> %s -> %s".format(b, c, d, e)))))
}
def f(a: ValidationNEL[Throwable, Int]): ValidationNEL[Throwable, String] = {
  import Validation.Monad._

  a >>= { b =>
    mul101(b) >>= { c =>
      toText(c) >>= { d =>
        toLabel(d).map {e =>
          "%s -> %s -> %s -> %s".format(b, c, d, e)
        }
      }
    }
  }
}

for式

さて、本題のfor式で書くと以下のようになります。for式は前回の場合と複雑度はほとんど変わりません。

def f(a: ValidationNEL[Throwable, Int]): ValidationNEL[Throwable, String] = {
  for {
    b <- a
    c <- mul101(b)
    d <- toText(c)
    e <- toLabel(d)
  } yield "%s -> %s -> %s -> %s".format(b, c, d, e)
}

より普通のfor式っぽく以下のように書くこともできます。

def f(a: ValidationNEL[Throwable, Int]): ValidationNEL[Throwable, String] = {
  for (b <- a; c <- mul101(b); d <- toText(c); e <- toLabel(d)) yield {
    "%s -> %s -> %s -> %s".format(b, c, d, e)
  }
}

ノート

flatMapで関数を接続する演算で、前段で実行した各関数の結果を直後ではない後段で参照するようなケースでは、(1)関数の実行結果を覚えておく変数(クロージャの引数)の導入、(2)flatMapをネストで実行、を行う必要があるため、プログラムが複雑化します。

一方、for式はこのような使い方でも使い勝手は全く一緒です。

このため、今回の課題ではfor式の方が簡潔に記述することができます。

Monadicプログラミングをしていくと、flatMapを多段につないでいくことになりますが、処理が複雑化していくと今回の課題のようなネスト構造が登場して、プログラムの取り回しに苦労するようになります。これがMonadicプログラミングで頻出のパターンになので、これに対する文法糖衣としてfor式が用意されているわけですね。

簡単なパイプライン的な処理の場合はflatMapや>>=を使い、複雑になりそうな場合はfor式を選択するという形で、適材適所で使い分けをしていきたいところです。

諸元

  • Scala 2.9.2
  • Scalaz 6.0.4

2012年4月25日水曜日

Scala Tips / Validation (8) - forで演算の連結

ValidationはScalazが提供する成功/失敗の計算文脈を提供するモナドです。Validationを使ってOptionと同様の成功/失敗の計算文脈上でのMonadicプログラミングをすることができます。

前回はmapメソッドを用いて行う成功/失敗の文脈を切り替えない演算、flatMapメソッドを用いて行う成功/失敗の文脈を切り替える演算をfor式で記述する方法について説明しました。

for式も悪くないのですが、簡単な演算だとmapメソッドやflatMapメソッドを使う方がより簡潔な記述になりました。

今回は、もう少し複雑な演算を考えてみます。

課題

以下に示す3つの関数mul101、toText、toLabelを組合わせてValidationNEL[Throwable, Int]をValidationNEL[Throwable, String]に変換する演算を考えます。

def mul101(a: Int): ValidationNEL[Throwable, Int] = {
  if (a >= 0) (a * 101).success
  else new IllegalArgumentException("less than 0: " + a).failNel
}

def toText(a: Int): ValidationNEL[Throwable, String] = {
  if (a % 2 == 0) a.toString.success
  else new IllegalArgumentException("not even: " + a).failNel
}  

def toLabel(a: String): ValidationNEL[Throwable, String] = {
  if (a.length < 5) ("Success:" + a.toString).success
  else new IllegalArgumentException("large length: " + a).failNel
}
mul101
Intを101倍する。0未満の場合はエラー。
toText
IntをStringにする。奇数の場合はエラー。
toLabel
Stringを整形する。文字数が5以上の場合はエラー。

いずれの関数も、入力パラメタの値によってエラーになるところがポイントです。これらの関数を組合わせて、成功の文脈と失敗の文脈を切り替える処理を、正常系のアルゴリズム(成功の文脈)を簡潔に分かりやすく記述することを目指します。

(分類の基準)

Java風

キャストが多くなってしまうのはValidationにSuccessやFailureの値を直接取ってこれる機能がないのが原因ですが、かなり込み入ったコーディングになります。前回の簡単な処理ぐらいであれば許容範囲ですが、ちょっとロジックが複雑になると耐えられないコーディングになってしまいます。

def f(a: ValidationNEL[Throwable, Int]): ValidationNEL[Throwable, String] = {
  if (a.isSuccess) {
    val b = a.asInstanceOf[Success[NonEmptyList[Throwable], Int]].a
    val c = mul101(b)
    if (c.isSuccess) {
      val d = c.asInstanceOf[Success[NonEmptyList[Throwable], Int]].a
      val e = toText(d)
      if (e.isSuccess) {
        val f = e.asInstanceOf[Success[NonEmptyList[Throwable], String]].a
        val g = toLabel(f)
        if (g.isSuccess) {
          val h = g.asInstanceOf[Success[NonEmptyList[Throwable], String]].a
          Success(h)
        } else {
          g.asInstanceOf[ValidationNEL[Throwable, String]]
        }
      } else {
        e.asInstanceOf[ValidationNEL[Throwable, String]]
      }
    } else {
      c.asInstanceOf[ValidationNEL[Throwable, String]]
    }
  } else {
    a.asInstanceOf[ValidationNEL[Throwable, String]]
  }
}

Scala風

match式を使うとそれなりのコーディングにはなりますが、かなり面倒なコーディングであることは変わりません。ネストが込み入っているので全体の見通しが悪くなりますし、ボイラープレート的なコードでメインロジックが埋もれてしまっています。エラーハンドリングのコードが正常処理と同じ重み付けで全体の半分を閉めてしまうのも感心しないところです。

def f(a: ValidationNEL[Throwable, Int]): ValidationNEL[Throwable, String] = {
  a match {
    case Success(b) => {
      mul101(b) match {
        case Success(c) => {
          toText(c) match {
            case Success(d) => {
              toLabel(d) match {
                case Success(e) => Success(e)
                case Failure(g) => Failure(g)
              }
            }
            case Failure(h) => Failure(h)
          }
        }
        case Failure(i) => Failure(i)
      }
    }
    case Failure(j) => Failure(j)
  }
}

このコードはパターンマッチングは使っていますが、手続き型(OOP含む)の典型的なコーディングになります。

これではたまらないので、エラー処理に例外機構を用いて、正常系処理のコードを簡潔に保つのがOOPで一般に用いられている方法です。ただし、その場合でもシステムエラーもアプリケーションエラーも一律にエラー終了にするといったエラー処理はよいのですが、アプリケーションエラーをアプリケーションロジックで扱うといった用途とは相性が悪いので、コーディング上の工夫が必要になってきます。

Scala

ScalaではflatMapを用いて処理を簡潔に記述するのが普通のコーディングスタイルです。これはValidationがモナドであるために可能になっています。

def f(a: ValidationNEL[Throwable, Int]): ValidationNEL[Throwable, String] = {
  a.flatMap(mul101).flatMap(toText).flatMap(toLabel)
}

Scalaz

Scalaz的に>>=メソッドを使うと以下のようになります。

def f(a: ValidationNEL[Throwable, Int]): ValidationNEL[Throwable, String] = {
  import Validation.Monad._

  a >>= mul101 >>= toText >>= toLabel
}

for式

さて、本題のfor式で書くと以下のようになります。「Scala」、「Scalaz」と比べると若干冗長な気もしますが、「Java風」、「Scala風」と比べると比較にならないほど簡潔に記述することができます。

def f(a: ValidationNEL[Throwable, Int]): ValidationNEL[Throwable, String] = {
  for {
    b <- a
    c <- mul101(b)
    d <- toText(c)
    e <- toLabel(d)
  } yield e
}

より普通のfor式っぽく以下のように書くこともできます。

def f(a: ValidationNEL[Throwable, Int]): ValidationNEL[Throwable, String] = {
  for (b <- a; c <- mul101(b); d <- toText(c); e <- toLabel(d)) yield {
    e
  }
}

ノート

for式で簡潔に書ける例を考えてみたのですが、結局flatMapの方がもっと簡潔に書けてしまいました。Monadicプログラミングに慣れてくると、mapやflatMapを好むようになりますが、この辺の事情によりますね。

ただ、for式が「Java風」や「Scala風」よりも圧倒的に簡潔なのは確かなので、flatMapでの実現方法が思いつかない場合でも、for式を使う方向で考えていくとよいでしょう。

今回のように演算を単純につないでいく用途ではflatMapの方が便利ですが、そうでない場合はfor式の方が便利なケースがあります。次回はそのケースを取り上げたいと思います。

追記 (2011-04-26)

>>=メソッドを使うのに「 import Validation.Monad._」が抜けていたので追加しました。

諸元

  • Scala 2.9.2
  • Scalaz 6.0.4

2012年4月24日火曜日

Scala Tips / Validation (7) - for

ValidationはScalazが提供する成功/失敗の計算文脈を提供するモナドです。Validationを使ってOptionやEitherと同様の成功/失敗の計算文脈上でのMonadicプログラミングをすることができます。

今回は「Option (7)」や「Either (7) - for」で扱った課題のValidation版を考えてみます。

「Validation (4) - map」で成功/失敗の文脈を切り替えない以下の演算をmapで行う方法:

条件結果
Validation[A, B]がSuccess[A, B]Success[A, C]
Validation[A, B]がFailure[A, B]Failure[A, C]

「Validation (5) - flatMap」で成功/失敗の文脈を切り替える以下の演算をflatMapで行う方法について説明しました。

条件結果
Validation[A, B]がSuccess[A, B]でBに有効な値が入っているSuccess[A, C]
Validation[A, B]がSuccess[A, B]でBに無効な値が入っているFailure[A, C]
Validation[A, B]がFailure[A, B]Failure[A, C]

大枠ではB→Cの演算を行いたいわけですが、これをfor式を用いてValidation[A, B→C]の文脈の上で行うわけです。

今回はこの2つの演算をfor式を使って書いてみます。

以下ではValidation[NonEmptyList[Throwable], Int]をValidation[NonEmptyList[Throwable], String]に変換するプログラムを考えます。なお、Validation[NonEmptyList[Throwable], Int]はValidationNEL[Throwable, Int]と同等なので、可能な場合はValidationNELの方の表記を用います。

文脈切替なし

「Validation (4) - map」で取り上げた成功の文脈と失敗の文脈の切り替えが発生しない演算です。以下の表に示す演算になります。

条件結果
Validation[A, B]がSuccess[A, B]Success[A, C]
Validation[A, B]がFailure[A, B]Failure[A, C]

ValidationNEL[Throwable, Int]からValidation[Throwable, String]への変換は以下のようになります。mapメソッドと同等の処理をfor式で素直に記述できます。

def f(a: ValidationNEL[Throwable, Int]): ValidationNEL[Throwable, String] = {
  for (b <- a) yield {
    b.toString
  }
}

文脈切替あり

「Validation (5) - flatMap」で取り上げた成功の文脈と失敗の文脈の切り替えが発生する演算です。以下の表に示す演算になります。

条件結果
Validation[A, B]がSuccess[A, B]でBに有効な値が入っているSuccess[A, C]
Validation[A, B]がSuccess[A, B]でBに無効な値が入っているFailure[A, C]
Validation[A, B]がFailure[A, B]Failure[A, C]

Intは0以上のものが有効という条件付きのValidationNEL[Throwable, Int]からValidation[Throwable, String]への変換は以下のようになります。

def f(a: ValidationNEL[Throwable, Int]): ValidationNEL[Throwable, String] = {
  def g(c: ValidationNEL[Throwable, Int]) = {
    c.flatMap { d =>
      if (d >= 0) c
      else new IllegalArgumentException(d.toString).failNel
    }
  }

  for (b <- g(a)) yield {
    b.toString
  }
}

Optionの場合と違ってValidationではif句で成功文脈から失敗文脈への切り替え条件を指定することはできません。ValidationはwithFilterメソッド、filterメソッドを持っていないためです。

そこで、ジェネレータに指定する前に成功文脈から失敗文脈への切り替え判定をしておくようにプログラミングしてみました。この処理をfor式内に直接記述するとプログラムの見通しが悪くなるので、ローカル関数を作成し、これを呼び出すようにしています。

プログラムの見通しはかなり悪くなってしまっていて、ちょっと無理がある感じですね。このためValidationを計算文脈として成功文脈から失敗文脈への切り替えを持つ処理をfor式で書くのはあまり得策ではないようです。

ノート

for式はモナドの文法糖衣で、うまくはまると便利なのですが、成功文脈から失敗文脈への切り替えはうまくハンドリングできない感じです。

今回のような簡単な例だとflatMapメソッドを直接使ったほうが簡潔で便利です。

ただし、計算文脈上で多段の計算を行う場合はfor式が便利なのは確かで、「成功文脈から失敗文脈への切り替え」の不細工さを勘案してもfor式を選ぶ方が良いケースもありそうです。このあたりの取捨選択は追々考えていく予定です。

諸元

  • Scala 2.9.2
  • Scalaz 6.0.4