公開技術情報

[English] [Japanese]

lctags を開発している時に改めて感じた C 言語規格のイケてないところ

まずは宣伝

lctags は libclang の AST 情報を利用した C/C++ 向けのソースコードタグシステムです。

ベース機能はソースコードタグシステムですが、 シンボル補完機能等のコーディングをサポートするさまざまな機能を提供しています。

lctags の詳細については、次を参考にしてください。 特に emacs ユーザ向けの機能を色々と取り揃えております。

../

とまぁ lctags の宣伝はこの辺にして本題に。

lctags を開発している際に、 C の言語規格で「イマイチ」と思った点がいくつかあったので、 ネタとして書いておきます。

イケてないところ

C 言語を扱っているエンジニアであれば C の言語規格に対して何かしら不満を持っているとは思いますが、 ここではソースコードタグシステムの開発側からの視点で思うことを挙げます。

「イマイチ」と思った点は次のものです。

  • #include が自由過ぎる
  • どこでも名前なし struct、 union、 enum が宣言出来る
  • 関数引数で新しい型宣言が出来る
  • 型の名前空間がモジュールで閉じている

#include が自由過ぎる

C 言語には無くてはならない #include ですが、 規格があまりにも単純過ぎて、何でもアリになっています。

誰でも知っている #include の仕様ですが、念のため明記すると次の内容です。

「#include で指定されたファイルの内容をそのまま展開処理する」

省略している部分もありますが、概ねこの認識で間違いありません。

この仕様は、大規模プログラムのコンパイルが遅い原因の一つにもなっています。

なお、#include の影響によるコンパイルの遅さに関しては、 プリコンパイルによって一応の解決を得ています。 しかし、プリコンパイルにもいくつかの制約があるため、根本解決とは言えません。

#include の仕様によってコンパイルに時間がかかるのは 多くの人が経験していることだと思いますが、 ここでは、ソースコードタグシステムを作成する上で問題となることを説明していきます。

どこでも #include 出来る

ソースコードタグシステムは、 ソースコードを解析し、シンボルの宣言位置・参照位置を記録します。

struct TEST {
  int value;
};
struct TEST2 {
  int value;
};

例えば上記のようなコードでは、以下の情報を記録します。

  • 1 行目に struct TEST の宣言
  • 2 行目に TEST::value の宣言
  • 4 行目に struct TEST2 の宣言
  • 5 行目に TEST2::value の宣言

#include を使うと、上記コードは次のようにも書ます。

----- field.h ------
int value;
----- test.c ------
struct TEST {
#include "field.h"
};
struct TEST2 {
#include "field.h"
};

void sub( struct TEST test ) {
  test.value = 1;
}
--------

この場合、 field.h の int value が、 TEST::value の宣言場所であり、 TEST2::value の宣言場所でもあります。

このとき、ソースコードタグシステムで test.value = 1 の test.value の宣言位置を 問い合わせると、結果は field.h の 1 行目となります。

あなたはその結果を見て「なんのこっちゃ?」と思いませんか?

確かに int value の宣言位置に間違いありませんが、 他にどんなメンバがあるのかとか、他のメンバは何処で宣言されているのかとか、 が全く分かりません。

これはまだ良い方で、次のような書き方も出来てしまいます。

----- name1.h ------
TEST
----- name2.h ------
TEST2
----- field.h ------
int value;
----- test.c ------
struct
#include "name1.h"
{
#include "field.h"
} test;
struct
#include "name2.h"
{
#include "field.h"
} test2;
--------

さらにやる気になれば、トークン単位で #include に分けられます。 そうなると、どのシンボルがどこで定義されているかまともに表現できません。

もちろん、現実的にそんな書き方をする人はいないでしょうが、規格上出来てしまいます。

このようなことが出来てしまうため、 インクルードファイルに更新がある場合、 そのファイルを #include しているファイルの全ての情報を解析しなおす必要あります。

#include に制限があれば、情報更新の範囲を狭くでき、 その分だけソースコードタグシステムの解析時間を短縮できます。

インクルードファイル単体では完結しない

これは普通にヘッダファイルを書いていても良くあることですが、 ヘッダファイルが別のファイルで宣言しているシンボルを参照しているのに、 そのファイルを #include していないことがあります。

これの何が問題かというと、 ヘッダファイルに宣言している構造体メンバの参照箇所を調べたい場合、 まずはその構造体の宣言がどういった内容なのかを調べる必要があります。 しかし上記のように、そのヘッダファイルが別のファイルのシンボルを参照していて、 なおかつ、必要なファイルを #include していないと、 そのヘッダファイル単体では構文解析が正常に行なえず、 構造体の宣言が分からないため参照箇所も調べられない、 ということになります。

ヘッダファイル単体で解析が出来る保証が無いため、 lctags でヘッダファイルを解析する場合は そのファイルを #include しているソースファイルを解析して、 得られた AST からヘッダファイルの該当箇所の宣言を調べるようにしています。

ソースファイルの解析が必要になるということは、 そのソースファイルが #include している他のヘッダの解析まですることになり、 その分の無駄な時間がかかることになります。

#include には制限を設けるべき

以上のことから、#include には次の制約を持たせるべきだと思います。

  • 「文の途中に #include を挟めない。」
  • 「ヘッダ単体で構文解析可能な状態でなければならない。」

この制約を持たせることが出来れば、 多くの無駄な解析を省くことができます。

この制約を持たせた際に影響を受けるようなソースは、 そもそも可読性やメンテナンス性に問題があることが予想できます。

この制約を持たせることで、 そういった問題のあるコードを書けなくするという効果も期待できます。

ただ、この制約を持たせると従来のソースコードとの互換性が無くなってしまうため、 #include そのものの仕様を変更することは難しいでしょう。

それに、中途半端に規格を変更するくらいなら、 単純にファイルを展開する今の仕様ではなく、 イマドキの言語に良くあるメタ情報を import する方式に切り替えるべきでしょう。

ですので現実的な解としては、 例えばヘッダファイルの最初の行にコメントとして何らかのメタ情報を記載することで、 そのヘッダファイルが制約を満しているかどうかを示し、 そのメタ情報を認識してツール側で対処する、というのが妥当なところでしょう。

ただ、現状の #include の規格が技術的負債であるのは間違いないと思います。

C 言語規格の改版があるのであれば、検討項目に入れいただきたいところです。

まぁそのような改版があったとしても、 組込み向け CPU メーカー製コンパイラ(ARMは除く)では、 その規格をサポートすることはないでしょうが。。

anonymos な struct、 union、 enum が宣言出来る

これは言語規格の問題というよりは、 ソースコードタグシステムで扱う際の問題です。

ソースコードタグシステムでは、宣言、参照箇所にタグを付けます。

タグは、シンボルを基にタグ付けしています。

一方で、C の言語規格として struct、 union、 enum には名前を付ける必要がありません。 いわゆる anonymos 構造体等です。

例えば次のような宣言が可能です。

struct TEST {
    int val;
} test;

struct {
    int val;
} test0, test1;

最初の TEST 構造体は名前のある宣言で、 2 つ目は名前のない構造体宣言です。

TEST 構造体は、 TEST シンボルを基にタグを付けることが出来ますが、 anonymos 構造体は名前がないためシンボルを基にタグを付けることが出来ません。

少し話が変わりますが、 イマドキの多くの言語には、ラムダ式等の anonymos 関数(無名関数)があります。 通常 anonymos 関数は、関数の引数に与えられるか、 何らかの変数にセットして使われるため、そのスコープは限定されます。

一方 struct, union, enum のスコープは宣言場所に依存し、 一番広い場合はグローバルです。 グローバルにもかかわらず、名前がなくて良いんです。

スコープがグローバルであることの何が問題かというと、 ローカルであれば、ローカルでユニークのタグを付ければ良いのに対し、 グローバルであれば、グローバルでユニークなタグを付けなければならないことです。

これはなかなかのハードルです。

anonymos な struct 宣言を使うケースとしては、 次のように struct を union で共用する場合や、 struct 宣言内に struct 宣言を持つ場合に使うことが多いと思います。

union VAL {
    struct {
        int val;
    } INT;
    struct {
        char val;
    } CHAR;
};

このように、anonymos な宣言が限られた名前空間内にあるのであれば、 ユニーク性を保つタグを付けることもそれほど難しくないですが、 グローバルな anonymos 宣言では、ユニーク性を保つタグを付けるのは難易度が高くなります。

普通は、意図してグローバルな anonymos 宣言をすることはないでしょうが、 それが出来てしまうのは問題があると思います。

そもそもグローバルな struct, union, enum を使わなければならないケース、 というものが思い付きません。

関数引数で新しい型宣言が出来る

普通はやらないと思いますが、規格上は次のようなコードが書けてしまいます。

void sub( struct TEST { int val; } * test )
{
    test->val = 1;
}

void func()
{
    struct TEST { int val; } test;
    sub( &test );
}

上記のように関数の引数で構造体を宣言するのは極端な例ですが、 次のように関数の引数で関数ポインタ型を宣言することは多くの方が利用していると思います。 標準ライブラリの bsearch() もそうですしね。

void sub2( void (*pFunc)(void) )
{
    pFunc();
}

void func2()
{
    sub2( func );
}

ソースコードタグシステムを開発していると、 上記 1 番目の struct 宣言は当然として、2番目の関数ポインタの例に関しても、 いかがなものかと思ってしまいます。

なぜならば、引数の型宣言をタグ付け対象にすることを考えると、 その宣言にどのようなタグを付けるべきか問題になるためです。

たとえば、次のようなコードがあった場合、 どちらも関数ポインタ(add, output)のインタフェース(引数、戻り値)は同じです。

void exec( int (*add)( int val1, int val2 ) )
{
    add( 0, 1 );
}

void dump( int (*output)( int val1, int val2 ) )
{
    output( 0, 1 );
}

しかし、処理内容を見れば add と output の処理内容は全く異なることが予想できます。

この時、 add と output に付けるべきタグを同じにすべきか? それとも異なるタグを付けるべきか? もし同じタグにするのであれば、 まったく関連性がない関数ポインタのタグが同じになり、 そのタグを検索したときにノイズだらけになってしまいます。 一方、異なるタグにした場合、 今度は同じタグになる宣言が無くなり、タグ付け自体の意味がなくなります。

有用なタグ付けをするにも、 引数宣言では型宣言を禁止にし関数ポインタ等は typedef で定義したものだけに限る、 とするべきだと考えています。

こうすることで引数の型に意味が付き、 検索も typedef で定義した型名のタグで検索することで、 意味のある検索ができます。

typedef 宣言するのが面倒だという意見もあると思います。 私も全てにおいて typedef 宣言すべきだとは思っていません。

ではどのような場合に typedef 宣言すべきかと言うと、 全く同じ用途の宣言が 2 つ以上出てくるような場合です。

例えば次のような場合は、 引数の関数ポインタ型 callback は typedef 宣言するべきでしょう。

void sub( void (*callback)( void ) )
{
    callback();
}
void func( void (*callback)( void ) )
{
    sub( callback );
}

上記 callback は全く同じ用途の関数ポインタを示しています。 この場合、 引数で関数ポインタ型を宣言するのではなく、 次のように typedef 宣言するべきです。

typedef void callback_t( void );
void sub( callback_t * callback )
{
    callback();
}
void func( callback_t * callback )
{
    sub( callback );
}

こうすることで sub と func の引数 callback を見ただけで、 それが同じ用途のポインタであることが分かります。 これが typedef ではなく関数ポインタ型宣言をしている場合、 引数 callback が単に IF が同じ関数ポインタなのか、 それとも用途が同じものなのかが不明になります。 また typedef しておくことで、 将来 callback 関数ポインタの IF 変更が必要になった場合も、 typedef を変更するだけで済みます。

もちろん sub と func の関数リファレンスに、 callback がどのような用途なのかを明記すれば良い、という考え方もあると思いますが、 リファレンスを見ずとも関数 IF だけ見れば分かる方がより良いことは間違いありません。

なお C 言語の場合、関数定義をするには一部例外を除いて prototype 宣言と定義を行なう必要があります。

その関数の引数に関数ポインタ型があれば、 当然 prototype 宣言と定義の 2 箇所に関数ポインタ宣言が出てきます。

// prototype 宣言
void sub( void (*callback)( void ) );


// 関数定義
void sub( void (*callback)( void ) );
{
    callback();
}

上記のように、関数ポインタ宣言が 2 箇所出ているため、 これは typedef 宣言するべきです。

よって、一部例外を除いて typedef 宣言をするべきだと考えています。

型の名前空間がモジュールで閉じている

型の名前空間情報がモジュールで閉じてしまっています。

例えば次のように typeA.h と typeB.h にそれぞれ struct TEST を定義することができます。

// ----- typeA.h -------
struct TEST {
  int valueA;
};
// ----- typeB.h -------
struct TEST {
  int valueB;
};

typeA.h と typeB.h を同時に include すればコンパイルエラーになりますが、 別ソースから include すれば正常にコンパイルできます。

これが出来てしまうのは C 言語では仕方がないことですが、 これによって全く用途の異なる struct TEST に対して同じタグが付いてしまい、 それだけノイズになります。

C 言語でこのようなことが置きないようにするには、 名前を付ける際に何らかの prefix や suffix を付ける必要があり、 その分名前が長くなってしまいます。

C++ では名前空間を利用することができますが、 これは prefix や suffix を付けることに対する代替手段であり、 モジュール単位で型の名前空間が閉じてしまっていることには代わりません。

つまり、異なるモジュールで同じ名前の型を定義することは可能です。

異なるモジュールで同じ名前のメソッドを定義することは出来ませんが、型名やメンバ名は定義できます。

関数にグローバルとローカルがあるのと同じ様に、 ソースコードタグシステムとしては、 型に対してその型がグローバルかローカルなのかの情報が欲しいところです。 そうすれば、その情報を基にタグを付けることができます。

個人的には、ヘッダで定義している型はグローバルで、 ソースファイル内で定義している型はローカルになると思います。 一方で、入門書によっては prototype 宣言や構造体宣言は全てヘッダで定義する、 ということが書かれているものもあったりします。

つまり、言語規格上にない限り、 ソースコードタグシステムとしては全ての型情報をグローバルとして扱うか、 ローカルとして扱うかのどちらかになってしまいます。 そして、グローバルとして扱うとノイズが増え、 ローカルとして扱うと検索でヒットしなくなってしまうジレンマで、 どちらにするか決めかねるところです。

まとめ

「プログラミング言語の仕様を理解するには、 その言語の簡易的なインタプリタを作れば良い」

これを今迄の持論としていました。

長年 C 言語を使ってきて C 言語のダメな書き方を知っていたつもりでしたが、 ソースコードタグシステムを開発していると、改めて分ったことがありました。

そこで、これからは

「インタプリタだけでなくソースコードタグシステムを作るとさらに理解が深まる」

と考え直しました。

簡易的なインタプリタや独自言語などを開発した経験は、多くの方があると思います。 一方で、ソースコードタグシステムを開発した経験のある方は少ないのではないかと思います。

皆さんも、今度ソースコードタグシステム開発にチャレンジしてみてはいかがでしょうか。

新たな発見があるかもしれませんよ?