空の配列に対するmaxは何を返すか

ちょっと前に「配列中のすべての要素が条件を満たすかどうか判別する関数で、空の配列はTrueを返すべきかFalseを返すべきか」のような話が話題になってました。
まあこれは「Trueを返す」が答えなわけですが、では「配列中の最大値を返す関数で空の配列の場合は何を返すか」が気になりました。

「配列中のすべての要素が条件を満たすかどうか判別する関数」について言えば、簡単に言えばこんな感じ。
まず、配列のすべての要素が偶数であるかどうか判別する関数を考えます。

void main() {
  int[] data = { 23, 44, 12, 98, 5 };
  System.out.println(allEven(data));
}
boolean allEven(int[] data) {
  for (int n : data) {
    if (n % 2 != 0) return false;
  }
  return true;
}

これを配列の要素が0のときにfalseを返そうとすると、わざわざそのための条件を入れる必要があります。
ということで、trueを返すのが自然。

ただ、それだけだと「単に実装の都合なのに、Trueであるべきっていうのは納得いかん!」となる気がします。 実際には、「すべての要素が条件を満たすかどうか」という処理の性質としてTrueを返すのが自然なので、それについて説明してみます。

その前に、数値を全部合計する処理を考えてみましょう。

void main() {
  int[] data = { 23, 44, 12, 98, 5 };
  var result = 0;
  for (int n : data) {
    result = result + n;
  }
  System.out.println(result);
}

ちなみに今回のコードは、Java 21の試用機能として、そのまま実行できるようになる予定。
まだ試用機能なので--enable-previewが必要ですが、来年のJava 23くらいには正式機能になるはず。

で、他に、数値を倍にしたリストを得る処理を考えてみます。

import java.util.*;
import java.stream.*;
void main() {
  int[] data = { 23, 44, 12, 98, 5 };
  var result = new ArrayList<Integer>();
  for (int n : data) {
    result = Stream.concat(result.stream(), Stream.of(n * 2)).toList();
  }
  System.out.println(result);
}

この2つの処理は、同じようなパターンになってますね。
ということで、allEvenも同じようなパターンで書いてみましょう。

void main() {
  int[] data = { 23, 44, 12, 98, 5 };
  var result = true;
  for (int n : data) {
    result = result & (n % 2 == 0);
  }
  System.out.println(result);
}

これらの処理で、違っている部分をまとめると次のようになります。

処理 初期値 計算処理
sum 0 +
toList 空リスト concat
allEven true &

ここでどういう値が初期値になっているか見てみると、計算処理を行っても相手に影響を与えない値になっています。
つまり、a+0も0+aもaになり、aに空リストを連結しても 空リストにaを連結してもaになり、そして、a & TrueもTrue & aもaになります。 こういった、演算しても相手に影響を与えないような値を、その演算の単位元といいます。
ということで、「すべての要素が条件を満たすかどうかという処理の演算は &演算なので、空配列のときの値は &演算の単位元であるTrueを返すのが適切」となります。
ちなみに「場合による」と言ってる人が叩かれたりしてましたが、場合によるときには要件が「すべての要素が条件を満たすかどうか」ではなく、例えば「カートが出荷可能か(カートの中のすべての商品が出荷可能であり、しかしカートが空であれば出荷可能ではない」のような別のビジネス要件になるはず、という感じですね。

さて、ここで本題の「空の配列に対するmaxは何を返すか」です。
これを先ほどと同じパターンで書いてみると次のようになります。

void main() {
  int[] data = { 23, 44, 12, 98, 5 };
  var result = Integer.MIN_VALUE;
  for (int n : data) {
    result = Math.max(result, n);
  }
  System.out.println(result);
}

となると、maxの単位元は-∞ってことになるんでは、となります。 というところで、Javaのライブラリを見てみます。CollectionsmaxNoSuchElementException、StreamのmaxOptionalを返します。

jshell> Collections.max(List.<Integer>of())
|  例外java.util.NoSuchElementException
|        at ImmutableCollections$ListItr.next (ImmutableCollections.java:375)
|        at Collections.max (Collections.java:674)
|        at (#2:1)

jshell> IntStream.of().max()
$3 ==> OptionalInt.empty

これは、「オブジェクトを考えると単位元になる最小値が一意に決まるとは限らない」ということで、数値以外を考慮するならemptyや例外になる、となっています。JavaScriptのmaxは-Infinityを返すらしい。
つまり、これは「場合による」が正しいということに。

こういった話は数学だ、と言われてもあまり学校で習った数学ではなさそう。 じゃあ何かとなるのだけど、これは「離散数学」という分野を勉強するとよさそう。離散数学の中でも、代数型とか群・環・体とかいうやつ。
で、「離散数学」で勉強しようとすると難しそうな本が多かったりするので、この本くらいがおすすめ。

ということに近い話をWEB+DB PRESS Vol.135に書いていたところだったので、少し詳しく書いてみました。
「ループと状態遷移、ついでにJava 21」として、プログラムの処理中の状態遷移についてまとめています。コードサンプルにJava 21を使っているので、非常にコンパクトに完動するコードサンプルを多く載せれていて、これはいいなとなっています。興味ある人は見てみてください。