[Common Lisp] Obsoletedなエイリアスを定義するマクロ

小さなマクロ1個の小ネタ(+おまけ)です。

ライブラリを書き、ある程度使ったあたりで関数名などの命名のまずさに気付くこともあると思います。かといって、いくつかのプロジェクトで使い始めているので、今さら名前を変更するのも面倒臭い…。そういったときに、旧名はエイリアスとして残しておいて、使われたときには警告を出すというのは常套手段であると思います。

Common Lispにはそういった時にデフォルトで利用できるものが見つからなかったので、6行程度の簡単なマクロを書いてみたメモです。

目次

利用イメージ

(defun bad-name-func (x y)
  (+ x y))

うっかりダメな名前で関数を作ってしまった…。しかも、もう外で使われている…。

(defun good-name-func (x y)
  (+ x y))

(def-obsoleted-alias bad-name-func good-name-func)

関数名を改善する。互換性を保ちたいので、旧名もエイリアスとして残しておく(def-obsoleted-alias の実装は後述)。

> (bad-name-func 1 2)
; Warning: "BAD-NAME-FUNC" is obsoleted. Please use "GOOD-NAME-FUNC" instead.
; While executing: BAD-NAME-FUNC, in process repl-thread(13).
3

引き続き旧名も使える、が怒られる。

実装

def-obsoleted-alias の実装は次の通りです。エイリアスとして旧名でマクロを生成します。生成されたマクロは利用時(コンパイル時)に警告を出力します。

(defmacro def-obsoleted-alias (obsoleted-name alter-fn)
  (let ((rest (gensym)))
    `(defmacro ,obsoleted-name (&rest ,rest)
       (warn ,(format nil "\"~A\" is obsoleted. Please use \"~A\" instead."
                      obsoleted-name alter-fn))
       `(,',alter-fn ,@,rest))))

未だにマクロを生成するマクロはじっくり見ていると良く分からなくなってくるので、一例展開してみると次のようになります。生成されたbad-name-macroマクロは、利用箇所で単なるgood-name-funcの呼び出しに展開されるため、実行時のオーバーヘッドはありません。

(def-obsoleted-alias bad-name-func good-name-func)
;; ->
(defmacro bad-name-func (&rest #:g346681)
  (warn "\"bad-name-func\" is obsoleted. Please use \"good-name-func\" instead.")
  `(good-name-func ,@#:g346681))

次に書く問題はあるのですが、お手軽なのでちょっとした用途には十分かと思います。

その問題ですが、bad-name-funcが関数からマクロに変わってしまったため、applyしているなど明示的に関数扱いしているコードに対しては互換性を保てないというものです。後ろのおまけで関数生成バージョンも試してみますが、マクロ生成バージョンではコンパイル時に警告を出せるのに対し、関数生成バージョンでは実行時まで警告を出せません。差し引きで(簡易利用用途としては)マクロ生成バージョンの方が良いだろうと思っています。

おまけ

関数生成バージョン

エイリアスをマクロとしてではなく関数として生成してみます。

前述の通り、警告の出力タイミングは実行時になります。頻繁に利用する関数で何度も警告を出すと応答不可になりかねないので、一度警告が出された関数はハッシュテーブルに記録して二度は出ないようにしています*1

(defvar *table-output-obsoleted-warning* (make-hash-table))
(defun has-output-obsoleted-warning-p (obsoleted-name)
  (gethash obsoleted-name *table-output-obsoleted-warning*))
(defun register-output-obsoleted-warning (obsoleted-name)
  (setf (gethash obsoleted-name *table-output-obsoleted-warning*) t))

(defmacro def-obsoleted-fun (obsoleted-name alter-fn)
  (let ((rest (gensym)))
    `(defun ,obsoleted-name (&rest ,rest)
       (unless (has-output-obsoleted-warning-p ',obsoleted-name)
         (warn ,(format nil "\"~A\" is obsoleted. Please use \"~A\" instead."
                        obsoleted-name alter-fn))
         (register-output-obsoleted-warning ',obsoleted-name))
       (apply #',alter-fn ,rest))))

展開例は次のようになります。

(def-obsoleted-fun bad-name-func good-name-func)
;; ->
(defun bad-name-func (&rest #:g346722)
  (unless (has-output-obsoleted-warning-p 'bad-name-func)
    (warn "\"bad-name-func\" is obsoleted. Please use \"good-name-func\" instead.")
    (register-output-obsoleted-warning 'bad-name-func))
  (apply #'good-name-func #:g346722))

アノテーションにしてみる

関数のObsolete化というと、C#Obsolete属性や Java@deprecated アノテーションのように、アノテーション的にやるイメージがあるので、試しに cl-annotを使ってアノテーション化してみます。

(ql:quickload :cl-annot)
(use-package :cl-annot)
(enable-annot-syntax)

(defannotation obsoleted-alias ((&rest obsoleted-names) definition-form) (:arity 2)
  `(progn ,@(mapcar (lambda (name)
                      `(def-obsoleted-alias
                           ,name
                           ,(cl-annot.util:definition-form-symbol definition-form)))
                    obsoleted-names)
          ,definition-form))

次のように使います。一応、obsoletedな名前はカッコ内に複数並べて書けるようにしています。これで何度下手な名付けをしても安心です :-)

@obsoleted-alias (bad-name-func)
(defun good-name-func (x y)
  (+ x y))

本格的にcl-annot と連携しようと思うと、 @export (など?)との兼ね合いも考えないといけないので、これでは足りないのでしょうね…。

追記:コンパイラマクロ利用版

コメントでコンパイラマクロを利用する方法を教えて頂きました。「コンパイル時に何か(警告出力)したい」という話なので確かにコンパイラマクロが適任ですね。頭になかったです……。

(defmacro def-obsoleted-alias (obsoleted-name alter-fn)
  (let ((rest (gensym)))
    (flet ((make-body ()
             (if (macro-function alter-fn)
                 ``(,',alter-fn ,@,rest)
                 `(apply #',alter-fn ,rest))))
      `(progn (,(if (macro-function alter-fn) 'defmacro 'defun) ,obsoleted-name (&rest ,rest)
                ,(make-body))
              (define-compiler-macro ,obsoleted-name (&rest ,rest)
                (warn ,(format nil "\"~A\" is obsoleted. Please use \"~A\" instead."
                               obsoleted-name alter-fn))
                ,(make-body))))))

REPLでの利用時はコンパイルが走らないため警告が出ませんが、最終的にコードに落とす段階で気付けるので実用上の問題はないと思われます。

さらに追記:最初 def-obsoleted-fun の代替として関数対応版を書いたのですが、せっかくなので関数・マクロ両用版に書き直しました。

; ; 準備
(defun good-name-func ())
(defmacro good-name-macro ())

;; 関数の場合
(def-obsoleted-alias bad-name-func good-name-func)
;; ->
(progn (defun bad-name-func (&rest #:g347909)
         (apply #'good-name-func #:g347909))
       (define-compiler-macro
         bad-name-func
         (&rest #:g347909)
         (warn "\"bad-name-func\" is obsoleted. Please use \"good-name-func\" instead.")
         (apply #'good-name-func #:g347909)))

;; マクロの場合
(def-obsoleted-alias bad-name-macro good-name-macro)
;; ->
(progn (defmacro bad-name-macro (&rest #:g347910)
         (list* 'good-name-macro #:g347910))
       (define-compiler-macro
         bad-name-macro
         (&rest #:g347910)
         (warn "\"bad-name-macro\" is obsoleted. Please use \"good-name-macro\" instead.")
         (list* 'good-name-macro #:g347910)))

*1:流石に1回出力ではどこで利用されているかの追跡が困難なので、キーにパッケージも加えて、1つのパッケージで1回までなどとした方が良さそうです