Common Lispの定数宣言と
ヒープ最適化
(defconstant)

  • Common LispのSBCLでdefconstantを使っても、C++のconstexprのような即値埋め込みは保証されません。
  • 理由はCommon Lispが対話的な再定義を前提に設計されていて、コンパイラが安全策をとるため。
  • 性能を引き出すにはdefconstantよりdeclare (type ...)による型宣言とdeclaim (optimize ...)を使います。
  • fixnumdouble-floatで型を明示するとボックス化を避けてレジスタ上で直接演算できます。

関連記事

1. 他の言語での「定数」は何をしているか

C言語やC++では、不変な値は定数にすると計算効率を上がることがあります。

他の言語での「定数」 C言語 #define テキスト置換 即値に展開 C++ constexpr コンパイル時確定 即値保証 Common Lisp defconstant 意図の宣言のみ 保証なし 最適化への効き:強い ← ← ← 限定的

Common LispのSBCLでも同じような定数宣言を期待したのですが、どうもdefconstantは、コンパイラへの最適化ヒントとしてはちょっと弱い存在のようです。

1.1. C言語の #define マクロ

C言語では、定数として扱われる値を #define で定義します。

#define SCALE 1024

int process(int x) {
    return x * SCALE;
}Code language: PHP (php)

#define は、コンパイル前にソースコードを機械的に置き換える単純な仕組みです。
そのため SCALE はコンパイル時にはすでに 1024 に展開されていて、

return x * 1024;Code language: JavaScript (javascript)

と同じコードとして扱われます。

この数値は、 即値(immediate) です。
これは、ヒープオブジェクトへのポインタではなく、値そのものがオブジェクト表現に直接埋め込まれる方式を指します。
つまり、変数としてメモリを確保する必要はなく、再代入や再定義の概念もありません。

ただし、構文的なチェックがないため、複雑な式は括弧で包んでおかないと、思わぬ誤動作が紛れ込むこともあります。

1.2. C++のconstexprconst

C++では、constも似た動作をしますが、defineの代替なら C++11で導入された constexprのほうが適しています1

constexprはコンパイル時に値が確定することをコンパイラに約束します2

constexpr int scale = 1024;

int process(int x) {
    return x * scale;
}Code language: JavaScript (javascript)

コンパイラはscaleを即値として埋め込んでよいと確信できます。
これも再定義の余地はなく、値は不変です。

2. Common Lispのdefconstant

一方、Common Lisp の defconstant
これは、名前付きの値が変更されないことを宣言する定義形式です。

defconstant が弱い理由 設計思想 REPL優先 対話的に再定義できる 環境を前提にする → 大胆な置換は危険 コンパイラの対応 安全策を優先 即値に埋め込まない 可変オブジェクトも来る → 最適化は限定的 名前の不変性 ≠ 値の不変性

ただし、C++ の constexpr のように「必ずコンパイル時に即値として展開される」ことを強く保証するものではありません。

(defconstant +scale+ 1024)

(defun process (x)
  (* x +scale+))

defconstantは「この名前を定数として使う意図がある」という宣言です3
処理系によっては定数畳み込みが起きることがありますが、保証はありません。

理由は言語の設計思想に根差しています。

2.1. 対話的再定義との共存

Common Lispは、REPLで動かしながらコードを積み上げていく開発スタイルを前提にしています。

コンパイラが+scale+を即値として全面的に埋め込んでしまうと、あとからdefconstantを再評価したときに、すでにコンパイルされた関数だけが古い値を持ち続けます。

(defconstant +scale+ 1024)
(defun process (x) (* x +scale+))

;; あとでREPLで再評価しようとすると…
(defconstant +scale+ 2048)  ; 処理系によっては警告またはエラー

同じ値での再評価は許容する処理系が多いですが、別の値への再定義は通常エラーになります4

2.2. 値の不変性と名前の不変性は別

C++のconstexprは、名前も値も変わらないことをコンパイル時に確定します。
しかし、Common Lispでは「名前への再代入をしない」と「値そのものが不変である」は別の話です。

;; 配列を定数名に束縛した場合
(defconstant +buf+ (make-array 10 :initial-element 0))

;; 名前への再代入ではないので、これは防げない
(setf (aref +buf+ 0) 99)Code language: CSS (css)

数値や文字のような不変オブジェクトなら問題ありませんが、配列やハッシュテーブルのような可変オブジェクト、つまり中身を書き換えられるオブジェクトが来た場合、コンパイラは即値として扱いにくくなります。

2.3. コンパイル時と実行時の分離

Common Lispでは、ファイルをコンパイルして.faslファイルを作り、あとからロードする運用が普通です5

コンパイル時に見えている定数定義と、実行時にロードされる環境が完全に一致するとは限りません。
コンパイル順やロード順によって前提が変わりうるため、コンパイラは安全策を取ります。

3. 最適化に効く型宣言

ちなみに、Common Lispのコンパイラが重視するのは、値が定数かどうかより、型が明確かどうかです。

最適化に効く型宣言 型宣言なし ボックス化 (defun add (x y) (+ x y)) 型不明 → ヒープ確保 汎用ルーチンを使用 遅い 型宣言あり レジスタ演算 (declare (type fixnum x y)) (the fixnum (+ x y)) 型確定 → ヒープ不要 ネイティブ整数演算 速い fixnum / double-float で型を明示する
(declaim (optimize (speed 3) (safety 0)))

(defun add (x y)
  (declare (type fixnum x y))
  (the fixnum (+ x y)))Code language: PHP (php)

fixnumはプラットフォーム依存の高速整数型で、多くの処理系でマシンのネイティブ整数幅に対応します6
declareで型を伝えると、コンパイラはボックス化を避けた整数演算を生成できます。
theフォームは戻り値の型をコンパイラに伝え、戻り値のチェックも省きます7

(defun add (x y)
  (+ x y))

xyの型が不明なので、コンパイラは汎用の加算ルーチンを使います。
整数か浮動小数点か、あるいはビッグナムかもしれない。

型宣言がなければ、Common Lispは浮動小数点演算でもオブジェクトをヒープに確保することがあります。これをボックス化といいます8

型を明示すると、レジスタ上で直接演算できるようになります。

浮動小数点の場合は、

(declaim (optimize (speed 3) (safety 0)))

(defun scale-value (x factor)
  (declare (type double-float x factor))
  (the double-float (* x factor)))Code language: PHP (php)

3.1. defconstantと型宣言を組み合わせる

defconstant単体よりも、型情報を合わせて与えることで処理系が最適化しやすくなります9

(defconstant +scale+ 1024)
(declaim (type fixnum +scale+))

(defun process (x)
  (declare (type fixnum x))
  (the fixnum (* x +scale+)))Code language: PHP (php)

数値リテラルの場合、処理系によってはここで定数畳み込みが走ります。

4. インライン展開との組み合わせ

inline宣言も同様に性能に効きます10

インライン展開との組み合わせ ① inline宣言 (declaim (inline square)) 関数呼び出しを 展開して除去 ② 型宣言 (declare (type fixnum x)) ボックス化を 回避 ③ 最大最適化 周囲の型情報と 組み合わさり より強い最適化 呼び出しコスト0 defconstant は「変えない意図」の明示。最適化は型宣言で行う。

関数呼び出しのオーバーヘッドがなくなるからです。

(declaim (inline square))

(defun square (x)
  (declare (type fixnum x))
  (the fixnum (* x x)))

(defun sum-of-squares (a b)
  (declare (type fixnum a b))
  (the fixnum (+ (square a) (square b))))Code language: PHP (php)

周囲の型情報と合わさることで、より強い最適化が可能になります。

5. まとめ

defconstantが最適化に弱いのは、Common Lispが「再定義できる対話的な環境」を前提に設計されているからです。
コンパイラが大胆な置換を安全に行えるだけの保証を、言語仕様が与えていません。

言語定数の仕組みコンパイラへの保証最適化への効き
C#define置き換え強い
C++const再代入不可(ポインタ経由は別)やや強い
C++constexprコンパイル時確定、再定義不可強い(即値埋め込み保証)
Common Lispdefconstant処理系依存限定的
Common Lispdeclare (type ...)コンパイラへの直接ヒントやや強い

性能を引き出したいなら、defconstantより型宣言と最適化宣言を中心に置くのが実態に合っています。
defconstantは「この名前は変えない意図がある」という意味の明確化として使い、最適化はコンパイラに型情報を渡す形で行うのが、Common Lispらしいアプローチです。

  1. constはポインタやリファレンス経由での変更を防げない場合があります。たとえばconst int* ppの指す先の変更を禁じますが、int* const pはポインタ自体への再代入を禁じるだけで、指す先の値は変更できます。 – constexpr (C++) | Microsoft Learn
  2. constexprはC++11で導入されたキーワードです。C++14以降は記述できる式の制約が緩和され、より複雑な処理をコンパイル時に評価できるようになりました。 – constexpr specifier (since C++11) – cppreference.com
  3. Common Lispでは定数名を+name+のようにプラス記号で囲む慣習があります。グローバル変数の*name*(アスタリスクで囲む「イヤーマフ」と呼ばれる記法)と区別するためのコーディング規約です。 – Common Lisp Style Guide
  4. Common Lisp HyperSpec(CLHS)は「別の値でのdefconstant再定義は結果が未定義(consequences are undefined)」と規定しています。SBCLではDEFCONSTANT-UNEQLコンディションが発生します。 – CLHS: Macro DEFCONSTANT
  5. .faslはFast Load(高速ロード)の略です。処理系によってファイル拡張子が異なり、SBCLでは.fasl、CLISPでは.fas、LispWorksでは.ofaslなどを使います。 – Common Lisp – Wikipedia
  6. CLHSはfixnumの範囲を「少なくとも-2^15から2^15 - 1の範囲を含む」と定めており、具体的な値は処理系に任されています。64ビット版SBCLではmost-positive-fixnum4611686018427387903(62ビット符号付き整数の最大値)です。 – The Common Lisp Cookbook – Numbers
  7. theフォームは宣言した型と実際の値が一致しない場合、動作は未定義です。safetyを高く設定している環境では型チェックが行われますが、(optimize (safety 0))のもとでは検査なしにそのまま実行されます。 – CLHS: Special Operator THE
  8. ボックス化とは、数値などのプリミティブな値をヒープ上のオブジェクトとしてラップする処理です。毎回メモリ確保とGCの対象になるため、ループ内での繰り返しが多いほどパフォーマンスへの影響が大きくなります。 – The Common Lisp Cookbook – Performance Tuning and Tips
  9. declaimはファイルのトップレベルで使うグローバル宣言で、コンパイル時に効果を持ちます。一方declareは関数本体の先頭など、特定のスコープ内でのみ有効なローカル宣言です。 – CLHS: Declaration TYPE
  10. (optimize (speed 3) (safety 0))は最適化品質を指定します。speedは実行速度(0〜3)、safetyはエラーチェックの厳しさ(0〜3)です。safety 0は境界チェックや型チェックを省くため、バグの発見が難しくなります。本番コードではsafety 1以上を保ちながらホットパスだけ絞る運用が一般的です。 – SBCL User Manual – Efficiency