fpdartの型たち①(Option/Either)
はじめに
最近はfpdartを使って関数型プログラミングの勉強を色々やっているので、その中から得た気づきや学んだことを残してみたいと思います。
前回の記事
この記事では、関数型プログラミングにおける最も重要(最も使われている)型を解説していきます。
Option
Option
型は、意味のある値が存在するかどうかわからない値を表すデータ型です。
通常、Some
(値が存在する)、またはNone
(値が存在しない)の2つのサブタイプで構成されます。
sealed class Option<T>
class Some<T> extends Option<T>
class None extends Option<Never>
T?
とOption
値の欠如を表現するにはnull
を使うのが多いでしょう。
Dartは型安全のため、静的解析を通じてNULL許容の値を安全に操作することができます。
void main() {
int noNull = 1;
noNull.isEven; // bool
int? nullInt = nullable();
// nullable.isEven; ❌
nullInt?.isEven; // bool?
// 以下のような処理に相当する
nullInt == null ? null : nullInt.isEven; // bool?
// nullでないことを保証できる場合、!を使っても良い
int? noNullNullable = 1;
noNullNullable!.isEven; // bool
}
T?
の特性を活かせば、NULL判定を挟まずにAPIを使えるので、とても便利に開発できますね。
一方、Option
でnull
を扱ってみると
void main() {
int? nullableInt = Random().nextBool() ? 1 : null;
Option<int> optionInt = Option.fromNullable(nullableInt);
// `map`利用して保っている値を`isEven`の結果に変換する
optionInt.map((noNull) => noNull.isEven); // Option<bool>
// 値が存在するかどうかによって異なる処理を実行する
optionInt.match(
() => print('no value'),
(isEven) => isEven, // bool
);
// もしくは
if (optionInt.isSome()) {
optionInt; // Option<bool>
} else {
print('no value');
}
}
パッと見、T?
を捨ててOption
を迎えるメリットはほんとにあるの?と思うかもしれませんが、続いてもう一つの例を見てみましょう。
int doSomething(String str) => str.length + 10 * 2;
int doSomethingElse(int number) => number + 10 * 2;
void main() {
String? nullableStr = Random().nextBool() ? 'banana' : null;
double? nullableDouble = (nullableStr != null
? doSomethingElse(doSomething(nullableStr))
: doSomethingElse(20)) /
2;
}
さって、この処理は何をして何を返してくれますか?
すぐ答えられない?大丈夫、書いた人も分からないはずです。
そしては、Option
っぽい書き方で同じ処理を実装してみて、その違いを見ていきます。
int doSomething(String str) => str.length + 10 * 2;
int doSomethingElse(int number) => number + 10 * 2;
void main() {
String? nullableStr = Random().nextBool() ? 'banana' : null;
Option<double> optionDouble = Option.fromNullable(nullableStr)
.map(doSomething)
.alt(() => some(20))
.map(doSomethingElse)
.map((number) => number / 2);
}
Option
を採用するべき理由
このようなメソッドチェーンを実装できることは、Option<T>
が持っている最も強いところです。
Option
の便利なAPIを利用することで、同じオブジェクトに対して一連の処理を連結させて書けるようになって、もっと読みやすく、もっとメンテナンスしやすく、そしてもっと安全なコードを実装できます。
また、null
というものは、「この値は空かもしれない」ということを宣言しているだけで、値自身はint?
であろうとString?
であろうと、どのような型でも代入できます。
それと比べて、Option<T>
は完璧なClass
となっているため、None<String>
のような明示的に「空文字」を表すことができます。
Either
関数型プログラミングにおいて、Either<L, R>
はエラーハンドリングの手法の一つで、try-catch
やthrow
などの代替案として使われています。
Either<L, R>
はL
とR
の2つの型を含んでいるが、Option
と似たような感じでどちらかの一つの値しか持つことができません。
-
失敗時の戻り値(
Left
と呼ばれる) -
成功時の戻り値(
Right
と呼ばれる)
このような特性を持っているため、Either
は戻り値が成功と失敗を明確に宣言しています。
try-catch
とEither
void feedCats(String food) {
if (food == 'chocolate') {
throw Exception('Cat cannot eat it');
}
print('meow');
}
try {
feedCats(); // 成功時の処理
} catch (e) {
handleError(e); // 失敗時の処理
}
上記の通り、try-catch
を利用した従来のエラーハンドリングは、誰でも使って、シンプルで慣れているやり方です。
ただ、この方法は本当に安全なのでしょうか?
すべての例外を網羅するため、処理を実行するたびにtry-catch
で囲む必要があります。数ヶ所だけであれば漏れなく実装できるが、特に大規模なプロジェクトには、数十もしくはそれ以上のところに呼ばれている可能性があります。
その場合、どこかの一箇所にtry-catch
を書き忘れてしまうのもおかしくないでしょう。
また、レアケースではありますが、一部の例外に対して我々はそのままスローしたほうが望ましいことがあります。こういった例外中の例外と普通の例外が混ぜられたら、処理するのにさらに難しくなってきます。
Either<Exception, String> feedCats(String food) {
if (food == 'chocolate') {
return left(Exception('Cat cannot eat it'));
}
return right('meow');
}
feedCats().match(
(e) => handleError(e), // 成功時の処理
(meow) => print(meow), // 失敗時の処理
);
正常値と異常値両方を一つのEither
で返しているので、try-catch
を書く必要がなくなりました。
Either
を採用するべき理由
上例を通して、Either
は以下のようなのメリットがあると思われるでしょう。
-
安全に使える
エラーを処理するタイミングがランタイム時からビルド時になり、書き忘れのようなミスを起こすことがなくなります。 -
豊かなAPIを持っている
Option
と同様、map
などのAPIが提供されているため、もっとリーダブルなコードを実装できます。 -
例外が明確になる
throw
で例外を投げる場合、メソッド内部のコードを見ないとエラーが存在することが分からないが、Either
を使ったらすぐ気づきます。
つづく
文章が長くなりそうなので、残りの型たちは次の記事で紹介させていただきます。
次回の記事
Discussion