どうも、失敗系モナドが大好きな人です。
OptionとEitherのFor式を紹介するために、ステップを踏んだ演習を考えてみました。
折角考えてみたのできれいにまとめて公開してみようかと思います。
この記事はなに?
だれ向け?
普段Javaを書いていて、Optionalをなんとなく使っている後輩達を想定して最初は考えました。
「Java8?よくわからないけど、nullはOptionalにすれば良いのね?」
って理解でnullチェックをisPresentに変えただけの人!いませんか?
OptionalをifとisPresentでの条件分岐に使っている様な人は、是非読んでもらいたいです。
他に、Scalaを触ってみたことがある人、触ってみようと思っている人や、Haskellをちょっと触ったことがあるよ、という人も是非目を通してみたください。
この記事ではScalaのOption, Either, For式について触れますが、例えばHaskellのMaybe, Either, doの様に相当する概念がある言語でも良いと思います。
お前だれ?
実はScalaのFor式は初めて読み書きします。
そもそもScalaでの業務経験もありません。
ですが、Haskellを学びながら HaskellのFunctorとApplicativeFunctorとMonad や HaskellのData.Either.Validationを使う という記事を書きました。
根底の概念や考え方はある程度習得できているのではないか、と思います。
とは言えScalaもHaskellも自習のみで経験が浅いので、指摘をしていただけると嬉しいです。
何語?
Scalaを用います。
対象読者にJavaの人を含みますが、残念ながらJavaにはFor式がありません。(javaslangにはあるようですが)
調べればすぐにわかる程度の文法しか用いないので、Replを使ったりしながら進められると思います。
(この記事の目的はOptionalを理解することで、Javaの文法チートシートではありません。
得たスキルは考え方をJavaで上手く活かすか、言語選定の材料にしてもらいたいと思っています。)
Repl?
れぷる。
scala> val x = 5
x: Int = 5
scala> x + 2
res0: Int = 7
コマンドラインで起動し、その場ですぐ実行できます。
初めて使うクラス等はこれでちょっと触ってみるととても理解がスムーズです。
おそらく普通にインストールをすれば、scalaコマンドで起動すると思います。
単語
以降、OptionとEitherで統一します。
OptionはSomeとNoneのどちらかで、EitherはRightとLeftのどちらかとします。
また、SomeとRightを成功、NoneとLeftを失敗と呼びます。
やらないこと
Scalaのインストールやエディタの設定等の説明は行いません。
また、最低限JavaのOptionalに相当する概念を聞いたことはある程度であると、入りやすいかと思います。
(例えばScalaであればOption, HaskellであればMaybeが相当します)
他には例えばflatMapの様なメソッドの詳細説明は容量の都合上割愛します。
ヒントを載せるに留めますので、必要であれば調べながら進めてください。
それでは
一記事に納め、関連コードもQiitaに集約したかったので、かなり長くなってしまっています。
目次はQiitaの生成するインデックスを参照してください。
よろしければ最後までお付き合いいただけると嬉しいです。
是非、都度スクロールする手を止めてコーディングをしてみる事を強くオススメします。
書いて動かす、エディタの出す型のヒントを読み取ろうとする、知らないメソッドを調べる等、大事なスキルの経験値になると思います。
演習
Option基礎
Optionに慣れる 〜成功チェック、包む、取り出す、写す〜
- 目標
- Optionの基本メソッドを知る
- 文脈という概念を知る
- 写せるメリットを知る
Optionは、失敗するかも知れないということを表現します。
例えば何らかの値が手に入るかもしれない様な場合に、Some(x)かNoneを使ってそれを表現します。
Replを使って試してみましょう
scala> val a: Option[Int] = Option(5) // Optionで「成功」を生成
a: Option[Int] = Some(5)
scala> val b: Option[Int] = None // Noneで「失敗」を生成
b: Option[Int] = None
scala> a.isDefined // 成功かチェックしたり出来る
res3: Boolean = true
scala> b.isDefined
res4: Boolean = false
scala> a.get // 中の値を取り出せる
res5: Int = 5
scala> b.get // けど、失敗に対して中身を要求してはいけない!
java.util.NoSuchElementException: None.get
at scala.None$.get(Option.scala:347)
at scala.None$.get(Option.scala:345)
... 32 elided
Mapからキーで値を手に入れるときなんかがイメージしやすいでしょうか。
scala> val map = Map("result" -> "ok", "code" -> "ok-1")
map: scala.collection.immutable.Map[String,String] = Map(result -> ok, code -> ok-1)
scala> map.get("code")
res0: Option[String] = Some(ok-1) // 成功したよ(んで、値は ok-1 だよ)
scala> map.get("error-message") // 失敗したよ(そんなキーは「なかった」よ)
res1: Option[String] = None
この様に戻り値がOptionのメソッドは、「失敗するかもしれない、失敗したら何も手に入らない」ということを現します。
これをOptionの文脈と言ったりします。
さて、では手を動かしてみましょう。
Option[Int]にIntの2を加えるdef add2(x: Option[Int])を、上で紹介したメソッドで実装してください。
このメソッドはOptioinの中身がある場合は計算を行い、無ければそれをそのまま返します。箱に数字がつまっているイメージです。
+---+ +---+
| 5 | -- add2 --> | 7 |
+---+ +---+
+---+ +---+
| | -- add2 --> | |
+---+ +---+
上で紹介したメソッドで実装すると、どうなるでしょうか。
実装タイム
:
:
:
:
:
def add2(x: Option[Int]) = {
if (x.isDefined)
Option(x.get + 2)
else
None
}
十中八九この様になったかと思います。が、Replの例でb.getで例外が発生したことを思い出してください。
そう、getは実行例外が発生する可能性があります。
中身を取り出す前に必ずチェックをしなければならないというのは、守りきるのは案外難しいです。
ifのチェックを書き忘れたり、ifの中身だけコピペされたりする恐れもありますし、ifの中で中身と関係ない処理までなんでも書けてしまいます。
正しくチェックをしても、ifの{やreturn等をミスするかもしれないし、取り出し方を間違えたり、また詰め直す際にミスをする可能性も考えると、不安の種は尽きません。
原則として、Optionの中身を変えたい場合は、Optionを自分で剥がすことは推奨されません。
中身に対して変換処理を行う場合は、mapというメソッドを使います。
mapは中身がある場合のみ、中身を使って処理を行い、また詰め直してくれます。
def add2(x: Option[Int]) = {
x.map(it => it + 2) // it はつまっていた Int の値を保持する変数で、 it + 2 は x が Some だった場合のみ実行される
}
写すという単語は数学の写像から来ています。add2は5を7に写します。
チェックして、Optionを剥がして、計算して、またOptionに詰める、という処理の内、計算部以外はScalaがやってくれています。
Optionの扱い自体はScalaに任せることで、凡ミスをする余地が無くなります。
また「あったら/なかったらと言うOptionの文脈に関する処理」と「メインの計算ロジック」を分離することが出来ています。
導入は以上です。
いろいろReplをいじってみてください。
Option[Int]の足し算 〜全部成功している場合のみ処理する〜
- 目標
- いくつかの方法で実現できることを知る
- flatMapを知る
- 自分で書くことのリスクを知る
さて、少し演習です。
3つのOption[Int]の変数を受け取り、全てが成功していたら加算してSome[Int]で、1つでも失敗していたらNoneで返却するメソッドを3種類の方法で実装してください。
def sum_by_if(a: Option[Int], b: Option[Int], c: Option[Int]): Option[Int] = {
// isDefined と get を用いてください
// if は入れ子になってはいけません
}
def sum_by_nested_if(a: Option[Int], b: Option[Int], c: Option[Int]): Option[Int] = {
// 同じく isDefined と get を用いてください
// if を入れ子にしてください
}
def sum_by_flatMap(a: Option[Int], b: Option[Int], c: Option[Int]): Option[Int] = {
// isDefined と get を用いず flatMap のみを用いてください
// flatMap は写した結果がネストした Option になってしまうのを解消します
// Replで Option(5).map(it => Option(it + 2)) と Option(5).flatMap(it => Option(it + 2)) 等を比べてみてください
// map で実装すると Some(Some(Some(Some(x)))) になってしまうので flatMap を用います 平らにされる様を強くイメージしてください
}
val a: Option[Int] = Option(2)
val b: Option[Int] = Option(3)
val c: Option[Int] = Option(1)
println(
sum_by_if(a, b, c) // Some(6)
)
val x: Option[Int] = None
println(
sum_by_nested_if(a, x, c) // None
)
println(
sum_by_flatMap(a, x, c) // None
)
以下に3問まとめて解答例を乗せますが、ひとつずつ確認したい人は上から1メソッドずつ確認してください。
実装タイム
:
:
:
:
:
def sum_by_if(a: Option[Int], b: Option[Int], c: Option[Int]): Option[Int] = {
if (a.isDefined && b.isDefined && c.isDefined)
Option(a.get + b.get + c.get)
else
None
}
def sum_by_nested_if(a: Option[Int], b: Option[Int], c: Option[Int]): Option[Int] = {
if (a.isDefined)
if (b.isDefined)
if (c.isDefined)
Option(a.get + b.get + c.get)
else
None
else
None
else
None
}
def sum_by_flatMap(a: Option[Int], b: Option[Int], c: Option[Int]): Option[Int] = {
a.flatMap(_a =>
b.flatMap(_b =>
c.flatMap(_c => Option(_a + _b + _c))
)
)
}
大体同じ実装でしたでしょうか?
さて、せっかく書いてみましたが、それぞれのイケてない点をいくつか上げてみます。
ifを使う方法は、先述の通りチェックや取り出し、入れ直しやフロー制御にバグが入る余地があります。
下のコードのバグっている箇所、一目で分かりますか?(全部のメソッドがバグっています)
def sum_by_if(a: Option[Int], b: Option[Int], c: Option[Int]): Option[Int] = {
if (a.isDefined && c.isDefined && c.isDefined)
Option(a.get + b.get + c.get)
else
None
}
// if の別の例
def sum_by_if(a: Option[Int], b: Option[Int], c: Option[Int]): Option[Int] = {
if (a.isEmpty)
None
if (b.isEmpty)
None
if (c.isEmpty)
None
Option(a.get + b.get + c.get)
}
def sum_by_nested_if(a: Option[Int], b: Option[Int], c: Option[Int]): Option[Int] = {
if (a.isDefined)
if (b.isDefined)
if (c.isDefined)
Option(a.get + a.get + c.get)
else
None
else
None
else
None
}
(バグは上から1つずつ「bのチェックが欠けている」「この場合はreturnが省略できない」「b.getをしていない」です)
何をバカなw と思うかも知れませんが、実際のコードはもっと複雑ですし、何よりバグる余地がある時点でイケてないですよね。
またflatMapの例はリスクは減りましたが、なかなか挙動を一目で理解するのが困難そうです。
For式登場 〜自分で書かずに安全高品質〜
- 目標
- For式を知る
- 自分で書くより安全でリスクも少ないと知る
- 実処理と文脈の分離に触れる
さて、今度は同じ問題をFor式を使って解いてみましょう。
これから使うforは、いつものぐるぐる回す時に使うforとはちょっと違います。
(とは言え、Optionも最大長1のコレクションと言えるので本質的には同じと捉えられますが、それは今回の範囲外とします)
Scala for式等で調べれば使い方は学習できると思いますので、さっそくやってみましょう。
def sum_by_for(a: Option[Int], b: Option[Int], c: Option[Int]): Option[Int] {
// for のみを用いてください
}
実装タイム
:
:
:
:
:
def sum_by_for(a: Option[Int], b: Option[Int], c: Option[Int]): Option[Int] = {
for {
_a <- a
_b <- b
_c <- c
} yield _a + _b + _c
}
こんな感じになりましたか?
やや見慣れない感じがしますが、flatMapよりは読み書きが遥かに簡単なはずです。
最初は「forの中ではOptionを剥がす事が出来て」、「yieldでまたOptionに入れ直してくれる」と考えても良いかもしれません。
エディタを使っているなら、aや_aの型を見てみてください。
あたかも無思慮にチェックをせずgetをしている様に見えるかもしれませんが、内部処理的にはflatMapに置き換えられているのでNoneが混入していても当然安全です。
実現方法はflatMapに近いですが、見た目は解答例のsum_by_ifの別解に似ていますね。
上からひとつずつOptionを剥がすけど、Noneだったらそこで終了。最後までSomeだったら全部足す、とifの様に読めます。
これがFor式です。馴染むまでいじってみましょう。(Replでも複数行書けますし、;を使えば1行でも書けます)
リスクと分離という観点から見てみましょう。
isDefined(), get(), map(), flatMap(), Option()のいずれも使っていないのが最大のポイントです。
チェック、取り出す、入れ直すという処理はScalaが責任を持ってくれます。
その辺にくだらないバグが入り込む余地が激減しています。
また、実際に記載したコードは総和を求める処理(yieldの部分)だけですね。
文脈の制御に必要な処理を限りなく見えなくしたので、本当に関心のある処理だけに集中できます。
4つの解答例の中で一番安全に処理だけを書けます。
是非とも習得しましょう。
Optionの基礎はここまでです。
Either基礎
Eitherに慣れる 〜成功チェック、包む、取り出す、写す〜
- 目標
- Eitherを知る
- Eitherの基本メソッドを知る
Optionには慣れましたか?
ここからはOptionに代わりEitherの基礎になります。
Optionは成功時の値か無で失敗を表現していたのに対し、Eitherは成功時の値か失敗時の値で失敗を表現します。
一般にはエラー内容を保持したい場合に用いられます。
EitherではSomeとNoneではなく、RightとLeftが提供されます。
正しいとかかっているのでRightが成功の方で、Leftは大抵はエラーメッセージ等が入ります。
成功時と失敗時の型は異なっても大丈夫です。
Option[Int]に対して、Either[String, Int]の様に左右両方の型を指定して使います。
Replで触ってみましょう。
scala> val a: Either[String, Int] = Right(5) // Rightで「成功」を生成
a: Either[String,Int] = Right(5)
scala> val b: Either[String, Int] = Left("some error message") // Leftで「失敗」を生成
b: Either[String,Int] = Left(some error message)
scala> a.isRight // チェックしたり
res0: Boolean = true
scala> a.isLeft
res1: Boolean = false
scala> a.right.get // 取り出したり
res2: Int = 5
scala> a.left.get // 実行例外が起きたりするのは大体 Option と同じ
java.util.NoSuchElementException: Either.left.value on Right
at scala.util.Either$LeftProjection.get(Either.scala:289)
... 32 elided
ScalaのEitherを返すメソッドの例がすぐにわからなかったのですが、例えば認証とかだとイメージできるでしょうか?
def authentication(userId: UserId, password: Password): Either[String, User] {
// 中身は割愛
}
println(
authentication(foo, password) // Right(User(略))
)
println(
authentication(bar, password) // Left(IdかPasswordが不正です)
)
println(
authentication(baz, password) // Left(Id状態が不正です)
)
// 認証だと外部通信とか発生しちゃうので、関数的という点では例として不適切だったかも...
authenticationの型を見てください。
UserIdとPasswordで認証をして、成功したらUserを、失敗したらStringを返す、と読めます。
この失敗しても何かを返すよ、というのがEitherの文脈と言えますね。
引数の型と戻り値の型と文脈があると、メソッド定義から得られる情報はとても表現が言語的になると思いませんか?
ところでEitherにもOptionと同じ様にmapもありますが、rightかleftどちらにかけるかを指定して使います。
mapの練習として、今度はEither[String, Int]にIntの2を加えるdef add2(x: Either[String, Int])と、
Stringを大文字にするdef toUpper(x: Either[String, Int])を実装してみましょう。
+---------------+ +---------------+
| | 5 | -- right add2 --> | | 7 |
+---------------+ +---------------+
+---------------+ +---------------+
| foo-error | | -- right add2 --> | foo-error | |
+---------------+ +---------------+
+---------------+ +---------------+
| | 5 | -- left toUpper --> | | 5 |
+---------------+ +---------------+
+---------------+ +---------------+
| foo-error | | -- left toUpper --> | FOO-ERROR | |
+---------------+ +---------------+
解答例は割愛しますが、ひとつ大きなポイントがあります。
それはIntの2を加える処理を文脈ごとに書いている点です。
def add2(x: Option[Int])とdef add2(x: Either[String, Int])を別に作るはめになってしまっていますね。
しかも例えば、左の型が変わったEither[Int, Int]の右側にIntの2を加えたい場合は、またadd2を作るのでしょうか?
いくらなんでもそれはナンセンスですね。
これはadd2が文脈に関する処理とメインの計算ロジック両方を持ってしまっている事に起因します。
直してみましょう。
add2から文脈に関する処理を消し、本来やりたかったInt + Intの処理のみとして定義します。
def add2(x: Int): Int = {
x + 2
}
そして、文脈に関する処理はScalaに任せて、メインロジックの部分は文脈と関係なく組み合わせます。
println(
Option(5).map(add2) // Some(7)
)
println(
Right(5).right.map(add2) // Right(7)
)
こうすれば今後文脈が変わる場合にもadd2を改修する必要がありません。
add2は文脈は知らず、ただInt + Intの動作保証にだけ注意していれば良いのです。
これが文脈とロジックの分離です。
Either[String,Int]の足し算 〜全部成功している場合のみ処理する〜
- 目標
- Optionとは違うメソッドで実現しなければいけないことを知る
- 違う文脈を自分で書くとまた別の落とし穴があることを知る
さて、では演習です。
Option[Int]の足し算を、今度はEitherでやってみましょう。
3つのEither[String, Int]の変数を受け取り、全てが成功していたら加算してRight[String, Int]で、1つでも失敗していたらLeft[String, Int]で返却するメソッドを3種類の方法で実装してください。
失敗していた場合は、最初に検出した失敗を返却してください。
def sum_by_if(a: Either[String, Int], b: Either[String, Int], c: Either[String, Int]): Either[String, Int] = {
// isRight と get を用いてください
// if は入れ子になってはいけません
}
def sum_by_nested_if(a: Either[String, Int], b: Either[String, Int], c: Either[String, Int]): Either[String, Int] = {
// 同じく isRight と get を用いてください
// if を入れ子にしてください
}
def sum_by_flatMap(a: Either[String, Int], b: Either[String, Int], c: Either[String, Int]): Either[String, Int] = {
// isRight と get は用いず flatMap のみを用いてください
// Replで Right(5).right.map(it => Right(it + 2)) と Right(5).right.flatMap(it => Right(it + 2)) 等を比べてみてください
}
val a: Either[String, Int] = Right(2)
val b: Either[String, Int] = Right(3)
val c: Either[String, Int] = Right(1)
println(
sum_by_if(a, b, c) // Right(6)
)
val x: Either[String, Int] = Left("error x")
val y: Either[String, Int] = Left("error y")
println(
sum_by_nested_if(x, b, c) // Left("error x")
)
println(
sum_by_flatMap(a, x, y) // Left("error x")
)
println(
sum_by_if(a, y, x) // Left("error y")
)
以下に3問まとめて解答例を乗せますが、ひとつずつ確認したい人は上から1メソッドずつ確認してください。
実装タイム
:
:
:
:
:
def sum_by_if(a: Either[String, Int], b: Either[String, Int], c: Either[String, Int]): Either[String, Int] = {
if (a.isLeft)
return Left(a.left.get)
if (b.isLeft)
return Left(b.left.get)
if (c.isLeft)
return Left(c.left.get)
Right(a.right.get + b.right.get + c.right.get)
}
def sum_by_nested_if(a: Either[String, Int], b: Either[String, Int], c: Either[String, Int]): Either[String, Int] = {
if (a.isRight)
if (b.isRight)
if (c.isRight)
Right(a.right.get + b.right.get + c.right.get)
else
Left(c.left.get)
else
Left(b.left.get)
else
Left(a.left.get)
}
def sum_by_flatMap(a: Either[String, Int], b: Either[String, Int], c: Either[String, Int]): Either[String, Int] = {
a.right.flatMap(_a =>
b.right.flatMap(_b =>
c.right.flatMap(_c => Right(_a + _b + _c))
)
)
}
どうですか?この様な感じになったでしょうか?
大体の雰囲気はOptionの時と同じですね。
Optionの時と違うのは、大きくは1点です。
それは、失敗時は最初の失敗の中身を返さないといけないことです。
Optionの時はとにかくNoneで良かったのですが、Eitherの場合はLeftでも自分で制御して最初のLeftを把握しなければなりません。
ですのでOptionの例にあったif (a.isDefined && b.isDefined && c.isDefined)の様な書き方は出来なくなりましたね。
文脈が変わった事によりコードが少し変わり、気をつけなければいけない事が変わりましたね。
For式登場 〜自分で書かずに安全高品質〜
- 目標
- Optionとほぼ同じ記述で実現できることを知る
同じ様にEitherもFor式を使って解いてみましょう。
大きくはOptionと変わらないので、さっそく問題です。
def sum_by_for(a: Either[String, Int], b: Either[String, Int], c: Either[String, Int]): Either[String, Int] {
// for のみを用いてください
}
実装タイム
:
:
:
:
:
def sum_by_for(a: Either[String, Int], b: Either[String, Int], c: Either[String, Int]): Either[String, Int] = {
for {
_a <- a.right
_b <- b.right
_c <- c.right
} yield _a + _b + _c
}
Optionとほぼ同じ書き方で出来ました。
これもOptionの時に述べたのと同様、自分でチェックや取り出し入れ直しを書いていないですね。
それに動作イメージはflatMapなので、当然ちゃんと最初に失敗したLeftが返されます。シンプルで強力ですね。
唯一Optionと違う点は左右の指定ですが、それについての詳細は割愛します。
気になる方は最下部の参考リンクをご覧ください。
Eitherも、なんとなく使える気がしてきましたか?
応用
- 目標
- 実践に活かせそうな状況を知る
- 既存のクラスに文脈を付与しても既存コードに改修が不要なことを実感する
- 文脈を付与しても既存クラスのテストが増えない事を実感する
- 文脈と既存クラスに異存が無いことを知る
ここからは少し実用例っぽい?お題をやってみて、馴染み始めたOptionとEitherをもっと馴染ませていきます。
解答例は一意ではありませんが、一応解答例も載せるのでぜひまずは自分で書いてみてください。
お題(共通部)
ユーザの入力を受け付けてMailクラスのインスタンスを生成するのが大きな目標です。
それをOptionで実装し、仕様追加をしてEitherで再実装します。
この「お題(共通部)」の項ではどちらにも共通する仕様について記載します。
まず既存クラスとして、以下のクラスが提供されます。
個人情報を渡すとメールの情報を組み立ててくれるMail.registrationがコアロジックです。
case class FirstName(value: String)
case class LastName(value: String)
case class MailAddress(value: String)
case class Mail(to: String, sub: String, body: String)
object Mail {
def registration(mailAddress: MailAddress, firstName: FirstName, lastName: LastName): Mail = {
Mail(mailAddress.value, "新規登録受付のお知らせ", "ようこそ、%s %sさん。".format(firstName.value, lastName.value))
}
}
姓、名、メアドを文字列で受け取り、メールを組み立ててください。
ただし以下で示すバリデータを通し、全てが成功だった場合に成功のMailを、そうでない場合は失敗を返してください。
全て成功だった場合にのみMailを組み立てる処理を、基礎編でやった様にif, nested_if, flatMap, そしてFor式それぞれで実装してください。
お題の共通部は以上です。
実践風Option 〜バリデーションして組み立てる〜
ユーザの入力をバリデートする処理ですが、成功しているか失敗しているかだけがわかれば良いです。
失敗理由は不要ですのでOptionが適切です。
以下の様なバリデータを用意してください。
def validateFirstName(value: String): Option[FirstName] = {
// 空文字は失敗
// それ以外は成功
}
def validateLastName(value: String): Option[LastName] = {
// 空文字は失敗
// それ以外は成功
}
def validateMailAddress(value: String): Option[MailAddress] = {
// 空文字は失敗
// @ が2つ以上ある場合も失敗
// それ以外は成功
}
これが全て通った場合に成功のMailを返すとは、つまりSome(Mail)を返す、もしくはNoneを返すという意味です。
実行例を記載します。
val firstName = validateFirstName("John")
val lastName = validateLastName("Doe")
val mailAddress = validateMailAddress("big-boss@fox.com")
println(
createRegistrationMail_if(firstName, lastName, mailAddress)
) // Some(Mail(big-boss@fox.com,新規登録受付のお知らせ,ようこそ、John Doeさん。))
val invalidFirstName = validateFirstName("")
println(
createRegistrationMail_flatMap(invalidFirstName, lastName, mailAddress)
) // None
以下の7メソッドを実装して動作確認をしてください。
validateFirstNamevalidateLastNamevalidateMailAddresscreateRegistrationMail_ifcreateRegistrationMail_nested_ifcreateRegistrationMail_flatMapcreateRegistrationMail_for
実装タイム
:
:
:
:
:
出来ましたか?
バリデータの中身は普通にifかmatch-caseでしょうから、解答例は割愛します。
組み立てのコードは代表してifとforの例を掲載します。
def createRegistrationMail_if(firstName: Option[FirstName], lastName: Option[LastName], mailAddress: Option[MailAddress]): Option[Mail] = {
if (firstName.isEmpty)
None
else if (lastName.isEmpty)
None
else if (mailAddress.isEmpty)
None
else
Option(Mail.registration(mailAddress.get, firstName.get, lastName.get))
}
def createRegistrationMail_for(firstName: Option[FirstName], lastName: Option[LastName], mailAddress: Option[MailAddress]): Option[Mail] = {
for {
f <- firstName
l <- lastName
m <- mailAddress
} yield Mail.registration(m, f, l)
}
(解答例の解説はEitherが終わってからまとめてやります)
実践風OptionをEitherで拡張 〜組み立てられなければエラーメッセージを返す〜
では仕様変更です。
バリデートの結果を呼び出し元に教えなくてはなりません。
バリデータを以下の様に改修し、メール組み立て部も成功のMailか最初に発生したバリデーションエラーのメッセージに改修してください。
def validateFirstName(value: String): Either[String, FirstName] = {
// 空文字は失敗
// それ以外は成功
}
def validateLastName(value: String): Either[String, LastName] = {
// 空文字は失敗
// それ以外は成功
}
def validateMailAddress(value: String): Either[String, MailAddress] = {
// 空文字は失敗
// @ が2つ以上ある場合も失敗
// それ以外は成功
}
実行例です。
val firstName = validateFirstName("John")
val lastName = validateLastName("Doe")
val mailAddress = validateMailAddress("big-boss@fox.com")
println(
createRegistrationMail_if(firstName, lastName, mailAddress)
) // Right(Mail(big-boss@fox.com,新規登録受付のお知らせ,ようこそ、John Doeさん。))
val invalidMailAddress1 = validateMailAddress("")
val invalidMailAddress2 = validateMailAddress("big@boss@fox.com")
println(
createRegistrationMail_nested_if(firstName, lastName, invalidMailAddress1)
) // Left(MailAddressが空です)
println(
createRegistrationMail_flatMap(firstName, lastName, invalidMailAddress2)
) // Left(@が2つ以上あります)
if, nested_if, flatMap, forそれぞれで実装してください。
これで実装は最後です。
実装タイム
:
:
:
:
:
何度も繰り返し演習してきたので、もうあまり悩みませんでしたかね?
Optionの時と同様に、ifとforの例のみを掲載します。
def createRegistrationMail_if(firstName: Either[String, FirstName], lastName: Either[String, LastName], mailAddress: Either[String, MailAddress]): Either[String, Mail] = {
if (firstName.isLeft)
Left(firstName.left.get)
else if (lastName.isLeft)
Left(lastName.left.get)
else if (mailAddress.isLeft)
Left(mailAddress.left.get)
else
Right(Mail.registration(mailAddress.right.get, firstName.right.get, lastName.right.get))
}
def createRegistrationMail_for(firstName: Either[String, FirstName], lastName: Either[String, LastName], mailAddress: Either[String, MailAddress]): Either[String, Mail] = {
for {
f <- firstName.right
l <- lastName.right
m <- mailAddress.right
} yield Mail.registration(m, f, l)
}
以上で実装は全てお終いです、お疲れ様でした。
お題をやってみてのまとめ
For式の書き方は知れましたね?
ここではお題で扱ったMail.registrationに注目してこの設計のメリットを考えてみます。
ロジックと文脈
まずMail.registrationのやってくれていることとして、以下の様なコアロジックが存在するところから始めました。
- 宛先は
MailAddressの値をToにすること - 新規登録時は「新規登録受付のお知らせ」という件名であること
- 本文は「ようこそ」ではじめて、姓と名は半角で繋ぐこと
これがロジックです。
対して「全て成功していれば成功に包んで生成する、失敗していれば適切な失敗を返す」という要求が文脈の処理です。
解答例のifの方もforの方も、このロジックと文脈処理の分離をちゃんとやっています。
ここが出来ているので、Mail.registrationの改修を一切しなくて済みました。
文脈が増えてもコアロジックの改修が不要ですし、逆にコアロジックの変更があった場合に文脈の数だけ直したりもしなくて済みます。
加えてもう一つが、自分で文脈に関する処理を極力しないために、ifよりforの解答例の方が高可読性で低記述量だからおすすめだ、ということでした。
以上の理由から、For式の利用を(僕は)強く推奨します。
剥がすという捉え方に関する補足
最初は「
forの中ではOptionを剥がす事が出来て」、「yieldでまたOptionに入れ直してくれる」と考えても良いかもしれません。
初めに上記の様に「Optionを剥がせる」と書きましたが、こう捉えるのであればOptionは文脈に読み替える方がより適切だと思います。
forの中では文脈を剥がせて、yieldでまた文脈に入れ直すのです。
従ってforを使う限りでは文脈がなくなってしまったり出し入れを間違えたりする事がなく、適切な文脈が保たれることが保証されると考えています。
実際に使う
この辺りまで理解していて、かつ言語がそれを出来るのか否かでOptionやEitherの使い方を考えられたら良いと思います。
「俺らのOptionalではこれは真似出来ねー」と思う場合は、それがOptionalの使い方として適切なのか、それとも言語上出来ないのかを考えてみると良いと思います。
使い方が不適切な場合は、Optionalをフラグの様に扱っていたりする場合か、Optionalで包む切り口がおかしい場合が多いと(周りを見る限りではとても)思います。
例えばoptional.isPresent()を連打して処理を分岐する様なことはただのフラグ管理です。
ひどい場合はisPresentの場合に別にそれのgetをしないで別の値を操作する様なことがあります。
これは明らかなフラグによる分岐ですし、そもそもOptionalに包む物の切り口もおかしいです。
文脈からは出さずに、写す。という発想をしてみると変わるかもしれません。
言語上の問題であれば、Javaであればjavaslangを使えば似た事が出来る気がします。(詳細は割愛)
使いどころ
これはもう業務やチームや言語によるので、正直なところ「ここまでを習得した上で、それを織り込んで設計と言語選定をするしか無い」と思います。
それでも現存するコードの中でなんとか局所的に使えそうな所としては、以下の様な箇所がすぐに思いつきました。
- Databaseのカラムをクラスに変換するORM層
- 例えば
解約予定日、解約予約日,解約理由の全てが非nullなら云々、みたいな処理
- 例えば
- 複数のチェックロジックを連打する層
- 例えば
会員状態のチェック、商品の組み合わせチェック、在庫のチェックが全て成功なら云々、失敗なら理由を、みたいな処理
- 例えば
おしまい
どうでしたか?
それぞれの項目に目標を掲げていたので、意図したことを実感してもらえたなら良いのですが。
Java8になってOptionalに初めて触れた人が多い様に感じますが、単純なNPE脱却の次のステップや、他言語にある本来の意図の様な物を紹介出来たでしょうか?
僕自身はこれらは全部Haskellで学びました。
他言語を学ぶ事で別の言語の理解が深まる事は多いと思いますが、全然違う言語を0から学ぶのは敷居が高いのもありますし、この記事が誰かの学習素材として役立てばうれしいなと思います。
それにしても、なが〜〜い記事だ!
もしここまで読んでくれている人がいたら、ありがとうございます!
参考
参考資料としていくつかを掲載します。
興味があれば見てみてください。
- 「最初のエラー」ではなくて「全てのエラー」を返したい場合、
Validationというのがあります- EitherとValidation
-
scalazですが、validationの例があります - 他にも
applicativeや右優先について書いてあります
- 執筆終盤でほぼ同じ事を言っている記事があることに気付きましたw
- Option型を使いこなして初心者から中級者へ
-
Eitherは少なめ - コレクションに焦点を当てて?書かれています
- 文脈というものに興味が出たら
- HaskellのFunctorとApplicativeFunctorとMonad
- 僕の記事で恐縮ですが、「すごいHaskell楽しく学ぼう」の学習記録です
- 文脈の扱いをとても体系立てて学べます
- 本当にオススメするのは本自体ですがいきなり本は買わないと思うので、雰囲気だけでも見てみたければ