読者です 読者をやめる 読者になる 読者になる

関数のドキュメント

common lisp read macro

JavaにはJavadocという有名なドキュメントコメントがある。
(実践しているかどうかは別として)関数その他にドキュメントをつけるという考えは良いと思う。
ただ、Javadocの場合、名前の通り(一定の規約に従った)コメントを使って(関数その他の)文書化を行っているので、(多分)プログラム内からそのドキュメントにアクセスすることは出来ない(と思う)
common lispの場合は、文書化のための機能があらかじめ言語に組み込まれている。

;; ドキュメント付きの関数定義
> (defun doc-example ()
    "関数定義の一番目の式が文字列の場合、その関数のドキュメントと認識される"
    'done)
--> DOC-EXAMPLE

;; ドキュメントを参照する
> (documentation 'doc-example 'function)
--> "関数定義の一番目の式が文字列の場合、その関数のドキュメントと認識される"

;; ついでに関数を実行
> (doc-example)
--> DONE

この場合、上の例にもあるように、プログラム内から簡単に参照できるので、少し便利*1

問題点

ただ、common lispのドキュメント機能には問題*2もある。
以下、その例。

;; common lispのドキュメントの問題点を示した関数定義
;; 長いコメントが定義本体を分かりにくくしている。
(defun Hello (name)
"これは、Hello関数のドキュメントです。
Hello関数は、一つの引数を受けとります。
引数の名前は、nameです。
nameはどんな型でも構いません。
Hello関数は、受け取ったnameをもとに、次のような出力を行います。
 \"Hello [name]!\"  # [name]の部分は、引数nameの値で展開されます。
この関数の戻り値は未定義です。"
  (format t "Hello ~A!" name))

ドキュメントは十分に説明的に書くのが望ましいと思うが、そうすると関数定義本体が分かりにくくなってしまう(と個人的には思う)
Javadocを真似て、特殊な規約に従ってコメントを書いて、それをドキュメントとする方法もあるが、その場合専用のパーサ*3が必要だし、既に書いたようにプログラム内から参照するのが難しくなってしまう。

解決策

で、その解決策。
参照: nlet

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;; Javadoc風の(関数定義の上に)ドキュメントを書くための準備
;; ドキュメント内容を格納しておくための変数
(defvar *doc* "")

;; 入力ストリームから、列end-quotsを検出するまで、文字を読み込む
;; 読み込んだ文字の列は、文字列として返す
(defun read-string (end-quots stream)
  (let ((len       (length end-quots))
        (end-quots (reverse end-quots)))
    (flet ((eos-reached? (s)
             (= (mismatch end-quots s) len)))
      (nlet self ((c #1=(read-char stream)) str)
        (if (eos-reached? str)
            (coerce (nreverse (nthcdr len str)) 'string)
          (self #1# (cons c str)))))))

;; #/.../#形式の文字列をリードマクロとして登録する
;; 最後に読み込まれた上記形式の文字列は、変数*doc*に格納されている
;;  TODO: nilを返す方が良い?
(set-dispatch-macro-character #\# #\/
  (lambda (stream char arg)
    (declare (ignore char arg))
    (setf *doc* (read-string "/#" stream))))


;;;;;;;;;;;;;
;;;; 関数定義
#/
これは、Hello関数のドキュメントです。
Hello関数は、一つの引数を受けとります。
引数の名前は、nameです。
nameはどんな型でも構いません。
Hello関数は、受け取ったnameをもとに、次のような出力を行います。
 "Hello [name]!"  # [name]の部分は、引数nameの値で展開されます。
この関数の戻り値は未定義です。
/#
(defun Hello (name)
  #.*doc*
  (format t "Hello ~A!" name))

やっていることは、文字列の別表記として#/.../#を導入し、そこで読み込んだ文字列を*doc*変数に保存、関数定義内で参照しているだけ。
ただ、これだけでも関数定義は大分見やすくなったように思う。
後、上の方法の場合、#/.../#内で文字列を読み込んでいる関数(read-string関数)を別の関数*4に変更することで、ドキュメント用の特殊記法なども使えるようにできる*5という些細なメリットもあると思う。


最後に、sbcl(1.0.34)でのdescribe実行例。

> (describe 'Hello)
COMMON-LISP-USER::HELLO
  [symbol]

HELLO names a compiled function:
  Lambda-list: (NAME)
  Derived type: (FUNCTION (T) (VALUES NULL &OPTIONAL))
  Documentation:
    
    これは、Hello関数のドキュメントです。
    Hello関数は、一つの引数を受けとります。
    引数の名前は、nameです。
    nameはどんな型でも構いません。
    Hello関数は、受け取ったnameをもとに、次のような出力を行います。
     "Hello [name]!"  # [name]の部分は、引数nameの値で展開されます。
    この関数の戻り値は未定義です。

  Source form:
    (LAMBDA ()
      (DECLARE (MUFFLE-CONDITIONS COMPILER-NOTE))
      (PROGN
       (SB-INT:NAMED-LAMBDA HELLO
           (NAME)
         (BLOCK HELLO (FORMAT T "Hello ~A!" NAME)))))

少し改良

*doc*変数を不要にした版。
柔軟性は若干劣る*6が、よりシンプル。

;; read-string関数の定義はそのまま

;; #/.../#の直後に関数定義が続くことを想定している
(set-dispatch-macro-character #\# #\/
  (lambda (stream char arg)
    (declare (ignore char arg))
    (let ((doc    (read-string "/#" stream))
          (fn-def (read stream))) 
      (destructuring-bind (defun name arg . body) fn-def
        `(,defun ,name ,arg ,doc ,@body)))))

;; 使用例
#/
これは、Hello関数のドキュメントです。
Hello関数は、一つの引数を受けとります。
引数の名前は、nameです。
nameはどんな型でも構いません。
Hello関数は、受け取ったnameをもとに、次のような出力を行います。
 "Hello [name]!"  # [name]の部分は、引数nameの値で展開されます。
この関数の戻り値は未定義です。
/#
(defun Hello (name)
  (format t "Hello ~A!" name))

*1:多分大抵の処理系のdescribe関数は、引数のシンボルに紐付くドキュメントがある場合は一緒に表示してくれる。sbclのsb-extやsb-impl内の関数の概要を知りたいときに結構重宝している。

*2:単にスタイル=コードの見栄えに関することなので、問題と感じるかどうかは人によりけりだと思うが。

*3:加えて、ドキュメントと関数の対応を保持しておく仕組みも必要。さらにややこしいことに、common lispJavaと異なり、関数定義がdefunという文字列で始まらなければならないという制約もないので、結局のところ、ドキュメントコメントを適切に認識するためには、完全なcommon lispインタプリタが必要になってしまうかもしれない

*4:例えば前に作成したeLisp文字列読み込み関数

*5:言い換えれば、ドキュメント記述に特化したミニ言語を割合手軽に作成・使用することが可能

*6:defunと同じ構造を持った式にしか使えなくなる