117
94

C言語の知られザル・許されザル仕様

Last updated at Posted at 2024-07-26

はじめに

どうも、y-tetsuです。

かれこれC言語には、10年以上携わっているのですが、最近ふと学びなおしをしています。

Cクイックリファレンス第2版」これを完走めざして読み始めました。全816ページの超大作!

先は長いので、日頃からかたわらに置いておき、表紙の牛さん(雌牛)と目が合ったら黙って少し読むようにしています。

言語の"歴"だけは長い筆者ですが、この本をちらっと読んだだけでもいまだに知らなかったことが結構潜んでいました。意外と己の"目"ってザルでした。

そんなこんなで学びなおしのため、今回は筆者が感じたままの知られザルそして許されザルなC言語の仕様について、備忘録を残します。

知られザル仕様

恥ずかしながら、今まで存じ上げザルだったシリーズ。

ダイグラフ

名前からして???だったんですが、キーボードによっては存在しない記号を別の2文字で表わすためのものだそうです。

!?…って、まだよくわからないですね。

(サンプルコード)

digraph.c
#include <stdio.h>

int main()
{
    int arr<::> = <%1, 10%>;
    printf("arr[0]=%d, arr[1]=%d\n", arr<:0:>, arr<:1:>);
    return 0;
}

(実行結果)

$ gcc ./digraph.c -o digraph
$ ./digraph
arr[0]=1, arr[1]=10

上の実行結果の出力内容についてですが、

1行目はコンパイルの実行で、2行目がコードの実行です。そして、3行目が肝心のコードに書かれた printf の出力結果です。

実はこのコードは、初期値設定を行った配列の要素2つを表示するだけのものです。

では、ダイグラフを使わずに書くとどうなるかというと、以下をご覧下さい。(ちょっと上下で見比べてみて下さい)

no_digraph.c
#include <stdio.h>

int main()
{
    int arr[] = {1, 10};
    printf("arr[0]=%d, arr[1]=%d\n", arr[0], arr[1]);
    return 0;
}

前のコードと違う箇所は、お分かりになりましたかね。

ポイントは、[ の代わりに <: と打てば、それは [ として扱いますよ、というC言語側の心意気の部分です。これは [ が入力できない(いにしえの)キーボードの場合でも、C言語を書けますよ!という事を意味しています。

そんなダイグラフの一覧、置いておきますね。

ダイグラフ 等価な文字
<: [
:> ]
<% {
%> }
%: #

う~ん、知られザル…。

トライグラフ

先ほどのダイグラフが2文字だったのに対して、こんどのトライグラフはさらに3文字で1文字を表す、というものです。

同様に、こちらも一覧を置いておきますね。

トライグラフ 等価な文字
??( [
??) [
??< {
??> }
??= #
??/ \
??! |
??' ^
??- ~

トライグラフを使うと、7ビットASCIIに対応した「ISO/IEC 646」という国際標準規格で定義されている文字だけでCプログラムを書けるんだそうです。加えて、お国柄で自由に変えられる範囲のコードは使わない──つまり、最低限この規格さえ満たす環境であれば世界中どこでもCが書ける──そうです。

さらに詳しい説明は、@YuneKichiさんに補足のコメントをいただいておりますので、参考にして下さい。(YuneKichiさん、大変ありがとうございました!)

ちなみに先のダイグラフだと、文字列定数や文字列リテラルの中では1文字としては解釈されず、そのままの表記となります。

unable_digraph.c
#include <stdio.h>

int main()
{
    int a = 10;
    printf("<: %d :>\n", a);
    return 0;
}

これは以下の通り、<:がそのまま表示されます。

<: 10 :>

一方で、トライグラフは文字定数や文字列リテラルでも使用可能となっています。こちらはコンパイル前のプリプロセッサ処理の、"さらに前"の段階で適用される(置き換えられる)んだそうです。(ところでダイグラフと同じ文字をトライグラフが包含していそうですが、それでもダイグラフいるの??なんて…)

トライグラフのサンプルは以下。

trigraph.c
#include <stdio.h>

int main()
{
    int a = 10;
    printf("??( %d ??)\n", a);
    return 0;
}

実行時は、以下の1行目の-trigraphsオプションの指定が必要でした。(GCCの場合)

$ gcc ./trigraph.c -o trigraph -trigraphs
$ ./trigraph
[ 10 ]

以下の記事では、昔のキーボードの例などイメージが沸き、とても参考になりました。

なお、C23(C の 2023 年の改定の通称)からトライグラフは廃止となっております。

カンマ演算子

カンマ(コンマ)演算子は、, を使う2項演算子です。, の左と右の式を順次評価します。2つ以上の複数の処理も , を複数使って表現できます。全体の評価結果は一番最後の式の結果となります。

こちらの演算子の凄まじさ(難解さ)を伝えるサンプルを示します。

comma.c
#include <stdio.h>

int main()
{
    int x = 0;
    int y = (x = 1, x + 1, x * 2);
    printf("x = %d, y = %d\n", x, y);
    return 0;
}

y への代入文のところ、見ていただけますか?そこにカンマ演算子が使われています。複数の処理(演算)が、, 演算子によって並んでいるのが分かるかと思います。

さて、この結果の出力(xy の値)がどうなるのか、予想できますでしょうか?

勿体ぶらずに示すと、結果は以下となります。

$ gcc ./comma.c -o comma
$ ./comma
x = 1, y = 2

パッとイメージどおりでした!という事でしたら、特に言う事はございません。(単なる筆者だけの無知でございます)

そうでなかった方のために、ロジックの流れを順に示しておきます。

    int x = 0;
    int y = (x = 1, x + 1, x * 2);
    printf("x = %d, y = %d\n", x, y);
  1. x = 0 により x の値は 0 になります
  2. 次にy への代入に際して、カンマ演算子の最初の処理 x = 1 により x の値は 1 になります
  3. 続いて、x + 1 の計算結果により 2 を返しますが、次に演算が続くのでそのまますぐ捨てられます
  4. 最後の x * 2 時点では x1 ですので、ここでは 1 * 2 の結果の 2 を返します
  5. カンマ演算子の最終結果である4.で返ってきた 2y に代入されます

という事で最終結果は

x = 1, y = 2

となりました。

特に3.のところで、演算結果が捨てられる(x にも y にも代入されない)ってあたりが、直感的にイメージこんがらがるかもと思いましたね。

う~ん、知られザル…。

auto指定子

はるか昔にCを初めて書いた頃、変数の型のところに auto って書いてたようななかったような、うっすらした記憶を思い出しました。

学びなおしによりコイツは、関数内で宣言された変数が"自動記憶期間"をもつものだ!と指定する時に使う、という事が分かりました。

型というよりは static とか extern とかの類で、"記憶域クラス指定子"と呼ばれます。auto 指定子を付けた変数は、その関数内で使用できるテンポラリ変数ですよ──関数抜けると自動で消えるよ!──と宣言するためのものである、と解釈できます。

実のところ今のC言語では auto 指定子については、わざわざ入れなくてもデフォルトで勝手にそうなります(どうりで馴染みがないわけです…)。残念ながら、これってもう時代遅れのものなんじゃないかなって感じがしますが、逆に言うと、昔は放っておくとグローバル変数が量産されていた?という事なのでしょうかね?それとも、C言語ができた当初からautoは不動の不要な指定子だったんでしょうか?(だとすると悲しい事ですね…)

auto.c
#include <stdio.h>

int main()
{
    auto int x = 0;
    auto int y = (x = 1, x + 1, x * 2);
    printf("x = %d, y = %d\n", x, y);
    return 0;
}

先ほどのカンマ演算子のサンプルに auto 指定子を入れてみましたが、ちゃんと動いてまったく同じ結果になりました。(なぜに今も仕様が残っているんでしょうね?)

追記 : キーワード (C言語) Wikipediaによると、C言語のご先祖様である"B言語"との互換性を意識したものらしいです。C言語にも親あり。う~ん、知られザル…。

なんと!C23から auto に型推論の役割が追加されています。

// こうすると初期化子が `double` なので `foo` は `double` 型と推論される
auto foo = 1.0;

ここで、以下のサイトにてC23での変更点の全体についてざっと確認出来たのでご紹介。

なお、C23での auto による型推論やトライグラフ(トリグラフ)削除の記載については、@SaitoAtsushiさんにコメントで教えていただきました、大変ありがとうございました!

register指定子

続いては register 指定子です。これは、先ほどの auto 指定子を付けたり(付けなかったり)していたテンポラリ変数へのアクセスをできるだけ高速化したい場合に使うものです。register 指定子は auto 指定子と同じく"記憶域クラス指定子"の一族でして、代々同じ種族を同時に宣言してはいけない、という掟があります。

たとえば、プログラム中のどこからでもアクセスOK!で有名な extern と、単一ファイル内でのみアクセスOK!な static とが"同時"に宣言されたとしたら、いかがでしょう。きっとコンパイラは「ど、どうしろと!?」ってなりますよね…。

話を戻して、register 指定子が付けられたテンポラリ変数は通常のメモリとは別の、CPUがもっとも高速にアクセスできるレジスタと呼ばれる特別なメモリ領域に格納されます。

これにより高速化が実現されます。が、これはコンパイラ側からすると努力目標の扱いで、できるときはするしできないときはしない、という類のいわばおまじないみたいなもののようですね。

あと、register 指定した変数は、アドレスの取得ができないという制約があり、アドレスの演算をしてはいけないそうです。

register.c
#include <stdio.h>

int main()
{
    register int x = 0;
    register int y = (x = 1, x + 1, x * 2);
    printf("x = %d, y = %d\n", x, y);
    return 0;
}

これもちゃんと動きました。もちろん高速化は実感"できません"でしたが。

restrict修飾子

知られザルシリーズ最後は restrict 修飾子です。コイツは"型修飾子"ということで、intなどの"型名"や先の"記憶域クラス指定子"とはジャンルが異なり、オブジェクトの性質のような部分を着飾りたい時に使います。

例えば、値が変わらない性質を持たせる事をコンパイラに伝える const といったものと"同類"になります。

restrict 修飾子はポインタ変数に対して使うもので、コンパイラによるメモリアクセスの最適化を促します。

restrict 修飾されたポインタのオブジェクトは、そのポインタ以外でのアクセスが "ない" ことを前提としていて(これはプログラマ側で保証する)、この前提によりコンパイラが最適化を行うという思想のようです。

が、イメージ的には restrict の前提を満たすコーディングはプログラマ側の努力次第(やむなく満たせていない可能性は0じゃない?)、だとかコンパイラ側の最適化は努力目標で「義務はない」といった記述が見受けられ、とってもふわっふわした印象を受けました。

この点の理解を少し深めるためにサンプルを示します。(ちょっと話が長くなりますが、踏み込んでみたいと思います)

restrict.c
#include <stdio.h>

void hoge(int d[], int* s)
{
    d[0] += *s;
    d[1] += *s;
}

void piyo(int d[], int* restrict s)
{
    d[0] += *s;
    d[1] += *s;
}

int main(void)
{
    int a[] = {1, 1};
    hoge(a, &a[0]);
    printf("a[0] = %d, a[1] = %d\n", a[0], a[1]);

    int b[] = {1, 1};
    piyo(b, &b[0]);
    printf("b[0] = %d, b[1] = %d\n", b[0], b[1]);
}

ほとんど同じ2つの関数、hogepiyo を実行するだけのものです。ポイントは、piyo の第二引数に restrict が使われている点です。

普通にコンパイルして実行してみます。

$ gcc ./restrict.c -o restrict
$ ./restrict
a[0] = 2, a[1] = 3
b[0] = 2, b[1] = 3

はい。hogepiyo も同じ結果です。特に異常なし。……って、えぇ!?

まあ確かに、hogepiyo もロジックは同じでしたので、そうなって然るべきなんですが…。では、一体 restrict って何の意味があるのでしょう?

はい。この restrict最適化を実施する際に活きてきます。

最適化オプション(-O)を付けて、もう一度結果を見てみます。(-O は基本的な最適化を行うオプションで、実行速度の向上とデバッグのしやすさのバランスを保ちます。他にも -O2 などがあります)

$ gcc ./restrict.c -o restrict -O
$ ./restrict
a[0] = 2, a[1] = 3
b[0] = 2, b[1] = 2

んんんっ!?よく見ると、b[1] の値が違っています。そういえば、配列 b の操作をしているのは restrict を使った piyo 関数の方でしたね。

この結果は、restrict が付いたポインタ変数は、それ以外によるアクセスはしませんよ!という、プログラマとの紳士協定を信じて、コンパイラがコードを最適化したことによるものです。

-O 指定時の、x86-64でのアセンブリコード(機械語の命令を人間が読めるようにした低水準なコード)を見てみます。

以下を実行すると、"restrict.s" というファイルにアセンブリコードが出力されます。

gcc -S restrict.c -O

続いて、アセンブリコードを一部抜粋して示します。

restrict.s
    :
hoge:
    :
	movl	(%rdx), %eax
	addl	%eax, (%rcx)
	movl	(%rdx), %eax
	addl	%eax, 4(%rcx)
	ret
    :
piyo:
    :
	movl	(%rdx), %eax
	addl	%eax, (%rcx)
	addl	%eax, 4(%rcx)
	ret
    :

何だか急に難しくなってきましたが、最適化の中身を知る上で、ここからが一番肝心な部分になります。

上が hoge 関数のアセンブリコードで、処理の内容は以下です。

  1. rdx (ここでは引数 s の値) が指すアドレスから値を読み取り、eax (一時計算用) に格納
  2. その値を rcx が指すアドレス(ここでは引数からの d[0])に加算
  3. 再度 rdx が指すアドレスから値を読み取り、eax に格納
  4. その値を rcx が指すアドレスから 4 バイト先(ここでは引数からの d[1])に加算
  5. 関数から戻る

一方、piyo の方は、1行少なくなっていますね。

2回目の

	movl	(%rdx), %eax

が最適化により、省略されています。(hoge 関数の3番目の処理)

結局 restrict s の方は、s からアクセスする以外に値は変わらないという前提がありました。ですのでこの場合だと s への書き換えは1度もしていないので、最初の読み出しだけで十分な"はず"でしょ、だから都度読み出す無駄な処理は省いておきましたよ!というコンパイラの粋な計らいが働いています。その結果、先の挙動になったと考えられます。

その実、プログラマ側は d[] 経由で s の示す範囲を書き換えてしまっている、という残念な"すれ違い"が発生しています…。そしてそのすれ違いが、出力結果を"意図せず"変えてしまいました…。

紳士協定、絶対守りましょうね。

restrict については、@fujitanozomu さんのコメントが大変参考になりました。ありがとうございました!

以上、ここまでが筆者的なC言語知られザルなシリーズでした。

volatileinline なんかも知られザルのシリーズに入れてもよさそうですが、個人的には組み込み系のコードでまあまあ見てきたので、入れザル…。

続いては、これはけしからん!許されザル!な言語仕様にスポットライトを当てていきます。

許されザル仕様

なぜそうなった!?という、今からするとちょっと理解が及ばザルなシリーズ。

カッコなしの制御文

突然ですが、以下のサンプルを見て下さい。

no_brace.c
#include <stdio.h>

int main()
{
    int x = 5;
    if (x == 0)
        printf("Here, x is 0.\n");
        printf("Here, x is also 0. (It should be)\n");
    return 0;
}

一件すると、if 文中の2つの printf は同じスコープに居てくれてそうですね。

最初の行ではx = 5 としています。そして、 次の行のif 文の条件は x == 0ですね。ですので、この場合は if 文の条件が"不成立"となります。

はいっ、だからこれを実行しても、printfは1つも呼ばれないはず。なので、何も表示されないですよねっ!と思いきや

実際にやってみると…

$ gcc no_brace.c -o no_brace
$ no_brace
Here, x is also 0. (It should be)

アレッ??何か出た!?となりました。

予想に反して、2つめの printf の内容が表示されていますね。なぜでしょう?

ここで、if 文などの制御文の {} は省略可能なことをご存じでしょうか。

制御文で実行するロジックとしては statement(文)というのを受け付ける仕様になっています。

文は簡単には1行1行の代入文だったり関数呼び出しだったりしますが、{} の中身をひっくるめた全体も実は文として扱われます。{} は複数の文をまとめた"塊"を表わすものですが、これも広い意味で"文"とされており、ブロック文複合文と呼ばれます。

という事で、{} でくくることが当たり前だと感じている方の方が多いと思うのですが、実は {} はなくてもC言語的には別によかったんです。そしてもし {} がなかった場合は最初の1行のみを"文"として実行するだけさ!な結果になります。

つまり、本節のサンプルコードの2つめの printf は最初から if 文の外にいた、というだけでした。

もしもPythonからプログラミング"初参戦"な方がおられたなら、きっと予測不可能!これは許されザル…。

やはり波カッコ {} (ブレース)は必ず書いた方がいいと思います。

brace.c
#include <stdio.h>

int main()
{
    int x = 5;
    if (x == 0)
    {
        printf("Here, x is 0.\n");
        printf("Here, x is also 0. (Definitely)\n");
    }
    return 0;
}

インクリメントの前置と後置

++ のインクリメントや -- のデクリメント(まとめて増分減分演算子と呼ぶ)は、変数の前か後ろのどちらかに置くことができるようになっています。

そして、どちらに置くかで微妙に挙動が変わります。

種類 説明 式の評価順序
前置インクリメント ++x 変数 x の値を1増やし、その新しい値を返す。 変数の値が増加した後に使用される。
後置インクリメント x++ 変数 x の現在の値を返し、その後値を1増やす。 変数の値が使用された後に増加する。
前置デクリメント --x 変数 x の値を1減らし、その新しい値を返す。 変数の値が減少した後に使用される。
後置デクリメント x-- 変数 x の現在の値を返し、その後値を1減らす。 変数の値が使用された後に減少する。

ここでサンプルを一つ。

inc.c
#include <stdio.h>

int main()
{
    char a[] = "Zaru";
    int i = 0;
    printf("%c\n", a[i++]);
    printf("%c\n", a[++i]);
    return 0;
}

この実行結果、ぱっと見で予測できますでしょうか?

結果は以下。

$ gcc ./inc.c -o inc
$ ./inc
Z
r

最初の printfZ で次が r 、というのが正解でした。(筆者としては、一度深呼吸してから落ち着いて読み解かないと、とてもすぐには答えられないです)

別の場面ですと、これのおかげで簡潔に1行で書けた!みたいな成功パターンもあるにはあるんでしょうけどね…。

認知負荷高過ぎ!これは許されザル…。

インクリメントは(例えば)後置のみを使うようにして、かつ必ず単体で実行するように制限すれば、ロジックの順序をより明確にできる、という対策案もあります。

inc2.c
#include <stdio.h>

int main()
{
    char a[] = "Zaru";
    int i = 0;
    printf("%c\n", a[i]);
    i++;
    i++;
    printf("%c\n", a[i]);
    return 0;
}

制御式の中の代入

if 文の分岐条件部分などの制御式には、= を使った代入の使用が許されています。(この代入は、厳密には文ではなく式として扱われます)

単純に、=== が似ていて、間違いに気付きにくいです。

equal.c
#include <stdio.h>

int main()
{
  int a = 5;
  if (a = 10)
  {
      printf("a is 10\n");
  }
  else
  {
      printf("a is not 10\n");
  }
  return 0;
}

↑のようなコードだと、if 文の条件判定のために a へ代入(a = 10)された値がそのまま返されます。そのため 10(0以外)が判定結果となり、おのずと条件は常に真となります。

よっぽどのことがない限り、本来この条件は a10 と等しい(a == 10 が成立する)場合に、ロジックを分岐させたかったハズですよね。

(常に表示される実行結果)

a is 10

知らないと迷宮入りの恐れアリ!これは許されザル…。

制御式で代入する場合は、意図的であることを示すために、余分な丸カッコ ()を付けるのが習慣となっています。

equal2.c
#include <stdio.h>

int main()
{
    int a = 5;
    // ↓ 余分なカッコ
    if ((a = 10))
    {
        printf("a is 10\n");
    }
    else
    {
        printf("a is not 10\n");
    }
    return 0;
}

GCCでは-Wallオプションを付けておくと、余分なカッコがない場合に警告が出て気付ける仕組みになっています。

(警告の様子)

$ gcc ./equal.c -o equal -Wall
equal.c: In function 'main':
equal.c:6:9: warning: suggest parentheses around assignment used as truth value [-Wparentheses]
     if (a = 10)

goto文

goto 文は同じ関数の他の文へ無条件でジャンプできます。

知らずに乱用すると、その性質上、制御の流れがぐちゃぐちゃになってしまう──いわゆる"スパゲッティコード"の──元になりかねない、と教わった方も少なからずおられるのではないでしょうか。

今では goto 文を多用するコードは読みにくく、入れ子の深いループからの脱出のように明確な利便性がある場合にのみ使う物とされています。

ここは、筆者も多分に漏れずC言語をはじめたての時期に、既にそのように教わってきました(当時はどちらかというと"絶対"使わザルでよろしく!みたいな勢いでしたが…)。そして、幸か不幸かその教えに従うかのように仕事場では1度も見たことがなかったですね。(皆さんにおかれましても用法・用量を守って正しくお使いいただきますように…)

例えば、以下のような平均を求めるサンプルで考えてみましょう。

ave.c
#include <stdio.h>

int main()
{
    int arr[] = {1, 2, 3, 4, 5};
    int n = sizeof(arr) / sizeof(arr[0]);
    int sum = 0;
    float average;
    for (int i = 0; i < n; i++)
    {
        sum += arr[i];
    }
    if (n != 0)
    {
        average = (float)sum / n;
        printf("Average: %f\n", average);
    }
    else
    {
        printf("Error: Division by zero\n");
    }
    return 0;
}

ちょっと長いですが、少し時間を取って見て頂けるとすぐに分かるかと思います。

これをgoto 文で頑張って書き換えると、次のようになります。

goto.c
#include <stdio.h>

int main() {
    int arr[] = {1, 2, 3, 4, 5};
    int n = sizeof(arr) / sizeof(arr[0]);
    int sum = 0;
    float average;
    int i = 0;

start:
    if (i >= n)
    {
        goto end;
    }
    sum += arr[i];
    i++;
    goto start;

end:
    if (n != 0)
    {
        average = (float)sum / n;
    }
    else
    {
        goto error;
    }
    printf("Average: %f\n", average);
    return 0;

error:
    printf("Error: Division by zero\n");
    return 1;
}

goto 文が実行されると、goto のすぐ後に指定されているラベルに応じて、そのラベルが定義されている行へと処理する位置がジャンプします。

goto start; なら start: の箇所に飛んで、その次の行から処理開始となります。

start:
    if (i >= n)
    {
        goto end;
    }
    sum += arr[i];
    i++;
    goto start;

ここの部分、条件が成立してgoto end;が実行されるまでは繰り返し配列要素を順に足す、という処理を表しています。

ループを回すだけなのに、もはや別言語の様相!これは許されザル…。

以上で、許されザルシリーズも終わりです。

ひょっとすると、C言語を揶揄したり揚げ足とりするような記事にも見えてしまったかもしれませんが、そういう意図はございません。今日のプログラミング言語の祖の一つとして、C言語がいまだに現役でご活躍頂いているおかげで、筆者の今があると思っています。1972年発祥とのことで、人間で言うともう52歳(2024年現在)!ぜひこれからも、よりお近づきになりたい一心でこのような記事を書きました!

おわりに

「知られザル・許されザルを知り、ハマらザルを得る」

ということで、結局「~ザル」っていっぱい言いたかっただけでした。

wood_kanban.png

関連記事

C言語について、他の記事も書いております。良かったら読んでください。

  • 本記事の許されザル仕様を飛び越えた"仕様外"となる、真に許されザルな「未定義動作」がテーマとなっております
117
94
27

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
117
94