はじめに
最近、1976年のThomas McCabeの論文を読んだので、理解した内容についてまとめようと思います。
循環的複雑度(Cyclomatic Complexity)は、プログラムの制御構造の複雑さを定量的に測定するソフトウェア指標の一つであり、特に大規模開発や長期保守が求められるソフトウェアにおいて重要性が高いものです。
ここでは、循環的複雑度の定義と計算方法をまとめ、C 言語による具体例を用いてその意味を明確化しようと思います。
循環的複雑度とは
プログラム中の独立した制御パスの数を示します。分岐構造が増えるほどテストすべきパスが増加し、
保守性・可読性が低下するため、循環的複雑度は品質指標として多くの企業や静的解析ツールに採用されています。
単純化された定義
多くの静的解析ツールでは、以下の簡易式が採用されているケースがあります。
CC = 1 + 分岐数
分岐数とは、
-
if / else-if -
for -
while -
do-while -
switch(case の数) -
論理演算子(&& / ||)による条件分岐
-
三項演算子 ?:
本来のグラフ理論に基づく定義
M = E − N + 2P
E:制御フローのエッジ数
N:ノード数
P:接続成分数(通常 1)
C言語による具体例
以下に循環的複雑度の計算例を示します。
分岐を持たない関数(CC = 1)
int add(int a, int b) {
return a + b;
}
複雑度は 1。
CC=1 は最も単純な関数
if 文を 1 つ含む関数(CC = 2)
int f(int x) {
if (x > 0)
return 1;
return -1;
}
CC = 1 + if(1) = 2
ループと複数分岐の例(CC = 4)
int g(int n) {
int sum = 0;
for (int i = 0; i < n; i++) { // +1
if (i % 2 == 0) { // +1
sum += i;
} else if (i % 3 == 0) { // +1
sum += 2 * i;
} else {
sum += 3;
}
}
return sum;
}
CC = 1 + for(1) + if(1) + else-if(1) = 4
switch-case(CC = 4)
int h(int x) {
switch (x) {
case 0: return 10; // +1
case 1: return 20; // +1
case 2: return 30; // +1
}
return -1;
}
case 3 つなので、
CC = 1 + 3 = 4
循環的複雑度の評価基準
CCの値は経験的に以下のように評価されます。
| 複雑度 | 評価 |
|---|---|
| 1–10 | 適切。保守しやすい。 |
| 11–20 | やや複雑。改善を検討。 |
| 21–50 | 高リスク。リファクタリング推奨。 |
| 50+ | 極めて高リスク。テスト困難、バグ潜在性大。 |
循環的複雑度の実務的な意義
テストケース数の下限を決定する
循環的複雑度は独立経路の数であるため、
網羅的テストに必要な最小テストケース数 ≥ CC となると考えられます。
例:CC=18 の関数
→ 少なくとも18テストケースが必要。
これによりテストコストの見積もりや計画を行うこともできます。
保守性・可読性の指標として機能する
-
分岐の乱立
-
深いネスト構造
-
巨大関数
-
複雑な switch 文
といった可読性の悪いコードを客観的に検出できることにも繋がります。
C言語における循環的複雑度を低減するための設計・実装戦略
ここでは、実務上有効とみなすことのできる複雑度低減手法を考えてみます。
関数分割
巨大な関数は複雑度が増加しやすいため、
機能単位で関数を分割することが最も強力です。
Before
void process(int x, int y) {
if (x > 0) {
if (y > 0) {
...
}
}
}
After
bool is_valid(int x, int y);
void handle_positive(int x, int y);
void process(int x, int y) {
if (!is_valid(x, y)) return;
handle_positive(x, y);
}
関数内部の分岐が減り、循環的複雑度が大幅に低減しています。
ガード節によるネストの排除
深いネストは複雑度を増やすだけでなく読みやすさもいます。
そこで、早期 return(ガード節)を用いることで改善できます。
Before
if (p != NULL) {
if (p->value > 10) {
if (flag) {
...
}
}
}
After
if (p == NULL) return;
if (p->value <= 10) return;
if (!flag) return;
...
条件判定が直線的になり循環的複雑度が低減します。
switch-caseをディスパッチテーブル化する
多数の case を持つ switch は循環的複雑度の増加要因となる。
関数ポインタによるディスパッチテーブルへ置換することで改善できる。
Before
switch (code) {
case 1: f1(); break;
case 2: f2(); break;
case 3: f3(); break;
case 4: f4(); break;
}
After
void f1(); void f2(); void f3(); void f4();
void (*table[])() = { f1, f2, f3, f4 };
void process(int code) {
if (code < 1 || code > 4) return;
table[code - 1]();
}
case 数が変わっても循環的複雑度は増加しません。
複雑な条件式を分離する
論理演算子が増えると循環的複雑度も増加します。
条件式を分離して意味的に命名することで複雑度を低減できます。
Before
if ((x > 0 && y > 0) || (z == 3 && w > 10 && flag)) {
...
}
After
bool cond1 = (x > 0 && y > 0);
bool cond2 = (z == 3 && w > 10 && flag);
if (cond1 || cond2) {
...
}
状態遷移をテーブル化する(Stateパターン)
大規模な状態遷移を switch-case で記述すると循環的複雑度が急増します。
状態をテーブル化することで制御構造を単純化できます。
typedef void (*StateHandler)();
StateHandler state_table[] = {
init_handler,
wait_handler,
run_handler,
stop_handler,
};
void execute_state(int state) {
state_table[state]();
}
最後に
循環的複雑度は、プログラムの分岐構造による複雑さ・保守性・テスト容易性を定量化する重要なソフトウェア計測指標です。
直感的に考えてみても、循環的複雑度が高いコードは理解しにくく、バグが発生しやすいと思います。
品質の高いプログラムを実現するためには、循環的複雑度を意識した設計・実装が不可欠であると考えます。
また、今回紹介した例はほんの一例ですので、ぜひいろいろのパターンを教えていただけますと幸いです。