C言語でのデバッグでは、思わぬバグや前提の破綻を実行中に素早く見つけられると効率が大きく上がります。
そこで役立つのがassertマクロです。
本記事では、assertでプログラムの不整合を即座に検出する方法を、初心者の方でも迷わないように基礎から丁寧に解説します。
NDEBUGによる無効化やgccの指定例、典型パターンのサンプルコードも掲載します。
C言語のassertマクロの基本
assertの役割
assertは「ここが成り立っていなければプログラムが間違っている」という内部前提を実行時に確認するためのマクロです。
条件が偽ならプログラムを即座に停止し、ファイル名や行番号などのデバッグ情報を表示します。
重要なのは、ユーザー入力や外部エラーの通常処理には使わないことです。
assertは開発時の不整合検出に使い、運用時のエラー処理は別途実装します。
assert.hの読み込み方法
使う前に#include <assert.h>を記述します。
最小例は次のとおりです。
// 基本のインクルード
#include <assert.h>
#include <stdio.h>
int main(void) {
int x = 10;
// xは0以上であるべきだという前提を確認
assert(x >= 0);
printf("x = %d\n", x);
return 0;
}
この例では条件が真なので何も起きず、普通に実行が進みます。
失敗時の動作
assertの条件が偽になると、標準エラー出力(stderr)にメッセージを出してプログラムを中止します。
多くの環境ではabort()が呼ばれ、コアダンプ(環境次第)が生成されます。
表示される典型的な情報は次のとおりです。
- 実行ファイル名
- ソースファイル名
- 行番号
- 関数名
- 失敗した条件式
この出力の読み方は記事末尾の「失敗時の出力例と読み方」で詳しく解説します。
初心者が押さえるポイント
初心者の方は次の4点をまず覚えておくと迷いません。 1) assertは「プログラマの想定が破れたら止める」ためのもの、2) ユーザー入力の検証や通常のエラーハンドリングには使わない、3) リリースビルドでは無効化できる、4) 条件式に副作用(インクリメントなど)を書かない、の4つです。
次の表に要点をまとめます。
| 観点 | 位置づけ |
|---|---|
| 目的 | 開発中に内部不整合を即検出する |
| 使い所 | 前提条件、不変条件、到達不能分岐の検出 |
| 非対象 | 入力検証、外部エラーの通常処理、例外の代替 |
| ビルド別 | デバッグ: 有効、リリース: NDEBUGで無効化可 |
assertの書き方と使いどころ
基本構文
assert(条件式);の形で記述します。
条件式がtrueなら何も起きず、falseなら停止します。
#include <assert.h>
#include <stdio.h>
int main(void) {
int value = -1;
// valueは非負であるべきという前提
assert(value >= 0); // 偽なのでここで停止
// ここには到達しません
printf("value = %d\n", value);
return 0;
}
実行すると次のようなエラーメッセージが標準エラーに出ます(環境により差異があります)。
a.out: example.c:8: main: Assertion `value >= 0' failed.
Aborted (core dumped)
前提条件のチェック
関数の入り口で引数の妥当性を確認して、「この条件が破れたら呼び出し側の使い方が間違い」ということを明示できます。
#include <assert.h>
#include <stdio.h>
// 配列の合計を計算する関数
int sum_array(const int *arr, size_t len) {
// 呼び出し側が満たすべき前提条件
assert(arr != NULL); // 配列ポインタはNULLでない
assert(len > 0); // 長さは0より大きい
int sum = 0;
for (size_t i = 0; i < len; ++i) {
sum += arr[i];
}
return sum;
}
int main(void) {
int a[] = {1, 2, 3};
int s = sum_array(a, 3);
printf("sum = %d\n", s); // sum = 6
return 0;
}
不変条件のチェック
ループやデータ構造の内部で常に成り立つべき条件を確認します。
例えばスタックの大きさは常に0 <= size <= capacityでなければなりません。
#include <assert.h>
#include <stdio.h>
typedef struct {
int data[4];
int size; // 要素数
} Stack;
void stack_init(Stack *s) {
assert(s != NULL);
s->size = 0;
assert(s->size >= 0 && s->size <= 4); // 不変条件
}
void stack_push(Stack *s, int v) {
assert(s != NULL);
assert(s->size >= 0 && s->size < 4); // 空きがある前提
s->data[s->size++] = v;
assert(s->size >= 0 && s->size <= 4); // 不変条件
}
int stack_pop(Stack *s) {
assert(s != NULL);
assert(s->size > 0); // 要素がある前提
int v = s->data[--s->size];
assert(s->size >= 0 && s->size <= 4); // 不変条件
return v;
}
int main(void) {
Stack s;
stack_init(&s);
stack_push(&s, 10);
stack_push(&s, 20);
printf("%d\n", stack_pop(&s)); // 20
return 0;
}
到達しない分岐の検出
「通常あり得ない分岐」に来たら即停止させ、想定外の値や処理漏れを早期に気づけます。
#include <assert.h>
#include <stdio.h>
typedef enum { RED, GREEN, BLUE } Color;
const char *to_name(Color c) {
switch (c) {
case RED: return "RED";
case GREEN: return "GREEN";
case BLUE: return "BLUE";
default:
// ここに来るのは想定外
assert(0 && "unexpected Color value");
return "UNKNOWN"; // NDEBUG時の保険(実際は到達しない想定)
}
}
int main(void) {
// 故意に不正値を渡して検出する
printf("%s\n", to_name((Color)123)); // ここでassert失敗
return 0;
}
配列とポインタのチェック
配列アクセスでは境界の手前で確認しておくと、典型的なバグを最短距離で特定できます。
#include <assert.h>
#include <stdio.h>
int at(const int *arr, size_t len, size_t index) {
assert(arr != NULL);
assert(index < len); // 範囲チェック
return arr[index];
}
int main(void) {
int a[3] = {10, 20, 30};
printf("%d\n", at(a, 3, 1)); // 20
// printf("%d\n", at(a, 3, 5)); // 有効化するとassertが失敗します
return 0;
}
エラーハンドリングとの違い
assertは「バグ検出用」、エラーハンドリングは「運用時の正常な失敗処理」です。
例えばファイルが開けない、ネットワークが切れたなどはプログラムの不整合ではありません。
これらはifや戻り値、エラーコードで処理します。
次の対比が目安になります。
- 例: 関数の引数がNULLであるべきでないのにNULLだった → assertで停止 (呼び出し契約違反)
- 例: ユーザーが存在しないファイル名を入力した → 通常のエラー処理 (想定内の失敗)
| 目的 | assert | エラーハンドリング |
|---|---|---|
| 主対象 | バグ、不整合の検出 | 入力や環境起因の失敗 |
| 動作 | 偽なら即停止 | 回復、再試行、ユーザー通知 |
| リリース | NDEBUGで除去 | 常に必要 |
副作用のある式は書かない
assertの式に副作用を入れるのは禁物です。
NDEBUGで無効化すると式自体が評価されなくなり、デバッグとリリースで挙動が変わるからです。
#include <assert.h>
#include <stdio.h>
int main(void) {
int i = 0;
int n = 5;
// 悪い例: i++に副作用がある
assert(i++ < n);
// デバッグビルド: 上の式が評価され、iは1になる
// リリースビルド(NDEBUG): 上の式ごと消えるので、iは0のまま
printf("i = %d\n", i);
return 0;
}
デバッグビルドの実行結果例:
i = 1
リリースビルド(-DNDEBUG)の実行結果例:
i = 0
assert内で状態を書き換えないことが重要です。
デバッグ設定とNDEBUG
デバッグとリリースの違い
一般にデバッグビルドでは診断を最大化し、リリースビルドでは性能と配布物の軽量化を優先します。
assertはデバッグ時に活用し、リリースではNDEBUGで無効化するのが標準的です。
NDEBUGでassertを無効化
#define NDEBUGが定義されていると、assertは完全に取り除かれます。
ソース先頭で定義してもよいですが、通常はコンパイラオプションで定義します。
#include <assert.h>
#include <stdio.h>
int main(void) {
#ifdef NDEBUG
puts("assert: disabled");
#else
puts("assert: enabled");
#endif
int x = -1;
assert(x >= 0); // NDEBUGが定義されていれば何も生成されない
puts("continue");
return 0;
}
デバッグビルドの実行結果例:
assert: enabled
a.out: sample.c:12: main: Assertion `x >= 0' failed.
Aborted (core dumped)
リリースビルドの実行結果例:
assert: disabled
continue
gccの指定例
gccでの代表的な指定は次のとおりです。
# デバッグビルド: アサート有効、デバッグ情報、最適化オフ
gcc -std=c17 -Wall -Wextra -g -O0 -o app_debug main.c
# リリースビルド: アサート無効(NDEBUG定義)、最適化
gcc -std=c17 -O2 -DNDEBUG -s -o app_release main.c
MSVCなら次のように指定できます。
REM デバッグ(例): /MDdはデバッグランタイム、アサート有効
cl /Zi /Od /MDd main.c
REM リリース(例): /DNDEBUGでアサート無効化、最適化
cl /O2 /DNDEBUG /MD main.c
パフォーマンスと安全性
リリースではassertが消えるため、実行コストはゼロになります。
一方で、assertだけに頼ると運用時の安全性は確保できません。
ユーザー入力や外部依存の失敗は、戻り値やエラーコードで堅牢に処理してください。
また、_Static_assert(C11)はビルド時に条件を確認する別物です。
実行時のassertと役割が異なるため、コンパイル時に確定できる不変条件は_Static_assertで、実行時の前提はassertで確認するとよいです。
よく使うサンプルコード
NULLポインタのチェック
#include <assert.h>
#include <stdio.h>
#include <string.h>
// 文字列の長さを表示する(引数はNULLであってはならない)
void print_len(const char *s) {
assert(s != NULL); // 呼び出し側契約の確認
printf("len = %zu\n", strlen(s));
}
int main(void) {
print_len("hello"); // OK: len = 5
print_len(NULL); // NG: ここでassertが失敗
return 0;
}
失敗時の出力例:
a.out: sample.c:10: print_len: Assertion `s != NULL' failed.
Aborted (core dumped)
配列インデックスのチェック
#include <assert.h>
#include <stdio.h>
int get_at(const int *arr, size_t len, size_t idx) {
assert(arr != NULL);
assert(idx < len); // 範囲外アクセスを未然に検出
return arr[idx];
}
int main(void) {
int a[] = {4, 8, 15, 16, 23, 42};
printf("%d\n", get_at(a, 6, 3)); // 16
// printf("%d\n", get_at(a, 6, 99)); // 有効化するとassert失敗
return 0;
}
switch defaultの検出
#include <assert.h>
#include <stdio.h>
typedef enum { OP_ADD, OP_SUB, OP_MUL } Op;
int apply(Op op, int x, int y) {
switch (op) {
case OP_ADD: return x + y;
case OP_SUB: return x - y;
case OP_MUL: return x * y;
default:
// 列挙の追加漏れ・入力の破損などを検出
assert(!"unreachable default in apply");
return 0; // NDEBUG時の保険
}
}
int main(void) {
printf("%d\n", apply(OP_ADD, 3, 5)); // 8
printf("%d\n", apply((Op)777, 3, 5)); // ここでassert失敗
return 0;
}
失敗時の出力例:
a.out: ops.c:14: apply: Assertion `!"unreachable default in apply"' failed.
Aborted (core dumped)
失敗時の出力例と読み方
典型的には次のように表示されます。
a.out: file.c:25: foo: Assertion `p != NULL' failed.
Aborted (core dumped)
読み方は次のとおりです。
- a.out: 実行ファイル名
- file.c: ソースファイル名
- 25: 行番号(ここをエディタで開くと原因箇所にジャンプできます)
- foo: 関数名
- Assertion `p != NULL’ failed.: 失敗した条件式
- Aborted (core dumped): プロセスが中止され、コアが出力された可能性(環境設定による)
ここに書かれた条件式と行番号が、デバッグの最短ルートです。
変数の中身をログ出力したり、デバッガでブレークして原因を絞り込みましょう。
まとめ
assertは「この条件が破れたらバグ」という想定を機械的に保証してくれる強力な道具です。
関数の前提条件、不変条件、到達しないはずの分岐に設置することで、バグを発生直後に捕まえ、原因箇所へ最短で辿り着けます。
一方で、副作用を含む式を書かない、エラーハンドリングの代替にしない、NDEBUGで無効化される前提を理解するといった注意は欠かせません。
ビルド設定では、デバッグ時は有効、リリース時は-DNDEBUGで無効化する運用が定石です。
「assertは開発時の安全ネット、運用時は堅牢なエラーハンドリング」という役割分担を徹底し、効率よく品質を高めていきましょう。
