🔥

Dartにおける関数型プログラミング

2023/12/25に公開

はじめに

Dartはオブジェクト指向言語ですが、時間とともに進化しています。

今年、Dart 3が正式にリリースされました。
その中、パターンマッチングswitch文の機能拡張sealed修飾子などの新機能が登場され、Dartにおいても関数型プログラミングを行うことが可能になりました。

https://medium.com/dartlang/announcing-dart-3-53f065a10635

本記事ではDartを用いた例で関数型プログラミングの解説をしていきます。

命令型と関数型

命令型プログラミング
void imperative() {
  const numbers = <int>[1, 2, 3, 4];

  var sum = 0;
  for (var i = 0; i < numbers.length; i++) {
    sum = sum + numbers[i];
  }

  // Print: 10
  print(sum);
}
関数型プログラミング
void functional() {
  const numbers = <int>[1, 2, 3, 4];

  final sum = numbers.fold(
    0, // 初期値
    (previousValue, element) => previousValue + element,
  );

  // Print: 10
  print(sum);
}

上記の2つの処理はどちらも1から4までの合計を求めていますが、何か違いがあるでしょう?

  • 命令型プログラミング
    • 状態が変更可能
    • 処理がやや複雑

結果を得るため、まずは可変なsumを宣言しています。
そしてもう一つ、業務ロジックとほとんど関係ない、for文を行うだけのために変数iも宣言する必要があります。

  • 関数型プログラミング
    • 状態が変更不可
    • 処理が比較的シンプル

Dartのfoldメソッドを使用して、numbersの要素の累計を求めています。

もう少し複雑な例

命令型プログラミング
void imperative() {
  const strings = <String>['Apple', 'Orange', 'Banana'];

  final charCounts = <(String, int)>[];
  for (var i = 0; i < strings.length; i++) {
    if (strings[i].length > 5) {
      charCounts.add((strings[i], strings[i].length));
    }
  }

  // Print: [(Orange, 6), (Banana, 6)]
  print(charCounts);
}
関数型プログラミング
void functional() {
  const strings = <String>['Apple', 'Orange', 'Banana'];

  final charCounts = strings
      .where((string) => string.length > 5)
      .map((string) => (string, string.length));

  //  Print: [(Orange, 6), (Banana, 6)]
  print(charCounts);
}

なるほど、どれも6文字以上の文字列と、その長さをペアにして出力していますね。
この2つの実装についてはどう思うでしょうか?

もう一つ少し極端な例を見てみましょう。

final result = list
    .where((ele) => ele > 2)
    .plus([1, 2, 3])
    .drop(2)
    .intersect([1, 2, 3])
    .map((ele) => ele * 2)
    .take(3)
    .first;

命令型プログラミングのやり方で同じ実装をしようとしたら、どんな結果になるでしょうか?

結論

とてもシンプルな例ですが、命令型プログラミングは以下のようなデメリットがあると考えられます。

  • ロジックは複雑になる
  • コードの可読性は下がってしまう
  • 抽象化しづらいため、重複コードが大量増殖

エンジニアとしては嫌なシチュエーションですね…

オブジェクト指向と関数型

さって、ケーキとクッキーを作りましょう!

オブジェクト指向プログラミング
abstract class Recipe {
  final int time;
  final int temp;
  final List<String> ingredients;

  Recipe({
    required this.time,
    required this.temp,
    required this.ingredients,
  });

  void bake() {};
}

class Cake extends Recipe {
  Cake()
      : super(
          time: 40,
          temp: 325,
          ingredients: ['小麦粉', '卵', '牛乳'],
        );

  
  void bake() => time * temp;
}

class Cookies extends Recipe {
  Cookies()
      : super(
          time: 25,
          temp: 350,
          ingredients: ['小麦粉', 'バター', '砂糖'],
        );

  
  void bake() {
    (time / 2) * temp;
    rotate(); // 途中で天板を回転させる
    (time / 2) * (temp - 15);
  }
}

抽象クラスRecipeと、Recipeを継承した2つのサブクラスCakeCookiesを作成し、Recipeで宣言したフィールドとメソッドもそれぞれでオーバーライドしています。

ここまでは、典型的なオブジェクト指向のやり方ですね。

じゃ、関数型プログラミングの場合はどうなる?

関数型プログラミング
sealed class Recipe {
  final int time;
  final int temp;
  final List<String> ingredients;

  Recipe({
    required this.time,
    required this.temp,
    required this.ingredients,
  });
}

class Cake extends Recipe {
  Cake()
      : super(
          time: 40,
          temp: 325,
          ingredients: ['小麦粉', '卵', '牛乳'],
        );
}

class Cookies extends Recipe {
  Cookies()
      : super(
          time: 25,
          temp: 350,
          ingredients: ['小麦粉', 'バター', '砂糖'],
        );
}

void bake(Recipe recipe) {
  switch (recipe) {
    case Cake():
      recipe.time * recipe.temp;

    case Cookies():
      (recipe.time / 2) * recipe.temp;
      rotate(); // 途中で天板を回転させる
      (recipe.time / 2) * (recipe.temp - 15);
  }
}

Recipeの修飾子をabstractからsealedに変えて、各クラスにメソッドを持たせない代わりに、一つのトップレベルのメソッドを定義しています。
sealed classswitch文で網羅性を確認することができます。

結論

オブジェクト指向の方は、クラスの中にメソッドを定義してクラスの中のフィールドを使う手法を取っています。
一方、sealedを用いた代数的データ型は、データとロジックを分割してbake()を純粋関数[1]にしています。
そのため、テストしやすい・メンテナンス性が高いというメリットがあります。

fpdartを用いた関数型プログラミング

Dart界隈では、以前からdartzfpdartなどの関数型の考え方を取り入れたライブラリが存在しています。
では、fpdartを用いた関数型プログラミングの例として、エラーハンドリングについてを解説していきます。

Dart
enum Fruits {
  apple,
  orange,
  banana;

  static Fruits parse(String fruit) => switch (fruit.toLowerCase()) {
        'apple' => Fruits.apple,
        'orange' => Fruits.orange,
        'banana' => Fruits.banana,
        _ => throw ArgumentError('Invalid fruit: $fruit'),
      };
}

void main() {
  // Uncaught Error: Invalid argument(s): Invalid fruit: Peach
  final fruit = Fruits.parse('Peach');
  eat(fruit);
}

上記では、appleorange、またはbananaを持つ列挙型を実装しています。
また、文字列を列挙型の値に変換するメソッドもあり、有効な文字列以外の値を渡すとArgumentErrorという例外を発生させています。

ちょっと面倒ですが、このレベルの実装であれば、Fruits.parse()を呼ぶたびにtry-catchするのを忘れることはないはずですね。

しかし、プロジェクトの規模が大きくなればなるほど、どの処理にどの例外は発生するのをいちいち覚えることはどんどん難しくなります。
そして、このようなエラーはコンパイル時に発見できないため、気づかないうちにリリースしてしまう可能性が高いです。

このリスクを回避するため、できる限りランタイムエラーではなく、コンパイルエラーとして投げたいですね。

Either、あなたの出番です!

Eitherを使う例

pubspec.yaml
dependencies:
  fpdart: ^1.1.0

ますはfpdartを導入します。

Either
enum Fruits {
  apple,
  orange,
  banana;

  static Either<String, Fruits> parse(String fruit) =>
      switch (fruit.toLowerCase()) {
        'apple' => right(Fruits.apple),
        'orange' => right(Fruits.orange),
        'banana' => right(Fruits.banana),
        _ => left('Invalid fruit: $fruit'),
      };
}

void main() {
  final fruitOrError = Fruits.parse('Peach');
  fruitOrError.match(
    (e) => handleError(e),
    (fruit) => eat(fruit),
  );
}

Either<L, R>LまたはRのどちらかの型の値を保持する型で、Rightには成功した結果の値を、Leftには失敗の原因等を格納します。
上記例において、RightにはFruitsの値、Leftにはエラーメッセージが保持されています。

Eitherを使用したことによって、以下のコードをコンパイルするときにエラーが発生します。

void main() {
  final fruit = Fruits.parse('Peach');
  // Error: Argument type 'Either' can't be assigned to parameter type 'Fruits'.
  eat(fruit);
}

おっと、Fruits.parse()の結果はRightでもLeftでも、両方をキャッチしないといけないですね!
つまり、今まで実行時しか発生しないエラーを、コンパイル時に発生させるようにしたってことですね!

Eitherswitch

Either<L, R>sealed classなので、match以外に通常のswitch文を利用することも可能です。
※ただ、matchメソッドを使うのが一般的です。

Either feat. switch
void main() {
  final fruitOrError = Fruits.parse('Peach');
  switch (fruitOrError) {
    case Left(value: final e) => handleError(e),
    case Right(value: final fruit) => eat(fruit),
  }
}

まとめ

関数型プログラミングは、コードが複雑になった場合でも読みやすさを保つことができるため、大規模な開発には適しています。
このように、Dartでは関数型プログラミングの概念を活用することで、コードの再利用性や予測可能性を向上させることが可能です。

ご参考になれば幸いです。

関連記事

https://zenn.dev/minma/articles/2d9f977809a246

脚注
  1. 「同じ引数に対して必ず同じ返り値を返す」、「外部に副作用を引き起こさない」の2つの性質を同時に満たす関数を指す。 ↩︎

GitHubで編集を提案

Discussion