「配列のすべての要素が条件を満たすならtrueを返す関数」は、空の配列に対してはtrueを返すべきというお話
お話1
むかしむかし、あるところに関数くんがいました。
関数くんは前を通りかかる配列たちひとつひとつに、必ず質問をします。
関数「あなたの持つ要素は、すべて私の出す条件を満たしますか?」
配列たちはそれぞれ答えます。
配列1「僕の持つ要素はすべてきみの条件を満たすよ!」
関数「それなら、ここを通っても問題ないですね。」
配列2「僕の持つ要素のうち1つはきみの条件を満たさないや…。」
関数「もしそうなら、ここは通れませんよ?」
このように、関数くんは配列たちに声をかけます。
あるとき、空配列くんが関数くんの前を通りました。
関数くんは質問をします。
関数「あなたの持つ要素は、すべて私の出す条件を満たしますか?」
空配列くんは答えます。
空配列「僕は要素をひとつも持っていないから、条件を満たすかわかりません。」
関数くんは驚いて、少し頭をひねったあと、別の質問をしました。
関数「では、あなたは私の出す条件を満たさない要素を、ひとつでも持っていますか?」
空配列くんは自信満々に答えます。
空配列「僕は要素をひとつも持っていないから、条件を満たさない要素はひとつも持っていません!」
その答えを聞いた関数くんは、にこりと笑って言いました。
関数「それなら、ここを通っても問題ないですね。」
もう少しきちんとしたお話1
たとえば、「すべての火星のユニコーンは紫色である」という命題を考えてみましょう。現在の科学的な認識では、火星にユニコーンは存在しません。しかし、この命題はその存在しないユニコーンについて何かを主張しています。結果として、この主張は真であるとみなされます。なぜなら、存在しない火星のユニコーンについて、この命題が間違っていると証明する方法がないからです。
「すべてのXはYである」という形の命題を考えたとき、この命題はXが存在しない場合(Xの集合が空集合である場合)でも真となります。これは、Xが存在しないため、その否定(「すべてのXがYでない」という命題)を証明できないからです。このような状況の真実を「Vacuous truth」と呼びます。
"配列のすべての要素が条件を満たす"とは、具体的には、「配列の中に条件を満たさない要素が存在しない」ことを意味します。したがって、空の配列に対してこの命題を評価するとき、空の配列の中にはどのような要素も存在しないため、とくに条件を満たさない要素も存在しません。結果として、「配列のすべての要素が条件を満たす」は空の配列に対して真となります。これは先に述べた「Vacuous truth」の概念に基づいています。
「Vacuous truth」はコーディングにおける一般的な規則であり、例えばJavaのStream APIやPythonの組み込み関数all()もこの規則に従っています。これらの関数は空のコレクションに対してtrueを返します。
また、この振る舞いは、論理的な一貫性を保つために重要です。もし空の配列に対してfalseを返すようにした場合、配列が条件を満たさない具体的な要素が存在するわけでもないのにfalseを返すという、直感的でない結果になってしまいます。
空の配列を入力として受け取ったときに関数がfalseを返すようにすると、これは「配列の中に条件を満たさない要素が存在する」ことを意味します。しかし、空の配列には何の要素も存在しないため、これは矛盾していると言えます。
お話2
むかしむかし、あるところに関数くんがいました。
関数くんは前を通りかかる配列たちに、必ず質問をします。
配列たちがグループを結成しているときは、グループに対して質問をします。
関数「あなたがたの持つ要素は、すべて私の出す条件を満たしますか?」
配列たちはそれぞれ答えます。
配列1・配列2・配列3「僕の持つ要素はすべてきみの条件を満たすよ!」
関数「それなら、ここを通っても問題ないですね。」
あるとき、関数くんの前を問題なく通れる配列1くんと、空配列くんがグループを組んで関数くんの前を通りました。
関数くんは質問をします。
関数「あなたがたの持つ要素は、すべて私の出す条件を満たしますか?」
空配列くんは、自分が果たして関数くんの条件を満たすのか、自信がありませんでした。
しかし、グループを組んでいた配列1くんは言いました。
配列1「僕の持つ要素はすべて条件を満たすし、僕と空配列くんをグループでみても、すべての要素は条件を満たすよ!」
空配列くんはビックリして、しかし、そのあと大きくうなずきました。空配列くんには要素がないので、配列1くんが条件を満たすなら、配列1くんと空配列のグループも条件を満たします。
関数くんは、にこりと笑って言いました。
関数「それなら、ここを通っても問題ないですね。」
もう少しきちんとしたお話2
実装する関数をis_stringとします。
2つの配列a、bについて、is_stringを考えた後に結果を合算することを考えます。
1つ目の考え方として、is_string(a) and is_string(b)があります。aとbのすべての要素についてtrueであることを確認したいので、orではなくandになります。
2つ目の考え方として、先に2つの配列を連結して、そのあとにis_string関数を適用する方法があります。Pythonだとis_string(a + b)です。これもやはりaとbのすべての要素についてtrueであることを確認しています。
ここで、aはすべての要素がstringである空ではない配列、bは空の配列だとしましょう。もし、is_string関数が空の配列に対してfalseを返す場合、前者のis_string(a) and is_string(b)はfalseとなります。なぜなら、is_string(a)はtrueですが、is_string(b)はfalseになるからです。
一方で、後者のis_string(a + b)はtrueになります。bは空の配列ですから、aが条件を満たすならa + bも条件を満たします。
そうなると、前者のis_string(a) and is_string(b)ではfalseなのに、後者のis_string(a + b)はtrueとなり矛盾します。両方とも同じことを確認しているはずなのに、やり方によって結果が異なってしまうのです。これは困ります。
では、is_string関数が空の配列に対してtrueを返す場合はどうでしょうか。前者のis_string(a) and is_string(b)はtrueとなります。なぜなら、is_string(a)、is_string(b)ともにtrueだからです。後者のis_string(a + b)もtrueになります。これは先ほどと同じです。両方とも同じことを確認していて、結果も一致します。
よって、矛盾を生じさせないために、is_string関数は空配列に対しtrueを返すべきでしょう。
もっときちんとしたお話
この辺を読んでください[1]。この記事は厳密な証明をする記事ではないので割愛します。
個人的に思うこと
空の配列を渡されたときに例外を返すという実装は、関数の切り分け方が悪いと思います。1関数に1仕事の考えだと、空の配列を渡されたときに例外を返す処理と、配列のすべての要素が条件を満たすかどうかの処理は別の関数で実装されるべきだと思います。よって、「配列のすべての要素が条件を満たすならtrueを返す関数」に空の配列を渡したときは、論理的に正しいtrueを返すよう実装したほうが良いでしょう。
しかし、関数を使う人間が、どのような意図で関数を使うかはわかりません。そのため、もし仕様として「空の配列を渡されたときは例外を発生させる」と決まっているならば、関数内に空の配列に対する例外処理を入れておくのも悪くはないと思います。空の配列を渡されたときのエラーを早いうちにつぶせるというのは、実運用上ではメリットになりうるとも思います。
どちらにせよ、関数のドキュメントに空の配列を渡されたときに何を返すか明記すべきでしょう。
-
ほかにも良い記事や説明があれば教えていただけますと幸いです。 ↩︎
Discussion