All You Need Is

Java の Optional をより知りたい人に贈る解説

2024/12/16
プログラミングJavaScala関数型プログラミング

はじめに

Java の OptionalStream を使ってコードは書いているものの mapflatMap って何者だろう、 for 文でも同じことできるよね?と思っている人向けに OptionalStream と同じ機能を提供するコードを実装しながら Functor へと誘う記事を書いてみる。

この手の記事は世の中にたくさん出回っている。何故たくさん出回ってしまうのかと言えば最初の一歩が難しく、挫折してしまう人が多いからだと思う。また、自身の理解度を高める上でアウトプットとして技術記事を書くのは有効な手段なので、それを実践しているというのもあるだろう。

それでも本記事を書くのは、巷に溢れる関数型プログラミング由来の概念の解説記事を読んでもなんのこっちゃとなってしまった人向けにより優しい入門が必要だと思ったからだ。そのため、用語や数学的な概念部分において厳密さを求めていない。また、単に Functor を解説するのではなく、Functor に辿りつくまでに寄り道をしつつ目的地まで進むので諸々のことを知っている人には回りくどく感じてかもしれない。

想定読者

解説のため具体的なプログラミング言語として Java、Scala を用いるが、何かしらのプログラミング言語を一つでも使えるレベルまで習得している人であれば理解できるように努めたい (願望)。

map を理解するまでの道のり

本記事は次の流れで進行する。

Java で Maybe クラスを実装

はじめに Maybe という名前でよく知られているデータ構造を Java で実装する。このデータ構造は Maybe という名前の他、OptionOptional と呼ばれることが多い 2Maybe 🤔 初めて聞いたな、という人は Java の Optional のようなものだと思ってもらって大丈夫だ。ただし Optional とは異なり、値が null になっても Optional#empty() のような値が存在しないことを表現する値へ変換はされない。

Maybe は、Optional と同様に値が存在する、もしくは値が存在しないの二つのパターンを表現する。 Optional では存在する場合と存在しない場合を表現するインスタンスが内部クラスに隠れてしまっているため意識することはなかったかもしれないが、値が存在する場合はフィールドに value という変数で値を持つ Optional のインスタンスと、valuenull が代入された Optional のシングルトンインスタンスの二つでそれぞれ表現している3

Maybe では Just で値が存在する場合を表現し、Nothing で値が存在する。最初の実装は次のようなコードとなる。

Maybe.java
1
public sealed interface Maybe<T> {
2
3
public class Just<T>(T value) implements Maybe<T> {
4
}
5
6
public record Nothing<T>() implements Maybe<T> {
7
}
8
}

Java 17 に慣れていない人は sealed (シール) というキーワードを初めて見るかもしれない。簡単に説明すると sealed interfaces は同一クラス内でのみ拡張または実装されることを強制することができる。今回の場合は、Maybe を実装するのは同一ファイルに定義された JustNothing レコードのみでそれ以外のインターフェースを継承、クラスやレコードで実装することはできない。また、sealed interfaces では合わせて pertmis というキーワードも新たに導入されているが使っていないため説明は省略する。気になる人は Oracle の解説を参照のこと。

話を戻そう。MaybeJustNothing の定義は簡単だ。これだけでも一応使える。 jshell を起動して Maybe クラスを読み込んだ後、次のコードを実行してみて欲しい。

jshell
1
Maybe<String> hello = new Maybe.Just<>("Hello");
2
3
switch (hello) {
4
case Maybe.Just(var value) -> System.out.println(value);
5
case Maybe.Nothing nothing -> System.out.println("Goodbye");
6
}
7
8
Maybe<String> silent = new Maybe.Nothing<>();
9
10
switch (silent) {
11
case Maybe.Just(var value) -> System.out.println(value);
12
case Maybe.Nothing nothing -> System.out.println("Goodbye");
13
}
実行結果
jshell
1
$ jshell --enable-preview
2
| JShellへようこそ -- バージョン21.0.5
3
| 概要については、次を入力してください: /help intro
4
5
jshell> public sealed interface Maybe<T> {
6
...>
7
...> public record Just<T>(T value) implements Maybe<T> {
8
...> }
9
...>
10
...> public record Nothing<T>() implements Maybe<T> {
11
...> }
12
...> }
13
| 次を作成しました: インタフェース Maybe
14
15
jshell> Maybe<String> greeting = new Maybe.Just<>("Hello");
16
...>
17
...> switch (greeting) {
18
...> case Maybe.Just(var value) -> System.out.println(value);
19
...> case Maybe.Nothing nothing -> System.out.println("Goodbye");
20
...> }
21
...>
22
...> Maybe<String> silent = new Maybe.Nothing<>();
23
...>
24
...> switch (silent) {
25
...> case Maybe.Just(var value) -> System.out.println(value);
26
...> case Maybe.Nothing nothing -> System.out.println("Goodbye");
27
...> }
28
hello ==> Just[value=Hello]
29
Hello
30
silent ==> Nothing[]
31
Goodbye

Maybe には何のメソッドも用意されていないが、値が存在すること、値が存在しないことを表現できているのがわかるだろう。しかし、それは何も特別なことではなく、値が存在すればレコード (Just) のコンストラクタに渡してフィールドに値を持たせ、値が存在しないければフィールドを持たないクラス (Nothing) のインスタンスが生成しているだけに過ぎない。それ以上でも、それ以下でもない。これにて閉廷!

とはならない。次の話に進む前に今の実装は少しばかり不便なので Maybe インターフェースにメソッドを追加する。

Maybe.java
1
public sealed interface Maybe<T> {
2
3
public static <T> Maybe<T> just(T value) {
4
return new Just<>(value);
5
}
6
7
public static <T> Maybe<T> nothing() {
8
return new Nothing<>();
9
}
10
11
17
}

気を取り直して次のような場面を考えてみよう。ここに 2/3 の確率で鳴く猫がいる 🤔

jshell
1
Maybe<String> meow() {
2
var rand = new Random();
3
var num = rand.nextInt(3);
4
5
if (num == 0) {
6
return Maybe.nothing();
7
} else if (num == 1) {
8
return Maybe.just("Meow");
9
} else if (num == 2) {
10
return Maybe.just("Mew");
11
}
12
13
return Maybe.nothing();
14
}

この猫が鳴いたときだけ鳴き声によって Meow World もしくは Mew World という文字列を作りたい。さて、どのようなコードを書けばどうすればよいだろうか。愚直に書くなら次のようなコードになるだろう。

jshell
1
var maybeMeow = meow();
2
3
Maybe<String> meowHello = switch (maybeMeow) {
4
case Maybe.Just just -> Maybe.just(just.value() + " World");
5
case Maybe.Nothing nothing -> Maybe.nothing();
6
};

猫が鳴いていたときだけ末尾に World を付けたいだけなのに…なんだが凄いことになっちゃったぞ。

毎回これをやらねばならぬのか?

Maybemap メソッドを実装

その日人類は思い出した。 Maybe に支配されていた恐怖を… Maybe の中に囚われていた屈辱を……

Maybe の中身に触ろうとする度に swtich 式を書くのは現実的ではない。我々は既に解決方法を知っていたはずだ。Optional でいうところの map メソッドがあればいいということを。早速 Maybemap メソッドを追加してみよう。

やるんだな!?今!ここで!

Maybe.java
1
public sealed interface Maybe<T> {
2
3
<U> Maybe<U> map(Function<? super T, ? extends U> mapper);
4
5
13
14
public record Just<T>(T value) implements Maybe<T> {
15
16
@Override
17
public <U> Maybe<U> map(Function<? super T, ? extends U> mapper) {
18
return new Just<>(mapper.apply(value));
19
}
20
}
21
22
public record Nothing<T>() implements Maybe<T> {
23
24
@Override
25
public <U> Maybe<U> map(Function<? super T, ? extends U> mapper) {
26
return new Nothing<>();
27
}
28
}
29
}

Maybe はインターフェースなので map メソッドのシグネチャだけを宣言し、JustNothing にそれぞれ実装を任せることにした。実装の中身に難しいところはない。Just ではにフィールドの value に引数で受け取った mapper を適用し、計算結果を持つ Just のインスタンスを改めて生成している。 Nothing の場合は、返り値の型パラメータを U にする必要がある関係で Nothing をインスタンスを作り直している。ここでは、インターフェースでメソッドを宣言し、JustNothing で実装する方式を採用したが、次のような実装をしても問題ない。

Maybe.java
1
public sealed interface Maybe<T> {
2
3
default <U> Maybe<U> map(Function<? super T, ? extends U> mapper) {
4
return switch (this) {
5
case Maybe.Just(var value) -> Maybe.just(mapper.apply(value));
6
case Maybe.Nothing nothing -> nothing();
7
};
8
}
9
10
24
}

しかし、オブジェクト指向の考えに従うのであれば前者の方が自然な実装だろう[要出典]

さっそく 2/3 の確率で鳴く猫のコードを map で書き直してみよう。

jshell
1
var maybeMeow = meow();
2
3
var meowHello = maybeMeow.map(v -> v + " Hello");

先ほどのコードを比べると一目瞭然。書きたい処理が明確に表現できている。また、ご存知の通り map メソッドは汎用的に用いることができるため、 Maybe が持つ値の型に依らず値が存在するときだけ存在する値に処理を適用することにフォーカスしてコードを記述できる。

最初の目標である Maybemap メソッドの実装まで行ったので次に LazyList に話を移そう。

Java で LazyList を実装

ここで実装する LazyList は Java でいうところの Stream に似たデータ構造だ。ただし、Stream とは違い今から実装する LazyList は更に計算を遅延する。

map を説明するための例として LazyList を採用したが二分木くらいにしておくべきだったかと少し後悔している。 Optional と比べると複雑になってしまい、本質的でない部分が多くなってしまった。この手のコードに見慣れていない人にとってはスラスラ読めないかもしれないが難しいことをしているわけではないので一つずつ理解していって欲しい。

さっそく LazyList の実装に入ろう。LazyList の基本的な構造は連結リストと同じだ。連結リストとの違いはコンスセルで値とリンクを直接持つのではなく、必要になって初めて計算される仕組みを提供する CallByNeed として持つ。 CallByNeed については LazyList の実装の後に見る。まずは LazyList の実装を確認しよう。

LazyList.java
1
public sealed interface LazyList<T> {
2
3
public record Cons<T>(CallByNeed<T> t, CallByNeed<LazyList<T>> tail) implements LazyList<T> {
4
}
5
6
public record Nil<T>() implements LazyList<T> {
7
}
8
9
public static <T> LazyList<T> cons(CallByNeed<T> t, CallByNeed<LazyList<T>> tail) {
10
return new Cons<>(t, tail);
11
}
12
13
public static <T> LazyList<T> nil() {
14
return new Nil<>();
15
}
16
}

Mabye で行ったようにインスタンス生成を補助するために二つの静的メソッド consnil も一緒に実装している。 CallByNeed が気になるものの、連結リストを実装したことがある人にとっては馴染みのあるデータ構造となっているだろう。

CallByNeed (必要呼び)

次に CallByNeed の実装を見てみよう。こちらも LazyList と同様にインスタンスの生成を補助する静的メソッド oflazy なので気にしなくて問題ない。

CallByNeed.java
1
public class CallByNeed<T> {
2
3
private final Supplier<T> t;
4
5
private boolean evaluated;
6
7
private T get;
8
9
public CallByNeed(Supplier<T> t) {
10
this.t = t;
11
}
12
13
public synchronized T get() {
14
if (evaluated) {
15
return this.get;
16
}
17
18
this.get = t.get();
19
this.evaluated = true;
20
21
return this.get;
22
}
23
24
public static <T> CallByNeed<T> of(T value) {
25
return new CallByNeed<>(() -> value);
26
}
27
28
public static <T> CallByNeed<T> lazy(Supplier<T> t) {
29
return new CallByNeed<>(t);
30
}
31
}

ClassByNeed クラスはコンストラクタで Supplier を受け取ってフィールドで値を持つ。 Supplier は呼ぶと値が返ってくる get メソッドを定義する関数型インターフェースだ。これにより、コンストラクタで受け取った段階では値を求める計算は発生せず、CallByNeedget メソッド内で初めて Supplierget メソッドを呼んで値を取り出している。 SupplierCallByNeed の違いとして、Supplier は呼び出される度に get メソッド内の処理がすべて実行されるが、 CallByNeed ではコンストラクタで受け取った Supplierget メソッドが一度でも呼ばれると二回目以降は一回目の値をキャッシュして返す。

この違いは次のようなコードを書くと一目でわかる。

1
Supplier<String> supplier = () -> {
2
System.out.println("supplier called");
3
return "supplier";
4
};
5
6
supplier.get();
7
supplier.get();
8
9
CallByNeed<String> callByNeed = new CallByNeed<>(() -> {
10
System.out.println("callByNeed called");
11
return "callByNeed";
12
});
13
14
callByNeed.get();
15
callByNeed.get();

SupplierCallByNeed で文字列を標準出力に出力するような処理を渡してそれぞれの get メソッドを二回呼び出す。この実行結果は次のようになる。

jshell
1
jshell> Supplier<String> supplier = () -> {
2
...> System.out.println("supplier called");
3
...> return "supplier";
4
...> };
5
...>
6
...> supplier.get();
7
...> supplier.get();
8
...>
9
...> CallByNeed<String> callByNeed = new CallByNeed<>(() -> {
10
...> System.out.println("callByNeed called");
11
...> return "callByNeed";
12
...> });
13
...>
14
...> callByNeed.get();
15
...> callByNeed.get();
16
supplier ==> $Lambda$21/0x000000030100a000@312b1dae
17
supplier called
18
$2 ==> "supplier"
19
supplier called
20
$3 ==> "supplier"
21
callByNeed ==> CallByNeed@cc34f4d
22
callByNeed called
23
$4 ==> "callByNeed"
24
$5 ==> "callByNeed"

suppliersupplier called を 2 回出力しているのに対して callByNeed は 1 回だけ callByNeed called を出力している。このように callByNeed は 1 回だけコンストラクタに渡された Supplierget メソッドを呼び、2 回目以降はキャッシュした値を返す。少しだけ賢い (?) Supplier だと思えばよさそうだ。

CallByNeed について理解したところで LazyList のインスタンスを生成してみよう。

jshell
1
jshell> LazyList.cons(CallByNeed.of(2), CallByNeed.of(LazyList.cons(CallByNeed.of(1), CallByNeed.of(LazyList.nil()))))
2
$3 ==> Cons[t=CallByNeed@31cefde0, tail=CallByNeed@439f5b3d]

よし、生成できた。でも中身がわからない 😝

中身を視覚的に確認できないのは困るので LazyList から Java 標準の List へ変換するメソッド toList を追加しよう。

LazyList.java
1
public sealed interface LazyList<T> {
2
3
default List<T> toList() {
4
var xs = new ArrayList<T>();
5
6
var h = this;
7
while (h instanceof Cons<T> cons) {
8
xs.add(cons.t().get());
9
h = cons.tail().get();
10
}
11
12
return Collections.unmodifiableList(xs);
13
}
14
15
30
}

ここでは LazyList の要素を先頭から辿りながら ArrayList に追加していき、最後に変更不可のリストにして返すように実装した。さっそく先ほどの toList を使って LazyList を目に見える形に変換しよう。

jshell
1
jshell> LazyList.cons(CallByNeed.of(2), CallByNeed.of(LazyList.cons(CallByNeed.of(1), CallByNeed.of(LazyList.nil())))).toList()
2
$5 ==> [2, 1]

ちゃんとリストに変換できているようだ。

LazyListmap を実装

準備もできたことだし LazyListmap メソッドを実装してみよう。

1
public sealed interface LazyList<T> {
2
3
<U> LazyList<U> map(Function<? super T, ? extends U> mapper);
4
5
17
18
public record Cons<T>(CallByNeed<T> t, CallByNeed<LazyList<T>> tail) implements LazyList<T> {
19
20
@Override
21
public <U> LazyList<U> map(Function<? super T, ? extends U> mapper) {
22
return LazyList.cons(
23
CallByNeed.lazy(() -> mapper.apply(t.get())),
24
CallByNeed.lazy(() -> tail.get().map(mapper)));
25
}
26
}
27
28
public record Nil<T>() implements LazyList<T> {
29
30
@Override
31
public <U> LazyList<U> map(Function<? super T, ? extends U> mapper) {
32
return new Nil<>();
33
}
34
}
35
36
44
}

CallByNeed を使っているためセルの値やリンクを取り出すときは get メソッドを呼ばないといけないが、コンスセルの値に対して引数で受け取った関数 (mapper) を適用し、後続のリストに対しては map を再帰的に呼び出している。その後、それらの値を持つ Cons を新たに生成している。新しいコンスセルの値、リンクのいずれも計算を CallByNeed によって遅延しているため map を呼び出した時点では一切計算されない。そして、終端である Nilmap メソッドの実装では何もせずに Nil のインスタンスを生成して返すだけだ。

さっそく LazyListmap を使ってそれぞれの要素を 2 倍してみよう。

jshell
1
jshell> LazyList.cons(CallByNeed.of(2), CallByNeed.of(LazyList.cons(CallByNeed.of(1), CallByNeed.of(LazyList.nil())))).map(v -> v * 2).toList();
2
$6 ==> [4, 2]

LazyList のままだと中身を確認できないため最後に toList を呼んで List に変換している。

これだけでは計算が遅延されていることが確認できないため、次のようなコードを考えてみよう。

1
LazyList.cons(CallByNeed.of(2), CallByNeed.of(LazyList.cons(CallByNeed.of(1), CallByNeed.of(LazyList.nil()))))
2
.map(v -> {
3
System.out.println(v + " * 2");
4
return v * 2;
5
})
6
.map(v -> {
7
System.out.println(v + " + 1");
8
9
return v + 1;
10
});

それぞれの要素を 2 倍した後に 1 を足している。また、その過程で計算内容を標準出力に出力している。このコードを jshell に渡してみよう。

jshell
1
jshell> LazyList.cons(CallByNeed.of(2), CallByNeed.of(LazyList.cons(CallByNeed.of(1), CallByNeed.of(LazyList.nil()))))
2
...> .map(v -> {
3
...> System.out.println(v + " * 2");
4
...> return v * 2;
5
...> })
6
...> .map(v -> {
7
...> System.out.println(v + " + 1");
8
...>
9
...> return v + 1;
10
...> });
11
$7 ==> Cons[t=CallByNeed@446cdf90, tail=CallByNeed@799f7e29]

Stream と同じようにこの段階では map に渡した関数内に書かれた標準出力への出力処理は行なわれない。 $7 に対して toList を呼び出し、LazyListList に変換したタイミングで初めて実行される。

jshell
1
jshell> $7.toList()
2
2 * 2
3
4 + 1
4
1 * 2
5
2 + 1
6
$8 ==> [5, 3]

また、出力を見るとわかるが * 2+ 1 の順番で先頭から計算されていることがわかる。つまり、Stream と同じようにリストの走査は 1 度だけ行なわれる。

さて、ここからが Stream と異なる部分になるが Stream は一度 toList を呼ぶと 2 回目の呼び出しで例外がスローされる。これはメモリ効率的にはよくなっているが、既に呼び出されているかを気にせずに扱いたい場合には少しばかり不便だ。一方、LazyListtoList を複数回呼び出しても問題ない。 toList の実装から明らかではあるが、呼び出す度に新しいリストを生成するため効率はよくない。

jshell
1
jshell> $7.toList()
2
$9 ==> [5, 3]

実行結果を見るとわかるが、各セルの値は最初の toList を呼んだタイミングで一度計算されているため、 2 回目以降に map で渡した処理が実行されることはない 💪

話が map から離れてしまったので LazyList と戯れるのはこのくらいにして前に進もう。

map に望むこと

さて、MaybeLazyListmap という名前のメソッドを実装したが map メソッドとは何だったのだろうか。もう一度 map メソッドのシグネチャを思い出してみよう。

1
<U> Maybe<U> map(Function<? super T, ? extends U> mapper);
1
<U> LazyList<U> map(Function<? super T, ? extends U> mapper);

さて、どちらも似たようなシグネチャをしており、MaybeLazyList も型変数を一つだけ受け取るような型で map メソッドは MaybeLazyList の持つ要素を引数で受け取った mapper に渡して T 型の値から U 型の値に変換している。もし、この二つをより一般的に定義できるのであれば、オブジェクト指向的に考えると MaybeLazyList はいずれも map というメソッド操作を行えるという性質を持つということだろう。こういう場合、オブジェクト指向プログラミング言語ではインターフェース (抽象型) を用いて実現しようとする。それでは、map メソッドを持つインターフェースを Mappable と名付けて Java で表現できるか考えてみよう。

Java で無理矢理 Mappable を定義して Maybe で実装すると次のようになるだろうか。

1
public interface F<T> {
2
}
3
4
public interface Mappable<F, T> {
5
6
<U> F map(Function<? super T, ? extends U> mapper);
7
}
8
9
public sealed interface Maybe<T> extends Mappable<Maybe, T> {
10
11
public record Just<T>(T value) implements Maybe<T> {
12
13
@Override
14
public <U> Maybe<U> map(Function<? super T, ? extends U> mapper) {
15
return new Just<>(mapper.apply(value));
16
}
17
}
18
19
public record Nothing<T>() implements Maybe<T> {
20
21
@Override
22
public <U> Maybe<U> map(Function<? super T, ? extends U> mapper) {
23
return new Nothing<>();
24
}
25
}
26
}

Java では高階型が使えないため F という型を一つ受け取る型を定義してそれを使って Mappable を定義している。よりよい定義の方法があるように思うが、今はこれで満足しよう。この辺の話が気になる人は Java で higher kinded polymorphism を実現する #Java - Qiita を読もう。これで map メソッドを持っている、という性質を Mappable というインターフェースで表現できた。

インターフェースに落とし込めたところで改めて map の定義について考えてみる。インターフェースが定めるのはあくまでメソッドのシグネチャだけでその定義まで制限することはできない。そのため、Maybemap の実装は次のようになっていてもコンパイルできるか、という点では何も問題ない。

1
public sealed interface Maybe<T> extends Mappable<Maybe, T> {
2
3
public record Just<T>(T value) implements Maybe<T> {
4
5
@Override
6
public <U> Maybe<U> map(Function<? super T, ? extends U> mapper) {
7
return new Nothing<>();
8
}
9
}
10
11
public record Nothing<T>() implements Maybe<T> {
12
13
@Override
14
public <U> Maybe<U> map(Function<? super T, ? extends U> mapper) {
15
return new Nothing<>();
16
}
17
}
18
}

ここで map メソッドは Just であっても Nothing であっても常に Nothing を返す。このようなコードであってもコンパイルは通る。しかし、実際のプログラミングで何か役に立つようなことはないだろう。つまり、map メソッドは単にシグネチャを満たす以上の何かがなければならないようだ。これは map に限った話ではなく、インターフェースが定義するメソッドの実装をするとき、その実装はインターフェースを定義したときに想定される何かしらの機能が実現されることを実装に対して望む。それでは map が望む何かしらの機能とは何だろうか。

MaybeLazyList の二つの例から得られる直観は、コンテナのようなもののすべての要素に対して引数で受け取った関数を適用するメソッドだろうか。もしそれが正しいと仮定したとき、その性質を厳密に定めることはできないだろうか。

Functor の導入

そこで導入されるのが Functor (関手) だ。Functor は圏論に由来する概念だが、プログラマにとっての Functor は前述の Mappable の実装に制限を加えたものになる。その制限は Functor 則と呼ばれており、次の 2 つのことを指す。ここではかなり緩い説明になっているため厳密な定義を知りたい場合は他のサイトや書籍をあたって欲しい。また、説明の都合から Java によるコードを用いているため、数学的には厳密な説明ではない。

  1. 恒等律の保存

    1
    fa.map(Function.identity()) === Function.identity().apply(fa)

    この式は「famap に恒等関数を渡した結果 (左辺)」 と「fa に恒等関数を適用した結果 (右辺)」は任意の fa について等しいように map は定義されなければならない、と言っている。つまり、恒等射を map に渡しても fa の持つ値は変わらないように map を実装せよ、ということだ。

  2. 合成の保存

    1
    fa.map(f.compose(g)) === fa.map(f).map(g)

    この式は「famap に関数 fg の合成を渡した結果 (左辺)」と「famapf を渡した結果に対して map を呼び出して g を渡した結果 (右辺)」は任意の fafg について等しいように map は定義されなければならない、と言っている。つまり、map の中身に合成された関数を適用した結果と、合成されている関数を別々に map に渡しても結果は常に同じくなるように実装せよ、ということだ。

ここで faMaybeLazyList のような map を実装するクラスの任意のインスタンスを表している。これら二つの規則を満たすような map を実装している MaybeLazyList は Functor と呼ばれているものになる 4

さて、この二つを map が満たすか、という視点で Maybe の役に立たない map の実装を思い出してみよう。 JustNothing のいずれも Nothing を返す実装では恒等律を満たさないことに気がつくだろう。具体的に Maybe.just(1) を考えると、左辺は次のようになるが、

1
Maybe.just(1).map(Function.identity())
2
// ==> Nothing[]

右辺は、次のような結果となる。

1
Function.identity().apply(Maybe.just(1))
2
// ==> Just[value=1]

当然これらは等しくないため恒等律は満たされない。

1
Maybe.just(1).map(Function.identity()) != Function.identity().apply(Maybe.just(1))

つまり、常に Nothing を返すような実装では Functor としての実装にはふさわしくなかったということだ。

それでは最初に実装した MaybemapLazyListmap はこれらの規則を満たしているのだろうか。それは読者であるみなさん自身で確認してもらいたい。

Functor とは何か、ということを Java を使って説明した。本記事では MaybeLazyList を使って説明したが Functor 則を満たしつつ、map のようなメソッドを実装できるデータ構造はいくらでもある。普段のプログラミングで使っている型を思い出して、あれも実は Functor なのではないかと考えを巡らしてみよう。そうするといたるところに Functor が潜んでいることに気がつくだろう。

Java 標準の ArrayList に対しても Functor 則を満たすような map メソッドが定義できることは LazyList の例からも明らかだろう。しかし、ここで一つ問題がある。Mappble のようなインターフェースを実装させる方法では既存のクラスに map メソッドを追加することができない。そのため、次のような static メソッドとして実装するか、

1
public class ArrayListOps {
2
3
public <T, U> ArrayList<U> map(ArrayList<T> xs, Function<? super T, ? extends U> mapper) {
4
// ...
5
}
6
}

map を持つようなクラスに変換 (Java 標準の Stream のように) しなければならない。それを既存のクラスに入れて回るのは大変なので Java 標準では map のような操作をしたければ Stream を経由して行うことになる。値を ArrayList で持っていたとしても map を使いたければ計算を遅延することが目的でなくとも stream メソッドを呼んで Stream に変換した後、toList メソッドや collect メソッドを使って List に戻す、ということをしなければならない。これは標準ライブラリのクラスに対してだけでなく、誰かが作ったクラスに Functor 則を満たす map が定義できる!、自分が過去に書いたクラスに Functor 則を満たす map を定義できる!と思ったときに同じような悩みが発生する。

これまで見てきたように mapMaybeLazyList のようなデータ構造に対して中身を取り出すことなく関数を適用するような操作を提供してくれる便利な操作だ。しかし、Java で同じようなことをしたいと思ったら障害が多い。それでは Haskell や Scala をはじめとする Functor を活用している言語では map のような操作をオブジェクト指向で言うところの抽象データ型とは異なるアプローチで実現している。

ここからは Java の世界から Scala の世界に足を踏み入れてみよう。

One more thing...

Functor について知ったところで Java の Optional を思い出してみよう。そこで問題。

Q. Optional は Functor だろうか?

答え

A. Functor ではありません 👎

理由は考えてみてください。ヒントとして null が絡んだ場合の合成を考えると、合成の保存が成り立たない例を見つけられるでしょう。

Scala で MaybeLazyList を実装

ここから先は Scala 3 のコードを使って説明する。 Java では型クラスと呼ばれる言語機能を提供していないからだ。型クラスの基本的な仕組みについては 香川大学の講義資料 (?) が参考になるだろう。こちらの資料で説明しているのは Haskell の型クラスとなっている (Scala も同じように型クラスを処理しているかはわからない)。この記事を読む上では知る必要のないことなので、気になった人は後で調べてみて欲しい。

Java では jshell を使ってシェル上でコードを試していたが、ここからは Scala CLI 上でコードを実行する。手元に Scala の実行環境を用意するのが面倒な人は Scastie にコードを貼って動作を確認して欲しい。 Scala CLI の使い方については、日本語の記事だと 初心者向け: Scala CLI で Scala をはじめよう - Lambda カクテル が参考になる。

さっそく Java で定義した MaybeLazyList を Scala で実装してみよう。

Maybe

Maybe の Scala による実装は次のようになる 5

Maybe.scala
1
sealed trait Maybe[T]
2
3
case class Just[T](val value: T) extends Maybe[T]
4
5
case class Nothing[T]() extends Maybe[T]

Java にも sealed キーワードや record が導入されたため、このあたりの Scala と Java のコードは以前よりも大分似てきている。 Maybemap については後で実装するので先に LazyList の Scala による実装も見てみよう。

LazyList

Scala での LazyList の実装は次のようになる。 Scala では標準ライブラリに LazyList が存在しているため、ここでは MyLazyList という名前で定義している。

MyLazyList.scala
1
sealed trait MyLazyList[T] {
2
3
def toList(): List[T] =
4
this match {
5
case cons @ Cons(_, _) => cons.value :: cons.tail.toList()
6
case Nil() => List()
7
}
8
}
9
10
case class Cons[T](val t: () => T, val xs: () => MyLazyList[T]) extends MyLazyList[T] {
11
lazy val value = t()
12
13
lazy val tail = xs();
14
}
15
16
case class Nil[T]() extends MyLazyList[T]

Java との違いとして toList でのパターンマッチと lazy val というキーワードだろうか。 Scala では Java の switch 相当の機能は ... match { case ... => ...} という形で書く。基本的な構造は Java の swtich と同じなのでキーワードが違うことがわかれば読めるでしょう。ここで Java と違うのは cons @ Cons(_, _) という書き方だ。 @ の左側にマッチしたパターン全体を束縛する変数を書き、マッチさせるパターンを @ の右側に書く。 _ は束縛しない変数を表す。つまり thisCons(_, _) というパターンだったら Cons(_, _)cons に束縛されて => の右側のコードが実行される。

toList は Java では ArrayList に詰めていくような実装をしていたが、ここではリストを連結していくような実装にしている。ここでは楽をするためこのような実装としているが、リストが長くなるとスタックオーバーフローが発生するため遊びのコード以外では書かないように。

型クラスの導入

さぁ、ここからが本題。Scala で実装した MaybeMyLazyListmap を実装するわけだが、Java とは違い型クラスを使い実装していく。型クラスって何ぞ?と思うかもしれないが今は Java にはない言語機能の一つだと思ってもらえればいい。

Functor 型クラス

Scala で Functor をコードに落として込むと次のようになる。

1
trait Functor[F[_]]:
2
extension [A](fa: F[A]) {
3
def map[B](f: A => B): F[B]
4
}

これは解説が必要だろう。traitMaybeMyLazyList の定義でも出てきたが Java で言うところの interface と同じような意味のキーワードだ。 trait Functor[F[_]] は Java で表現するなら interface Function<F<?>> のようになる。 Java では型を受け取る型を定義できなったが Scala ではそのような型を扱うことができる。このような型を受け取る型を高カインド型という。高カインド型についてより知りたい人は 関数型言語で登場する高カインド型(Higher-Kinded Types; HKT) と(_ -> _) -> *みたいなやつの早わかりメモ - Lambda カクテル が参考になるだろう。

ここで F[_]Maybe[T]MyLazyList[T] のような任意の型 T を受け取る型である MaybeMyLazyList を変数 F として扱うことを宣言している。つまり、Functor は任意の型を受け取る型について定義されるということを表現している。

次のキーワード extension は既存の型を拡張するために使われる。ここでは F[A] 型の値について map メッドを追加することを宣言している。 A は任意の型で map はこれまで見てきたシグネチャと同じだ。ここだけ見ると fa が使われていないためどのように使われるかわからないが、ここではシグネチャを宣言しているだけに過ぎないので実際の使われ方は後からこの後に解説する。 TypeScript の関数の型も引数の名前がシグネチャに含まれて宣言されるが、それと同じようなものだと思ってもらえればよい 6extension についてより詳しい解説が欲しい人は公式ドキュメントを参照して欲しい。

これだけ見ると Java の interface と変わないと思うかもしれないが Functor を使って MaybeMyLazyListmap メソッドを追加する方法が異なる。 Java では interfaceimplements を使って実装するしかなかったが Scala では違う方法で MaybeMyLazyListmap メソッドを追加する。それでは Maybe の例から見てみよう。

Maybe Functor

Maybe Functor の実装は次のようになる。

1
given Functor[Maybe] with
2
extension [A](fa: Maybe[A]) {
3
override def map[B](f: A => B): Maybe[B] =
4
fa match {
5
case Just(v) => Just(f(v))
6
case Nothing() => Nothing()
7
}
8
}

ここでは型クラスのインスタンス 7 を定義している。 Java で Maybemap を実装する方法を二つ紹介したが Maybe インターフェースのデフォルト実装と似ているため説明は不要だろう。コードの構造に着目してみると Functor で宣言したシグネチャの F の部分を Maybe で具象化し、map の中身を埋めている、という関係がわかるだろう。 traitgiven になり :with になっている。インターフェースに対するクラスの実装と似たような関係だ。

さて、問題になるのはこれを定義すると何ができるか、だろう。 REPL でここまでのコードを読み込んでみよう。

REPL
1
scala
2
Welcome to Scala 3.5.2 (17.0.13, Java OpenJDK 64-Bit Server VM).
3
Type in expressions for evaluation. Or try :help.
4
5
scala> sealed trait Maybe[T]
6
|
7
| case class Just[T](val value: T) extends Maybe[T]
8
|
9
| case class Nothing[T]() extends Maybe[T]
10
// defined trait Maybe
11
// defined case class Just
12
// defined case class Nothing
13
14
scala> trait Functor[F[_]]:
15
| extension [A](fa: F[A]) {
16
| def map[B](f: A => B): F[B]
17
| }
18
|
19
// defined trait Functor
20
21
scala> given Functor[Maybe] with
22
| extension [A](fa: Maybe[A]) {
23
| override def map[B](f: A => B): Maybe[B] =
24
| fa match {
25
| case Just(v) => Just(f(v))
26
| case Nothing() => Nothing()
27
| }
28
| }
29
|
30
// defined object given_Functor_Maybe

そして次のコードを実行する。

REPL
1
scala> Just(1).map(_ * 2)
2
val res0: Maybe[Int] = Just(2)

なんと、Just (Maybe) には map メソッドが定義されていないにもかかわらず map メソッドを呼ぶことができた。これが型クラスによるポリモーフィズム。オブジェクト指向プログラミング言語では、継承によってポリモーフィズムを実現するが、Scala では継承の他に extensiongiven の組み合わせによる型クラス相当の機能もサポートしているためこのようなことができる。

このように Scala では異なる型を持つクラスに共通の操作を持たせたい場合、既存のクラスに手を加えることなく後から追加できる。これは継承によるポリモーフィズムにはない利点だ。継承ではクラスを定義する段階で想像できていた方法でのみ型を分類 (階層化) できるが後から定義済みのクラスに対してメソッドを追加することができない。そのため、あるクラスに Functor 則を満たす map が定義できることに気がついたとしても、クラスの定義段階で提供できなければ、そのクラスを継承したクラスを作るか、記事内で触れたように static メソッドとしてクラスに紐付かない方法で実装しなければならない。

同じことの繰り返しになるが無理矢理クラス図を使って説明しよう。ぼくらの手元には次のよう二つのクラスがあった。

そして、これら二つのクラスにはシグネチャの似た map というメソッドが定義できることに気がついた。これをオブジェクト指向プログラミング的に表現するには、継承によって Functor 則を満たすような map メソッドを持つ、ということを継承関係で表現した。

Java でこの関係を表現するためには、クラス図でも表現されているように MaybeLazyListFunctor インターフェースを実装するように書かないといけなかった。一方、型クラスがサポートされている言語であれば、MaybeLazyList とは関係ないところで型を分類し、それらが Functor 則を満たす map という操作を持つことを表現できる。

Functor という型の分類があるのであれば、それに従ったインスタンス (図中の FunctorInstances) を定義していくことで既存の型に新しい操作を追加できる。

書きたいことを書き尽してしまったが、次に進む前に一応 MyLazyListFunctor インスタンスも見ておこう。

MyLazyList Functor

MyLazyListFunctor インスタンスは次のようになる。

1
given Functor[MyLazyList] with
2
extension [A](fa: MyLazyList[A]) {
3
override def map[B](f: A => B): MyLazyList[B] =
4
fa match {
5
case cons @ Cons(_, _) => Cons(() => f(cons.value), () => cons.tail.map(f))
6
case Nil() => Nil()
7
}
8
}

ここまでの説明が理解できていればコードが読めるはずだ。 REPL での実行結果も見ておこうか。

REPL
1
scala> val xs: MyLazyList[Int] = Cons(() => 1, () => Cons(() => 2, () => Nil[Int]()))
2
val xs: MyLazyList[Int] = Cons(rs$line$6$$$Lambda$2039/0x0000008801608640@6f926d01,rs$line$6$$$Lambda$2040/0x0000008801608908@c67a89)
3
4
scala> xs.map(_ * 2)
5
val res0: MyLazyList[Int] = Cons(rs$line$5$given_Functor_MyLazyList$$$Lambda$2085/0x0000008801668c48@7f09ff10,rs$line$5$given_Functor_MyLazyList$$$Lambda$2086/0x0000008801668f10@531b1778)
6
7
scala> res0.toList()
8
val res1: List[Int] = List(2, 4)

Maybe と同じように mapMyLazyList に生えていることが確認できた。

ふりかえり

長い旅もここで終わり。これまで見たものをふりかえってみよう。最初に Java で MaybeLazyList を実装し、それぞれのクラスに map という名前のメソッドを追加した。その後、map メソッドのシグネチャが似ていることに気づいて Mappable というインターフェースで map という操作を持つ、という性質をコード上で表現した。そこで一度立ち止まり、map メソッドが満たしていると好ましい性質について考え、Functor 則を学んだ。 Functor は便利な概念ではあるものの Java で表現するには限界があるため Scala による再実装を通して型クラスによるポリモーフィズムについて見た。

さて、ここで記事の冒頭に戻ろう。

Java の OptionalStream を使ってコードは書いているものの mapflatMap って何者だろう、 for 文でも同じことできるよね?と思っている人向けに OptionalStream と同じ機能を提供するコードを実装しながら Functor へと誘う記事を書いてみる。

ここまでの説明を受けて mapfor の代わりだろうか。 Java の Stream やこの記事で扱った LazyList に対する map の実装は for と似かよっているように思えるが Functor 則を思えば StreamLazyList にとっては for の代わりのような操作であったに過ぎないことがわかるだろう。一般的には 文脈 (context) と呼ばれるもので説明されるがそれについては別の記事で扱う。

そして Monad へ続く

Functor について理解できたら次は Monad に進んでみよう。 Functor では MaybeLazyList の中身に対して関数を適用できた。しかし、これだけではまだ不都合なことが起きてしまう。例えば、Maybe[Int] に対して f: Int => Maybe[Int] のようなシグネチャを持つ関数を適用するとどうなるだろうか。その結果は Maybe[Maybe[Int]] という型を持つ値になる。そうするとその結果に対して map したいとなったとき g: Maybe[Int] => ... というシグネチャの関数でなければ適用できなくなってしまう。 Maybe がどんどん積み上がっていってしまうし、コードも無駄に長くなるため本質的な部分が埋もれていく。

そこで Monad という考え方の出番だ。Monad がこの問題を解決してくれる。 Monad については記事を分けて書こうと思っている。

おわりに

記事が長くなってしまい途中で飽きて説明が雑になってしまった。知っている人向けの説明になってしまっているところもあるのでその部分については今後改善していこうと思う。

Functor を説明するという目的に対して遠まわりだし、やたら仰々しくなってしまったが、抽象的な解説ではなく具体的なコードで一から (?) 説明できたのではないだろうか。関数型プログラミングに慣れ親しんだ人ではなく、これまでオブジェクト指向プログラミングにしか触れてこなかった人に異なるプログラミングパラダイムの雰囲気でも感じられたら目的は達成できたということで、今日はここで終わる。

厳密な説明はしないとは言うけどそれはどうなのよ?ってところがあればコメントで指摘してもらえるとありがたいです。

Footnotes

  1. 当初は Java 17 の範囲で解説しようとしたが switch 式のパターンマッチが貧弱で断念

  2. Option 型 - Wikipedia

  3. https://github.com/openjdk/jdk21/blob/master/src/java.base/share/classes/java/util/Optional.java

  4. 圏論における関手は圏の対象と射をここで説明した性質を満たしつつ別の圏に移す対応付けのことを指す。プログラミングにおいて関手を考えるとき、対象は型のことを指し、射は関数のことを指す。詳しくは Hask 圏で検索検索ゥ!

  5. あとから気がついたが enum を使った実装の方が Scala 3 としてはいいのかもしれない。Scala 2 から頭の中をアップデートできていない弊害か…。

  6. 関数の型の宣言 (function type declaration) | TypeScript 入門『サバイバル TypeScript』

  7. この文脈におけるインスタンスはオブジェクト指向プログラミングでいうクラスのインスタンスとは異なる。型クラス (ここでは Functor 型クラス) に所属するデータ型 (MaybeMyLazyList) のことを指している。


Buy Me A Coffee