初級C言語Q&A(7)

初出: C MAGAZINE 1995年12月号
Updated: 1996-03-12

[1つ前] [1つ後] [質問一覧] [記事一覧] [ホームページ]


不定と未定義

 C言語で書いたプログラムのバグの中には、言語仕様上の動作が処理系に依存 するようなコードが原因のものがあります。コンパイラをバージョンアップした のでコンパイルし直してみたら動かなくなった、というのはよくある話です。コ ンパイラ自体のバグだったという悲劇的なケースもないわけではありませんが、 コンパイラのバージョンに依存するようなコードを書いていたため、ということ も案外多いようです。基本的には、結果が不確実なコードは書かない、というの が原則ですが、そのためには、不定、未定義という考え方を身に付けておく必要 があります。


不定、未定義の定義


Q 【不定】

 不定とは何か。

 C言語の仕様としては正しい書き方であるが、その結果が決まっていないような ふるまいのことをいいます。
  a = foo() + bar();
これをコンパイルすれば、どちらかが先に呼ばれて、その後もう一つが呼ばれる、 というコードが生成されることは保証されます。しかし、 foobar のどちらが先に呼ばれるかは分かりません。

JIS Cでは不定を「未規定の動作」という表現を使っていますが、俗には「不定」 という表現の方がよく使われるようです。「未規定の動作」の定義は「正しいプ ログラム構成要素及び正しいデータに対する動作であり、この規格が明示的に何 ら要求を課さない動作。」となっています。


Q 【不定の例】

 不定である場合としては、どのようなものがあるか。

 典型的なものとしては、次のようなものがあります。

Q 【未定義】

 未定義とは何か。不定と何が違うのか。

 未定義とは、その結果どうなるかが、言語仕様として決められていないことを 意味します。そして、その結果がどうなっても、そのC言語の処理系はは規格に 合致しているとみなされます。コンパイラは未定義のコードを処理する時に独自 の解釈をしてもよいし、あるいは、不正なコードを生成して、実行した途端にシ ステムを停止させても構いません。コンパイル時にエラーにしても構いません。

 インターネットのニュースグループ、comp.std.cやcomp.lang.cでは、未定義の コードを実行した結果「鼻から悪魔が飛び出しても仕様に反しない」というjoke が流行したことがありました。今でもたまにこのような表現を見ることがありま す。

 英語では、未定義のことをundefined、不定のことをunspecifiedと表現します。

 不定と未定義の最も大きな違いは、不定のコードはプログラムとしては正しい が、未定義のコードは間違いであり、動作する保証すらないという所にあります。 従って、未定義のコードは絶対に書くべきではありません。不定の場合は、結果 がどのようになるかは処理系に依存しますが、プログラム自体は正しいとみなさ れることが保証されています。従って、 a = foo() + bar(); のようなコードは、どちらが先に呼ばれても構わない状況で用いる限り、安心し て使うことができます。

JISでは未定義の動作について、次のように記述しています。「可搬性がない若 しくは不正なプログラム構成要素の使用における動作、又は不正なデータ若しく は不確定な値を持つオブジェクトの使用における動作であり、この規格が何ら要 求を課さない動作。未定義の動作に対して、その状況を無視して予測不可能な結 果を返してもよい。」

(参考 JIS X3010)


Q 【未定義の例】

 未定義である場合としては、どのようなものがあるか。

 主なものとして、次のようなものがあります。なお、未定義のケースは、これ 以外にも多数あります。コンパイル時にエラー、Warningが出る場合には、未定義 であることが多いと考えてよいでしょう。

Q 【a[i] = i++;】

 配列に0から始まる整数の値を入れたい。次のコードはなぜうまく動かないか。
    int a[10];
    int i = 0;

    while (i < 10)
        a[i] = i++;

i++ という式を実行すると、 i の値が変化します。このような振る舞いのことを副作用と呼びます。ある変数に 対して副作用が生じるような式においては、他の箇所でその変数を参照すること はできません。その結果は未定義とされています。

 この式の場合、 ii++ の他に、配列 a の要素を指定するために参照されているため、その結果は未定義となるのです。

 K&Rの記述を読むと、上の結果は不定であると解釈できるような記述があり ますが、ANSI Cの規格では、不定ではなく未定義の扱いとすることになっています。

(参考 comp.lang.c FAQ 3.1)


Q 【i++ * i++】

 次のコードを実行すると49と表示された。掛け算の評価の順は不定だが、どち らから評価しても56になるはずでは。
    int i = 7;
    printf("%d¥n", i++ * i++);

 掛け算の演算子「*」の評価順序が不定であるということは間違っていません。 しかし、この場合重要なのは、一つの式の中で、副作用が発生する操作を二回行 うということです。その結果は未定義なので、この printf がどのような値を表示してもC言語の仕様としては問題ありません。極端な場合、 ここでプログラムが異常終了してしまっても問題ありません。

 i++ * i++ は、一見すると、

  1. まずどちらかの ++ が実行されて、 i++ という式の値としては7となり、 i の値が8になる。
  2. 次に残りの ++ が実行されて、 i++ という式の値は8となり、 i の値が9になる。
 のように思えるかもしれませんが、実はそうではありません。というのは、 i の値が実際に増加するタイミングは、 i++ * i++ という式が終了するまでのいつかである、という範囲でしか保証されていないか らです。

(参考 comp.lang.c FAQ 3.2)


Q 【予期した通りの結果】

 次のコードを実行すると、配列a[]には、期待通りに0〜9の値が入った。仕様 では未定義だが、期待通り動作するからいいのでは。
    int a[10];
    int i = 0;

    while (i < 10)
        a[i] = i++;

 未定義というのは、あくまでどんな結果になるか仕様としては定めていないと いうことなので、処理系によっては期待通りに動作してしまうこともあるでしょ う。しかし、それは本当に処理系のローカルな仕様によってそうなったのか、あ るいは単なる偶然なのか、はっきりしないはずです。今後のバージョンアップで 動作が保証されるかどうかも分かりません。

 それに、たいていの未定義、あるいは不定なコードは、ほんの僅かな変更で、 定義されたコードに修正することができます。この例の場合は次のように書くと 明白です。

    int a[10];
    int i;

    for (i = 0; i < 10; i++)
        a[i] = i;

Q 【i = i++】

 次のコードを実行したら、 i の値が7になってしまった。どうしてこんな変な値になってしまうのか。
    int i = 3;

    i = i++;

i = i++; の結果は未定義です。どんな値になってもC言語としては正しい結果です。

なお、 i = i++; 自体がほとんど無意味であることにも注目してください。これは他言語に慣れた 初心者がうっかり間違うことの多い例です。もしかすると、この人は i = i + 1; と書くつもりだったのですが、たまたま、C言語にはインクリメントの演算子があ るということを発見したため、 i + 1 という表現の部分をうっかり i++ に置き換えてしまったのかもしれません。

 ここは、深く考えずに、 i = i + 1; と書いておくか、あるいは i++; とだけ書けばよいのです。

(参考 comp.lang.c FAQ 3.3)


Q 【値の交換】

a ^= b ^= a ^= b というコードを使えば、 ab の値を交換することができることを動作確認した。しかし、この書き方はよくな いと言われた。なぜか。

 これはトリッキーなコードとして非常に有名です。 a^a が0になることを利用している所がポイントです。しかし、見てすぐ分かるよう に、一つの式の中に副作用が複数回あり、従って、この結果は未定義となります。

 次のようにカンマで区切って書くと、副作用の影響は消滅します。なぜなら、 カンマ演算子は、左から右に評価すると仕様で決められているからです。

    a ^= b, b ^= a, a ^= b
 しかし、これでもなお、このコードは同一のオブジェクトに対して動作しない (aとaを交換するとどんな値になるか?)という欠点があるため、実際にプログラム に使うのは止めておいた方が無難でしょう。

(参考 comp.lang.c FAQ 10.3)


評価順序


Q 【評価順序】

 評価の順序が不定である、というのはどういうことか。

a = foo() + bar(); のようなコードに対して、 foobar のどちらが先に呼ばれるか決まっていない、ということです。

Q 【優先順序】

 評価と優先順序とは何が違うのか。

 優先順序というのは、異なる演算子が式の中で組み合わせられた場合に、どの 結合を優先的に行うかを決めた規則です。算数で、掛け算は足し算よりも先に計 算する、という規則があるのと同じです。

 しかし、優先順序に関らず、それぞれの項の評価順序は不定のままです。例え ば、次の式は、どのような順序で関数が呼ばれるか分かりません。これで言える のは、 f2() を呼び出して得られた値と f3() を呼び出して得られた値をまず掛け算 し、その結果と f1() を呼び出して得られた値を加算する、ということだけです。最後に足し算が行わ れるにもかかわらず、 f1() が真っ先に呼び出されて、掛け算が終わるまでその値が保存されているかもしれ ません。

    i = f1() + f2() * f3();

Q 【評価順序を変更する】

 括弧を使って評価順序を変更することはできるか。

 できません。括弧を使えば演算子の優先順序よりも強い力で式をまとめること ができますが、評価の順序まで変更するわけではありません。

 評価順序を確定させるためには、複数の文に分けるのが簡単です。

(参考 comp.lang.c FAQ 3.4)


Q 【漢字コードの処理(1)】

 次のコードが思った通りに動いてくれない。「C」の漢字コードが入ることを 期待しているのだが、実際は上下バイトが入れ代わったものになってしまう。
    kcode = (getchar() << 8) + getchar();

 「 + 」という演算子の両辺の評価順序は不定です。つまり、+の両側に書かれた getchar() のどちらが先に実行されるか分かりません。右から順に評価する処理系もあれば 、左から順に評価する処理系もあるでしょう。この人の使った処理系は、たまた ま右の getchar() が先に呼び出されるものだったようです。

 C言語の仕様では、多くの演算子の評価順序は不定です。左から順に実行される という先入観を持ちがちなので、注意が必要です。


Q 【漢字コードの処理(2)】

 結局、次のように書いたらうまくいった。しかし、このように書いたら先輩に 怒られた。なぜか。
    kcode = getchar() + (getchar() << 8);

 評価順序が不定であるにもかかわらず、評価の順序に結果が依存するようなコー ドを書くべきではありません。それは今のところたまたま思った通りに動作する かもしれませんが、他の機種に移植した途端に動かなくなるかもしれないし、も しかするとコンパイラをバージョンアップしただけで動作が変わってしまうかも しれないのです。

 この例では、評価順序に依存しないコードに修正することは、実に簡単です。 次のように二行に分けて書けばいいのです。

    kcode = getchar() << 8;
    kcode += getchar();

Q 【&&や||と副作用完了点】

 &&|| を使ったコードの中には、評価順序が左から右という前提で書かれているものが あるが、評価順序は不定ではないのか。

 例外として、評価順序が決まっている演算子があります。 &&|| 、カンマ演算子の「 , 」、三項演算子の「 :? 」です。これらは、左を評価し、その後に右が評価されることになります。

 ある時点においてそこまでの副作用が全て完了することが保証されている区切 りのことを、副作用完了点(sequence point)と呼びます。前述の演算子は、左の 式を評価した直後が副作用完了点となっています。つまり、左が処理し終わって から右が処理されることが保証されているわけです。

 なお、関数の引数を区切るのに使うカンマは、カンマ演算子ではないため、評 価順序は不定です。


Q 【&&と&の違い】

 (1)のようなコードを見た。意味としては、(2)のようなことがしたいとしか思 えないのだが、なぜ(1)のように書いたのだろうか。
    i = foo() & bar();  /* (1) */
    i = foo() && bar(); /* (2) */

 単なる間違いかもしれません。しかし、意図した可能性もあります。

&& という演算子は、左から右へ評価し、全てが真だった時には式全体の値を真とし ます。評価した結果が偽だった場合、そこで評価が打ち切られ、式全体の値を偽 とします。

&&|| という演算子は、他の多くの演算子とは異なり、左から右の方向に順番に評価さ れることが仕様で決まっているのです。従って、 foo() && bar() というコードは、 foo() が0を戻してきた場合、そこで打ち切られ、 bar() は呼び出されることはありません。 foo() が0になることが分かった時点で式全体の値が0になることが分かりますから、残 りを評価しないという仕様は無駄のないことといえるでしょう。

 しかし、場合によっては、結果にかかわらず、とりあえずどちらの関数も呼び 出しておきたいということがあります。この場合、明らかに全く問題のない方法 は、

    i = foo();
    i = bar() && i;
 のように別々に呼び出すことです。

 プログラマーによっては、凝った方法を使う人もいます。ここでは、 foo()bar() も0または1という値を戻すことが分かっていて、かつ、どちらが先に呼ばれても 結果としては影響がなく、さらに、どのような場合でもとりあえず両方の関数を 呼び出したい、という条件の下でのみ、 foo() & bar() という書き方が意味を持ってきます。


Q 【&&、||】

 次の二つのコードは、どこが違うのか。
    i = (c >= 0x81 && c <= 0x9f) || (c >= 0xe0 && c <= 0xfc) /* (1) */

    i = c >= 0x81 && (c <= 0x9f || (c >= 0xe0 && c <= 0xfc)) /* (2) */

シフトJISの漢字判断のコードとして、よく見掛けるものです。 &&|| が、左から右へ評価することに注目してください。評価の順序が分かるように、 if で書いてみると、次のようになります。
  (1)
    if (c >= 0x81) {
        if (c <= 0x9f)
            i = 1;
        else
            i = 0;
    } else {
        if (c >= 0xe0) {
            if (c <= 0xfc)
                i = 1;
            else
                i = 0;
        } else {
            i = 0;
        }
    }

  (2)
    if (c >= 0x81) {
        if (c < 0x9f) {
            i = 1;
        } else {
            if (c >= 0xe0) {
                if (c <= 0xfc)
                    i = 1;
                else
                    i = 0;
            } else {
                i = 0;
            }
        }
    } else {
        i = 0;
    }
 内容に殆ど違いがないことが分かります。一つだけ明白な違いは、 c <= 0x80 の場合の評価の回数です。この範囲にある場合、(1)の方法だと、まず、 c >= 0x81 の評価で偽になりますから、 || の後の評価に移ります。そして、 c >= 0xe0 の評価も偽になり、結果としては偽になるでしょう。しかし、考えてみれば、 c >= 0x81 の結果が偽ならば、0x81よりも小さい数であることがその時点で分かっているの ですから、わざわざ再度確認するまでもなく、 c >= 0xe0 も必ず偽になことが明らかです。(2)では、 c >= 0x81 が偽になったらその時点で全体の値を偽にするので、一度の評価で済ませている ことになります。

 (1)と(2)のどちらが良いかというのは、議論の余地があると思われます。


(C) 1995-1996 Phinloda, All rights reserved
無断でこのページへのリンクを貼ることを承諾します。問い合わせは不要です。
内容は予告なく変更することがあります。