All You Need Is

Optional の話

2024/09/08
Javaプログラミング

はじめに

設計思想を理解せよ!1

TL;DR

Optional

Optional は Java 8 から標準ライブラリに導入されたクラスだ。 Optional は、値が存在するか、存在しないかのいずれかの状態を表現するクラスとなっている。 Java において値がない、というのは null を意味する。 Java 8 より前の Java では、変数に代入すべき値が存在していないとき null を代入することで値が存在しないことを表現していた。 Optional の導入によって、値が存在しないということを「値が存在しないことを表現するオブジェクト」で表現する方法が公式に提供された。

Optional のように値が存在しない可能性がある値をデータ構造で表現する考え方は新しいものではない。 Haskell を始めとする多くのプログラミング言語の標準ライブラリで提供されている。 Haskell の前身 (?) とも言える Miranda の段階で既に存在していたかは不明だが、1990 年代には Maybe と呼ばれる型構築子に対する言及が見つけられる。正確なところはわからないが、1990 年代には既に登場していた考え方がようやく Java にも導入されたわけだ。何なら Haskell は Java が公開された年 (1995 年) よりも前 (1990 年) に公開されたプログラミング言語なわけだが…。

Optional のインスタンス

Optional で包まれた値を作るには、

のいずれかの静的メソッドを呼び出す。それぞれ用途が異なるので一つずつ確認していこう。

Optional#ofNullable(T)

1
jshell> Optional.ofNullable(null)
2
$1 ==> Optional.empty
1
jshell> Optional.ofNullable("123")
2
$2 ==> Optional[123]

Optional#of(T)

1
jshell> Optional.of("123")
2
$3 ==> Optional[123]
1
jshell> Optional.of(null)
2
| 例外java.lang.NullPointerException
3
| at Objects.requireNonNull (Objects.java:209)
4
| at Optional.of (Optional.java:113)
5
| at (#5:1)

Optional#empty()

1
jshell> Optional.empty()
2
$5 ==> Optional.empty

Optional の使い方

Optinoal を使った例

Optional の動作を理解するための例として、文字列から整数への変換を考える。文字列から整数への変換は失敗する可能性のある計算の簡単な例だろう。そのような処理では文字列が整数の形式ならば整数型 (Integer) が返されることが期待される。

Java だと Integer#parseInt(String) を使って実装するのが一般的だろう。

1
jshell> Integer.parseInt("123")
2
$6 ==> 123

文字列が整数に変換できる場合は数値を返せばよいが、整数に変換できない文字列の場合はどうなるだろう。 Integer#parseInt(String) では変換に失敗したとき NumberFormatException という非検査例外をスローする。

1
jshell> Integer.parseInt("abc")
2
| 例外java.lang.NumberFormatException: For input string: "abc"
3
| at NumberFormatException.forInputString (NumberFormatException.java:67)
4
| at Integer.parseInt (Integer.java:668)
5
| at Integer.parseInt (Integer.java:786)
6
| at (#8:1)

つまり、整数ではない可能性のある文字列を Integer 型に変換したい場合は、次のようなコードを書くことになる。

1
Integer number;
2
try {
3
number = Integer.parseInt(str);
4
} catch (NumberFormatException e) {
5
// 変換に失敗した場合の処理
6
number = ...;
7
}

毎回このような処理を書くのは手間なのでユーティリティを定義したくなるのが人の性だろう。ここでは、ユーティリティの実装として Optional を使わない実装と、Optional を使った場合の実装の二つを考える。

変換できなかったことを null で表現

Java では計算に失敗した場合、例外を投げるか null を返すのが一般的だろう。 Integer#parseInt(String) は非検査例外をスローするが、例外をスローされると先に見た例のように都度スローされた例外をキャッチしないといけない。

そこで、例外をスローするのではなく null を返すようなメソッドを定義するユーティリティクラスを書いてみよう。

1
jshell> public class NumberUtils {
2
...>
3
...> public static Integer parseInt(String str) {
4
...> try {
5
...> return Integer.parseInt(str);
6
...> } catch (NumberFormatException e) {
7
...> return null;
8
...> }
9
...> }
10
...> }
11
| 次を作成しました: クラス NumberUtils

実際に整数文字列とそれ以外の文字列を渡してみよう。

1
jshell> NumberUtils.parseInt("123")
2
$9 ==> 123

整数文字列の場合は数値が返される。そして、整数以外の文字列を渡した場合は次のように null が返ってくる。

1
jshell> NumberUtils.parseInt("abc")
2
$10 ==> null

これで文字列を整数に変換したければ次のように書ける。

1
Integer number = NumberUtils.parseInt(str);

try ~ catch で囲むよりコードの見栄えは良くなっただろう。しかし、これで何も問題ないのだろうか。

ここでもう少しだけ複雑な例として、文字列として消費税抜きの価格が与えられたとき、消費税 (10%) を考慮した価格を求めるプログラムを書いてみよう。

もう少し詳しくお題目を書くと、

とする。少しは現実で書く場面があるような例ではないだろうか。

ここでは静的メソッドとしてそのような関数を定義しよう2。クラス定義は次のような大枠とする。

1
public class Price {
2
3
public static String taxIncluded(String price) {
4
// 消費税 10% を加算した価格を返す処理
5
}
6
}

さて、メインの処理を書いてみよう。価格の数値部分を取り出すには正規表現を使うこともできるが、ここではよりライトな実装をする。

1
jshell> public class Price {
2
...> public static String taxIncluded(String jpPrice) {
3
...> if (jpPrice == null) {
4
...> return null;
5
...> }
6
...>
7
...> if ("未定".equals(jpPrice)) {
8
...> return "未定";
9
...> }
10
...>
11
...> if (jpPrice.length() > 1 && jpPirce.charAt(jpPrice.length() - 1) == '円') {
12
...> String price = jpPrice.substring(0, jpPrice.length() - 1);
13
...> Integer priceInt = NumberUtils.parseInt(price);
14
...>
15
...> return ((int) Math.floor(priceInt * 1.1)) + "円";
16
...> }
17
...>
18
...> return null;
19
...> }
20
...> }
21
| 次を作成しました: クラス Price

良心的な企業なので消費税は切り捨てで計算3することにし、 未定XXX円 以外のフォーマットの場合は null を返すことにした。

これは上手く動くだろうか?

1
jshell> Price.taxIncluded("1000円")
2
$12 ==> "1100円"
3
4
jshell> Price.taxIncluded("未定")
5
$13 ==> "未定"
6
7
jshell> Price.taxIncluded(null)
8
$14 ==> null
9
10
jshell> Price.taxIncluded("1,000円")
11
| 例外java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "<local2>" is null
12
| at Price.taxIncluded (#18:15)
13
| at (#20:1)

Oops 😵
NumberUtils#parseInt(String) は整数に変換できないフォーマットの場合は null を返すのだった。 null が返ってくる場合を考慮して修正する。

1
jshell> public class Price {
2
...> public static String taxIncluded(String jpPrice) {
3
...> if (jpPrice == null) {
4
...> return null;
5
...> }
6
...>
7
...> if ("未定".equals(jpPrice)) {
8
...> return "未定";
9
...> }
10
...>
11
...> if (jpPrice.length() > 1 && jpPrice.charAt(jpPrice.length() - 1) == '円') {
12
...> String price = jpPrice.substring(0, jpPrice.length() - 1);
13
...> Integer priceInt = NumberUtils.parseInt(price);
14
...>
15
...> if (priceInt == null) {
16
...> return null;
17
...> }
18
...>
19
...> return ((int) Math.floor(priceInt * 1.1)) + "円";
20
...> }
21
...>
22
...> return null;
23
...> }
24
...> }
25
| 次を変更しました: クラス Price

これでいいだろう。しかし、このコードはやりたい事に対して気にしたくない部分がコードに現れている。具体的には、

の二つの点だ。ここでは勿論わざとではあるが NumberUtils#parseInt(String)null を返すことを忘れていたため NullPinterException を発生するコードを書いてしまった。

次にこのコードを Optional を使って書き直してみよう。

Optional を使った場合

Optional を使わなかった場合のコードの説明が長くなってしまったが、ようやく本題の Optional を使う話だ。

早速コードを見てみよう。 Optinoal を使わない場合と同じように NumberUtils の定義から始める。

1
jshell> public class NumberUtils {
2
...>
3
...> public static Optional<Integer> parseIntOptional(String str) {
4
...> try {
5
...> return Optional.of(Integer.parseInt(str));
6
...> } catch (NumberFormatException e) {
7
...> return Optional.empty();
8
...> }
9
...> }
10
...> }
11
| 次を置換しました: クラス NumberUtils

Optional を使うときは null の代わりに Oprional.emtpy() を返し、整数に変換できた場合は Optional#of(T) で整数を Optional で包んで返している。

NumberUtils#parseIntOptional(String) を実際に使ってみると、次のような動作をする。

1
jshell> NumberUtils.parseIntOptional("abc")
2
$18 ==> Optional.empty
3
4
jshell> NumberUtils.parseIntOptional("123")
5
$19 ==> Optional[123]

さて、この NumberUtils#parseIntOptional(String)Optinoal を使って Price#taxIncluded(String) を書いてみよう。人に依って最終的なコードに差が出るところではあるが、次のような実装をしてみる。

1
jshell> public class Price {
2
...> public static String taxIncluded(String jpPrice) {
3
...> return Optional.ofNullable(jpPrice)
4
...> .flatMap(str -> {
5
...> if (str.equals("未定")) {
6
...> return Optional.of("未定");
7
...> }
8
...>
9
...> return Optional.of(str)
10
...> .filter(it -> it.length() > 1 && it.charAt(it.length() - 1) == '円')
11
...> .map(it -> it.substring(0, it.length() - 1))
12
...> .flatMap(NumberUtils::parseIntOptional)
13
...> .map(it -> (int) (it * 1.1))
14
...> .map(it -> it + "円");
15
...> })
16
...> .orElse(null);
17
...> }
18
...> }
19
| 次を作成しました: クラス Price

もしくは、以下のように 未定 の部分は flatMap の外に出してもいいだろう。

1
public class Price {
2
public static String taxIncluded(String jpPrice) {
3
if ("未定".equals(jpPrice)) {
4
return "未定";
5
}
6
7
return Optional.ofNullable(jpPrice)
8
.flatMap(str ->
9
Optional.of(str)
10
.filter(it -> it.length() > 1 && it.charAt(it.length() - 1) == '円')
11
.map(it -> it.substring(0, it.length() - 1))
12
.flatMap(NumberUtils::parseIntOptional)
13
.map(it -> (int) (it * 1.1))
14
.map(it -> it + "円"))
15
.orElse(null);
16
}
17
}

動作を確認すると次ように上手く動いていることがわかる。

1
jshell> Price.taxIncluded("1000円")
2
$21 ==> "1100円"
3
4
jshell> Price.taxIncluded("未定")
5
$22 ==> "未定"
6
7
jshell> Price.taxIncluded(null)
8
$23 ==> null
9
10
jshell> Price.taxIncluded("1,000円")
11
$24 ==> null

さて、Optional を使った場合のコードと使わなかった場合のコードを見比べるとどうだろうか。

1
public class Price {
2
public static String taxIncluded(String jpPrice) {
3
if (jpPrice == null) {
4
return null;
5
}
6
7
if ("未定".equals(jpPrice)) {
8
return "未定";
9
}
10
11
if (jpPrice.length() > 1 && jpPrice.charAt(jpPrice.length() - 1) == '円') {
12
String price = jpPrice.substring(0, jpPrice.length() - 1);
13
Integer priceInt = NumberUtils.parseInt(price);
14
15
if (priceInt == null) {
16
return null;
17
}
18
19
return ((int) Math.floor(priceInt * 1.1)) + "円";
20
}
21
22
return null;
23
}
24
}
1
public class Price {
2
public static String taxIncluded(String jpPrice) {
3
if ("未定".equals(jpPrice)) {
4
return "未定";
5
}
6
7
return Optional.ofNullable(jpPrice)
8
.flatMap(str ->
9
Optional.of(str)
10
.filter(it -> it.length() > 1 && it.charAt(it.length() - 1) == '円')
11
.map(it -> it.substring(0, it.length() - 1))
12
.flatMap(NumberUtils::parseIntOptional)
13
.map(it -> (int) (it * 1.1))
14
.map(it -> it + "円"))
15
.orElse(null);
16
}
17
}

コードの行数を見ると Optional を使った場合の方が短くなっているが、ここで重要なのはそこではない。 Optional を使ったコードでは計算途中で値が null になることを気にすることなくやりたいこと (文字列から整数に変換し、消費税込みの価格を計算) を表現できている。

また、Optional を使わない書いたときは、NumberUtils#parseInt(String)null を返していたため NullPointerException を発生させてしまったが、 Optional を使うと NumberUtils#parseIntOptional(String) が整数への変換に失敗した場合は Optional#empty() が返されるため、NullPointerException が発生しない。

計算結果が Optional になるような入れ子は flatMap によって潰すことできるため、計算が成功した場合だけ実行されるような処理を後続の map で書ける。

これにより、文字列から整数への変換、消費税込みの価格計算までの途中で失敗した場合のことは最後 (Optional#orElse(T)) でだけ考えればよくなる。今回の要件では、消費税込みの価格が計算できない場合は、それ以外 の値を返すとなっていたため、null を返すような実装をしたが Optional を使わない例に引っぱられていたようだ。計算が失敗する可能性があるのであれば Optional をそのまま返すのでいいではないか。

1
jshell> public class Price {
2
...> public static Optional<String> taxIncluded(String jpPrice) {
3
...> if ("未定".equals(jpPrice)) {
4
...> return Optional.of("未定");
5
...> }
6
...>
7
...> return Optional.ofNullable(jpPrice)
8
...> .flatMap(str ->
9
...> Optional.of(str)
10
...> .filter(it -> it.length() > 1 && it.charAt(it.length() - 1) == '円')
11
...> .map(it -> it.substring(0, it.length() - 1))
12
...> .flatMap(NumberUtils::parseIntOptional)
13
...> .map(it -> (int) (it * 1.1))
14
...> .map(it -> it + "円"));
15
...> }
16
...> }
17
| 次を置換しました: クラス Price

このコードを見て「別に null を返すのでもいいじゃないか?」と思ったあなた m9

NumberUtils#parseInt(String) の誤ちを忘れてしまっているようだ。この静的メソッドを使うとき、返ってくる値の null チェックを忘れない自信のほどは?

Optional は失敗する可能性のある計算を表現

Optional を使わないコードと使ったコードを見てきたわけだが、ここで Javadoc をもう一度見てみよう。

null 以外の値を含む場合と含まない場合があるコンテナ・オブジェクト。

...

API のノート:
Optional は、主に、"結果なし、"を表す明確な必要があり、null を使用するとエラーが発生する可能性があるメソッドの戻り型として使用することを目的としています。 型が Optional の変数は、それ自体が null になることはなく、常に Optional インスタンスを指す必要があります。

確かに Optional 単体としては null 以外の値を含む場合と含まない場合を表現するオブジェクトという説明で十分ではあるが、 Optional.empty() を計算に失敗した場合に返し、成功した場合は Optional#of(T) で値を包んで返すことで単に null 以外の値を含むという意味だけではなく、計算の成功・失敗を表現することができている4

そして、Optional を使えば、成功の場合を中心に処理を記述し、失敗した場合にどのような回復処理を行えばよいか最後にまとめて考えればいいようなコードが書ける。

このことを頭に入れて消費税を計算を計算するコードを見直してみよう。

{7-14}
1
public class Price {
2
public static Optional<String> taxIncluded(String jpPrice) {
3
if ("未定".equals(jpPrice)) {
4
return Optional.of("未定");
5
}
6
7
return Optional.ofNullable(jpPrice)
8
.flatMap(str ->
9
Optional.of(str)
10
.filter(it -> it.length() > 1 && it.charAt(it.length() - 1) == '円')
11
.map(it -> it.substring(0, it.length() - 1))
12
.flatMap(NumberUtils::parseIntOptional)
13
.map(it -> (int) (it * 1.1))
14
.map(it -> it + "円"));
15
}
16
}

Optional を使ったコードでは、計算に成功した場合の処理のみがハイライトされた部分で記述されており、数値への変換に失敗した場合の条件分岐は表立ってコードに現れていない。そして、何より失敗した場合に null チェックのような明示的な値の検証を意識せず、上手くいったときのことだけを考えていればよい。

一方、Optional を使わない例を見ると、ハイライトされた部分では計算に失敗した場合の処理が書かれており、メインの処理である消費税の計算が分断されている。

{15-17,22}
1
public class Price {
2
public static String taxIncluded(String jpPrice) {
3
if (jpPrice == null) {
4
return null;
5
}
6
7
if ("未定".equals(jpPrice)) {
8
return "未定";
9
}
10
11
if (jpPrice.length() > 1 && jpPrice.charAt(jpPrice.length() - 1) == '円') {
12
String price = jpPrice.substring(0, jpPrice.length() - 1);
13
Integer priceInt = NumberUtils.parseInt(price);
14
15
if (priceInt == null) {
16
return null;
17
}
18
19
return ((int) Math.floor(priceInt * 1.1)) + "円";
20
}
21
22
return null;
23
}
24
}

このことから、Optional を使うことで本来やりたかったことをコード上のまとまった位置に書けるようになることがわかるだろう。今回の例では、計算が失敗する可能性があるのは、引数が null の場合、整数への変換が行えないフォーマットの場合の 2 つだけであったが、より多くの失敗する可能性のある計算が増えたとしても Optional を上手く使えば実装したい処理にフォーカスしたコードを維持できる。

Optional を使うメリット

Optional を使うメリットは主に三つあるだろう。

Optional の問題点

さて、ここまで Optional の良いところを書いてきたわけだが、もちろん Optional にも問題点はある。

成功・失敗を表現できるが失敗した理由を表現できない

Optional は成功した場合は値を返すことができるが、失敗した場合はすべて Optional.empty() に丸められてしまう。これでは、失敗した原因によって回復処理を変えるような処理を書けない。

失敗した場合を表現するには一般的に Either (Left, Right) や Result (Ok, Err) と呼ばれるが必要だ。 Etiher では Right に成功した場合の値 (right なので Right が正しい場合)、Left に失敗した場合の値を入れる。これによって、成功・失敗だけでなく、失敗した場合に値も返せる。もちろん、mapfilter を始めとするメソッドで Right の場合だけ値にアクセスするようなコードを書ける。

将来的に標準ライブラリから提供されるのではないか (提供してくれ…) と思っているが Java 21 時点では提供されていない。そのため、vavr のような Java で関数型プログラミングをする支援をしてくるライブラリを導入するのが最も手軽ではあるが、このようなライブラリを実務で使うハードルは高い。

エンジニアのスキルの問題もあるが、マイナーなライブラリは存続の危機に陥いってメンテされなくなるリスクも伴う。

Optional のようなクラスを使った書き方はやはりメジャーではない

Optional がモナドでもなければファンクタでもない

Optional を使うときに守るべきこと

おわりに

Optional の基本的な使い方からその性質までを説明した。

世界から一つでも NullPointerException が減ることを祈りつつ、今回はここで筆を置くことにする。

あわせて読んで欲しい記事

Footnotes

  1. メディアの違いを理解せよ!とは (メディアノチガイヲリカイセヨとは) [単語記事] - ニコニコ大百科

  2. 消費税が絡む計算を静的メソッドで実装するだと?けしからんという声が聴こえてくる。

  3. (Q9) 現在の「税抜価格」を基に「税込価格」を設定する場合に円未満の端数が生じることがありますが、どのように処理して値付けを行えば良いのですか。

  4. null がない言語で Optional に似た型 (e.g. Haskell の Maybe) を説明する場合は、このように計算の成功・失敗を表現するといった説明がされることがある。

  5. 実際には Optional の使い方を知らない人が Optional#get() を使うため実行時例外が発生する 😡


Buy Me A Coffee