前日譚
私が通っていた小学校では3年生までは担任にすべての教科を教わっていたが、4年生以降は科目によっては専門の先生に教わることとなっていた。教育委員会の思惑か学校になにか狙いがあったのかはわからないが、私が4年生になるときに、算数を担当するYという教師がやってきた。
Yは40代の独身で、長身でちょいワル系のダンディな中年ではあったが、独特のネットリ感がある話し方で男女ともに評判はよくなかった。教え方も特別うまくなかったようにも思う。そのYが最初の授業で自己紹介がてら「ゼロとゼロの計算」について話したのを覚えている。おもむろに黒板に、
の4つの式を書き、要領悪くクラスメイトの誰かを当てて答えさせた。そして「うん。これは0だ」を繰り返すこと3回、最後に がやってきた。おそらくみんなの興味もそこにあった。
私はすでにゲームやパソコンを通じて、計算できない式があることや、無限大という概念があることを知っていたので、これは少なくとも小学生の範囲では答えがないものと考えていた。
Yの答えも同じで、これは計算できないという、子供たちをちょっとがっかりさせる答えを述べつつも、こうした問いかけを通じて算数の不思議な性質を説いて、その楽しさを伝えたかったのだろうが、後ろで腕を組んでそのやりとりを聞いていた担任は「何いってんだコイツ」みたいな顔をしていて聞いていたし、私もおおむねそんな感想を持った。
しかしゼロ除算はやってくる
義務教育や答えのある問題ではゼロ除算が出ないようにうまく問題が作られているか、あるいはそのように誘導されている。算数だけの文脈に焦点を当てたときに、ゼロ除算が出たとしても「計算できない」と答えるしかないし、ゼロ除算が出るたびにこうしたやりとりを繰り返すのは無意味だからだ。
しかし、現実の問題を取扱い始めるとゼロ除算はやってくる。0という数値を頻繁に見ることになるし、運の悪いことに割る数や分母として現れる。レポートが動かなくなったとかエラーが発生したとか言われて見にいくと、何故だか紛れ込んだゼロで割ってしまっているおかげでシステムが動作しなくなっているのだ。
この情報化社会では手書きで大量の計算をすることはなく、ほとんどはコンピュータ任せだ。しかしコンピュータの世界であってもすべてのゼロ除算が失敗するわけでもないし、やりようによっては誤魔化すこともできる。ここではゼロ除算によって起きることを整理して、いくつかの解決策を考えてみる。もちろんその結果として既存の解決策や概念に収束するかもしれない。
整数型のゼロ除算
多くのプログラミング言語では整数型でゼロ除算をすると例外を送出する。C言語のような機械語に近い言語では、例外ではなくアプリケーションがクラッシュすることもある。それでも今ではアプリケーションレベルでゼロ除算を検知できることが多いし、そうでなくても前もって割る数がゼロであるかどうかを確認することもできる。しかし、それはしばしば無視されるし、万が一ゼロであったときに処理を続行するには何かしらの手立てが必要となる。
浮動小数点数のゼロ除算
浮動小数点数でゼロ除算をしたときは整数型の場合と異なり、計算結果がNaNになることがある。どんなプログラミング言語も同じというわけではなく、JavaScriptではNaNになるし、PythonではZeroDivisionError例外になる。多少の違いはあれど、ゼロ除算は依然としてなんらかの怪しい結果を返すことになる。
NaNはNot a Numberの略で非数を表す。面白いことにこれは数値ではないことを示す数値で、NaNはあらゆるほかの浮動小数点数と等しくないし、NaNとも等しくない。そのため、NaNであることを調べるには isNaN() や Number.isNaN() を使わねばならない。
NaN == NaN // false
NaN != NaN // true
isNaN(NaN) // true
Number.isNaN(NaN) // trueこのおかげで浮動小数点数は反射律を満たさない。整数型においてはあらゆる同じ値は等しいが、浮動小数点数はあらゆる同じ値について等しくない可能性があるのだ。
また、NaNは伝播する。NaNが現れる式を評価すると結果はNaNになる。短絡評価やMaybeモナド、Option<T>型のように動作して、どこかで計算が失敗するとNaNに収束するようになっている。それであっても計算としては成功するというある種の厄介な性質を持つので、整数除算以上に検証されないことが多い。
ゼロ除算をどう処理すべきか?
入口で抑える
除算が発生するところはわかっているわけだから、入口で抑えるのはもっともな対策だ。割る数を逐一ゼロでないかを判定して、もしゼロであったらなんらかの回避策を講じればいいのだ。だが、これはあまり現実的ではない。
除算が出現するたびに条件分岐が発生して、ほとんどの状況で起こりもしないゼロに怯えるような強迫観念に満ちたコードは書きたくないし、見通しも悪くなる。ゼロ除算が明らかでその登場回数が十分に少ないときには入口で抑えることは十分可能ではあるものの、コードクローンが発生しやすくなるし、計算過程でゼロが発生するような状況では、すべての除算に対してゼロ除算が行われていないかどうかを検査するガードを追加するのは骨が折れる。
出口で抑える
出口で抑える、つまり計算全体を対象に例外を捕捉するか、NaNを検査するのは入口で捕捉する方法よりも現実的な方法である。これなら計算過程でエラーが発生しても問題ないし、最終的な答えを得るところで値にエラーがないかどうかを確認すればよい。
ただし、これにも問題がないわけではない。人はほとんど成功することに対しては楽観的に考える節があり、事後の判定を忘れがちだ。思慮深いプログラマであればゼロ除算に対して気を配って例外を捕捉するかもしれないが、そもそもの話、その言語がゼロ除算専用の例外を送出するかどうかも気にしなければならない。ゼロ除算もほかの計算エラーも一緒にArithmeticExceptionになってしまうのであれば、ほかの計算エラーとの切り分けが難しくなってしまうからだ。
別の値に置き換える
例外というものはそこで処理を止めて大域脱出ができる便利な特性を持っていつつも、その例外を誰が処理すべきかという点については曖昧である。呼び出し側がすぐに処理すべきこともあるし、ユーザーインタフェースまで素通しにして直接メッセージを見せることだってある。責任の所在が一貫していないのだ。
昨今のResultやOptionパターンの普及を見ると、呼び出し側に処理を強制させることで、然るべき状況になったときに、どうすべきかの判断をプログラマに強要する。計算エラーが発生するような状況で、誰が拾ってくれるかわからない例外よりも具体的な解決方法であり、そのためロジックも曖昧になりにくくなる。
JavaScriptではプロトタイプのオーバーライドができるので、これを使ってゼロ除算を回避することを考えてみよう。
/**
* @param {number} value
* @param {number} defaultAnswer
* @returns {number}
*/
Number.prototype.divOr = function (value, defaultAnswer) {
return value == 0 || Number.isNaN(this / value)
? defaultAnswer
: this / value
}これを使えば、ゼロ除算が起きそうなコードは次のように書ける。これはかなりシンプルになるし、既存の式にも自然に組み込むことができる。既定値に関数を受け取ってその結果を返すバリアントを作ってもいいかもしれない。
(20).divOr(0, 42) // 42
(20).divOrElse(0, () => 42) // 42もうひとつ、NaNは割り算に限らず現れることがある。これに対象するために、通常の数値を非NaNに変換するメソッドを用意しておくと、対応できる状況が増えるかもしれない。
/*
* @returns {number} defaultValue
*/
Number.prototype.ifNaN = function (defaultValue) {
if (Number.isNaN(this)) {
if (typeof defaultValue != 'number' || Number.isNaN(defaultValue)) {
throw new Error('`defaultValue` must be a Number')
}
return defaultValue
}
return this
}分数型
分数は小数では表現が難しい数をうまく表すためだけでなく、やがて来るべき計算を先送りにする表現方法でもある。無限の時間と空間があれば答えに行き着くはずではあるが、便宜的にいい感じのところで計算を止めているのだ。これは遅延評価の考え方でもある。
この性質を使えば、分数型では という表現が可能だ。もしかしたらプログラミング言語やそのライブラリの分数クラスではこれを許さないかも知れないが、ここでは許すものとして考える。そうであってもこの という分数に対して式を繋げることはできたとしても、評価することはできないので、強制的に評価したときには計算が失敗したことを示す例外を送出するか、NaNにするしかない。ただ、ゼロ除算の評価を先送りできたのは進歩があったと言える。
しかし、連分数に対応しない限りは分数単体のゼロ除算にしか効力が及ばないので、実際のところは、そんなに複雑なことをするよりも、前項のようにプログラマに対して選択を迫るほうがずっと見通しがいいし、実際のところそこまで数学的な処理が必要になることは多くない。