Dartにおける関数型プログラミング
はじめに
Dartはオブジェクト指向言語ですが、時間とともに進化しています。
今年、Dart 3が正式にリリースされました。
その中、パターンマッチング、switch文の機能拡張、sealed修飾子などの新機能が登場され、Dartにおいても関数型プログラミングを行うことが可能になりました。
本記事では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つのサブクラスCake
とCookies
を作成し、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 class
はswitch文
で網羅性を確認することができます。
結論
オブジェクト指向の方は、クラスの中にメソッドを定義してクラスの中のフィールドを使う手法を取っています。
一方、sealed
を用いた代数的データ型は、データとロジックを分割してbake()
を純粋関数[1]にしています。
そのため、テストしやすい・メンテナンス性が高いというメリットがあります。
fpdartを用いた関数型プログラミング
Dart界隈では、以前からdartzやfpdartなどの関数型の考え方を取り入れたライブラリが存在しています。
では、fpdart
を用いた関数型プログラミングの例として、エラーハンドリングについてを解説していきます。
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);
}
上記では、apple
、orange
、またはbanana
を持つ列挙型を実装しています。
また、文字列を列挙型の値に変換するメソッドもあり、有効な文字列以外の値を渡すとArgumentError
という例外を発生させています。
ちょっと面倒ですが、このレベルの実装であれば、Fruits.parse()
を呼ぶたびにtry-catch
するのを忘れることはないはずですね。
しかし、プロジェクトの規模が大きくなればなるほど、どの処理にどの例外は発生するのをいちいち覚えることはどんどん難しくなります。
そして、このようなエラーはコンパイル時に発見できないため、気づかないうちにリリースしてしまう可能性が高いです。
このリスクを回避するため、できる限りランタイムエラーではなく、コンパイルエラーとして投げたいですね。
Either
、あなたの出番です!
Either
を使う例
dependencies:
fpdart: ^1.1.0
ますはfpdart
を導入します。
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
でも、両方をキャッチしないといけないですね!
つまり、今まで実行時しか発生しないエラーを、コンパイル時に発生させるようにしたってことですね!
Either
とswitch
Either<L, R>
はsealed class
なので、match
以外に通常のswitch文
を利用することも可能です。
※ただ、match
メソッドを使うのが一般的です。
void main() {
final fruitOrError = Fruits.parse('Peach');
switch (fruitOrError) {
case Left(value: final e) => handleError(e),
case Right(value: final fruit) => eat(fruit),
}
}
まとめ
関数型プログラミングは、コードが複雑になった場合でも読みやすさを保つことができるため、大規模な開発には適しています。
このように、Dartでは関数型プログラミングの概念を活用することで、コードの再利用性や予測可能性を向上させることが可能です。
ご参考になれば幸いです。
関連記事
-
「同じ引数に対して必ず同じ返り値を返す」、「外部に副作用を引き起こさない」の2つの性質を同時に満たす関数を指す。 ↩︎
Discussion