プログラミングの禁じ手Web版 C言語編 - 変数に関するパターン /Top/今週のソースコード/プログラミングの禁じ手Web版 C言語編/ [←前] [次→] [C言語版一覧] [C++版一覧] |
|
|
|
自動変数の不定値を使う 深刻度:★★★(重度) [症状] わけのわからないバグに悩まされることになります。また,たまたま期待どおりに動作している場合でも移植性や安定性に欠けるプログラムであることに変わりありません。 [原因] ケアレスミス。C言語の自動変数の仕様を理解していなかったなどの原因が考えられる。 [対策/予防] まず,コンパイラの出すwarningに注意して,デバッガで丹念に追いかけることです。根本的に解決するためにはC言語の自動変数の仕様を理解することが必要です。 [例外] なし。 [備考] List 8のようなプログラムは,自動変数の不定値のためにプログラムそのものの動作が不安定になる可能性があります。この場合,aは不定値なので必ず「case 0」を通過すると思っていると大間違いです。仮に,このようなコードを含むプログラムを実行して,「case 0」を通過したとしても,それはaの値が偶然,a%3=0になるような値であっただけにすぎません。 List 8void f() { int a; switch(a % 3){ case 0: ... case 1: ... case 2: ... } } このプログラムは,わざとらしい例で,実際にこういうドジをする人がいるとは思えません。ところが,関数の行数が多い場合,変数の定義をした場所と実際にその変数を利用する場所が離れていると,変数が不定値のまま評価されるような間違ったコードを書く可能性はあります。 あるいは最初は不定値になっていなかったプログラムを,仕様変更などの修正でいじっていくうちに不定値になっていたというパターンもあります。 コンパイラによっては「変数aの値がきちんと設定されていないのに評価された」という意味の警告(warning)を出すものがあります。このwarningが出た場合は,きちんと対応しないと動作は保証されません。また,できるかぎり,この警告を出す設定をするべきです。 人によっては,以上のような不定値パターンを避けるために丁寧に自動変数に初期値を付ける習慣や規約を採用している例もあります(List 9)。ただ,筆者個人は,ここまでする必要性があるとは断言できないので何ともいいませんが,不定値パターンで悩んでいる場合にはこのような方法も検討する余地はあると思います。 List 9void f() { int theI = 0; char *theCp = NULL; ... } グローバル変数が多い(外部変数が多い) 深刻度:★★★(重度) [症状] プログラムの解読や改造が困難になり,バグが発生しても退治しにくくなります。 [原因] グローバル変数の有害さを認識していない,すなわち不勉強が原因です。 [対策/予防] もちろん極力,グローバル変数を使わないことです。といっても,「それならどうすればいいんだ」という話になってくるのが,このグローバル変数問題のやっかいなところでもありますが……。 [例外] なし。 [備考] 変数に関して深刻であることを指摘しておきたいのがグローバル変数[注1]です。グローバル変数は,どこで読まれて,どこで書き替えられて,……と実際に使用されている場所を探すのがローカル変数[注2]に比べると広いため,グローバル変数に関わるバグの被害も深刻になります。しかし,昔から「goto文の弊害」をいうプログラマがいても「グローバル変数の弊害」をいうプログラマが少ないのは世界の七不思議に入れてもいいぐらい不思議なことです。 実をいうと,筆者はプログラマの有能さ(あるいは無能さ)を見るとき,どのくらい「グローバル変数の弊害」に気づいているか,グローバル変数の削減に努力しているかをポイントにしているほどです。どういうわけか無能なプログラマほどグローバル変数を偏愛し,有能な人ほど避けるものです。 ところが,それほど害のあるグローバル変数が偏愛されたり,害があることに気づかなかったりするのはなぜかというと,まさにグローバル変数の持つ強力さ,つまり,どこにでも簡単に導入できたり,関数を越えてパラメータを簡単に渡せるところが魅力的だからです。とくに他人の重厚長大なバグ持ちプログラムの応急処置には重宝します(しかし,応急処置はしょせん応急処置の効能しかないのに気づくべきです)。 また,グローバル変数のやっかいなのは特定の1個のグローバル変数が害になるパターンだけではなく,複数のグローバル変数が複合して害をおよぼす,「組み合わせの爆発現象」とも呼ぶべきジレンマに陥る場合があることです。これはたとえば,グローバル変数Aのとりうる値が3パターン,同じくBが3パターンあるとすれば,それぞれが単体で使われた場合,3パターンの検証で済むところが,両方同時に使うことで3×3=9パターンの検証が必要になるような現象をいいます。当然,パターン数が増えたり,変数の数が増えると検証すべきパターンもそれに伴って増えていくので,ますます問題は深刻になります。 「組み合わせの爆発現象」は,もちろんローカル変数でも起こりうるのですが,被害の広さや深刻さはグローバル変数のほうが,より凶悪です。 [注1]有効範囲がプログラム全体の範囲にわたる変数。C言語では外部変数がグローバル変数の性格を持つ [注2]有効範囲が特定の範囲しか持たない変数。C言語では自動変数がローカル変数の性格を持つ 引数を使わずにグローバル変数を使う 深刻度:★★★(重度) [症状] プログラムの解読や改良・修正が困難になります。また,バグが発生しても退治しにくいという問題もあります。 [原因] これも,グローバル変数の有害さを認識していない,。不勉強が原因です。 [対策/予防] 極力,グローバル変数を使わないことです。 [例外] なし。 [備考] グローバル変数の持つ強力な性質を愛用していると,ついつい関数呼び出しの際にも引数ではなくグローバル変数にデータを持たせたくなります。しかし,そうなるとどれが本来のグローバル変数でないと困る変数なのか,単に引数の代用として使用している変数なのか区別しにくくなります。List 10のプログラム例を見てください。これは本来なら,List 11のようにすればグローバル変数を使用する必然性はありません。「引き渡すパラーメータが多すぎる」「試行錯誤で修正が多い」「いちいち引数の考慮なんかやってられない」のようないいわけをよく聞きますが,これらは筆者が見るところ,単に構造体を知らないだけか,怠慢,未熟,不勉強なだけだと思えます。 List 10int gInt; void func_f() { ... gInt = 123; /* ←ここで設定 */ func_g(); ... } void func_g() { ... func_h(); ... } void func_h() { ... func_i(gInt); /* ←ここで利用 */ ... } List 11void func_f() { ... func_g(123); ... } void func_g(int inI) { ... func_h(inI); ... } void func_h(int inI) { ... func_i(inI); ... } 構造体にまとめないでバラバラの変数にする 深刻度:★★(中程度) [症状] プログラムの解読や改良・修正が困難になります。また,バグが発生しても退治しにくいという問題もあります。 [原因] 構造体を知らないことが原因です。 [対策/予防] 構造体について勉強して,積極的に使用することです。 [例外] なし。 引数の数が多い 深刻度:★(軽度) [症状] みっともないというだけで,意外に実害は少ない。 [原因] 不勉強と怠慢が原因です。 [対策/予防] 引数を構造体でまとめられないか検討しましょう。 [例外] 試作段階や試行錯誤している場合は許されるかもしれません。しかし,本番では読みやすくまとめましょう。 [備考] List 12のような感じのプログラムは,たまに見ます。ギャグとしてわざと読みにくくしているのかと思いきや,多くの場合本人は真剣です。普通ならこれだけ引数があれば構造体にまとめるはずですが,案外,その方法を知らないというケースがあります。それどころか「構造体恐怖症」みたいな人もいます。List 12のプログラムは構造体を使って,List 13のようにすれば少しは見やすくなります。 List 12void f() { ... g(1,23,456,"abc","def",789,10,11,12,13,14,15,16,17.0); ... } void g(int i,int j,int k,char *l,char *m, int n,int p,int q,int r,int s,int t,int u,int v,double w) { ... } List 13typedef struct { int i,j,k; char *l; char *m; int n,p,q,r,s,t,u,v; double w; } g_struc; void f() { g_struct theG; ... theG.i = 1; theG.j = 23; theG.k = 456; theG.l = "abc"; theG.m = "def"; theG.n = 789; theG.p = 10; theG.q = 11; theG.r = 12; theG.s = 13; theG.t = 14; theG.u = 15; theG.v = 16; theG.w = 17.0; g(&theG;); ... } void g(g_struc *inS) { ... } 割り込みやスレッドで共用する変数をvolatileにしない 深刻度:★★★(重度) [症状] 不可解なバグに悩まされることになります。また,コードをいじるたびに症状が変わる場合もあります。 [原因] 割り込みやスレッド特有の制約を知らないことが原因で,これも不勉強のせいだといえます。 [対策/予防] 割り込みやスレッドについて,よく勉強することです。 [例外] なし。 [備考] 「スレッド」や「割り込み」は,昔は組み込みプログラムやデバイスドライバなどのデバイス周りの人だけ注意すればよかったのですが,最近はそうでもなくなっています。スレッドというのはプログラムを並行に動作させる仕掛けです。割り込みは文字どおり,通常のプログラムを実行している途中で,一時的に違う作業を要求する仕組みです。たとえばキーボードを押したときに,押されたキーの情報をどこかに保存する「キーボード割り込み」は普通のパソコンでは当たり前のように行われています。 スレッドや割り込みがやっかいなのはひとつのプログラムの流れを考慮して高速化を行うコンパイラが,変数をレジスタに割り付けしてしまうことです。このとき,ほかのスレッドや割り込みと共有可能な変数も同じように最適化をすると,うまくいかなくなる場合があります。 たとえば,List 14のプログラムではgVarという変数が割り込み(interrupt)のなかで1に変化することを利用し,割り込みが実行されたことをf()で検出しようとしています。ところが最適化によって,while(gVar == 0)が見かけ上,List 15のようなプログラムになってしまった場合,恐ろしいことにgVarの変化が検出できなくなります。 List 14int gVar = 0; void interrupt() { ... gVar = 1; } void f() { ... while(gVar == 0){ ... } ... } List 15void f() { register theReg; theReg = gVar; ... while(theReg == 0){ ... } ... gVar = theReg; } なぜ,こういう最適化が起きるのでしょうか? 一般的には,よく使う変数をレジスタに割り付けておくとプログラムの動作が高速になります。そこで,コンパイラが「気をきかせて」gVarではなくレジスタに一時的に割り当ててしまう場合があるのです。この最適化は必ずしも起きるわけではなく,プログラムをいじっている間に突然起きたり,消滅することもあり,やっかいです。デバッグモードで不具合が出て,出荷モードでは現れないという不可解な症状の原因にもなります。 C言語では,こういう最適化による弊害を消すため「volatile」というキーワードが用意されています。volatileの付いた変数は最適化による不都合が起きないようコンパイラが配慮をします。 ポインタと配列を混同している 深刻度:★★(中程度) [症状] うまくいく環境もあれば落ちる環境もあるので,悩まされます。また,うまくいっている場合でもプログラムの移植性は低くなります。 [原因] 不勉強が原因です。 [対策/予防] ポインタと配列の違いをしっかり勉強して,きちんと使い分けるようにすることです。 [例外] なし。 [備考] C言語で,ポインタと配列を混同している例は昔からよくあります。この道何年というベテランのプログラマですら平気でやらかしていることもあるので油断はできません。たとえば,List 16のようなプログラムは片方はOKですが,片方はNGになります。意外なことに,この道何年ものベテランでも答えられないことがありますが,両者の違いはわかりますか? List 16void f() { char *a = "ABC"; char b[] = "DEF"; *a = '1'; /* NG */ b[0] = '1'; /* OK */ } List 16のコメントでも示していますが,ポインタで定義したほうはNGになる場合があります(ならない場合もあるので,やっかいなんですが)。 これは,"ABC"という文字列がリードオンリーな領域に配置され,そのポインタ値をaに格納するようにコンパイルされる場合にNGとなるのです。リードオンリーな領域とは,たとえば組み込みプログラムならROMの領域だったり,プロテクトのしっかりしたOSの場合,読み込みはOKでも書き込み処理を行おうとすると例外を発生するような領域を意味します。 一方,配列で定義したほうは常に"DEF"を読み書き可能な領域に置きます。また,データ"DEF"を格納可能なサイズで配列を用意していますし,書き換えも可能です。 C言語の参考書で注意しなくてはならないのは,配列とポインタは違う概念のものなのに,あたかもすべて相互に置換可能であるかのように錯覚させる記述をしているものが少なくないことです。あるいは参考書を書いた筆者自身が錯覚している場合もあって,そのまま読者も錯覚をひきずってしまう危険があるということです。 文字列を格納する配列のサイズが小さい 深刻度:★★★(重度) [症状] わけのわからないバグに悩まされます。また,移植性や安定性に欠けるプログラムになります。 [原因] 文字列の扱い,および配列に関する勉強不足が原因です。 [対策/予防] 文字列・配列の勉強をやり直すしかありません。 [例外] なし。 [備考] 文字列の扱いもまたC言語でよく混乱の元になります。C言語では「文字列型」という型などありません。文字列を扱う場合は,一般にchar型の配列を使用しますが,これを「文字列に都合よく最適化された型だろう」と自分の都合のいいように解釈すると,いとも簡単に落とし穴にハマります。 たとえば,List 17のようなプログラムは問題をかかえています。というのも,配列aは4バイトのサイズしか用意されていないのに,そこに5バイトのデータをセットしようとしているからです。文字列は"ABCD"だから4バイトだろうというのは大間違いで,実際には文末コード(つまり'\0')もあるので,合計で5バイトぶん用意しないとまずいのです。 List 17void f() { char a[4]; strcpy(a,"ABCD"); } しかも,C言語では配列のあふれが発生しても,その場で問題が発覚するのではなく,隣接する変数を破壊したり,関数の戻りアドレスを破壊しながら処理を続けます。実際の障害が発覚するのはこれらの破壊された領域にアクセスしたときなので,症状を見ただけではそれがどのような原因によって引き起こされているか判断しにくいという問題もあります。 ほかのプログラム言語では配列の範囲外のアクセスが発生すると例外で落としたり,あるいは配列自身を自動拡張することで対応する例もありますが,あくまで「効率優先。効率のためなら危険な目にあってもかまわない」というスタンスのC言語ではプログラマ自身が覚悟しないことには,たかが配列とて油断ならないのが現実です。 文字列に関しては,いろいろと落とし穴があるので,ここで典型的なパターンを紹介しておきましょう。 文字列の長さを調べるにはstrlenという関数を使いますが,これは文字列の文末コードを含まない値を返すので,List 18は確実にメモリ破壊を行います。これもなかなか発覚しにくいし,どこが悪いのかを探しにくい,やっかいなバグになります。 List 18char *my_strdup(const char *inS) { char *theAns = malloc(strlen(inS)); /* ←間違い! strlen(inS)+1 が正しい */ if(NULL != theAns){ strcpy(theAns,inS); } return theAns; } また,たまに勘違いしている人がいますが,strlenとsizeofを混同するパターンもあります。sizeofは,単に指定された変数のサイズを返すだけにすぎません。したがって,List 19のプログラムではinSを格納するのに十分なメモリを確保することはできず,予期しない領域のデータを破壊する結果となります。 List 19char *my_strdup(const char *inS) { char *theAns = malloc(sizeof(inS)); /* ←間違い! これでは「const char *」の 占有サイズしか取れない */ if(NULL != theAns){ strcpy(theAns,inS); } return theAns; } このように,ポインタと配列,strlenとsizeof,どういうわけかこれらを混同していたり混乱しているために文字列処理を正しく行えなくなっているプログラムをよく見かけます。 自動変数をポイントして,それを関数の戻り値にする 深刻度:★★★(重度) [症状] 再現性の低いバグで悩まされることになります。また,プログラムは移植性や安全性が極端に低いものになります。 [原因] 自動変数に対する不勉強。 [対策/予防] 自動変数に定義したものを静的変数に変更できないかを検討します。また,自動変数の性質を勉強する必要があります。 [例外] なし。 [備考] 自動変数の性格をまるでわかっていないプログラマも珍しくありません。自動変数は,関数のなかで有効になり,関数から出ていくと,とたんに無効になるという性格があり,実装の際にも「関数から出ていくと消えてしまう」という性質上,スタック領域に割り当てられる例が多くなります。 したがって,List 20のようなプログラムではtheAnsはAddTextから抜けた瞬間に消滅するため,正しい戻り値を返せないという問題をかかえています。正しくは, static char theAns[256];というように静的変数にしておかなくてはいけません。このように静的変数にしておけば関数から出ていっても,その内容は消されませんから,関数は正しい戻り値を返すことができます。 List 20char *AddText(const char *inA,const char *inB) { char theAns[256]; strcpy(theAns,inA); strcat(theAns,inB); return theAns; } ところがやっかいなことに,この手のプログラムは見過ごされている例が多々あります。というのも,自動変数が割り付けられたスタック領域は,関数から抜けると同時に完全に消されるのではなく,その領域に「残骸」が残るため,しばらくはその残骸にアクセスできてしまう場合があるのです。そのような場合,プログラムは一見正常に動作し,あたかも問題がないかのように見えてしまうのです。 しかし,これはゴミ箱に捨てた紙が,しばらくはゴミ箱に残っていて,紙に書かれている文章が読めてしまうのと同じで,いつ回収されて読めなくなるかわかりません。 この類似パターンとして,次に紹介するように「一度解放したものを意味のあるものとして使う」例があります。 解放したものを使う 深刻度:★★★(重度) [症状] 再現性の低いバグで悩まされます。また,移植性や安全性が極端に低くなります。 [原因] プログラマの不注意や不勉強によるメモリ管理の甘さが原因です。 [対策/予防] メモリ管理についてよく勉強し,プログラムの設計時から計画的なメモリ管理を心掛けることです。 [例外] なし。 [備考] おそらく,プログラムの修正を繰り返すうちに,既に解放したものをついうっかり使ってしまうというパターンがもっとも多いと思われますが,List 21に示すように,あきらかに不勉強だと思われる例もよく見かけます。List 21の関数fでは,freeで解放した領域を意味あるものとして使っています。しかし,これもいったんゴミ箱に捨てたものを使っているようなもので,目の前で回収されても文句はいえません。このようなプログラムは,仮に期待どおりに動作したとしても移植性が悪く,ある環境では問題がなかったのに,別の環境では問題が起きたりします。また,意味不明の症状が起きて,なかなか原因を特定できない悪質なバグになるので,やっかいです。ちなみに関数fを正しく書き直すとList 22のようになります。 List 21struct Cell { struct Cell *next; char *name; }; void f() { Cell *theTop; Cell *thePtr; ... thePtr = theTop; while(NULL != thePtr){ free(thePtr); /* ←いきなり解放している。だめ。 */ free(thePtr->name); /* しかも解放したものの値を利用している */ thePtr = thePtr->next; /* ここもそうですね */ } } List 22void f() { Cell *theTop; Cell *thePtr; Cell *theNext; ... thePtr = theTop; while(NULL != thePtr){ theNext = thePtr->next; free(thePtr->name); free(thePtr); thePtr = theNext; } } List 21のようなプログラムを作るプログラマからは「実際にやってみて問題ないから,これでよかった」という,まるで「ウランをバケツですくっても問題はなかった。まさか臨界事故になるなんて思ってもみなかった」といっているのとたいして変わらないようないいわけをよく聞きます。 変数の数をケチって共有する 深刻度:★★(中程度) [症状] 不可解なバグで悩まされます。コードをいじるたびに症状が変わることもあります。 [原因] 意味のないケチ根性と,現実を理解していない不勉強によるものです。 [対策/予防] 細かい技巧に凝らずに大局観を持ってプログラミングすることです。 [例外] 今日でも組み込みマイコンのような極端に資源に乏しい場合は許されるかもしれません。しかし,仕様変更や追加が頻繁にある場合は考え直さないといけません。 [備考] C言語に限った話ではないのですが,資源をケチることによる弊害はありがちです。とくに最近までマイコンやパソコンはメモリにかぎりがあり,CPUも遅かったので,特殊なテクニックや技巧が必要な場面も少なく有りませんでした。いまでも一部の組み込み用途や特殊用途では「技巧」が必要ですが,もはや数メガ,数十メガの記憶容量は当たり前で,CPUのクロックも数百メガから,まもなく1ギガを超そうかという時代に,いつまでも数十キロのメモリや記憶容量にとらわれるのはナンセンスといえるでしょう。 その昔,筆者も8ビットマイコンのアセンブラで,いかに少ないレジスタをうまく使い回すかという,一種の職人芸みたいな技巧に挑戦していましたが,その後の仕様変更や追加追加の際に苦しめられ,それ以来,考えが変わりました。 ここでいっている「ケチ」は,たとえば,List 23のようなプログラムがあったとき,theCountとtheNumが共有できるという間違った判断をして,List 24のように書き替えてしまうことをいっています。変数をケチることにばかりに注意を向けて,実は関数の最後でtheCountを使っていることを見逃したというお粗末な例です。 List 23int f() { int theCount,theNum; ... for(theCount = 0; theCount < 10; theCount++){ ... } ... for(theNum = 1; theNum < 20; theNum++){ ... } ... return theCount; } List 24void f() { int theCount; ... for(theCount = 0; theCount < 10; theCount++){ ... } ... for(theCount = 1; theCount < 20; theCount++){ ... } ... return theCount; } |