📚

fpdartの型たち①(Option/Either)

2024/01/14に公開

はじめに

最近はfpdartを使って関数型プログラミングの勉強を色々やっているので、その中から得た気づきや学んだことを残してみたいと思います。

前回の記事

https://zenn.dev/minma/articles/53c23d9119a1bc

この記事では、関数型プログラミングにおける最も重要(最も使われている)型を解説していきます。

Option

Option型は、意味のある値が存在するかどうかわからない値を表すデータ型です。

通常、Some(値が存在する)、またはNone(値が存在しない)の2つのサブタイプで構成されます。

Option
sealed class Option<T>

class Some<T> extends Option<T>

class None extends Option<Never>

T?Option

値の欠如を表現するにはnullを使うのが多いでしょう。
Dartは型安全のため、静的解析を通じてNULL許容の値を安全に操作することができます。

T?
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を使えるので、とても便利に開発できますね。

一方、Optionnullを扱ってみると

Option
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を迎えるメリットはほんとにあるの?と思うかもしれませんが、続いてもう一つの例を見てみましょう。

T?
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っぽい書き方で同じ処理を実装してみて、その違いを見ていきます。

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-catchthrowなどの代替案として使われています。

Either<L, R>LRの2つの型を含んでいるが、Optionと似たような感じでどちらかの一つの値しか持つことができません。

  • 失敗時の戻り値(Leftと呼ばれる)

  • 成功時の戻り値(Rightと呼ばれる)

このような特性を持っているため、Eitherは戻り値が成功と失敗を明確に宣言しています。

try-catchEither

try-catch
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
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を使ったらすぐ気づきます。

つづく

文章が長くなりそうなので、残りの型たちは次の記事で紹介させていただきます。

次回の記事

https://zenn.dev/minma/articles/9a6d64b005adab

GitHubで編集を提案

Discussion