log x²と2 log xは定義域が指定されていない式であり、定義域次第で同じ写像にも別の写像にもなる。- Common Lisp で
2 log(-3)を計算すると複素数が返るように、写像は「定義域・終域・対応規則」の三つ組で決まり、式だけでは関数は定まらない。 assertやcheck-typeで定義域を明示し、想定外の入力を早期に検出することがバグを防ぐ設計につながる。
1. 対数関数の問いを考えてみる
「 と は同じ関数か?」
という問題を見かけました。
対数の性質 を使って、素朴に式変形すれば、
と変形でき、同じと答えるでしょう。
しかし、ちょうど と書いてしまうような話と同じで、式変形が常に成り立つとは限りません。
であって、 では です。
これをもとに、対数のグラフを思い浮かべると、
は の領域にも存在しますが、 は にしか現れない。
ということは、「同じ関数ではない」と言えそうです。
1.1. Common Lispで計算してみる
しかし、ほんとにそれでよいか、確かめてみます。
試しに 2 log x をコードで書いて、 を計算してみましょう。
(defun f (x)
(* 2 (log x)))
(f -3)
;=> #C(2.1972246 6.2831855)Code language: PHP (php)
エラーにはならず、複素数が返ってきました。
実は、 は実数では定義されませんが、複素数の範囲では と計算できます。
そのため、 という値を返します。
対数グラフから「 は にしか存在しない」と言いましたが、 でも値が求まってしまいました。
処理系は式を渡されたら、「定義できる範囲」で計算します。
では log x² のほうはどうでしょう。
(defun g (x)
(log (* x x)))
(g -3)
;=> 2.1972246Code language: PHP (php)
こちらは実数が返ります。
同じ を渡して、f は複素数、g は実数を返しました。
グラフから「違う」と気づいた直感は正しかったわけですが、その理由は「定義域が違う」ではなく、 において式の値そのものが違う、ということになります。
2. 暗黙の前提に気づく
ここで最初の問いに戻ります。
式変形の結果をそのまま「同じ関数」と結論づけたときには、定義域の違いが視野に入っていませんでした。
一方、対数グラフから「違う」と答えたときには、定義域を意識しています。
ただ、そこでもう一つの問いが生まれます。
「 の定義域は 」と言ったとき、その定義域はどこに書いてあるのか?
書いていません。
高校数学では、「実数の範囲で定義できる最大の集合を定義域とする」という規約が暗黙に適用されます。
この規約は略されていて、式をもとに定義域を決める習慣が身についています。
しかし、この「実数範囲で考える」という暗黙の前提は、常には成り立ちません。
Common Lispの処理系が複素数を返してきたのは、そんな規約を持っていないからです。
2.1. 式には定義域が書かれていない
ここで、関数と式という言葉の違いに注意深くなる必要があります。
写像としての関数は「定義域・終域・対応規則」の三つ組で決まります。
式は、対応規則の候補を記述する道具です。log x² も 2 log x も、定義域を指定されていない式で、関数ではありません。
定義域 を指定しなければ写像は定まりません。
つまり、定義域が書かれていないものに「同じ関数か違う関数か」と問うのは、問い自体が未完成だったのです。
定義域を と指定すれば、両者は同じ写像になります。
と をそれぞれ与えれば別の写像になります。
答えは、定義域をどう取るかに依存していて、式だけを見ても決まらないのです。
これが、この問いの引っかけの構造です。
「同じ」と答えた人は式変換が常に成り立つと思い込み、「違う」と答えた人は定義域を式から無自覚に読み取りました。
どちらにも、式・定義域・関数の三者の関係についての混同があったわけです。
3. プログラミングでの定義域と未定義動作
さて、この問題はただの「引っ掛け」ではなく、プログラミングにおいて実用的な意味があります。
というのも、プログラムのバグの大きな部分が、関数の定義域をあまり意識できていないことに起因しているからです。
「この関数にどんな値を渡してよいか」を曖昧にしたまま実装すると、想定外の入力が来たときに想定外の結果を返します。
これがバグの元になります。
Common Lispの処理系が で複素数を返したように、式は渡された値で計算を試みます。
ただ、それが正しい振る舞いかどうかは、その関数に何を期待するか、という人間の判断で決まります。
3.1. assertによる定義域の明示
Common Lispの場合、自分で定義域を明示することで、未定義動作に早めに気づくことができます。
(defun f (x)
(assert (> x 0) (x) "定義域外: ~A" x)
(* 2 (log x)))Code language: JavaScript (javascript)
assert が定義域の指定に相当します。
式とは独立した、設計上の決定です。
3.2. declareによる型宣言
declare で型を宣言する方法もあります。(real 0) は「0以上の実数」を表す型指定子です。
(defun f (x)
(declare (type (real 0) x))
(* 2 (log x)))Code language: PHP (php)
ただし declare は主にコンパイラへの最適化ヒントとして機能します。
実行時に違反を検出するかどうかは処理系とセーフティレベルの設定に依存します。
3.3. check-typeによる型制約
実行時に型による制約を明示したい場合は check-type が使えます。
「定義域を型として表現する」という意図が読みやすくなります。
(defun f (x)
(check-type x (real 0) "正の実数")
(* 2 (log x)))Code language: JavaScript (javascript)
これは、assert の派生で、assert が任意の条件式を取るのに対して、check-type は型指定子による制約に特化しています。
4. 定義域を示すマクロ構文
関数の定義域は、declareやcheck-typeを組み合わせて示すことができます。
(defun f (x)
(declare (type (real 0) x))
(check-type x (real 0) "正の実数")
(* 2 (log x)))Code language: PHP (php)
ただし、定義と処理がやや煩雑です。
定義域も、関数定義の構文として直接書けたほうがスマートです。
たとえば、こんな構文があれば、定義域が関数の一部であることが一目でわかります。
(my-defun f (x :from real :where (> x 0))
(* 2 (log x)))Code language: CSS (css)
Common Lispのマクロは、このような構文を実現できます。
(defmacro my-defun (name args &body body)
(let* ((clean-args (loop for a in args
until (keywordp a)
collect a))
(from-clause (let ((pos (position :from args)))
(when pos (nth (1+ pos) args))))
(where-clause (let ((pos (position :where args)))
(when pos (nth (1+ pos) args)))))
`(defun ,name ,clean-args
,@(when from-clause
`((declare (type ,from-clause ,@clean-args))
(check-type ,(car clean-args) ,from-clause
,(format nil "~A must be of type ~A" (car clean-args) from-clause))))
,@(when where-clause
`((assert ,where-clause (,@clean-args)
"定義域外: ~A" (list ,@clean-args))))
,@body)))Code language: JavaScript (javascript)
このマクロでは :from と :where をキーワードで受け取り、通常の引数リストと分離したうえで、declare・check-type・assert を自動的に挿入しています。
先ほどの my-defun f は、以下のように展開されます。
(defun f (x)
(declare (type real x))
(check-type x real "x must be of type real")
(assert (> x 0) (x) "定義域外: ~A" (list x))
(* 2 (log x)))Code language: PHP (php)
もちろん、実用上は assert や check-type を直接書けば十分です。
しかし、普通の defun は、定義域の条件を書くことを促していないことには、自覚的である必要があります。
これは、自由に書ける Common Lisp の良さなのですが、バグの温床にもなります。
数学的には「こう書く」と思い描けば、定義域を関数定義と切り離せないものと意識できます。
引数にどんな値を渡してよいかを自覚することが、バグを防ぐ設計の出発点になるのです。