見出し画像

C言語教室 解答編 第11回

お待たせしました。今回の内容が思った以上にややこしいことになってしまい解答を用意するのが遅くなってしまいました。今回もまだ課題が済んでいないのであれば、ぜひ挑戦してから読んで下さい。

C言語教室 第11回 - プリプロセッサとマクロ

今回の課題と演習は以下の内容です。

課題
說明で使った MAX(x,y) というマクロで、引き数にインクリメント演算子を含んだ変数を書くと何が起こるか考察しなさい。

演習
プリプロセッサが自分の書いたコードを、どのように展開しているのかを確認する方法を調べなさい。

https://note.com/kazushinakamura/n/nb0b6ca29b15c

という事で、今回もうっかり中身を見ないために、少しばかり閑話など。


今回の課題に直接関係はしていないのですが、インクリメント演算子を使う時に注意しなければならないのが式の評価において実行されない式があるという「短絡評価」の影響です。

短絡評価

論理演算を行う時に、左の式から順に実行されて値が評価されるのですが、最後まで評価しなくても途中で結果が定まっていしまうことがあります。例えば論理和(OR)演算子であれば、ひとつでも真になれば残りの結果に関わらず、値は真です。

無駄な評価を省略してくれるなんて効率が良くていいじゃないか。と思ったかもしれません。でも式の中にはいわゆる副作用と呼ばれる、実行することによって値を計算する以外の効果があることがあります。

例えば式の中で関数を呼び出していれば、式が実行されなければ関数は呼び出されません。この関数が計算をするだけで何らかの副作用が無ければ、それでも問題ないのですが、もしかしたらグローバル変数の値を書き換えたり、入出力を実行しているかもしれません。これって実はデバッグ用の関数を差し込んだ時にヤリガチで「あれ?値が出てこないな」という事故を起こしたりします。

そしてもちろんインクリメント演算子などを使っていた場合、その変数がインクリメントされなくなります。

この短絡評価は、C言語以外の多くの言語でも行われますし、C言語においてもカーニハンの時代からハッキリと書いてあります。ただこの仕様がmustと解釈されていないようで、稀に短絡評価を行わない処理系もあるにはあるようです。


さて、そろそろ演習に進みましょう。

今回は「考察しなさい」なので、コードを書かなくても良いのですが、やはり走らせて確認したほうがよく分かると思います。教室で例に出した MAX マクロを使って説明します。まずマクロではなくて関数で呼び出してみましょう。

#include <stdio.h>

int max(int x, int y) {
  return (x > y) ? x: y;
}

void main() {
  int i = 1;
  int j = 10;
  int k;

  printf("i=%d,j=%d\n", i, j);

  k = max(i++, j++);

  printf("max=%d,i=%d,j=%d\n", k, i, j);
}

もちろん結果は。

i=1,j=10
max=10,i=2,j=11

となります。後置インクリメントなので、インクリメント前に式の評価が行われ、その後、i も j もインクリメントされています。

ではマクロにしてみましょう。

#include <stdio.h>

#define MAX(x,y)  ((x) > (y) ? (x) : (y))

void main() {
  int i = 1;
  int j = 10;
  int k;

  printf("i=%d,j=%d\n", i, j);

  k = MAX(i++, j++);
 
  printf("MAX=%d,i=%d,j=%d\n", k, i, j);

}

あら不思議、結果は以下のようになります。

i=1,j=10
MAX=11,i=2,j=12

i は 1 しか増えていませんが、なぜか j は値が2つ増えていますね。先のマクロを展開してみましょう。MAXは文字列的に置き換えられて以下のように解釈されます。

k = ((i++)>(j++)?(i++):(j++));

ひとつしか書いていなかった i++ と j++ が2回ずつ出てくる形になりました。式は左から順に実行されるので、まず i の値と j の値が比較され、i と j がインクリメントされます。ここで式は偽であったので、この時点の j の値が式の値になり k に代入されます。そして最後に 2個目の j のインクリメントが実行されるわけです(2個目の i++ は実行されない)。

三項演算子は閑話で書いた短絡評価の対象ではないので、すべての式が実行されるはずで、この結果が正しいはずなんですが、そもそも関数呼び出しと異なる結果が出てしまうのは混乱するだけです。マクロはあくまで文字列的な置換を行うだけなので、副作用がある式を展開すると予想外の結果になってしまいます。マクロの引き数には副作用のある式を書かないのが安全です。インクリメント演算子が式には見えないかもしれないですけどね。

なお、今回のコードは gcc で確かめました。予想通りというかブラウザ版のC言語では異なる結果が得られました。おそらくですがインタプリタで実装されていると結果的に短絡評価になってしまうのではないかと思います。コンパイラによっては、または最適化によって、2回めのインクリメントをサボる実装があってもビックリしません。やはり危ない橋は渡らないほうが良いですし、このようなコードを見つけたら、値が想定通りに変化するのかテストするコードを走らせておいたほうが良さそうです。

2023-1-24 追記:AyumiKatayama さん、ご指摘をありがとうございます。うっかりミスをしてしまっていたようで、おかげで直せました。


演習の方ですが、こちらは使う処理系によって異なるので「自分の処理系ではこうなるよ」という情報はぜひとも教えて下さい。

gcc であれば、gcc -E <C言語のソースファイル名> で標準出力にプリプロセッサの結果を出してコンパイルせずに終了します。ヘッダファイル内の #ifdef の評価であったり、今回の例のようなマクロの展開に自信が無いときに使うことがあります。やってみるとわかるのですがコメントはこの段階でキレイに削除されています(コメントを残すオプションもある)。

他にも展開結果を “.i” に出力するオプションもあったような気もするのですが、これは VC だけだったかも。


さて、今回も Akio van der Meer さんから答案を頂きました。ちょっと苦しんでしまったようですが、ありがとうございます。

(答案提出)C言語教室 第11回 - プリプロセッサとマクロ

はい。前置インクリメントは、式の評価に先立って、後置は評価が済んでから実行されるのがC言語の仕様であるようです。printfの引き数で渡されるときには式の評価が済んでからなので、いずれもインクリメントされた後になりますね。基本的には前述した通りマクロの引き数に副作用のある式を書かないということなんだと思いますが、見落とすこともあると思うので、assertマクロなんかを紛れ込ませて置くのが良いとは思います(assertマクロについては近く書きます)。


AyumiKatayama さんは答案という形ではないのですが、見事にハマられたようで、以下の2つの記事を書いていただいています。

C言語でコンパイラ依存した話

C言語コンパイラ依存した話の続編「clang はどうコンパイルするのか」

実際にコンパイラから出力されたバイナリコードを使って検証して頂いていて、なんだか恐縮です。ARMのアセンブラは慣れていないのですが、今流のレジスタのたくさんあるコードを読ませて頂きました。この部分の実装が怪しいのは確かなのですが、一応、gccの動作が正しかろうとは思っています。C言語の仕様書なんぞをメクッていると副作用完了点というワードが登場して、この辺りを説明しているようなのですが、これがまた七面倒臭い書き方なので、解釈がいろいろあることも充分にアリそうだと思います。

副作用完了点について

自信の無い部分があったり、いろいろな環境での情報が足りないので、追加の情報を頂いたら、適宜、加筆訂正します。よろしくお願いします。

ヘッダ画像は、いらすとやさんから
https://www.irasutoya.com/2020/04/blog-post_243.html

この記事が気に入ったらサポートをしてみませんか?