Cプリプロセッサ vs D
C言語が開発された時代は、コンパイラ技術は貧弱なものでした。 強力な機能を言語に付け加えようと思ったら、 テキスト処理マクロを使うのが 簡単な近道だったのです。しかし、留まることなく巨大化・複雑化を 続ける今のプログラムを見ていると、このテキスト処理による方法が 沢山の問題を生んでいることが明らかになってきました。 そんなわけで、Dにはプリプロセッサはありません。しかし、 プリプロセッサと同じ問題を解決する、 もっとスケーラブルな方法を提供します。
ヘッダファイル
Cのプリプロセッサでは
C と C++ では、ヘッダファイルをテキストとしてincludeする、という機能が重要な役割を果たしています。 この結果、多くの場合、各ソース毎に何万行ものコードを再コンパイルすることになり、 当然ながらコンパイル時間が遅くなってしまいました。 ヘッダファイルが普通どう使われるかを考えれば、この処理は、テキストとしての挿入ではなく、 定義されたシンボルの挿入、という形で行われるのが望ましいはずです。その形での処理は import 文によってなされます。シンボルのincludeというのは要するに、コンパイラは単に、 既にコンパイルされたシンボルテーブルをロードするだけで済ますということです。 二重#includeを防ぐためのマクロによるガードだとか、#pragma onceという珍妙な 構文だとか、プリコンパイル済みヘッダを使うための謎の構文だとかは、 D言語には不要ですし関係のない話です。
#include <stdio.h>
Dでは
D ではシンボルの import を行います:
import std.c.stdio;
#pragma once
Cのプリプロセッサでは
Cのヘッダはたいていの場合、 複数回#includeされることを防ぐために、 次のような一行を含めることになります:
#pragma once
あるいはもっと移植性のある書き方をすると:
#ifndef __STDIO_INCLUDE
#define __STDIO_INCLUDE
... ヘッダファイルの中身
#endif
Dでは
Dはシンボルのimportを採用しているので、この手の構文は D には全く不要です。import宣言の回数にかかわらず、 シンボルは一度だけimportされます。
#pragma pack
Cのプリプロセッサでは
Cでは、構造体のメンバを指定のメモリ境界に揃えるために使われています。
Dでは
Dのクラスに関しては、メモリ配置を調整する必要はありません。(実際は、 最適なレイアウトのためにコンパイラが勝手にメンバを並べ直したりします。 ローカル変数のスタック上での配置をコンパイラが決定するのと同じ感じですね。) Dの構造体は、定義されたとおりにメモリに配置されます。 こちらには境界への整列の必要があって、次のように指定します:
struct Foo
{
align (4): // 4バイト境界へ整列
...
}
マクロ
プリプロセッサのマクロは、Cに強力な機能と柔軟性をもたらしました。 しかし、欠点もあります:
- マクロには、スコープの概念がありません。定義された点で有効になったマクロは、 ソースの終わりまでそのまま有効です。違う.hファイルの中であっても、ネストしたブロックであっても構わず乗り越えていきます。 何十個ものファイルをincludeして、何千行ものマクロ定義を扱っていると、 思わぬところでマクロが展開される不具合を避けるのが大変になってきます。
- デバッガはマクロを認識しません。 デバッガはマクロそのものでなく、 マクロが展開されたあとのコードから生成されたシンボルのみしか知りませんから。
- マクロはソースコードの直接の字句解析を不可能にします。 マクロを変更すると後続のコードのトークン分けは幾らでも変わり得ます。
- マクロは純粋にテキストベースであるせいで、簡単に、 首尾一貫しない使い方ができてしまい、エラーを生みやすくなっています。 (この問題の解決として、C++ではtemplateが導入されました。)
- マクロは、言語の表現能力に関する欠陥を覆い隠すのに使われます。 例えば、ヘッダの周りにおく"ラッパ"など。
以下によく使われるタイプのマクロを並べ、 対応するDの機能を紹介します:
- リテラル定数の定義
Cのプリプロセッサでは
#define VALUE 5
Dでは
const int VALUE = 5;
- 値やフラグのリスト:
Cのプリプロセッサでは
int flags: #define FLAG_X 0x1 #define FLAG_Y 0x2 #define FLAG_Z 0x4 ... flags |= FLAG_X;
Dでは
enum FLAGS { X = 0x1, Y = 0x2, Z = 0x4 }; FLAGS flags; ... flags |= FLAGS.X;
- ASCII文字とワイド文字の区別:
Cのプリプロセッサでは
#if UNICODE #define dchar wchar_t #define TEXT(s) L##s #else #define dchar char #define TEXT(s) s #endif ... dchar h[] = TEXT("hello");
Dでは
dchar[] h = "hello";
Dコンパイラの最適化ルーチンは関数のインライン化にともなって、 文字列定数の変換などをコンパイル時に行います。 - 古いコンパイラのサポート:
Cのプリプロセッサでは
#if PROTOTYPES #define P(p) p #else #define P(p) () #endif int func P((int x, int y));
Dでは
Dのコンパイラをオープンソースにすることで、 構文の後方互換性の問題はほぼ全て避けることができるでしょう。 - 型の別名:
Cのプリプロセッサでは
#define INT int
Dでは
alias int INT;
- 1つのヘッダと宣言と定義の両方に使い回す:
Cのプリプロセッサでは
#define EXTERN extern #include "declarations.h" #undef EXTERN #define EXTERN #include "declarations.h"
declarations.h の中身:EXTERN int foo;
Dでは
宣言と定義は同じものなので、 一つのソースから宣言と定義の 両方を生成するためのトリック的なものは不要です。 - 軽量なインライン関数:
Cのプリプロセッサでは
#define X(i) ((i) = (i) / 3)
Dでは
int X(ref int i) { return i = i / 3; }
最適化ルーチンが関数をインライン化します。効率のロスは生じません。 - Assert関数, 行番号情報:
Cのプリプロセッサでは
#define assert(e) ((e) || _assert(__LINE__, __FILE__))
Dでは
assert() が組み込みの式となっています。コンパイラが assert() に関する知識を持つことで、_assert() 関数は決してreturnしない、 などの情報に基づいた最適化も可能になります。 - 関数呼び出し規約の指定:
Cのプリプロセッサでは
#ifndef _CRTAPI1 #define _CRTAPI1 __cdecl #endif #ifndef _CRTAPI2 #define _CRTAPI2 __cdecl #endif int _CRTAPI2 func();
Dでは
呼び出し規約をブロック単位で指定できるので、 関数毎の変更は必要ありません:extern (Windows) { int onefunc(); int anotherfunc(); }
- __nearポインタ や __farポインタ という邪悪なものを隠す:
Cのプリプロセッサでは
#define LPSTR char FAR *
Dでは
Dは16bitコードの生成や、ポインタサイズの混在、 複数種のポインタなどに対応していません。 従ってこの問題とは無関係です。 - 簡単な generic programming:
Cのプリプロセッサでは
どの関数を使うかをテキストベースの方法で選択します:#ifdef UNICODE int getValueW(wchar_t *p); #define getValue getValueW #else int getValueA(char *p); #define getValue getValueA #endif
Dでは
Dでは、 他のシンボルの aliases であるようなシンボルが定義できます:version (UNICODE) { int getValueW(wchar[] p); alias getValueW getValue; } else { int getValueA(char[] p); alias getValueA getValue; }
条件コンパイル
Cのプリプロセッサでは
条件コンパイルはCプリプロセッサの強力な一面ですが、 やはり欠点があります:
- プリプロセッサにはスコープの概念がありません。#if/#endif は全く構造化されていないままコードと混ざり合うことができていしまい、 流れを追いかけるのが難しくなります。
- 条件コンパイルは、 プログラム内の識別子と衝突しうるマクロを切り替え条件にしてしまうかもしれません。
- #if 式は、 Cの式と違った方式で評価されます。
- プリプロセッサの言語とCの言語は基本的なところで異なっています。 例をあげると、空白文字や改行文字はCでは意味を持ちませんが、 プリプロセッサでは意味のある文字です。
Dでは
Dは条件コンパイルをサポートします:
- バージョン固有の機能の、固有のモジュールへの分離。
- debug文による、 冗長な文字出力などのデバッグ用コードのon/off。
- version文による、 一つのソースからの複数の異なったプログラムの生成。
- if (0) 文。
- ネスト可能なブロックコメント /+ +/ によるコードのコメントアウト。
コード整理
Cのプリプロセッサでは
関数内に、何度も繰り返し現れるコード片が存在することは 結構よくあります。パフォーマンスを考えると、 この繰り返す処理を別の関数にまとめることは避けたい、 そこでマクロとして実装します。例えば、 次のようなバイトコードインタプリタのコードの一部を考えましょう:
unsigned char *ip; // バイトコード命令ポインタ
int *stack;
int spi; // スタックポインタ
...
#define pop() (stack[--spi])
#define push(i) (stack[spi++] = (i))
while (1)
{
switch (*ip++)
{
case ADD:
op1 = pop();
op2 = pop();
result = op1 + op2;
push(result);
break;
case SUB:
...
}
}
これはいくつもの問題を抱えています:
- このマクロは式へと評価されねばならず、 変数を宣言することができません。 スタックの溢れをチェックするようにこのコードを書き換える手間を考えてみて下さい。
- マクロはシンボルテーブルの外側にある存在です。 このため、この関数の定義より外にもこのマクロのスコープが広がっています。
- マクロへの引数は値としてではなく、テキストとして渡されます。 結果として、マクロを実装するには引数を二度以上使わないように注意したり、 必ず()で囲むように気をつけたりする必要がでてきます。
- マクロはデバッガには見えません。 デバッガはマクロ展開後のコードのみを扱えます。
Dでは
Dでは関数のネストによって綺麗に表現することができます:
ubyte* ip; // バイトコード命令ポインタ
int[] stack; // 命令スタック
int spi; // スタックポインタ
...
int pop() { return stack[--spi]; }
void push(int i) { stack[spi++] = i; }
while (1)
{
switch (*ip++)
{
case ADD:
op1 = pop();
op2 = pop();
push(op1 + op2);
break;
case SUB:
...
}
}
上であげた問題点を考えてみると:
- ネスト関数ではDの関数で可能な全ての表現を使うことが出来ます。 ちなみに、配列の境界チェックコードは既にコンパイラが埋め込むように なっています。(コンパイル時のスイッチで変更可能です)
- ネストした関数のスコープは他の名前と同様のスコープに従います。
- パラメタは値渡しなので、 式の副作用に気を遣う必要はありません。
- ネストした関数はデバッガにも見えています。
更に、 関数は最適化によってインライン化され、 Cのマクロ版と同じ高いパフォーマンスが得られます。
#error と 静的表明
静的表明とは、コンパイル時に行われるユーザー定義のチェックです。 もしチェックに失敗したら、コンパイラはエラーを吐いて停止します。
Cのプリプロセッサでは
まず思いつくのが、#error ディレクティブを使う手です:
#if FOO || BAR
... コンパイルするコード ...
#else
#error "there must be either FOO or BAR"
#endif
これには、プリプロセッサの式であることに由来する制限があります。 (つまり、整数定数式しか表現できず、キャストや sizeof、 定数シンボルなども使えません。)
These problems can be circumvented to some extent by defining a static_assert マクロを定義することである程度は回避できます(M. Wilosonに感謝):
#define static_assert(_x) do { typedef int ai[(_x) ? 1 : 0]; } while(0)
次のように使います:
void foo(T t)
{
static_assert(sizeof(T) < 4);
...
}
これは、条件が偽の時にコンパイル時の意味エラーを起こす、 という原理で動作しています。 このテクニックの制限は、 コンパイラから出るエラーメッセージがしばしば大変わかりにくくなることと、 関数定義の外で static_assert が使えないことです。
Dでは
D には static assert があり、 宣言や文を書ける場所ならどこでも使用できます。例えば:
version (FOO)
{
class Bar
{
const int x = 5;
static assert(Bar.x == 5 || Bar.x == 6);
void foo(T t)
{
static assert(T.sizeof < 4);
...
}
}
}
else version (BAR)
{
...
}
else
{
static assert(0); // サポートされていないバージョン
}
ミックスイン
D テンプレート・ミックスイン は、 表面的には、 Cのプリプロセッサのようにコードブロックを挿入して、 実体化のスコープで解析を行っているように見えます。 しかし、マクロに対しミックスインには利点があります:
- ミックスインは、構文解析済みの宣言を使って構文木の形で置き換えを行います。 マクロの置換処理は、任意のプリプロセッサトークンを置き換え、 全くシステマチックでありません。
- ミックスインは元の言語と同じ言語です。 マクロは C++ の上層の、また別の言語です。 独自の式構文や独自の型、独自のシンボル表に独自のスコープ規則などに従っています。
- ミックスインは部分特殊化による選択をサポートします。 マクロはオーバーロードできません。
- ミックスインはスコープを構成します。マクロはしません。
- ミックスインは構文解析を行うツールと協調性があります。マクロはありません。
- ミックスインに関する意味情報とシンボル表は、デバッガへ渡されます。 マクロの場合は情報が失われます。
- ミックスインはオーバーライド衝突の解決規則を備えています。 マクロでは単に衝突するだけです。
- ミックスインは、 標準的な方法で自動的にユニークな識別子を持つことが出来ます。 マクロでは、トークン処理の小技を使って手動でやらなければならない作業です。
- ミックスインに対する副作用のある値引数は、 一度だけ評価されます。マクロの値引数は、 展開されるたびに評価され(邪悪なバグへの道を開き)ます。
- ミックスインへの引数渡しは、演算子の優先順位の問題を避けるための過剰な括弧による ‘保護’ を必要としません。
- ミックスインはどんな長さになっても、普通のDのコードと同じように記述できます。 マクロは、行末にはバックスラッシュが必要ですし、 //形式のコメントも使えません。etcetc.
- ミックスインは内部で他のミックスインを定義できます。マクロの中にマクロ定義はできません。