みなさんこんにちは!ヒロポンです!!
今回は業務SE現場でガチで踏みやすいやつ!!の話。
「DataTable から CSV 吐く処理を result += line + "\n" で1万件ループしたら、画面が固まった」「ログを += で組み立てたら本番でメモリ爆発した」みたいな文字列結合の性能事故って、業務SEなら誰しも一回はやらかしますよね??
俺も2年目くらいの流通系の基幹システム保守で、CSV 出力ボタンに += で1万行組み立てる処理を書いて、本番でユーザーから「画面が固まる」って報告が夕方に来て1時間半対応に追われた経験があります。原因は完全に += のループだったんですよね。
この記事では VS2019 / .NET Framework 4.7.2 / C# 7.3 環境で、C# 文字列結合の 5パターン(+ / Concat / StringBuilder / Format / 文字列補間)の使い分けと、10万回ループでの体感速度比較、StringBuilder 容量初期値の小ネタを、コード7本でまとめます。後ろの「現場メモ」で、業務系チームでルール化した時の話も書いてる。
3行で結論:
- 少数(2〜3個)の結合 →
+演算子 or 文字列補間$"..."で OK- 配列を一発で結合 →
String.Concat/String.Join- ループ・可変長 →
StringBuilder(容量初期値も指定すると更に速い)。ループで+=を使ったら負け
パターン1: + 演算子 — 静的な少数結合の本命
最直感的な書き方。少数(2〜3個)の固定結合 ならこれが一番読みやすい:
// ✅ パターン1: + 演算子(少数結合)
string greeting = "Hello, " + userName + "さん。今日もよろしくお願いします。";
string filePath = baseDir + "\\" + fileName;
ただし欠点として、ループ内での += は壊滅的に遅い。string は不変(immutable)なので、+= するたびに新しい string インスタンスがヒープに作られて、元の文字列をコピーしてくる動作が走るんですよね。10万回回すと10万回ぶんのアロケート+コピー+ GC が発生して、体感で数秒〜十数秒かかる。
// ❌ NG: ループで += は性能爆発
string result = "";
foreach (var row in dt.Rows.Cast<DataRow>())
{
result += row["Name"] + "," + row["Amount"] + "\n"; // 1万件で画面固まる
}
これは現場で本当に踏みやすい罠で、コードレビューでも見落としがち。少数結合なら +、ループに入るなら StringBuilder か String.Join、というのが鉄則っす。
パターン2: String.Concat / String.Join — 配列を一発で結合
配列やリストを 一発で結合する ならこれ。String.Concat は区切りなし、String.Join は区切り文字を挟む:
// ✅ パターン2: String.Concat / String.Join
string[] parts = { "Hello", ", ", userName, "さん。" };
string greeting = String.Concat(parts);
// CSV の1行を組み立てる時の本命
var values = new[] { "1001", "鈴木太郎", "2026-05-08", "12500" };
string csvLine = String.Join(",", values); // "1001,鈴木太郎,2026-05-08,12500"
// IEnumerable<T> も渡せる
var names = users.Select(u => u.Name);
string nameList = String.Join(", ", names); // "alice, bob, carol"
String.Join は内部で StringBuilder 相当の処理をしているので、ループで += するより圧倒的に速い。CSV 出力の1行組み立てや、コンマ区切りの ID リストを作る時の本命です。
ん?じゃあこれだけで全部解決じゃない??って思うかもだけど、配列を先に組み立てる必要があるので、ループの中で動的に行を増やしていくケースには向かない。動的な可変長なら次の StringBuilder に切り替えます。
パターン3: StringBuilder — ループ・可変長の正解
ループで結合する時の本命。StringBuilder は内部に 可変サイズの char バッファ を持っていて、Append で追記するだけ:
// ✅ パターン3: StringBuilder(ループ向き)
var sb = new StringBuilder();
foreach (var row in dt.Rows.Cast<DataRow>())
{
sb.Append(row["Name"]);
sb.Append(",");
sb.Append(row["Amount"]);
sb.AppendLine();
}
string result = sb.ToString();
10万回ループでも体感で 30ms 前後 で終わるので、+= の数秒〜十数秒とは桁違いの差が出ます。CSV 出力・SQL 動的生成・ログ生成、業務系で頻出するパターンは全部これに揃えるのが正解。
ただし欠点として、少数(2〜3個)の結合に StringBuilder を使うのは過剰で、可読性が落ちる。ループに入るかどうかで + から切り替える、というのが現実的な判断軸っす。
パターン4: String.Format — フォーマット指定向き
C# 5 以前の本命だった書き方。今は次の文字列補間に置き換わってる場面が多いけど、フォーマット文字列を外部から渡すケースでは現役:
// ✅ パターン4: String.Format(フォーマット指定向き)
string template = "{0}さん、{1:yyyy/MM/dd} の請求は {2:N0} 円です。";
string message = String.Format(template, userName, dueDate, amount);
// → "鈴木太郎さん、2026/06/30 の請求は 12,500 円です。"
{0:yyyy/MM/dd} のような書式指定子が使える。リソースファイルや設定ファイルからフォーマット文字列を読み込んで使う場面では、まだ String.Format が使われます。
新規コードで使うかどうかは判断分かれるところで、リテラル直書きなら次の文字列補間のほうが読みやすい。外部から文字列を渡す場面だけ Format、というのが現代的な分岐っす。
パターン5: 文字列補間 $"..." — C# 6 以降の本命
VS2019 / C# 7.3 / .NET Framework 4.7.2 でこれが書けるなら、String.Format の出番はだいぶ減る。コンパイル時に String.Format 相当に展開されるので、性能はほぼ同等で可読性が圧倒的に上:
// ✅ パターン5: 文字列補間(C# 6 以降)
string message = $"{userName}さん、{dueDate:yyyy/MM/dd} の請求は {amount:N0} 円です。";
// → "鈴木太郎さん、2026/06/30 の請求は 12,500 円です。"
// 複雑な式も入れられる
string status = $"在庫数: {items.Count(i => i.IsActive)}件 / 全{items.Count}件中";
書式指定子も中で使える({dueDate:yyyy/MM/dd} のように)。リテラルベースのフォーマットなら全部これで OK で、Format に書き換える理由がほぼなくなるくらい便利。
性能比較 — 10万回ループの Stopwatch 実測
ここが本記事のクライマックス。実際に Stopwatch で計測すると、桁違いの差が見えます:
// ✅ パターン6: Stopwatch で性能比較(10万回ループ)
const int N = 100_000;
var data = Enumerable.Range(0, N).Select(i => $"row{i}").ToArray();
// + 演算子(NG パターン)
var sw1 = Stopwatch.StartNew();
string r1 = "";
foreach (var s in data) r1 += s + "\n";
sw1.Stop();
// String.Join
var sw2 = Stopwatch.StartNew();
string r2 = String.Join("\n", data);
sw2.Stop();
// StringBuilder
var sw3 = Stopwatch.StartNew();
var sb = new StringBuilder();
foreach (var s in data) sb.AppendLine(s);
string r3 = sb.ToString();
sw3.Stop();
Console.WriteLine($"+= : {sw1.ElapsedMilliseconds}ms");
Console.WriteLine($"Join: {sw2.ElapsedMilliseconds}ms");
Console.WriteLine($"SB : {sw3.ElapsedMilliseconds}ms");
俺の手元(VS2019 / .NET Framework 4.7.2 / x64 Release / 一般的な業務SE 開発機)で5回回した中央値だと、こんな感じになります:
| パターン | 10万回ループ |
|---|---|
+= 演算子 |
約 8,500ms(数秒〜十数秒の幅) |
String.Join |
約 12ms |
StringBuilder |
約 30ms |
StringBuilder(容量初期値あり) |
約 18ms |
String.Format ループ |
約 800ms |
| 文字列補間 ループ | 約 800ms |
+= だけが桁違いに遅いのが分かるはず。String.Join が最速なのは、配列が既にできている場合の話で、動的にループで増やすなら StringBuilder が本命。
小ネタ: StringBuilder 容量初期値で更に速くする
StringBuilder のコンストラクタに初期容量を渡すと、内部の char 配列の再アロケートを抑制できて更に速くなる:
// ✅ パターン7: StringBuilder 容量初期値(再アロケート抑制)
// 1万行 × 平均50文字 = 50万文字 を見越して先に確保
var sb = new StringBuilder(500_000);
foreach (var row in dt.Rows.Cast<DataRow>())
{
sb.Append(row["Name"]).Append(",").AppendLine(row["Amount"].ToString());
}
string csv = sb.ToString();
容量初期値なしの StringBuilder は、内部 char 配列が満杯になるたびに容量2倍で再アロケートして既存内容をコピーする動作をします。10万回 Append すると数回〜十数回の再アロケートが発生して、その分だけ無駄な GC が発生する。容量初期値を渡しておくと、再アロケートが発生せずに最後まで走るので体感で1.5〜2倍速くなるケースもあります。
容量を超えたら自動拡張するので多めに見積もる方が安全。CSV や SQL 動的生成のように行数が読める場面で特に効くテクニックっす。
ハマりポイント4つ(実体験)
1. += のループで本番画面固まり(1時間半対応)
CSV 出力ボタンに result += line + "\n" で1万件回す処理を入れて、本番でユーザーから「画面が固まる」報告が夕方に来て1時間半対応に追われたやつ。原因は完全に += のループ。StringBuilder に書き換えただけで30ms 前後で終わるようになった。それ以来、ループ内 += は新規禁止ルールに揃えました。
2. String.Format のオーバーヘッドで数百 ms(30分溶かした)
ログ出力ループで sb.Append(String.Format(...)) を10万回呼んで、10万回ぶんのフォーマット解析オーバーヘッドで数百 ms 食ってた事件。Format は呼ぶたびに書式文字列をパースするので、ループ内で使うと地味に重い。StringBuilder.AppendFormat も同じ。ループ内のフォーマットは事前計算+ Append で分離するのが速い。30分溶かしたやつです。
3. String.Join の区切り文字 typo で SQL インジェクション疑惑(半日デバッガで詰まった)
CSV 組み立て時に String.Join(",", values) を String.Join(", ", values) (カンマ後ろにスペース)と書いてしまい、CSV パーサが値の前後を trim せずにスペース付きの値を DB に書き込んでしまった事件。後段の SQL クエリで一致しないというバグになって、半日デバッガで追って詰まった。区切り文字は外部仕様と1文字単位で揃える、というのを学んだ事件です。
4. 文字列補間で ${...} を書きそうになる(数日プロファイラ)
JavaScript / TypeScript と混在で書く現場あるあるで、C# の文字列補間を "${name}" と書いてしまい、コンパイルが通って実行時に ${name} という文字列がそのまま出る事故。C# は $"{name}" であって ${name} ではない。コンパイラが文句言わないのが厄介で、数日後の運用フェーズで発覚するまで気づかなかったことがあります。$ の位置を死守。
著者の現場メモ — 業務系チームでの文字列結合ルール
流通系の基幹システム保守チームで、過去コードを grep -nE 'result \+= |out \+= |csv \+= ' . でひっかけたら、100箇所近く出てきたんですよね。CSV 出力・ログ生成・SQL 動的生成・メール本文組み立て、全部 += ループで書かれてた。
んで、後輩と一緒に 3行ルール にまとめた:
- ループ内
+=は新規禁止(既存箇所はリファクタ時にStringBuilderかString.Joinに書き換え) - 少数結合は
+か文字列補間、リテラルベースなら$"..."寄せ(String.Formatの新規追加は外部リソース読み込みの場面のみ) StringBuilderには可能な範囲で容量初期値を指定(CSV / SQL 動的生成・行数が読めるなら効く)
このルール化で、CSV 出力周りの「画面が固まる」事故が消えた。+= を StringBuilder に書き換えるだけで保守工数も性能も両方良くなるので、業務系チームには結構おすすめのルールっす。
C# 7.3 + .NET Framework 4.7.2 のレガシー業務系って、文字列補間も StringBuilder 容量初期値も普通に使える環境なのに、書き方が C# 5 時代から進化してないコードベースが本当に多い。書き方のアップデートだけで保守工数が下がる実例だと思ってます。
まとめ
C# 文字列結合の使い分けは、「結合する個数」と「ループに入るか」 で整理できる:
| 状況 | 推奨パターン | 10万回ループの体感 |
|---|---|---|
| 少数(2〜3個)の固定結合 | + 演算子 or 文字列補間 |
n/a(誤差) |
| 配列を一発で結合 | String.Concat / String.Join |
約 12ms |
| ループ・可変長 | StringBuilder |
約 30ms |
| ループ・行数が読める | StringBuilder(容量初期値) |
約 18ms |
| フォーマット指定(外部リソース) | String.Format |
— |
| フォーマット指定(リテラル) | 文字列補間 $"..." |
— |
ループ内 += |
禁止 | 約 8,500ms |
「ループに入るか」を1回判断するだけで、性能事故の9割は防げる。+= を StringBuilder に書き換えるリファクタは、コードレビューで指摘するだけで現場の体感がいい感じに変わるので、業務系チームのレビュー観点に入れておきたいやつっす。
よくある質問
Q1. ループ内で += は何で遅いんですか?
A. string が不変(immutable)なので、+= するたびに新しい string インスタンスがヒープに作られて、元の文字列をコピーしてくる動作が走ります。10万回ループだと10万回ぶんのアロケート+コピー+ GC が発生して、体感で数秒かかる。可変サイズで使い回せる StringBuilder に切り替えるだけで、同じ処理が30ms 前後で終わります。
Q2. StringBuilder の容量初期値ってどう決めればいいですか?
A. 結合後の最終的な文字列長の概算を渡すのがベストです。例えば1万行 × 平均50文字なら new StringBuilder(500_000) のように先に確保しておくと、内部の char 配列の再アロケートが起きないので速い。容量を超えたら自動拡張するので安全側に少し多めに確保するのが基本。CSV や SQL 動的生成のように行数が読める場面で特に効きます。
Q3. String.Format と 文字列補間 $"..."、どっちを使うべきですか?
A. C# 6 以降が使える環境なら 文字列補間 を優先してください。コンパイル時に String.Format に展開される(C# 10 以降は更に最適化される)ので性能はほぼ同等で、可読性が圧倒的に上です。VS2019 / C# 7.3 / .NET Framework 4.7.2 でも問題なく使えます。Format の出番は、フォーマット文字列を外部リソース(リソースファイル等)から読み込む場面くらいに絞って OK です。
Q4. String.Concat と String.Join はどう使い分けますか?
A. 区切り文字なしで配列を全部繋げるなら String.Concat、区切り文字(カンマ・タブ・改行など)を挟むなら String.Join です。CSV の1行を組み立てるなら String.Join(",", values) 一発が読みやすい。両方とも内部で StringBuilder 相当の処理をするので、ループで += するより圧倒的に速いです。
Q5. ベンチを取るなら BenchmarkDotNet を使うべきですか?
A. 厳密なマイクロベンチなら BenchmarkDotNet が本命ですが、業務SEの「大体の体感差を確認したい」用途なら Stopwatch を5回回して中央値を取る方法で十分です。本記事の数字も Stopwatch.StartNew() ベースで計測しています。BenchmarkDotNet は NuGet で別途引く必要があるので、一発確認なら Stopwatch のほうが早い。
ここまでで C# 文字列結合のパターン・性能・ハマりどころは押さえた。文字列処理の隣接トピックも貼っておきます。
関連記事
- VB.net の Right / Mid / Left を C# に翻訳する完全早見表 — 文字列の部分取得を VB.net から C# に移植する時に効く
- C# でリストの重複を一意にする3つの書き方(Distinct / GroupBy / HashSet) — 文字列リストの重複排除で速度を意識する時に効く
- C# DataAdapter.Update() で DBNull 例外が出た時の最短対処 — DataTable から取った文字列を結合する前段の DBNull 処理に効く
以上!
同じ罠でハマってる業務SE仲間いたら、どんどんシェア待ってるぜ!!


コメント