← melang

C

melangの各noteで他言語と比較した、Cの設計をまとめたページ。ここに書かれている内容は、各noteの比較表から自動で集めたものです。

型

型推論の構文が伝統的になく、常に型を明示する。

int age = 20;
int age = 20;
char *name = "Alice"; // 型推論はなく、必ず明示する

型推論の構文が伝統的になく、変数は常に型を明示して宣言する。C23でようやく`auto`による限定的な型推論が追加されたが、対応は発展途上で、実務上はRustやGoよりずっと遅れて型推論を手に入れた言語という位置づけになる。ポインタ・配列・関数ポインタが絡む宣言は複雑になりやすく、後発言語が宣言構文自体を見直す動機の一つになった。

型注釈の位置

すべてprefix方式。C++・Java・C#が受け継いだ大元の構文。

int age = 20;
int age = 20; // 型が名前より前

char *greet(char *name) { // 引数・戻り値ともprefixで明示
    return name;
}

int (*compare)(const void *, const void *); // 関数ポインタは宣言が読みにくい典型例

`Type name`という前置構文の大元であり、C++やJavaの`Type name`はここから直接受け継がれた。型推論の構文がないため、宣言は常に完全な形で書く必要がある。関数ポインタや多次元配列が絡むと、宣言子を内側から外側へ読む複雑な規則(俗に「螺旋規則」とも呼ばれる)が必要になり、この読みにくさがGoのpostfix方式(`var name Type`)やRust/Swiftの`name: Type`のような設計を後押しした一因になっている。

型変換

キャスト構文で変換し、失敗を検出する標準的な仕組みは薄い。

long y = (long) x;
int x = 10;
long y = (long) x;
short s = (short) 70000; // 黙って値が変わる(結果は実装定義)

char *end;
long n = strtol("42", &end, 10);
long m = strtol("abc", &end, 10); // endがstrの先頭のまま。errnoの確認も必要

int bad = atoi("abc"); // 失敗しても0が返り、正当な0と区別できない

`(Type)`によるキャスト構文がC++の`static_cast`などの直接の起源であり、数値の縮小変換は検出されず黙って値が変わる。文字列のパースは、失敗と「0という正当な結果」を区別できない`atoi`のような関数と、`errno`とポインタ引数の両方を確認しないと失敗を検出できない`strtol`のような関数が混在しており、失敗を安全に扱う統一的な言語機能を欠いたまま、各ライブラリ関数が独自の規約で失敗を表している。

暗黙の型変換

整数昇格・usual arithmetic conversionsが広く働き、符号なし変換で事故が起きやすい。

int x = 10;
int x = 10;
long y = x;   // 暗黙変換でOK
double z = x; // int -> double も暗黙変換される

int a = -1;
unsigned int b = 3;
if (a < b) { /* 実行されない: aがunsignedへ暗黙変換され巨大な値になる */ }

算術型間の暗黙変換(integer promotion / usual arithmetic conversions)が広く働き、C++はこれをそのまま受け継いでいる。特にsignedとunsignedを混在させる比較・演算では、signedの値が暗黙にunsignedへ変換されるため`-1 < 3u`が偽になるといった直感に反する挙動が起き、実務上のバグの温床として知られる。この危険な暗黙変換の存在が、KotlinやSwiftのように数値型間の暗黙変換を一切禁止する設計への反面教師になっている。

Enum / Sum Type

enumは名前付きの整数定数にすぎず、payloadも網羅性チェックもない。

enum Status { PENDING, RUNNING, COMPLETED };
enum Status { PENDING, RUNNING, COMPLETED };

switch (status) {
case PENDING:
    /* ... */
    break;
case RUNNING:
    /* ... */
    break;
} // COMPLETEDを書き忘れてもコンパイルは通る(-Wswitchで警告は出せる)

`enum`はコンパイル時に整数値へ展開されるだけの名前付き定数の集まりで、型としての区別も弱く(実体は`int`と相互変換できる)、variantごとに異なるデータを持たせることもできない。payload付きの合算型を表現したいときは、タグ用の`enum`と`union`を自前で組み合わせた「tagged union」を手で書く必要があり、タグと`union`の対応が壊れていてもコンパイラソースコードを解析し、実行可能な形式(機械語や中間表現)に変換するプログラム。静的型付け言語では、実行前にコンパイラが型の不整合などをチェックする役割も担う。は検出しない。この手組みのtagged unionが、C++の`enum`+`std::variant`を経て、Rust・SwiftのenumがVariantとpayloadを安全な形で一体化するまでの出発点になっている。`switch`の網羅性チェックは標準では行われず、`-Wswitch`のようなコンパイラソースコードを解析し、実行可能な形式(機械語や中間表現)に変換するプログラム。静的型付け言語では、実行前にコンパイラが型の不整合などをチェックする役割も担う。拡張の警告に頼るしかない。

値の不変性

既定は変更可能。`const`はC++より歴史が古いが、キャストで容易に外せる。

const int x = 1; / const int *p
const int x = 10;
// x = 20; // コンパイルエラー

int arr[] = {1, 2, 3};
arr[0] = 9; // OK: 配列の要素は変更できる

const char *s = "hello";
char *mutable_s = (char *)s; // constを外すキャスト
mutable_s[0] = 'H'; // 文字列リテラルへの書き込みは未定義動作(実行時クラッシュもあり得る)

`const`修飾子はC++より先にC89で導入されたが、既定は変更可能でありC++の`const`とほぼ同じ設計思想を持つ(C++はここに`const_cast`という専用の抜け道キャストを追加した)。Cでは通常のキャストで`const`を外せてしまい、文字列リテラルのように実際には読み取り専用領域に置かれるデータを書き換えようとすると、コンパイルは通るのに実行時に未定義動作になるという、コンパイル時の検査と実行時の実体が食い違う典型例を持つ。

名前付き引数

名前付き引数はなく、C99の指定初期化子は構造体の初期化にのみ使える。

void foo(int width, int height);
void foo(int width, int height) {}
foo(10, 20); // 引数名は呼び出し側に現れない

struct FooArgs { int width; int height; };
void foo2(struct FooArgs args) {}
foo2((struct FooArgs){.width = 10, .height = 20}); // C99の指定初期化子(構造体のみ)

関数呼び出しに名前付き引数の構文はない。C99で追加された指定初期化子(designated initializers)は構造体・配列の初期化にのみ使え、C++20の集成体初期化子はこの機能をそのまま受け継いでいる。可変長引数(`...`と`stdarg.h`)を使えば引数の個数を柔軟にできるが、型安全性は呼び出し側の規律に委ねられ、`printf`系関数のフォーマット文字列と実引数の不一致は典型的な未定義動作の原因になる。

関数の引数

すべて値渡し。参照的に扱うにはポインタを明示的に渡す、Go・C++の直接の起源。

void f(int x); / void f(int *p);
void a(int n) { n = 1; }   // 呼び出し側には影響しない
void b(int *p) { *p = 1; } // ポインタ経由で書き換える

int n = 0;
a(n);
b(&n); // 呼び出し側にも & が付く

void c(int arr[]) { arr[0] = 1; } // 配列は関数に渡す時点でポインタへ「減衰」する

引数はすべて値渡しで、参照的に扱いたければアドレスを明示的にポインタとして渡す。Goの「値渡し+明示的なポインタ」はこの設計をほぼそのまま受け継ぎ、C++の参照(`T&`)はポインタを安全に隠す糖衣構文として追加されたものである。配列を関数に渡すと先頭要素へのポインタに「減衰(decay)」するため、関数側では元の配列のサイズを知る手段がなく、別途サイズを渡す必要がある。このサイズ情報を失うポインタ渡しはバッファオーバーランの主要な原因の一つであり、後発言語がスライスや境界チェック付き配列を導入する動機になった。

if 文/式

`if`はstatement。値の分岐は三項演算子`?:`で書く。

if (cond) { ... } else { ... } / cond ? a : b
int x = cond ? 1 : 2;

if (cond) {
    process();
}

`if`はstatementで、値を返す分岐は三項演算子で書く。`else`の使い方に制限はない。

I/O の明示

副作用を示す標準的な仕組みがない。C++・Goの直接の起源。

char *read_file(const char *path);
char *read_file(const char *path) {
    FILE *f = fopen(path, "r");
    /* ... */
    return buf;
}

int square(int x) {
    read_file("x"); // 呼べてしまう。戻り値を無視してもコンパイルは通る
    return x * x;
}

`errno`はI/Oを含む多くのライブラリ関数が失敗を示すのに使うグローバル変数だが、設定されるかどうかは関数ごとの規約に過ぎず、型シグネチャには一切現れない。I/Oを行う関数と行わない関数は構文上まったく区別されず、C++・Goはこの「区別なし」をそのまま受け継いでいる。後発言語のasync/awaitやeffectシステムは、この「どの関数が何をするか分からない」という問題への解決策の系譜と見なせる。

言語名

前身言語Bの次のアルファベットという単純な由来で、検索性は最低クラス。

前身であるB言語(BCPL由来)の次のアルファベットという、意味を持たせない単純な理由で名付けられた。一文字の言語名は一般的な検索エンジンでの絞り込みがほぼ不可能で、"C programming language"のように文脈を添えないと使えない。ISO標準化以降は`C89`・`C99`・`C11`・`C17`・`C23`のように制定年(西暦下2桁)を付けたバージョン呼称が定着し、こちらが版を区別する事実上の名前として機能している。