おことわり
この記事は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);
お仕置き
問題だらけなので箇条書きにしよう。
- 大量文字連結の必要性
本件に限らず、BenchmarkDotNetの使い方紹介記事などでも速度改善コード例としてよく出てくるのだが、実際に1文字(もしくは短い文字列)を大量に自力で連結しなければならない機会は稀である。万単位の連続した文字を文字列化するとなるとテキストファイルやネットワークからの読み込みが想定されるが、StreamReader.ReadLineAsync()
で行ごとに処理、もしくはメモリが許すならStreamReader.ReadToEndAsync()
で一気に読み込みなどを先に検討すべきである。他にも文字列補間など幅広く言語仕様やライブラリからの支援が受けられるので、大量の文字を自力で連結しそうになった時点で必要性を疑った方が良い。
以下、ダミーデータ作成などの要件でどうしても「あ」10万文字が必要になったものとして続ける*2。
StringBuilder
を文字列化せずに計測している
クソコードはStringBuilder
に10万文字連結して終わって数十倍高速と誇っているが、StringBuilder
のまま文字列情報として使うことはほぼない。最後にstring
に変換するところまで含めて「『あ』10万文字からなる文字列を作るのにかかる時間」とすべきである。
- 1文字は極力
char
で表現すべき
クソコードは1文字を表すのに"あ"
とstring
型を使っているが、'あ'
としてchar
型で処理した方が効率がいいことが多い。このことはCA1834などで表示されるので、生産者は日常的にコード分析結果を無視しているのではないかという疑念も生じる。
- そもそも
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
に限らず、for
やforeach
を回して内部で何かを加え続けること自体がアンチパターンだと認識すべきだろう。そのうえで、当初Javaにならって用意されたと思われるStringBuilder
は、近年のC#や.NETの進化によりレガシーに寄りつつあると把握しておくべきである。
*1:2023年11月25日時点でGoogle2位
*2:このひねり出した例もテキストエディタのマクロやPowerShellで十分そうなので、やはり必要となる状況は想像しがたい。
*3:charの方が遅いのも妙なので、今後さらに最適化されて再逆転するかもしれない。
*4:前回も書いたが、仮にそうだとしたら最初からそうとわかる例を提示すべきである。
*5:皮肉なことに、Visual Studio 2022でこれを入力しているとIntelliCodeによって次々と予測されていく。かなり普遍的なクソコードなのだろう。