プログラマーの理想と現実

第5回 C言語のテクニカルバグ—マクロの問題

2014.12.10

この記事は、『UNIX Magazine』2003年12月号(2003年11月18日発売)に掲載された同名記事の初稿(著者から編集部に提出したもの)を元に、Web掲載用に一部を修正したものです。10年以上前に執筆したものなので、現在のUNIXを取り巻く環境とは色々と異なることがありますが、プログラミングに対する心構えとしては現在でも通用するものと思い、再掲してみることにしました。

・・・・・・・・・・

C言語では、コンパイルの前にプログラムの字句を置き換える機能— マクロ 機能 が利用できる。マクロは確かに便利なものではあるが、バグを招きやすいことも事実で、巷では功罪が論じられている。

C++言語にはまだマクロが残っているが、JAVA言語にはもはや存在しないことからもわかるように、マクロは本質的にはプログラムのアルゴリズム記述に必須の機能ではない。にもかかわらず、マクロを一切使用していないC言語プログラムにはお目にかかったことがないし、自分でも(ごく小さいプログラムを除いて)マクロを使わずにC言語のプログラムを書くことはありえない。結局、欠点はあれど、利点も捨てがたいので使ってしまうということだろう。

しかし、マクロが絡んだテクニカルバグは非常に厄介である。元々がプログラムの字句を変えてしまう働きを持つため、ソースコードを眺めてもバグの原因がわかりにくい。ソースコードデバッガを使おうにも、マクロを含む行は展開されないまま表示されるので、ソースコードを眺めているのと大差はない。

結局は、”誤りのないように正しくマクロを使う”ことで対応するしかないのだが、見つけにくいバグの原因となりうる誤りのパターンはある程度限られている。今回は、そういったマクロの問題について論じてみたい。

マクロの問題

経験豊富なC言語プログラマにはおそらく退屈であろうが、まずは初心的C言語プログラマが引っ掛かる罠を示そう。

#define MULTIPLY(x, y)  x * y                              D1

上記D1は、マクロのパラメータxとyを乗算するためのマクロ定義を目指したものである。もちろん、この定義は失格である。

a = MULTIPLY(b, c);                                        S1
d = MULTIPLY(e + f, g + h)                                 S2

S1は、変数bとcを掛けてaに代入しようという文であり、結果的には正しく実行される。S2は、意図としては(e + f)と(g + h)を掛けてdに代入しようという文だと推測できるが、これは正しく実行されない。S2のマクロ展開後は

d = e + f * g + h;                                         S2'

という文になるため、演算子の結合優先度にしたがい、e + (f * g) + hと解釈されて実行される。これは、おそらく意図した処理とは異なる。また、次の文

i = j / MULTIPLY(k, l) * m;                                S3

S3は、(j / (k * l)) * mをiに代入しようという意図を持った文だと推察できるが、これも正しく実行されない。S3のマクロ展開後は、

i = j / k * l * m;                                         S3'

という文になるため、(j / k) * l * mと解釈されて実行される。これも、おそらく意図した処理とは異なる。

これらの例からわかるように、まず、マクロパラメータの扱いを慎重に行う必要がある。経験豊富なプログラマならば、D1は次のように定義するであろう。

#define MULTIPLY(x, y)  ((x) * (y))                        D1'

マクロの各パラメータを個別にかっこで囲み、また展開される内容全体もかっこで囲んでおくことで、上記のような計算上の結合順位などによる問題を排除できる。

マクロパラメータの二重評価

パラメータ付きマクロは、一見すると関数のように見えるが、”呼び出す”という概念もなければ”引数を評価する”という概念もない。単に、字句上の置き換えが行われるだけである。

#define SQR(x)  ((x) * (x))                                D2

上記D2は、パラメータxの自乗を計算するためのマクロ定義を目指したものである。この定義は、失格ではないが使い方が難しい。

a = SQR(b);                                                S4
c = SQR(d++);                                              S5

S4は、aにbの自乗を代入したいという文であり、結果的には正しく実行される。S5は、dの値を自乗してcに代入し、その後dの値を1だけインクリメントすることを目指した文だと推測できる。しかし、これは正しく実行されない。S5のマクロ展開後は、

c = ((d++) * (d++));                                       S5'

という式になる。cには結果として(d * (d + 1))が代入され、dはマクロ実行前から2増えてしまう。また、次の文

e = SQR(f(g));                                             S6

S6は、関数f(g)を呼び出し、その結果の得られた値を自乗してeに代入することを目指した文だと推測できる。しかし、場合によるとこの文は意図どおりに動作しない。S6を展開すると

e = ((f(g)) * (f(g)));                                     S6'

となる。S6’ではf(g)が2度呼び出されている。もし、f(g)の呼び出し回数に重要な意味がある場合、たとえばf(g)で何かファイルから値を読み出す場合などは意図どおりには動作しない。

問題は、マクロSQR()が1つのパラメータを2回置換するため、コンパイル時にはパラメータが2度評価されてしまうことにある。マクロSQR()を使う時、SQR()をあたかも関数のように考えてしまうと、d++やf(g)は「SQR()の呼び出し時に評価されて値が決まる」と思ってしまうため、このような誤りを招くことになる。

本質的に、マクロによるパラメータの二重評価問題をマクロ定義の側でうまく解決する手段はない。自分がマクロを定義するのであれば、なるべくパラメータを二重評価しないように工夫し、また呼び出す際には「マクロなんだから呼び出しも評価も関数とは違う」と常に認識しつつプログラムを書くしかない。

型に依存しないマクロ

次に示すマクロD3とD4は、二つのパラメータのうち値の大きい方、値の小さい方を返すものである。

#define MAX(a, b) \\
       (((a) > (b)) ? (a) : (b))                           D3
#define MIN(a, b) \\
       (((a) < (b)) ? (a) : (b))                           D4

D3もD4も、パラメータを二重評価するため、慎重に使う必要があることは前節で述べたとおりである。しかし、MAX()やMIN()は、実際のC言語プログラムで非常に良く使われ、また大変便利でもある。それは、これらのマクロにはパラメータの”型”が明記されていないからである。

MIN()やMAX()やSQR()のようなマクロは、パラメータが整数であれ浮動小数点数であれ、あるいは符合つき数であれ符合なし数であれ、それなりに正しく処理される。つまり、これらのマクロは型に依存せず、純粋にアルゴリズムを記述した 総称関数 だと考えることができる。int版のMAX()やlong版のMAX() やfloat版のMAX()などを、それぞれ個別に定義する必要はなく、ゆえに様々なケースで便利に使えるわけである。

便利であることは確かなので、こういったマクロを定義して使うことを否定するつもりはないが、しかしやはり二重評価問題などの副作用が危険であることを十分に把握している必要がある。

手続きのようなマクロ

複数の一連の文からなる処理を単一のマクロにまとめ、呼び出し時の手間を省くという使い方がある。

#define PROC(x) \\
        {func1((x)); func2((x));}                          D5

D5は、func1(), func2()の2つの関数の呼び出しを、PROC()という単一のマクロにまとめた例である。この定義には、パラメータが二重評価されること以外にも少々問題がある。

プログラムでは、次のようにPROC()を使う。2つの関数呼び出しをPROC()一行で済ませられるため、プログラムの見通しが良くなるという効果が期待できる。

a = b + c;                                                 S7
PROC(a);                                                   S8

S7で変数aの値を計算し、S8でPROC()にパラメータaを指定して一連の処理を行わせる。S8は、結果的には正しく処理される。しかし、次の例では厄介なことが起きる。

if(a > 0)                                                  S9
    PROC(a);                                              S10
else                                                      S11
    func3(a);                                             S12

意図としては、S9で条件判断を行い、a > 0の場合はS10でPROC(a)を実行し、そうでなければS12でfunc3(a)を実行する、と推測できる。ここで、PROC()を展開すると、次のようになる。

if(a > 0)                                                  S9'
    {func1((a)); func2((a));};                            S10'
else                                                      S11'
   func3(a);                                              S12'

これは、残念ながら文法違反である。S10’の最後のセミコロンが悪さをしている。マクロPROC()の意図は、”一連の文”を波かっこで囲むことでブロック(複文)とし、一単位として扱えるようにするというものである。しかし、C言語ではブロックはそれ自体で文と同等の扱いになるため、S10’における最後のセミコロンは文法上余計であり、単なる空行になってしまう。このため、S10’で elseの前に空行が生じ、文法違反になる。

D5のPROC()の定義をこのまま使うのであれば、S8もS10も最後のセミコロンは無い方が正しい。しかし、プログラマはPROC()を関数呼び出しと同じようなものと見てしまうため、ついつい最後にセミコロンを付けてしまう。また、S8や S10だけセミコロンが無いのは、見た目にもバランスが悪い。

このように、マクロ定義の実体がブロックである、という場合、文法上不必要なセミコロンを付けてしまうことによる空行問題が起きる。これを解消するには、PROC()を以下のように定義すれば良い。

#define PROC(x)        \\
       (func1((x)),func2((x)))                             D5'

または

#define PROC(x)        \\
   do {func1((x)); func2((x)); } while(0)                  D5''

D5’は、一連の関数呼び出し式をカンマ演算子によって結合したもので、この定義自体も”式”であって”文”ではない。よって、S8やS10のように、最後にセミコロンを置くことによりはじめて”文”になり、空行は生じない。

D5’‘は、一連の関数呼び出しを波かっこでまとめてブロックとするものの、do-while構造の本体に納めてしまうというものである。do-while構造はループ構造であるが、マクロ定義でwhile(0)と決め打っているため、ループを回ることはない。do-while構造は、C言語の他の構造 — ifやforやwhile — とは少々異なり、文として成立させるために末尾のwhileの後にセミコロンが必要である。この性質を利用するととで、S8やS10のように、最後にセミコロンを置くことではじめて”文”になり、空行は生じない。

近年では、D5’‘のようにdo-whileを使うのが流行しているようである。昔は、D5’‘のような定義を行うとコンパイラが余計なコードを生成して遅くなったりもしたが、最近のコンパイラではオプティマイザが優秀なので、D5’‘とD5で生成されるコードに差は生じない。

if文の書き方

上述のS9~S12において、もしこれを次のように書いていたら、PROC()の定義がD5のままでも文法違反にはならなかった。

if(a > 0) {                                                S9''
    PROC(a);                                              S10''
} else {                                                  S11''
   func4(a);                                              S12''
}

PROC(a)が空文を含めて複数の文に展開されたとしても、元々ifの後の文は波かっこでブロックにされているため、空文による影響が生じないためである。

次のプログラム

if(a > 0)                                                 S13
    if(b > 0)                                             S14
        func(c);                                          S15
else                                                      S16
    func(d);                                              S17

は、プログラマの意図どおりに字下げされているとする。すなわち、S13でaが 0より小さかったらS16のelseへ飛び、S17でfunc(d)を呼び出したいとする。もちろん、字下げは文法上何の意味もなく、このプログラムは意図どおりに動作しない。

S16のelseは、S14のifに対応するelseであるとも、S13のifに対応するelseであるとも読める。このように、C言語のif-else構造ではelseの対応に曖昧さが残る記述が可能で、その場合は”近い方のifと対応を取る”と決められている。すなわち、S16はS14のifと対応付けられる。結果として、S13~S17は次のS13’ ~S17’の字下げのように解釈される。

if(a > 0)                                                 S13'
    if(b > 0)                                             S14'
        func(c);                                          S15'
    else                                                  S16'
        func(d);                                          S17'

マクロ展開時の空行問題や、if-elseの対応付けの問題などを回避するには、たとえ文が一行であっても、なるべく波かっこで囲むようにした方がよい。S13~S17であれば、少なくとも次のように書くべきである。

if(a > 0){                                               S13''
    if(b > 0)                                            S14''
        func(c);                                         S15''
} else {                                                 S16''
    func(d);                                             S17''
}

インライン関数

C言語のマクロは、便利ではあるが色々と落とし穴があることを説明した。マクロ定義は、マクロがマクロを呼び、またそこでマクロを呼び、と連鎖する傾向があり、最終的に思いもよらない形に展開されることが良くある。特にパラメータ付きマクロは、形は関数のようであるが、多くの副作用が生じうるため、バグの原因となる可能性が高い。できれば、マクロは使わない方が良い。使わなければ、間違いなくバグを減らすことができる。

しかし、関数にまとめるほどの手続きでもなく、また関数呼び出しのオーバーヘッドも受け入れがたいというケースもある。そういう時は、インライン関数 を使うのも一つの手段である。

インライン関数は、最近の処理系ではほぼ間違いなく利用可能であるが、C言語の仕様には存在せず、機能や記述方法などが処理系依存である。ゆえに、プログラムの移植性に重大な影響を及ぼす。移植性、実行速度、マクロ使用のデメリットなどを良く吟味した上で、使用するかどうか判断する必要がある。

inline int sqr(int x) { return (x * x); }                  F1
c = sqr(d++);                                             S18

F1は、D2のSQR()マクロのインライン関数版で、S18はS5で問題となったインクリメント演算子を含む呼び出しである。sqr()がインライン関数であるため、S18に関数呼び出しのオーバーヘッドは無く、かつd++は呼び出し時に関数の引数としてただ一度だけ評価される。かなり良い線である。

しかし、残念ながらS18が成立するのは引数がintの場合だけである。D2の SQR()マクロは、パラメータにfloatの変数を渡してもそれなりに正しく処理できるが、F1のsqr()にfloatの変数を渡すことはできない。もし、実際の関数(インラインであれ実体があるものであれ)で総称的なものを記述したいならば、C++を使うしかない。

前回と今回の記事では、”C言語の仕様では”というような言い回しをずいぶん使った。どんなプログラミング言語であれ、バグの入り込みにくいプログラムを書くため、あるいは入り込んでしまったバグをうまく見つけるためには、言語の仕様を熟知していることが必要である。

たとえばC言語であれば、カーニハンとリッチーの共著による「プログラミング言語C 第2版 ANSI規格準拠」が一応の仕様ということになる。もし、C言語プログラムを書く立場にありながら、同書を読んだことがないのであれば、是非一度読んでみることをお勧めする。

注釈

  • 初稿に由来する表現の差異、ならびにWeb掲載に伴う修正のため、雑誌掲載の内容とは一部文章等が異なることがあります。
  • 転載許諾を頂いた株式会社KADOKAWAアスキー・メディアワークスブランドカンパニーならびに旧『UNIX Magazine』編集部の皆様に深く御礼を申し上げます。

著者プロフィール

tom

当社設立直後に入社して約30 年、UNIX の移植、日本語化、デバイスドライバ開発、周辺機器ファームウェア開発などに継続的に携わり、現在も現役でUNIX 系OS の移植、改造などの開発業務を行う。社内でもっともプログラムを書いている人の一人。代表取締役社長。

記事一覧Index