クソコード仕置き人両兵衛

物騒に見えても単に基本的な技術情報への誘導です

第3回 【C#】ループで文字連結といえばStringBuilder?⇒まず大量の連結自体を見直せ

おことわり

この記事はZennに書いていたシリーズを移したもので、初出は2023年11月25日である。

目次

今回の標的

クソコード仕置き人というからには標的はクソコードである。今回のクソコードは前回に引き続き下記の記事より。他でもよく見る大量の文字列連結を取り上げる。

クソコード生産者表示

クソコード仕置き人というからには標的はあくまでクソコードであるのだが、その量産業者がいるとすれば当然そちらも警戒せねばなるまい。
今回の生産者も堺康行…前回ClosedXML関連の検索で上位にくると書いたが、さらに「C# 高速化」でもかなりの上位に来ることが分かった*1。検索結果の順位が必ずしも情報の質の順でないことはいうまでもないが、それにしてもここまで目につく位置にあっては有害である。

クソコード概要

生産者が「測定に使用したコード」を引用する。「あ」が10万個連続した文字列を得るならStringBuilderを使おうと言いたいらしい。

using System.Diagnostics;  
using System.Text;  
  
//StringBuilderで文字列結合  
var stopwatch = new Stopwatch();  
stopwatch.Start();  
var sb = new StringBuilder();  
for (int i = 0; i < 100000; i++) {  
    sb.Append("あ");  
}  
stopwatch.Stop();  
Console.WriteLine(stopwatch.ElapsedMilliseconds);  
  
//stringで文字列結合  
stopwatch = new Stopwatch();  
stopwatch.Start();  
var s = "";  
for (int i = 0; i < 100000; i++) {  
    s += "あ";  
}  
stopwatch.Stop();  
Console.WriteLine(stopwatch.ElapsedMilliseconds);  

お仕置き

問題だらけなので箇条書きにしよう。

  1. 大量文字連結の必要性
    本件に限らず、BenchmarkDotNetの使い方紹介記事などでも速度改善コード例としてよく出てくるのだが、実際に1文字(もしくは短い文字列)を大量に自力で連結しなければならない機会は稀である。万単位の連続した文字を文字列化するとなるとテキストファイルやネットワークからの読み込みが想定されるが、StreamReader.ReadLineAsync()で行ごとに処理、もしくはメモリが許すならStreamReader.ReadToEndAsync()で一気に読み込みなどを先に検討すべきである。他にも文字列補間など幅広く言語仕様やライブラリからの支援が受けられるので、大量の文字を自力で連結しそうになった時点で必要性を疑った方が良い。
    以下、ダミーデータ作成などの要件でどうしても「あ」10万文字が必要になったものとして続ける*2

  2. StringBuilderを文字列化せずに計測している
    クソコードはStringBuilderに10万文字連結して終わって数十倍高速と誇っているが、StringBuilderのまま文字列情報として使うことはほぼない。最後にstringに変換するところまで含めて「『あ』10万文字からなる文字列を作るのにかかる時間」とすべきである。

  3. 1文字は極力charで表現すべき
    クソコードは1文字を表すのに"あ"string型を使っているが、'あ'としてchar型で処理した方が効率がいいことが多い。このことはCA1834などで表示されるので、生産者は日常的にコード分析結果を無視しているのではないかという疑念も生じる。

  4. そもそもstringのコンストラクタで一行で済む
    stringの引数付きコンストラクタには文字と個数を受け取るものや、char[]を受け取るものなどがある。これらで簡潔に記述すれば不具合も混入しにくい。

裏取り

裏取りに使ったコードはこちら
今回もBenchmarkDotNetで計測しているが、.NET6と.NET8で明確に結果の異なる箇所があったので両方掲載した。もっとも「いちいちStringBuilderインスタンスを作るまでもない」という結論は変わらない。

BenchmarkDotNet v0.13.10, Windows 10 (10.0.19045.4170/22H2/2022Update)
AMD Ryzen 5 5600, 1 CPU, 12 logical and 6 physical cores
.NET SDK 8.0.202
  [Host]   : .NET 8.0.3 (8.0.324.11423), X64 RyuJIT AVX2
  .NET 6.0 : .NET 6.0.28 (6.0.2824.12007), X64 RyuJIT AVX2
  .NET 8.0 : .NET 8.0.3 (8.0.324.11423), X64 RyuJIT AVX2
Method Runtime Mean Error StdDev Gen0 Gen1 Gen2 Allocated
KusoBuilder .NET 8.0 159.24 μs 1.457 μs 1.292 μs 62.2559 62.2559 62.2559 400.4 KB
BetterKusoBuilder .NET 8.0 167.88 μs 0.419 μs 0.350 μs 62.2559 62.2559 62.2559 400.4 KB
Normal .NET 8.0 61.08 μs 0.423 μs 0.396 μs 62.3779 62.3779 62.3779 195.36 KB
Linq .NET 8.0 122.45 μs 0.170 μs 0.142 μs 124.8779 124.8779 124.8779 390.74 KB
Create .NET 8.0 60.83 μs 0.316 μs 0.280 μs 62.3779 62.3779 62.3779 195.36 KB
KusoBuilder .NET 6.0 334.48 μs 2.628 μs 2.458 μs 62.0117 62.0117 62.0117 400.4 KB
BetterKusoBuilder .NET 6.0 176.57 μs 1.030 μs 0.913 μs 62.2559 62.2559 62.2559 400.4 KB
Normal .NET 6.0 59.89 μs 0.206 μs 0.172 μs 62.4390 62.4390 62.4390 195.36 KB
Linq .NET 6.0 123.47 μs 1.169 μs 1.093 μs 124.7559 124.7559 124.7559 390.74 KB
Create .NET 6.0 60.08 μs 0.081 μs 0.072 μs 62.4390 62.4390 62.4390 195.36 KB

以下、それぞれのアルゴリズムの引用コード中でCharacterCountは連結する文字の数を表す。すなわち、今回は10万である。
1. KusoBuilder

public string KusoBuilder()  
{  
    var sb = new StringBuilder();  
    for (var i = 0; i < CharacterCount; i++)  
    {  
        // ここでCA1834  
        sb.Append("あ");  
    }  
    return sb.ToString();  
}  

StringBuilderに10万回Append()する生産者案。純然たるクソコードである。

2. BetterKusoBuilder

public string BetterKusoBuilder()  
{  
    var sb = new StringBuilder();  
    for (var i = 0; i < CharacterCount; i++)  
    {  
        sb.Append('あ');  
    }  
    return sb.ToString();  
}  

「お仕置き」を踏まえ、Append()の引数にchar型を渡すよう改善したもの。.NET6では前出KusoBuilderの倍速ほどになるのだが、あまりにもクソコードが量産されて最適化対象になったのか、.NET8ではわずかながら逆転している*3。いずれにしてもクソコードの背比べではある。

3. Normal

public string Normal() => new('あ', CharacterCount);  

意図も明確だし、これで十分だろう。内部では結局StringBuilderを使って作っているのでは、と予想していると前出のBetterKusoBuilderの3倍弱速いことが意外かもしれない。.NETのソースを見るとVector<T>などを駆使して高速化を図っているのが分かる。これより有意に高速に実装できる自信がある場合のみ自力でやれば良い。

4. Linq

public string Linq() => new(Enumerable.Repeat('あ', CharacterCount).ToArray());

Normalを見て「違うの!本当は動的に異なる文字も連ねていくの!!」と言い出した場合*4を考慮して、何らかのIEnumerable<char>から作るとどれくらいのオーダーになるかの参考測定。一旦ToArray()が入ってしまうためGCを促してしまうが、それ込みでもKusoBuilder兄弟より確実に速い。可読性も考えて通常はStringBuilderよりも優先していいだろう。

5. (移転時追記)Create

public string Create() => string.Create(CharacterCount, 'あ', (span, c) => span.Fill(c));

初出において、現代C#で事前に長さが分かっている文字列の生成と言えば当然出してくるべきstring.Create()が欠けていたので、移転のついでに計測コードと結果に追加した。3.Normalと同等と言えるパフォーマンスが確保できている。Span<T>.Fill()も最終的にはVector<T>を利用した最適化に行きつくのだろう。今回のように、同一文字を連結するだけならこちらの記述を選ぶ意味は特にない。しかし、生成する内容によっては第3引数によって柔軟にSpan<char>を操作できる利点が生きることもあるだろうから選択肢として押さえておきたい。

戒め

.NET標準ライブラリが遅いのではない。貴方の書いたコードが遅いのだ。
濫造された「ループで文字を結合するときはStringBuilderを使えばn倍速」説を鵜呑みにしたのか、ループ回数に関わらずやたらとStringBuilderインスタンスを作るコードをよく目にする。例えばstring[] errorItemNamesにユーザーからの入力エラーのあった項目名(多くても数十)が入っているとして、「カンマ区切りで表示してくれ」という要件に対し下記のようなコードが書かれがちである。

var sb = new StringBuilder();  
foreach (var errorItemName in errorItemNames)  
{  
    if (sb.Length > 0)  
    {  
        sb.Append(',');  
    }  
    sb.Append(errorItemName);  
}  
// sb.ToString()を画面に表示  

開発現場で一度は上記のようなクソコード*5を目にしたことはないだろうか。あるいは、要件が「区切りのカンマは不要になった。そのまま続けて表示してくれ。」と変わった途端に上記に似たクソコードに逆戻りしてしまう例も多々ある。現代では、StringBuilderに限らず、forforeachを回して内部で何かを加え続けること自体がアンチパターンだと認識すべきだろう。そのうえで、当初Javaにならって用意されたと思われるStringBuilderは、近年のC#や.NETの進化によりレガシーに寄りつつあると把握しておくべきである。

*1:2023年11月25日時点でGoogle2位

*2:このひねり出した例もテキストエディタのマクロやPowerShellで十分そうなので、やはり必要となる状況は想像しがたい。

*3:charの方が遅いのも妙なので、今後さらに最適化されて再逆転するかもしれない。

*4:前回も書いたが、仮にそうだとしたら最初からそうとわかる例を提示すべきである。

*5:皮肉なことに、Visual Studio 2022でこれを入力しているとIntelliCodeによって次々と予測されていく。かなり普遍的なクソコードなのだろう。