品質保証者の憂鬱「SLOCの深淵を覗く:ソース行数計測の「なぜ?」を徹底解剖」
Back to Top
ソフトウェア開発プロジェクトに参加したことがある人であれば、プロジェクトの中で色々なメトリクス(ソフトウェアを定量的に測るための指標)を収集した経験があるのではないでしょうか?
メトリクスは工数だったり、工期だったり、規模、欠陥数、などなど様々だと思います。
今回はメトリクスの中でも特に「規模」についてちょっと考えるところがあり、改めて「ソフトウェア規模とは?」について深掘り(重箱の隅を絨毯爆撃)したいと思います。
まあ、中には「どれだけ規模のソフトウェアを生産したかなんて興味が無い、動けばいいんだ!」って豪語する人もいるかも知れませんが、ソフトウェアの規模を定量的に計測することについて多くの人は肯定的かと思います。
(昔は「ソースコード規模なんて関係ない」っていう人も多かったのですが)
ソフトウェア規模の測り方
#これまでたくさんの種類のソフトウェア開発手法や技法が研究・開発されてきましたが、ソースコードの規模を計測するための手法・技法としては、大きくは次の2つに絞り込まれていると言っていいと思います。
- FP(ファンクション・ポイント)法
- SLOC(Source Lines of Code:ソース行数)
FP法
#FP(ファンクション・ポイント)法は、ソフトウェアの機能的な規模を定量的に測る方法です。
FP法の特徴として「ユーザーの視点に立った測定」が上げられます。
ユーザーが「何をしたいか(機能)」に基づいてソフトウェア規模を測るため、開発言語や内部実装方法の影響を受けにくいなどの特徴があります。
また、要件定義書を書いた段階で、おおよその見積もりができるため早期の規模見積もりも可能になります。
ただし、FP法での規模計測には、人による判断・評価が必要になるため「機能の複雑さ」の判定に個人差が出やすいなどの課題もあります。
FP法を部分的に自動化支援してくれるツールは存在しますが、完全に「自動で正確にFPを算出する」ツールは存在しないと思います。
理由は先ほども述べたように、FP法がユーザー視点の機能的要件に基づく定性的な評価を含むからです。
過去、自分も経験がありますが、FP法を正しく使おうとすると、それなりの知見やスキルが必要でした。
(近年進歩が著しいAIを使えば、スキル不足や人判断による偏りをAIが補正してくれるかもしれませんが)
以前の記事でも書きましたが、FP法にはいくつかの種類があり、色々な改良がされているようです。
- IFPUG法
- COSMIC法
- フルファンクションポイント法
- フィーチャポイント法
- Mark Ⅱ法
- NESMA概算法
- SPR法
(出典:FP計測手法におけるFP規模と工数の相関の差より)
しかし、IPAが発行している「ソフトウェア開発分析データ集」によれば、近年FP法で計測されたプロジェクトデータはかなり減少しているようです。
(出典:「ソフトウェア開発分析データ集2022 ソフトウェア開発データのプロファイル」より)
SLOC
#SLOC(ソース行数)は、ひとことで言えば「ソースコードの行数」のことです。
SLOC はFP法と比較して、客観的かつ定量的な指標であると言えるでしょう。
SLOC の計測方法を明確に定義できれば、計測する人に起因する差が出にくい方法であり、ツールを使った SLOC の自動カウントも可能です。
ツールで自動化が可能という点で、静的解析ツールやGitなどの構成管理ツールとの相性もいいと思います。
昨今は多くのプロジェクトでCI/CD環境の構築が普通になってきており、ソフトウェア規模を FP法で計算するよりも、自動的に規模を計測できる SLOC を使うことが多くなっているように思います。
(もちろん、要件定義書や仕様書、設計書の規模把握にはドキュメントのページ数のようなものを並行して計測していくことになるでしょう)
ただし、SLOC は開発言語やコーディング・スタイルに強く依存する部分があり、同じ機能を実装しても、Python と C ではソース行数は全く異なります。
また当然、計測はソースコード実装後にしか行えないため、要件定義や仕様作成、設計段階では使えません。
先ほど「明確な定義が作成できれば」と前置きしましたが、組織やプロジェクトによって定義がバラバラだと思いもよらない乖離が生まれる可能性もあります。
疑問の発端
#ソフトウェア品質の定量的評価に関する資料をいくつか読んでいた時に、「SLOC」について些細な疑問がわきました。
IPAが発行する「ソフトウェア開発データ白書」を見てみると、SLOC の定義は以下のようになっています。
(出典:ソフトウェア開発データ白書2018-2019 P.361 より)
SLOC(実効SLOC)=「追加・新規」+「変更」+「削除」
(SLOC は、集められたプロジェクトデータのうち、「追加・新規」「変更」「削除」分の3つのデータが揃ったもののみを使用しているようです)
それぞれの意味を確認します。
追加・新規
#「追加・新規」の意味を改めて説明する必要もないでしょうが、念のため定義します。
- 既存のソースコードに、新たに追加されたソースコード(既存の機能に一部機能を追加した場合など)
- まったく新規に作成されたソースコード(関数、ファイル、ロジックなどを新規に記述した場合など)
削除
#こちらも同様に定義します。
- 既存のソースコードから、一部削除されたソースコード(既存の機能から一部の機能を削除した場合など)
- これまで使用していた関数やファイル、ロジックなどをそっくり削除した場合など
変更
#「追加・新規」や「削除」について疑いの余地は無いように思いますが、「変更」についてちょっと気になることがあります。
例えば、ここに以下のような1行のC++ソースコードがあると仮定しましょう。
printf("abc\n");
上記のコードを、以下のように変更したとします。
if (a > 0) printf("abc\n");
今回の変更の場合、以下の2通りの解釈が考えられると思いました。
- 1行のデータの「一部」を変更しただけなのだから、変更行数としては単に「1行変更」となる。
- この変更には条件部の追加があり、意味的に別物になっていると判断する。
よって、元の1行が「削除」され、新しく1行を「追加」したと考えて、変更行数は「1行削除+1行追加」の合計2行(差分合計)の変更量になる。
ネットで調べると、両方の解釈とも一理あり、どちらが正しいという訳ではないようです。
ちょっと細かすぎる疑問ですが、ソフトウェア開発データ白書を読んでも、「変更行数」の具体的な定義は見つかりませんでした。
ソース行数の「変更」に対する考察
#変更の程度で考える
#例えば、変更前のソースコードが以下のようであったとします。
if (a > 0) { /*なにがしかの処理*/ }
そして、上記のソースコードの条件式のところだけを一部変更して、以下のように変更したとします。
if (a >= 0) { /*なにがしかの処理*/ }
今回は条件式「a > 0」が「a >= 0」に変更された程度なので、実装で言うと「境界値の実装ミス」って感じでしょうか。
この程度の変更ならば変更量=「1行」って言ってもいいと思います。
しかし、変更後が以下のような1行だったら、どうでしょうか?
while (b++ <= 10) { /*全然別の処理*/ }
条件式も違うし、条件式内の変数も違う、またさらに実行される処理自体も違います。
これはもうまったくの別物ですよね。
こんなケースは「変更」っていうよりも、「1行削除して、新しく1行を追加した」っていうイメージの方が近いと思います。
ツールの立場から考えてみる
#「たかが行数、されど行数」って感じで奥が深い(?)ですね。
今度は、Gitなどの構成管理ツールを使用する立場から考えてみたいと思います。
Gitで SLOC を計測する方法としては、独立したコード行数計測ツール「cloc」などを使う場合もあるかと思います。
ですが、ここでは外部ツールに頼らず、Gitコマンド「git diff」を使う場合を考えてみます。
gitで1行を修正した場合、基本的に以下のように「1行削除」「1行追加」と記録されます。
- old line of code
+ new line of code
もっと詳細に計測したい場合は clocなどのツールを使うか、Pythonを使ってソースコード解析する必要があります。
ただ、簡単に計測したい場合は「変更=削除&追加」として SLOC を計測すると、計測の手間は少なそうです。
変更の影響範囲
#これまで変更した行”だけ”に焦点を当てて「変更行数」計測を検討してきましたが、本当に変更した箇所だけを「変更行数」とした数え方でいいのでしょうか?
例えば、1000行のソースコードの中のたった1行を修正したとしましょう。
この場合、変更行数は「物理的」には1行かもしれませんが、ソースコード変更後のレビューでは「この1行だけ」に注目してレビューすることは無いと思います。
少なくとも変更箇所が含まれる関数やモジュールの範囲がレビュー範囲に含まれるでしょう。
IPAが発行しているソフトウェア品質関連資料に「ESQR(Embedded System development Quality Reference:組込みソフトウェア開発向け 品質作り込みガイド)」があります。
ESQR では、ソースコード行数を計測する範囲(図中のサブシステムB,C,D全部)を以下のように定義しています。
(出典: IPA ESQR 「2.1章 組込みシステムの特性を考えた品質目標設定の考え方」より)
上記の定義に従うと、追加、変更、削除した「サブシステム」全部のソースコードがカウント対象になっています。
同じIPAから発行されている「ソフトウェア開発データ白書」の SLOC の定義とは異なっています。
もっとも、ESQRは「組み込み」に特化したリファレンスなので、1サブシステムの大きさはそれほど大きくないと想定されます。
影響の範囲を考えると、ESQR で主張している「追加や変更、削除したサブシステム全体のソース行数を対象とする」という方針も十分に説得力はあります。
よって、データ白書のように「エンタープライズ」向けのデータ集計の考え方と異なっていても仕方ない面はありそうです。
とはいえ、データ白書には「組み込みソフトウェア開発データ白書」も存在し、組み込み向けのデータ白書はエンタープライズ向けデータ白書と同じ算出方法でした。
IPAには、これら2つのドキュメント間の「SLOC規模の考え方の相違」について見解を聞いてみたい気がします。
コメント行と空行の扱い
#今度は「実効コード行数」以外のソースコードについて見てみたいと思います。
ソフトウェア開発データ白書や、ソフトウェア開発分析データ集で定義している SLOC は「コメント行や空行を除外」しています。
よって、コメント行、空行をいくら変更しても、変更行数には含まれません。
しかし、ESQR では違った定義をしています。
ESQRで定義している SLOC は「コメント行や空行を全部含め」ています。
(表中には「TLOC」と書かれていますが、他の導出指標の計算にこの TLOC が使われていますので、データ白書でいうところの 実効SLOC 扱いです)
ESQRの方式で計測すれば、コメント行や空行の変更も変更行数に入ります。(実際にコメント行や空行だけの変更があるかどうかは別ですが)
つまり、ソフトウェア開発データ白書や分析データ集などでは「論理SLOC」的な考え方を取り、ESQRは「物理SLOC」的な考え方を取っていると推測されます。
論理SLOCと物理SLOCの違いを以下に示します。
- 論理SLOC(Logical SLOC):コメント行や空行を除き、実際に意味のあるコードが記述されている行数を数えます。さらに、1行に複数のステートメントが記述されている場合、それらを複数行として数える場合もあります。
- 物理SLOC(Physical SLOC):コメント行や空行も含めたすべての行数を数えます。
コメント行や空行を含んだ変更をツールで計測するのって難しい
#コメント行や空行を含んだソースコードの変更行数をツールでちゃんと計測するのって意外と難しいと思います。
以下のようなPythonソースコードを考えてみました。
'''
コメント行1
コメント行2
コメント行3
コメント行4
・・・
コメント行m
'''
# コメント行n
print("abc")
上記のソースコード中の「コメント行n」の部分を削除した場合は「コメントを示す頭文字”#”」が削除行に含まれているので、計測ツールで「当該行を削除行として判断する」ことは難しくないと思います。
しかし、コメントブロック中の「コメント行2」とか「コメント行3」の部分を削除した場合、その行だけを見ても「その行がコメント行だったか」までは判断がつきません。
当該行がコメント行だったかどうかは前後の文も見てから判断しないとわからないのです。
つまり、gitの履歴だけから追跡しようとすると正しく判断できない可能性があると思います。
ちなみに、ChatGPTに「git に登録された2つのコミットログ間の行数差分を計測するプログラムを書いて」とお願いしてみました。
その際、「コメント行はソース行数に含めないで」と追加注文しました。
出来上がったソースコードは以下です。意外と面倒な実装になっています。
import subprocess
import csv
from collections import defaultdict
# === 対象ファイルの拡張子とコメント記号 ===
TARGET_EXTENSIONS = {
".py": "#", # Python
".js": "//", # JavaScript
".c": "//", # C
".cpp": "//", # C++
".h": "//", # C/C++ ヘッダーファイル
".java": "//", # Java
".cs": "//", # C#
}
def is_code_line(line: str, ext: str, in_block_comment: bool) -> tuple[bool, bool]:
"""空白・コメント行・ブロックコメントを除外"""
stripped = line.strip()
if not stripped:
return False, in_block_comment
if ext == ".py":
# Python ブロックコメント ''' or """
if in_block_comment:
if stripped.endswith("'''") or stripped.endswith('"""'):
return False, False
return False, True
if stripped.startswith("'''") or stripped.startswith('"""'):
if not (stripped.endswith("'''") or stripped.endswith('"""')) or len(stripped) < 6:
return False, True
return False, False
if stripped.startswith("#"):
return False, in_block_comment
elif ext in [".js", ".c", ".cpp", ".h", ".java", ".cs"]:
# C, C++, Java, C#, JS ブロックコメント: /* */
if in_block_comment:
if "*/" in stripped:
return False, False
return False, True
if stripped.startswith("/*"):
if "*/" not in stripped:
return False, True
return False, False
if stripped.startswith("//"):
return False, in_block_comment
return True, in_block_comment
def collect_sloc_by_date_and_file():
result = subprocess.run(
["git", "log", "--patch", "--pretty=format:COMMIT:%H|%ad", "--date=short"],
stdout=subprocess.PIPE,
text=True,
encoding="utf-8"
)
lines = result.stdout.splitlines()
sloc_by_date_file = defaultdict(lambda: defaultdict(lambda: {"insertions": 0, "deletions": 0}))
current_date = None
current_file = None
current_ext = None
in_block_comment = False
for line in lines:
if line.startswith("COMMIT:"):
_, date_str = line.split("|")
current_date = date_str.strip()
elif line.startswith("diff --git"):
parts = line.split(" b/")
if len(parts) == 2:
current_file = parts[1]
current_ext = next((ext for ext in TARGET_EXTENSIONS if current_file.endswith(ext)), None)
in_block_comment = False
elif current_ext:
if line.startswith("+++") or line.startswith("---"):
continue
if line.startswith("+"):
code = line[1:]
is_code, in_block_comment = is_code_line(code, current_ext, in_block_comment)
if is_code:
sloc_by_date_file[current_date][current_file]["insertions"] += 1
elif line.startswith("-"):
code = line[1:]
is_code, in_block_comment = is_code_line(code, current_ext, in_block_comment)
if is_code:
sloc_by_date_file[current_date][current_file]["deletions"] += 1
return sloc_by_date_file
def save_to_csv(sloc_data, filename="git_sloc_by_date_and_file.csv"):
with open(filename, mode="w", newline="", encoding="utf-8") as csvfile:
writer = csv.writer(csvfile)
writer.writerow(["Date", "File", "Insertions", "Deletions", "Total"])
for date in sorted(sloc_data.keys()):
for file, counts in sorted(sloc_data[date].items()):
ins = counts["insertions"]
dels = counts["deletions"]
total = ins + dels
writer.writerow([date, file, ins, dels, total])
print(f"✅ CSV出力完了: {filename}")
# 実行
if __name__ == "__main__":
sloc_data = collect_sloc_by_date_and_file()
save_to_csv(sloc_data)
print("✅ SLOCデータ収集完了")
これだけ頑張って書いてもらっても、コメントブロックの中身だけを修正した場合は、実効SLOC変更としてカウントされてしまいました。
ちょっと話は逸れますが、私が20年近く前によく利用した「かぞえチャオ」というツールはよく出来ていたなと思います。
(昔、お世話になったIBMコンサルタントさんから「自社のツールよりも優秀なので、こっち(かぞえチャオ)を使ってください」と言われた思い出があります)
かぞえチャオを使ったところ、追加・新規、変更、削除の情報をかなり正確に計測してくれました。
ただ、このツールは2019年を最後に更新が止まっているみたいですね。ちょっと残念です。
脳死状態のコメント
#コメント行、空行について議論すると「適正なコメント量ってどれくらい?」て話が必ず出てきます。
一概にどれくらいが適正かを明確にすることは出来ません。
コメント記述をどれくらい重視するかは、組織やプロジェクト、対象のソフトウェアの性質によって異なると思います。
1つ言えることは、適切なコメントは保守性を向上させます。
しかし、無駄・無意味なコメントもあります。
以下はそんな「無駄・無意味」なコメントの例です。
int iHoge = 0; // 初期化
(いや、書かなくても分かるから…)
int iFoo = 0;
・・・
iFoo++; // インクリメント
(いや、これも書かなくても分かるから…)
悪意ある NOP
#空行とはちょっと違いますが、アセンブリ言語や機械語で使われる命令に「NOP」っていう命令があります。
NOP は「何もしない命令」です。
書いていて「害」は無いけど、実行時に何もせず、ただメモリ領域を1バイト消費するだけです。
私がまだ新社会人だった頃の話ですが、当時はアセンブリ言語や機械語でのソフトウェア開発をしていました。
作成されたソフトウェアは「ROM」と呼ばれるメモリICに焼きこまれて納品されることが多かったです。
その時のソフトウェア開発への報酬は、ソフトウェアの規模で金額が計算されていました。
そうすると悪意のある輩たちは「何もしない命令(NOP)」をソフトウェアに大量に埋め込んで、コード量を水増しする行為が流行ったと聞いています。
ただ、すべての NOP が悪だったかいうとそういうわけではなく、事前にNOPを入れておくと、ソフトウェアをデバッグしている時、NOPのあった場所にパッチを当てることができました。
3つ NOP があればその場所に「JUMP命令(機械語:C3)」「メモリアドレス下位」「メモリアドレス上位」の3つのデータを埋め込んで、指定アドレスに強制的にプログラムを分岐させることも出来たわけです。(メモリアドレスの格納順が下位→上記の順なのは、当時使っていたCPU(Z80などの80系)がリトルエンディアンだからです)
今時、こんなパッチの当て方をするデバッグはしないかもしれませんが。
心理的障壁
#おっと、話がだいぶ逸れました。
何が言いたいかというと、コメント行や空行を「ソース行数」に含めると、無意味にコードを水増しして、見かけの生産性を上げてしまう輩が発生しないか?という不安があります。
しかし逆に、コメント行や空行を「作成コストに入れてもらえない行」と考えてしまうと、誰も労力を費やして「保守性の良いコメントを書く努力をしなくなってしまう」危険性もあると思います。
よって、やはり全ソース行数と実効ソース行数の両方計測して、全体におけるコメント行・空行率も測定しておおくことが望ましいと思いました。
このあたりの心理的なハンドリングって難しいと思う今日この頃です。
行数 VS ステップ数
#SLOCの話題から少し外れますが、思い出したので書いておきます。
私がまだ若い頃(もう数十年前)は ソースコードの規模計測には SLOC(行数)ではなくて、ステップ数という単位を使っていました。
ステップ数をカウントするツールもいくつかあったと思います。
当時は行数とステップ数の違いを明確に意識して使っていなかったですね。
SLOCはもういいとして、ステップ数について改めて調べてみました。
ステップ数(実行ステップ数)とは:
- 実際にCPUが実行する操作の単位の数を概念的に表したもの
- 「動作の複雑さ・実行される命令の粒度」という論理的な量
主な使い方:
- テストケース設計(命令網羅、分岐網羅など)
- 複雑さの評価(たとえば、行数が1行で10個の処理をしていれば、ステップ数は多い)
- パフォーマンス予測や制御フロー解析の参考
- 処理の効率性やパフォーマンスの評価
長所:
- アルゴリズムや実装の「効率性」を直接比較可能
短所:
- 実際のステップ数はハードウェア/コンパイラに依存するため、厳密な測定が困難
- 高級言語(Pythonなど)では、1行が複数のステップに変換されることがある
比較表を作ると以下のような感じでしょうか。
項目 | SLOC | ステップ数 |
---|---|---|
定義 | ソースコードの行数 | 実行される処理単位の数 |
単位 | 行(Line) | ステップ(Step / 命令) |
用途 | 開発規模、保守性、進捗管理 | 複雑さ、テスト設計、制御構造 |
影響要因 | 書き方、コーディングスタイル | ロジックの中身、分岐、繰り返し |
測定粒度 | 粗い | 細かい |
開発言語依存 | 高い(言語で大きく差) | 中程度 |
つまり、用途が違うのですね。
プログラムの生産性などを測るには SLOC で、ソースの複雑度や性能の評価には ステップ数 の考え方を適用って感じでしょうか。
例えば、可読性をガン無視すれば、C++だとこんなコードが書けます。
行数は1行ですが、ステップ数なら10ステップ超え。10倍以上の差が出るわけです。
int main(){for(int i=0;i<5;++i){if(i%2==0)for(int j=0;j<3;++j)std::cout<<"i="<<i<<", j="<<j<<"\n";else std::cout<<"odd i="<<i<<"\n";}return 0;}
まあ、こんなコードは保守性もダダ下がりですから、レビューで即指摘されるでしょう。
性能と可読性・保守性のバランスって大事ですね。
Pythonだと、内包表記・ラムダ・標準ライブラリなどを「知っている人」と「知らない人」では実装後のソース行数が天と地ほども開くでしょう。
初心者さんには、以下のようなコードは読めないんじゃないかと思います。(私は最近読めるようになりました。)
一目見ただけでは何をしているかわかりませんが、Pythonって意外とこんな書き方が多いですよね。
print(','.join(map(lambda x: str(x**3), [x for x in range(1, 21) if x % 2 == 0])))
まとめ
#結局、変更行数の計測は「こうすべき」という結論は出せませんでした。
しかし、ソース行数の計測を実施する前には、組織・プロジェクトとして「実装時のルール」や「スタイルガイド」をしっかりと決めて、全員の合意を取りつつ調整してくのがいいと思います。
他社の計測方法を過度に気にしすぎて、自分たちの組織・プロジェクトで出来ること以上のことを実施するのは本末転倒です。
誰かから「ソフトウェア開発データ白書のデータと比較したいんだけど」と相談された時に、「ああ、それはね」って答えられる、ちょっとした豆知識くらいに思っていただければ幸いです。