どうも、失敗系モナドが大好きな人です。
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メソッドを実装して動作確認をしてください。
validateFirstName
validateLastName
validateMailAddress
createRegistrationMail_if
createRegistrationMail_nested_if
createRegistrationMail_flatMap
createRegistrationMail_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楽しく学ぼう」の学習記録です
- 文脈の扱いをとても体系立てて学べます
- 本当にオススメするのは本自体ですがいきなり本は買わないと思うので、雰囲気だけでも見てみたければ