閉じる

【C#】文字列連結を高速化!ConcatやStringBuilder速度比較と最適手法

C#におけるプログラミングにおいて、文字列の操作は避けては通れない非常に重要な要素です。

ログの出力、Webレスポンスの構築、大量のデータ処理など、あらゆる場面で文字列の連結が発生します。

しかし、C#のstring型は不変(Immutable)であるという特性を持っているため、安易な連結はパフォーマンスの著しい低下やメモリの浪費を招く原因となります。

本記事では、string.ConcatStringBuilder、最新のC#で導入された高度な手法までを徹底的に比較し、状況に応じた最適な高速化手法を詳しく解説します。

文字列連結の基本原理とパフォーマンスの重要性

C#で文字列を扱う際、まず理解しなければならないのが「文字列の不変性」です。

一度作成されたstringオブジェクトは、その内容を変更することができません。

例えば、2つの文字列を結合すると、既存の文字列が拡張されるのではなく、結合後の内容を持つ新しい文字列オブジェクトがメモリ上のヒープ領域に新しく作成されます。

文字列が不変である理由

なぜC#の文字列は不変なのでしょうか。

それは、スレッド安全性やセキュリティ、そしてハッシュコードのキャッシュといった効率性を確保するためです。

もし文字列が自由に変更可能であれば、あるスレッドが文字列を参照している間に別のスレッドがその内容を書き換えてしまい、予期せぬバグを引き起こす可能性があります。

不変であることで、参照を共有しても安全であるという保証が得られます。

ガベージコレクション(GC)への影響

文字列の連結を繰り返すと、短期間に大量の「使い捨てオブジェクト」が生成されます。

これらはすぐに参照されなくなりますが、メモリを占有し続けます。

メモリが不足してくると、GC(ガベージコレクション)が発生し、アプリケーションの動作を一瞬停止(Stop The World)させます。

この頻度が高まると、スループットが低下し、ユーザー体験を損なうことになります。

そのため、高速なアプリケーション開発においては、いかに無駄なメモリ割り当て(アロケーション)を減らすかが鍵となります。

連結手法のバリエーションと動作の違い

C#には文字列を連結するための方法が複数用意されています。

それぞれの動作と、内部でどのような処理が行われているかを確認しましょう。

+ 演算子による連結

最も直感的で多用される方法です。

実は、この演算子はコンパイラによって最適化されます。

C#
string str1 = "C#";
string str2 = "is";
string str3 = "Fast";

// コンパイラはこれを string.Concat(str1, str2, str3) に変換する
string result = str1 + str2 + str3;

Console.WriteLine(result);
実行結果
C#isFast

+演算子を1行で記述した場合、コンパイラはそれをstring.Concatメソッドの呼び出しに書き換えます。

これにより、複数の文字列を一度に結合する処理が効率的に行われます。

しかし、ループ内で「+=」を使用すると、ループの回数分だけ新しい文字列が生成されるため、パフォーマンスは最悪になります。

string.Concat メソッド

string.Concatは、引数で渡された複数の文字列の総長さを最初に計算し、必要なメモリを一度だけ確保してコピーを行うため、少数の連結においては非常に高速です。

C#
// 4つの引数を取るオーバーロード
string result = string.Concat("A", "B", "C", "D");

string.Format と 文字列補間 ($””)

可読性に優れるのがstring.Formatや、C# 6.0以降で導入された$""(文字列補間)です。

C#
string name = "User";
int version = 12;

// 文字列補間
string message = $"Hello, {name}! This is version {version}.";

かつての文字列補間は、内部的にstring.Formatを呼び出しており、値型(intなど)を渡すと「ボックス化」というコストの高い変換が発生していました。

しかし、最新のC#ではコンパイラによる最適化が進み、特定条件下では非常に高速なハンドラーを介して処理されるようになっています。

StringBuilderによる劇的な高速化

連結する文字列の数が事前に分からなかったり、ループ内で何度も連結を繰り返したりする場合は、System.Text.StringBuilderクラスが最適です。

StringBuilderの基本的な使い方

StringBuilderは内部に可変の文字バッファを持っており、文字列を追加しても新しいオブジェクトを作成せず、既存のバッファに書き込みます。

C#
using System.Text;

// 1000回の連結をシミュレート
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
    sb.Append("Data-");
    sb.Append(i);
    sb.Append(" ");
}

string finalResult = sb.ToString();
Console.WriteLine(finalResult.Substring(0, 50) + "...");
実行結果
Data-0 Data-1 Data-2 Data-3 Data-4 Data-5 Data-6...

キャパシティの事前指定でさらに高速化

StringBuilderはデフォルトで一定のバッファサイズ(通常16文字)を持っています。

これを超えると、バッファを拡張(より大きな配列を確保して中身をコピー)します。

この拡張処理にはコストがかかるため、あらかじめ最終的な長さが予想できる場合は、コンストラクタでキャパシティを指定することで、再確保をゼロにできます。

C#
// 最初から十分なサイズ(例えば1024文字分)を確保しておく
StringBuilder sb = new StringBuilder(1024);

実践:手法別の速度比較ベンチマーク

実際に、どの手法がどれほど速いのかを比較してみましょう。

以下の3つのケースで検証します。

手法概要得意なケース
+ 演算子手軽な記述数個の静的な連結
string.Concat内部的な最適化配列や列挙体の結合
StringBuilderバッファ利用大量・ループ内の結合
Interpolated文字列補間($)可読性重視・書式指定

5回程度の少ない連結の場合

数回程度の連結であれば、string.Concat(または+演算子の1行記述)が最も速くなります。

StringBuilderはオブジェクトの生成コストがかかるため、かえってオーバーヘッドになります。

1000回以上のループ連結の場合

ここからStringBuilderが圧倒的な強さを発揮します。

+演算子によるループは、計算量がO(N^2)になり、指数関数的に処理時間が増大します。

ベンチマークコード例

C#
using System.Diagnostics;
using System.Text;

int count = 10000;
Stopwatch sw = new Stopwatch();

// 1. + 演算子 (非推奨なループ内使用)
sw.Start();
string s1 = "";
for (int i = 0; i < count; i++) { s1 += "a"; }
sw.Stop();
Console.WriteLine($"+ Operator: {sw.ElapsedMilliseconds}ms");

// 2. StringBuilder
sw.Restart();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < count; i++) { sb.Append("a"); }
string s2 = sb.ToString();
sw.Stop();
Console.WriteLine($"StringBuilder: {sw.ElapsedMilliseconds}ms");

実行結果 (環境により変動)

+ Operator: 452ms
StringBuilder: 1ms

10,000回の連結で、400倍以上の速度差が出ることがわかります。

連結回数が増えれば増えるほど、この差は致命的になります。

C# 10以降の最新機能:InterpolatedStringHandler

現代のC#では、文字列補間($"")のパフォーマンスが劇的に向上しています。

以前は実行時に解析していましたが、現在はコンパイル時にDefaultInterpolatedStringHandlerという構造体を利用するコードへ変換されます。

なぜ最新の文字列補間は速いのか

従来のstring.Formatでは、引数をobject型として受け取るため、数値型などを渡すとボックス化が発生していました。

一方、新しいハンドラーはジェネリクスを活用し、型を維持したままバッファに書き込みます。

また、スタックメモリを利用することで、ヒープへの割り当てを極限まで抑えています。

C#
// 最新のC#環境では、これが極めて効率的なコードに展開される
string GetLog(int id, string status) => $"ID:{id}, Status:{status}";

この進化により、「可読性のための文字列補間、速度のためのStringBuilder」という境界線が曖昧になり、単純な数個の変数埋め込みであれば文字列補間が最速かつ最善の選択肢となっています。

究極の高速化を目指す:Span<char> と ValueStringBuilder

極限のパフォーマンスが求められるライブラリ開発や、高頻度で実行されるホットパス(重要経路)では、Span<T>ValueStringBuilderといったテクニックが使われます。

Span<T> を活用した連結

Span<char>を使用すると、スタック上のメモリ領域(stackalloc)に一時的なバッファを確保できます。

これにより、ヒープメモリを一切汚さずに文字列を組み立てることが可能です。

C#
public string CombineFixed(string a, string b)
{
    // スタック上にバッファを確保 (ガベージコレクションの対象外)
    Span<char> buffer = stackalloc char[a.Length + b.Length];
    
    a.AsSpan().CopyTo(buffer);
    b.AsSpan().CopyTo(buffer.Slice(a.Length));
    
    return new string(buffer);
}

ValueStringBuilder (内部型)

.NETの内部ソースコードでは、ValueStringBuilderという構造体が多用されています。

これはStringBuilderの「クラス版」ではなく「構造体版」であり、ヒープアロケーションをゼロにするための仕組みです。

シーン別・最適手法の選び方まとめ

これまでの内容を踏まえ、状況に応じた最適な選択肢を整理します。

1
固定数の連結

選択肢:+ 演算子 または string.Concat

2~4個程度の固定された連結なら、1行で s1 + s2 + s3 と書けば十分です。

コンパイラが最適な Concat に変換してくれます。

2
変数や数値を埋め込んだメッセージ作成

選択肢:文字列補間 ($"")。

可読性が最も高く、最新の C# であればパフォーマンス上の懸念もほぼありません。

3
ループ内や連結数が不確定な場合

選択肢:StringBuilder

ループ内での連結や連結数が不確定な場合は迷わずこれを選択してください。

あらかじめ Capacity を指定できれば完璧です。

4
配列やリストを区切り文字で結合

選択肢:string.Join

string.Join(",", list) のように使用します。

内部で StringBuilder 的な最適化が行われるため、自前でループを回すより高速です。

5
高頻度で呼ばれる低レイヤ処理

選択肢:Span<char> / stackalloc

アロケーションを最小化し、GC の負担を減らす必要がある場合に検討します。

まとめ

C#における文字列連結の高速化は、単に「どのメソッドを使うか」だけでなく、メモリ管理の仕組みを理解することから始まります。

+演算子や文字列補間は、手軽で可読性に優れますが、ループ内での使用はアプリケーションのパフォーマンスを破壊する恐れがあります。

一方で、StringBuilderは大量のデータ処理において無類の強さを発揮し、最新のC#機能はそれらの隙間を埋めるように進化しています。

開発時には、まず「この連結は何回発生するか?」を自問自答してください。

静的な連結ならシンプルに、動的で大量の連結ならStringBuilderを。

そして、さらに上を目指すならSpan<T>を活用する。

この使い分けができるようになるだけで、あなたの書くC#コードの品質と実行速度は飛躍的に向上するはずです。

基本操作

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!