Menu

C#のStringBuilder:Append、Insert、Replaceと使うべき場面

C#でループの中で文字列の連結を繰り返すと遅い理由と、StringBuilderがそれをどう解決するかを解説します。Append、AppendLine、AppendFormat、Insert、Remove、Replace、容量、そして普通の連結やstring.Joinのほうが適している場面を扱います。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

C#の文字列は不変です。s += "x" は s を伸ばすのではなく、古い文字に新しい文字を加えた新しい文字列を作り、古い文字列はゴミになります。1回なら何でもありません。何千回も回るループでは、反復のたびにそれまでに組み立てたすべてをコピーするので、全体の処理量は長さの2乗に比例して増えます。

System.Text にある StringBuilder は変更可能なバッファーです。Append は末尾の空き領域に書き込み、ときどきバッファーを大きくし、ToString() が最後に1つの文字列を作ります。

出力:

Order #1042, total 59.97
Status: shipped
**********
51

ループの中の連結が遅い理由

同じテキストを両方の方法で組み立ててみます。時間はマシンによって異なるので、以下の数値は一例にすぎません。重要なのは差で、ループが大きくなるほど広がります。

出力例:

string +=     482 ms, length 188890
StringBuilder 1 ms, length 188890

20,000回の追加で、+= の版は合計で20億文字近くをコピーします。StringBuilder は各文字をおよそ2回、追加するときと ToString() のときにコピーするだけなので、1ミリ秒前後にとどまります。

Append、AppendLine、AppendFormat

Append にはすべての組み込み型のオーバーロードがあり、同じビルダーを返すので、呼び出しを連結できます。AppendLine は引数の後に改行を加えます。AppendFormat は、string.Format のような複合書式の文字列を受け取ります。

出力:

RECEIPT
-------
Espresso        2.40
Croissant       3.10
Orange juice    4.50
TOTAL          10.00

補間文字列を追加する(sb.Append($"..."))のは問題なく、読みやすくもあります。.NET 6以降では、コンパイラが中間の文字列を作らずに、各部分をビルダーに直接書き込みさえします。

AppendLine は Environment.NewLine を使います。LinuxとmacOSでは \n、Windowsでは \r\n です。ファイル形式が特定の改行コードを求める場合は、自分で追加します。

Insert、Remove、Replaceとインデクサー

文字列と違い、StringBuilder はその場で編集できます。

出力:

Hello, World
Hello, Maya
Maya
maya
maya!
[] length 0

Length を小さい値に設定すると切り詰められます。末尾の区切り文字を取り除く最も低コストな方法です。Clear() は長さを0にし、確保したバッファーは保つので、外側のループの反復をまたいで1つのビルダーを再利用できます。

インデクサーは最近追加した部分には高速ですが、非常に大きなビルダーへのランダムアクセスでは遅くなることがあります。内容が連結されたチャンクに格納されているからです。文字を読む必要があるなら、ToString() を1回呼んで、その文字列にインデックスでアクセスします。

末尾の区切り文字の問題

典型的な使い方は、カンマ区切りのリストの組み立てです。各項目の後に区切り文字を追加すると、末尾に1つ余分に残ります。

出力:

csharp, dotnet, linq
csharp, dotnet, linq

項目がすでにコレクションにあるなら、string.Join のほうが短く、少なくとも同じくらい高速です。StringBuilder は、条件付きのセクション、ネストしたループ、混在する書式など、ロジックによって形が変わるテキストのために取っておきます。

容量

StringBuilder は16文字分の領域から始まります。足りなくなると、すでに保持しているもの全体と同じ大きさ(1ブロックあたり最大8,000文字)の新しい格納ブロックをつなげるので、小さいうちは容量が2倍になり、その後は8,000文字ずつ増えます。増えるときに、すでに格納されているテキストがコピーされることはありません。結果のおおよその長さがわかっているなら、コンストラクターに容量を渡せば、ビルダーは一度も大きくなる必要がありません。

出力:

12000
10000
True
True

事前のサイズ指定は最適化であって、必須ではありません。拡張では新しいブロックが確保されますが、既存のテキストが移動することはないので、性能が重要な経路で多くの大きな文字列を組み立てるのでない限り、既定のままで十分です。

StringBuilderを使うべきでないとき

StringBuilder は、すべての + を置き換える高速な手段ではありません。コンパイラはすでに、1つの式の中の a + b + c + d を1回の string.Concat の呼び出しに変換し、それが最終的な長さを計算して各部分を1回だけコピーします。ビルダーがより多くのコードで行うのと同じ処理です。次のような場合は普通の文字列を使います。

  • 部分の数が少なく決まっている:$"{first} {last} ({age})"。
  • 部分がコレクションにあり、区切り文字が必要:string.Join(", ", items)。
  • 部分に区切り文字が不要:string.Concat(parts)。
  • ループの外で数回連結するだけ。

ループの中で追加する場合、事前にわからない回数だけ追加する場合、組み立てながらテキストを編集する(挿入、置換、削除)場合は StringBuilder を使います。行き来して変換する(ループの中で sb.ToString() を呼ぶ)と利点が失われるので、最後に1回だけ呼びます。

もう1つの代替手段はストリームです。大きなファイルを生成するなら、内容全体をメモリで組み立てるのではなく、StreamWriter に行を順に書き込んでいきます。ファイルを参照してください。

よくある質問

C#のStringBuilderとは何ですか?

System.Text.StringBuilder は、何段階にも分けて文字列を組み立てるための、変更可能な文字のバッファーです。Append は毎回新しい文字列を作る代わりにバッファーの末尾に追加し、ToString() は最後に1回だけ最終的な文字列を作ります。ループの中でテキストを組み立てるための標準的な道具です。

文字列の連結ではなくStringBuilderを使うべきなのはどんなときですか?

コレクションからレポート、CSVファイル、HTMLを組み立てる場合のように、ループの中で、または回数のわからない追加を行うときです。決まった少数の部分(a + b + c、または1つの補間文字列)なら、コンパイラがすでに1回の string.Concat の呼び出しにまとめてくれるので、普通の連結でも同じ速さで、読みやすくもあります。

StringBuilderから最後の文字を削除するには?

Length を減らします:if (sb.Length > 0) sb.Length--; はコピーなしで最後の文字を落とします。sb.Remove(sb.Length - 1, 1) でも同じです。末尾のカンマを取り除く一般的な方法ですが、string.Join ならそもそも末尾の区切り文字が生じません。

StringBuilderはスレッドセーフですか?

スレッドセーフではありません。同じ StringBuilder に複数のスレッドから同時に Append を呼ぶと、内容が壊れたり例外が投げられたりすることがあります。各スレッドに独自のビルダーを持たせて結果を結合するか、共有するものを lock で保護します。

C#のAppendLineは何を追加しますか?

テキストの後に Environment.NewLine を追加します。これはWindowsでは \r\n、LinuxとmacOSでは \n です。ファイル形式の \n やHTTPヘッダーの \r\n のように、特定の改行コードが必要な出力では、代わりに Append("\n") で明示的に追加します。

Coddy programming languages illustration

Coddyでコードを学ぼう

始める