22
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

1. はじめに

1.1 モナドは遠い概念に見える

モナドって、知ってますか?

共和国?それはモナコ。


モナコの国旗

あんこが入ってるサクサクの……それはモナカ。


美味そう

曇りなき……まなこ。


人による

モナド?聞いたことないって?

僕もそうでした、ほんの1週間前まで。

「モナド」と調べると、Haskellの話が出てきて、圏論の話が出てきて、
なんか急に「射」とか「自己関手」とか出てきて、そっとブラウザを閉じた人、きっといると思います。

でも、知ってみると、普段何気なく記述していた

  • 非同期関数
  • 配列の操作
  • null許容
    など、あらゆるコードについて以前より整理して捉えられるようになりました。

1.2 しかし普段のコードの中にすでに現れている

  • 非同期関数の型について理解できている気がしない
  • 結局、型ってなんだっけ?

もし、そんな疑問をもったら少し、この記事を読んでみてください。

自分には関係ない?本当ですか?以下のようなコードを書いたことがないなら、関係ないでしょう。

Future<int> asyncFunctions(int n) async => asyncFunction(n).then(
  (value) => asyncFunction2(value).then(
    (value) => asyncFunction3(value).then(
      (value) => asyncFunction4(value).then((value) => asyncFunction5(value)),
    ),
  ),
);

こんな非同期処理を書いたことはないですか?きっとあると思います。

でも、不思議ですよね。よく考えてみましょう。

Future<int> asyncFunction(int n) async =>
    Future.delayed(Duration(seconds: n), () => n);

それぞれの非同期関数は、引数が int、返り値が Future<int> です。
最初に挙げたコードでは、この int から Future<int> へ型が変化する処理を何度も繰り返しているのに、
最終的に asyncFunctions の返り値は Future<int> となっています。

int -> Future<int> を何度もつないでいるのだから、素朴には
Future<Future<Future<...>>>
のようにネストしていきそうです。

なぜそうならないのか。
これが、この記事の出発点です。

1.3 この記事で扱いたい問い

一言で言えば、「文脈をまたいで処理をつなぐとはどういうことか」

もう少し具体的には以下です。

  • なぜ .thenFuture をネストさせないのか
  • .then.map は何が違うのか
  • List.expandFuture.then は、なぜ似たような使われ方をするのか

この問いに答えるのが「モナド」という概念です。
難しい数学の話をしたいわけではない。
この記事でやりたいのは、「文脈つきの値をどうつなぐか」 という観点から、
普段書いているコードを整理して見直すことです。


2. まずは map を理解する

2.1 map は中身だけを変換する

少し注意が必要です。

この記事で言う「map」は、Dartの List.map() という特定のメソッド名だけを指しているわけではありません。
この記事では map を、操作の役割名として使います。

つまり、

文脈 M<T> の中身 T だけを U に変換し、外側の文脈 M<...> は保つ操作

のことを、ここでは map と呼びます。

型で書くと、こんな形です。

map : M<T> -> (T -> U) -> M<U>

Dartには、この役割に対応する書き方がいくつかあります。

// List の map
[1, 2, 3].map((x) => x * 2).toList(); // [2, 4, 6]

// Future に対する map 的な操作
Future.value(3).then((x) => x * 2); // Future<int>

// int? に対する map 的な操作
int? n = 42;
String? s = n?.toString(); // "42"(null なら null のまま)

メソッド名や構文はバラバラです。.map().then()?.
でもやっていることは同じです。

中の値だけを変換し、外側の文脈は保つ。
この記事では、この共通の仕事を map と呼びます。

Dartの .then は、渡す関数が int を返すときは map として捉えられます。
一方、Future<int> を返すときは bind(後述)として振る舞います。
この二重性は5章で改めて説明します。

2.2 外側の文脈は保たれる

map の重要な性質は「文脈が変わらない」ことです。

Listmap すると List のまま。
Future に変換操作を掛けると Future のまま。
int? に変換操作を掛けると int? のまま。

int? maybeInt = 42;
String? maybeString = maybeInt == null ? null : maybeInt.toString();
// 「nullかもしれない」という文脈は保たれたまま

Dartの null safety では ?. を使った書き方がこれに対応します。

String? maybeString = maybeInt?.toString();

中の値だけを変換し、外側の文脈は維持する。
これが map です。

2.3 map でうまく扱える関数と扱えない関数

まず、map が受け取れる関数は A -> B の形です。

int -> String   // OK: map で扱える
int -> double   // OK: map で扱える

中身を受け取って、別の中身を返す。これが map と相性の良い関数です。

ところが、実際のコードでは、 A -> M<B> の形の関数が頻繁に登場します。

int -> Future<int>   // 非同期関数
int -> List<int>     // 1つの値から複数の値を生成する関数
int -> int?          // 失敗するかもしれない関数

この種の関数を map で使おうとすると、困ったことが起きます。


3. map だけでは足りない場面がある

3.1 A -> B と A -> M<B> は違う

int -> Stringint -> Future<String> は、見た目は似ているようで、全く性質が異なります。

前者は「intを受け取ってStringを返す」という純粋な変換。
後者は「intを受け取って、いずれStringが得られるという約束を返す」という、文脈つきの変換です。

非同期処理、エラーになるかもしれない処理、複数の結果を返す処理——
これらはすべて「結果に文脈がついた関数」です。

そして、この種の関数は map だけではうまくつなげません。

3.2 map を使うと文脈がネストする

A -> M<B> の形の関数を素朴に map に渡すと、何が起きるか見てみましょう。
まずは List で見ると分かりやすいです。

final List<int> numbers = [1, 2, 3];

// 1つのintから複数のintを返す関数
List<int> expand(int n) => [n, n * 10];

// map で渡すと...
final result = numbers.map(expand).toList();
// [[1, 10], [2, 20], [3, 30]]  -> List<List<int>>

map は外側の List を保つので、各要素が List<int> になれば、結果全体は List<List<int>> になります。

List<int>int -> List<int> の関数を map すると、List<List<int>> になります。
文脈が二重になってしまいました。

Future でも考え方は同じです。

Future<T> に対して T -> Future<U> の関数を、そのまま map 的に持ち上げるだけなら、
結果は素朴には Future<Future<U>> のようにネストするはずです。

この「文脈が二重になる」という問題は、List でも Future でも共通しています。

3.3 なぜネストが困るのか

List<List<int>> のまま扱い続けると、そのたびに二重ループが必要になります。
Future<Future<int>> があっても、.await を二回書かないといけなくなります。

そして何より、処理をつなぐたびに List<List<List<...>>> と無限にネストしていく。
これでは、複数の操作を気持ちよくつなぐことができません。

では、どうすれば A -> M<B> の形の関数を、文脈を増やさずにつなげられるのでしょうか。


4. List で見るネストと平坦化

4.1 map すると List<List<T>> になる

3章で見た「文脈がネストする」という問題を、List で改めて確認します。

final List<List<String>> list = [["apple"], ["banana"], ["cherry"]];

final mappedList = list.map((inner) => inner).toList();
// [["apple"], ["banana"], ["cherry"]]  -> List<List<String>>

map は外側の構造を維持します。
だから、もともと List<List<String>> なら、結果も List<List<String>> のままです。

では、List<List<String>>List<String> にしたいとき、どうすればよいでしょうか。

ここで必要になるのが、map とは別の操作です。

4.2 expand は 1 段平らにする

final expandedList = list.expand((inner) => inner).toList();
// ["apple", "banana", "cherry"]  -> List<String>

expand は、「各要素をリストに変換し、その結果を1段だけ平らにしてつなげる」操作です。

言い換えると、expand

  • 各要素に対して T -> List<U> の関数を適用し
  • その結果できた List<List<U>>
  • その場で List<U> に平らにして返す

操作です。

別の例を見てみましょう。

final numbers = [1, 2, 3];
final result = numbers.expand((n) => [n, n * 10]).toList();
// [1, 10, 2, 20, 3, 30]

[n, n * 10] という int -> List<int> の関数を渡すと、ネストせずに1つの List<int> になります。
これが expand の仕事です。

4.3 map と expand の役割の違い

操作 渡す関数の形 結果
map T -> U List<U>
expand T -> List<U> List<U>

expandA -> List<B> の関数を受け取って、ネストを増やさずに List<B> を返します。
mapA -> B の関数しかうまく扱えません。


5. Future で見るネストと連結

5.1 非同期関数は A -> Future<B> を返す

非同期関数は、ほぼすべて A -> Future<B> の形をしています。

Future<int> fetchUserId(String name) async { ... }
Future<UserData> fetchUser(int id) async { ... }
Future<String> formatUser(UserData user) async { ... }

これらをつなぐとき、素朴に map 的な発想でつなごうとすると問題が起きます。

5.2 map 的に考えると Future がネストする

Future<int> userId = fetchUserId("alice");

// ここで fetchUser を渡すと...
// 概念的には Future<Future<UserData>> になるはずだが…
final nested = userId.then((id) => fetchUser(id));
// ↑ 実際には Dart がこれを許さない(後述)

Future<int>int -> Future<UserData> を渡すと、本来なら Future<Future<UserData>> になるはずです。

5.3 then はなぜ Future<Future<T>> を返さないのか

実際には、Dartの .then はそうなりません。

Future<int> userId = fetchUserId("alice");
Future<UserData> user = userId.then((id) => fetchUser(id)); // Future<UserData> になる

なぜかというと、.then のシグネチャを見ると理由がわかります。

Future<R> then<R>(FutureOr<R> onValue(T value));

引数の型が FutureOr<R> になっています。これは「R でも Future<R> でも受け取れる」という型です。

// 返り値が R のとき → map として振る舞う
future.then((x) => x * 2);         // Future<int>(中身を変換するだけ)
 
// 返り値が Future<R> のとき → bind として振る舞う
future.then((x) => fetchUser(x));  // Future<UserData>(ネストを潰す)

渡す関数が Future を返した場合、.then は自動的にそれを「展開」します。
つまり、.then は常に単純な map ではありません。

  • コールバックが普通の値を返すときは map 的
  • コールバックが Future を返すときは bind 的

に振る舞います。

つまり2章で「.then は map に相当する」と言ったのは半分だけ正しく、正確には map にも bind にもなれる 操作です。これ、どこかで見ましたよね。List.expand と同じ構造です。


6. expand と then に共通するもの

6.1 文脈つきの値を返す関数をつなぐ

expandthen も、「文脈つきの値を返す関数」を受け取ります。

  • expand : T -> List<U> を受け取る
  • then(flatMap的な動作): T -> Future<U> を受け取る

どちらも A -> M<B> の形の関数をつなぐための仕組みです。

6.2 ネストを増やさず計算を連結する

そして共通の振る舞いがあります。ネストを増やさない、ということです。

// List
[1, 2, 3].expand((n) => [n, n * 10]).toList()
// -> [1, 10, 2, 20, 3, 30]  ネストしない

// Future
fetchUserId("alice")
  .then((id) => fetchUser(id))
  .then((user) => formatUser(user))
// -> Future<String>  ネストしない

どちらも、操作をつなぐたびに文脈が増殖するのではなく、1層のまま保たれます。

6.3 API は違っても構造は同じ

expandthen はメソッド名も対象も違います。でも構造は同じです。

「文脈の中の値を取り出して、文脈つきの値を返す関数に渡し、結果の文脈をネストさせずに返す」

この操作には名前があります。

それが bind(あるいは flatMap)です。
文脈つきの値を取り出し、文脈つきの値を返す関数につなぎ、
しかも文脈をネストさせない操作です。


7. ここでモナドを定義する

7.1 モナドは何をする仕組みなのか

ここまで見てきた List.expandFuture.then の共通構造を、
一般化して言い表したものがモナドです。

モナドとは、文脈つきの値に対する処理のつなぎ方を統一する仕組みです。

具体的には、以下の2つの操作を持つものがモナドです。

  • pure(または return):普通の値を文脈で包む
  • bind(または flatMap):文脈つきの値に、文脈つきの値を返す関数をつなぐ

7.2 pure は何をするのか

pure は、普通の値を文脈に包む操作です。
言い換えると、普通の値から文脈つきの計算へ入るための入口です。

// List の pure
List<int> wrapped = [42]; // 42 を List に包む

// Future の pure
Future<int> wrapped = Future.value(42); // 42 を Future に包む

// int? の pure
int? wrapped = 42; // 42 を null許容型に包む

「何もしていないように見える」かもしれませんが、これは重要な操作です。
後述するモナド則で理由がわかります。

7.3 bind は何をするのか

bind は、文脈つきの値に対して、文脈つきの値を返す関数をつなぐ操作です。

// List の bind = expand
[1, 2, 3].expand((n) => [n, n * 10]).toList();

// Future の bind = then(Futureを返す場合)
fetchUserId("alice").then((id) => fetchUser(id));

// int? の bind = ?.(チェーンする場合)
String? result = maybeUser?.name?.toUpperCase();

Dartには bind という名前のメソッドはありませんが、この構造を持つメソッドは至る所にあります。


8. モナドとは結局どれのことか

8.1 Future そのものがモナドなのか

「Future がモナドです」という言い方をよく見ます。でも少し正確ではありません。

Future型コンストラクタです。
つまり、int を入れると Future<int> を、String を入れると Future<String> を作る、型の入れ物のようなものです。

つまり、Future は「型そのもの」ではなく、int を入れると Future を作る“型の型”のようなものです。

モナドとは、型そのものではなく、

  • M<T> という文脈つきの型
  • pure
  • bind

をまとめた構造のことです。

8.2 List そのものがモナドなのか

同様に、List もそれ単体はモナドではありません。

List という型コンストラクタに、[x](pure)と .expand(bind)が揃って初めて「Listモナド」と呼べます。

8.3 モナドは構造として捉えるべきである

モナドは特定のクラスや型ではなく、「このように振る舞う」という構造の話です。

  • M<T> という型コンストラクタがあり、
  • pure: T -> M<T> という操作と、
  • bind: M<T> -> (T -> M<U>) -> M<U> という操作を持つ。

これを満たすものが全てモナドです。
FutureListT? は見た目も用途も違います。
それでも、「値を文脈に包める」「文脈つきの関数をネストさせずにつなげる」という構造は共通しています。


9. モナド則は何のためにあるのか

ここまでで、モナドは「文脈つきの値をつなぐ仕組み」だと見てきました。

では、purebind がありさえすれば、何でもモナドと呼んでよいのでしょうか。
そうではありません。

つなぎ方が直感どおりに振る舞うためには、満たすべき約束が必要です。
それがモナド則です。

9.1 左単位元則

pure a >>= f == f a
⇔
pure(a).bind(f) == f(a)

pure で包んですぐ bind するのは、直接 f を適用するのと同じ。

// この2つは同じ結果になる
[42].expand((x) => [x * 2]).toList(); // pure してから bind
[42 * 2];                              // 直接計算
// どちらも [84]

9.2 右単位元則

m >>= pure == m
⇔
m.bind(pure) == m

bindpure につなぐと、元のリストと同じ。

// この2つは同じ結果になる
[1, 2, 3].expand((x) => [x]).toList(); // bind して pure
[1, 2, 3];                              // そのまま
// どちらも [1, 2, 3]

9.3 結合法則

(m >>= f) >>= g == m >>= (\x -> f x >>= g)
⇔
m.bind(f).bind(g) == m.bind((a) => f(a).bind(g))

結合法則は、処理のまとめ方や関数への切り出し方を変えても、意味が変わらないことを保証します。
処理のまとめ方を変えても、結果は変わらない。足し算の (1 + 2) + 3 == 1 + (2 + 3) と同じです。

// 括弧の付け方A:フラットにつなぐ
[1, 2, 3]
  .expand((x) => [x * 2])
  .expand((x) => [x + 1])
  .toList(); // [3, 5, 7]

// 括弧の付け方B:後ろ2つをまとめる
[1, 2, 3]
  .expand((x) => [x * 2].expand((y) => [y + 1]).toList())
  .toList(); // [3, 5, 7]

これは「関数に切り出しても壊れない」という保証でもあります。

// リファクタリング前
[1, 2, 3]
  .expand((x) => [x * 2])
  .expand((x) => [x + 1])
  .toList(); // [3, 5, 7]

// 後ろ2つを関数に切り出す
List<int> doubleAndAdd(int x) =>
  [x * 2].expand((y) => [y + 1]).toList();

[1, 2, 3].expand(doubleAndAdd).toList(); // [3, 5, 7] ← 同じ

9.4 なぜこの 3 つが必要なのか

3つの法則は「直感どおりに動く」ことの保証です。

  • 左単位元則pure してすぐ使うのは、最初から使うのと同じ
  • 右単位元則pure につなぐだけなら、何も変わらない
  • 結合法則:処理の切り出しやまとめ方を変えても、結果は変わらない

言い換えれば、「コードを安心してリファクタリングできる」という保証です。

この3つが保証されて初めて、expandthen で処理をつないでいくことに信頼が置けます。

9.5 Dart でモナド則を試してみよう!

モナド則をDartで試してみると、以下のようになります。

import 'package:collection/collection.dart';

void monadLaws(int a) {
  final eq = ListEquality();
  List<int> f(int n) => [n * 2];
  List<int> g(int n) => [n * -1];

  /// pure : a -> m a つまり pure()
  /// >>= : bind つまり .expand()
  /// m : コンテキストを持つ型 つまり List<T>

  /// 左単位律: pure a >>= f == f a
  final pureA = pure(a);
  final leftHand = bind(pureA, f); // pure a >>= f
  final rightHand = f(a);          // f a
  print("leftHand == rightHand: ${eq.equals(leftHand, rightHand)}");

  /// 右単位律: m >>= pure == m
  final m = pure(a);
  final leftHand2 = bind(m, pure); // m >>= pure
  final rightHand2 = m;            // m
  print("leftHand2 == rightHand2: ${eq.equals(leftHand2, rightHand2)}");

  /// 結合律: (m >>= f) >>= g == m >>= (\x -> f x >>= g)
  final m2 = pure(a);
  final leftHand3 = bind(bind(m2, f), g);                    // (m >>= f) >>= g
  final rightHand3 = bind(m2, (x) => bind(f(x), g));        // m >>= (\x -> f x >>= g)
  print("leftHand3 == rightHand3: ${eq.equals(leftHand3, rightHand3)}");
}

// pure: a -> m a
List<T> pure<T>(T value) => [value];

// bind: m a -> (a -> m b) -> m b
List<U> bind<T, U>(List<T> ma, List<U> Function(T) f) => ma.expand(f).toList();

// map: m a -> (a -> b) -> m b
List<U> map<T, U>(List<T> ma, U Function(T) f) => ma.map(f).toList();

これは数学的な証明ではありません。
正しく証明するためには「型自体」を扱う必要がありますが、Dartはこのような操作に向いていません。
HaskellやLeanなど、数学的証明に向いた言語を用いることのモチベーションは、このようなところで生まれることを実感しますね。


10. Future と Result を同じ目で見る

10.1 Future は非同期という文脈を持つ

Future<T> の「文脈」は「非同期」です。
値がいずれ得られる、というコンテキストを持った型です。

Future<int> fetchAge(String name) async { ... }
// 「今ではなく、いずれintが手に入る」という文脈

10.2 Result は成功失敗という文脈を持つ

Result<T> の「文脈」は「成功か失敗か」です。

Flutter公式のサンプルアプリ Compass App では、以下のような Result が実装されています。

/// Utility class that simplifies handling errors.
///
/// Return a [Result] from a function to indicate success or failure.
///
/// A [Result] is either an [Ok] with a value of type [T]
/// or an [Error] with an [Exception].
sealed class Result<T> {
  const Result();

  const factory Result.ok(T value) = Ok._;
  const factory Result.error(Exception error) = Error._;
}

final class Ok<T> extends Result<T> {
  const Ok._(this.value);
  final T value;

  @override
  String toString() => 'Result<$T>.ok($value)';
}

final class Error<T> extends Result<T> {
  const Error._(this.error);
  final Exception error;

  @override
  String toString() => 'Result<$T>.error($error)';
}

sealed class を使うことで、Result は必ず OkError のどちらかであることをコンパイラが保証してくれます。
switch 文で網羅的に処理できる、というのも sealed class の強みです。

使う側はこうなります。

// 返す側
Future<Result<UserProfile>> getUserProfile() async {
  try {
    final profile = await _apiClient.fetchProfile();
    return Result.ok(profile);
  } on Exception catch (e) {
    return Result.error(e);
  }
}

// 受け取る側
final result = await repository.getUserProfile();
switch (result) {
  case Ok<UserProfile>():
    print(result.value); // UserProfile が手に入る
  case Error<UserProfile>():
    print(result.error); // Exception が手に入る
}

「成功したら UserProfile が、失敗したら Exception が手に入る」という文脈を持った型です。

10.3 文脈が違ってもつなぎ方は似ている

// Future のチェーン
fetchUser(id)
  .then((user) => fetchPosts(user.id))
  .then((posts) => formatPosts(posts));

// Result のチェーン(Compass App スタイル)
final userResult = await getUserProfile();
if (userResult is! Ok) return userResult;

final postsResult = await fetchPosts(userResult.value.id);
if (postsResult is! Ok) return postsResult;

return formatPosts(postsResult.value);

.thenis! Ok ガード節も、やっていることは同じです。
「文脈の中の値を取り出して、次の文脈つき処理に渡す」——つまり bind です。

文脈が「非同期」か「成功 / 失敗」かは違います。
でも、「文脈の中の値を取り出して次の文脈つき計算へ渡し、文脈を増やさずにつなぐ」という形は同じです。


11. モナドを知ると何が嬉しいのか

11.1 API の見え方が整理される

thenexpandflatMap?.——
これらが全部「同じ構造の別の実装」として見えるようになります。

新しいライブラリを触ったとき、「これ flatMap 的なやつだな」と気づければ、使い方が直感で掴めます。

11.2 非同期やエラーハンドリングを統一して捉えられる

「なんで async/await はこう書くんだっけ」「なんで ?. はこう動くんだっけ」
こういった疑問が、「文脈つきの値をつなぐ操作」というひとつの軸で整理できます。

つまり、別々のテクニックに見えていたものが、同じ構造として整理されます。

11.3 抽象概念ではなく実践の見方として使える

モナドを知る意義は、Haskellを書くためではありません。

「この flatMap は文脈をネストさせないためにある」と理解して書くのと、
「なんかつなぐやつ」として書くのでは、コードを読む解像度が違います。

抽象的な定義を知ることで、具体的なコードを整理して見られるようになる。
モナドはそのための道具です。


12. おわりに

12.1 モナドは難しい概念ではなく整理の道具である

圏論、自己関手、射——
そういう言葉が出てきたらそっとブラウザを閉じたくなる気持ちはわかります。
自分自身も、それらの語をこの段階でちゃんと理解できているか、といえばできていません。
マサカリは優しく投げてください。

でも、日常のコードを振り返ると、Future<T>List<T>T? も、
全部「文脈つきの値」として同じ目で見られます。

そして .then.expand?. も、
全部「文脈をネストさせずにつなぐ操作」として捉えられます。

モナドは、それに名前をつけたに過ぎません。

12.2 普段のコードに戻って見直してみる

この記事を読んだ後、ぜひ今書いているコードを見直してみてください。

.then を書くとき、「これは bind だ」と思ってみる。
List.expand を書くとき、「これは文脈のネストを潰している」と思ってみる。

コードの意味が、少しだけ違って見えるはずです。

モナドは難しい概念の名前ではありません。
「文脈つきの値をどうつなぐか」という、普段のコードに何度も現れている構造の名前です。

22
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?