【追記】
.NET 8 では、ReadOnlySpan<T>
にCount(T)
・Count(ReadOnlySpan<T>)
とできる拡張メソッドが追加されました。
StringComparison
が不要の場合、(文字列をAsSpan()
して)それらを利用するのが簡潔で速いです。
文字列中の特定の文字(1文字)や文字列をカウントについて、一般的な方法を整理して計測してみる。
文字列中の特定の文字をカウントする場合、LINQはまず挙がる手段。
private string text = "あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら、うつくしい森で飾られたモリーオ市、郊外のぎらぎらひかる草の波。"; public int ByLinqCount() => text.Count(v => v == 'の');
他の手段としては、正規表現やループがある。
// 正規表現 (GeneratedRegex) public int ByRegexCount() { Regex regex = MyRegex(); return regex.Count(text); } [GeneratedRegex("の")] private static partial Regex MyRegex(); // ループ public int ByLoop() { var span = text.AsSpan(); int count = 0; int index; while ((index = span.IndexOf('の')) >= 0) { ++count; span = span[(index + 1)..]; } return count; }
比較してみる。
Method | Mean | Error | StdDev | Gen0 | Allocated | ------------- |----------:|----------:|----------:|-------:|----------:| ByLinqCount | 535.74 ns | 198.48 ns | 10.879 ns | 0.0095 | 32 B | ByRegexCount | 202.17 ns | 76.31 ns | 4.183 ns | - | - | ByLoop | 34.50 ns | 16.49 ns | 0.904 ns | - | - |
相対的には遅いが利用が簡易な LINQ を基本として、必要ならループを選ぶことになりそう。
カウント対象が文字列の場合はどうか。
次のようなガイドがあり、そこでは次のような LINQ が使われている。
文字列での単語の出現回数をカウントする方法 (LINQ) (C#) - C# | Microsoft Learn
private string text = @"Historically, the world of data and the world of objects" + @" have not been well integrated. Programmers work in C# or Visual Basic" + @" and also in SQL or XQuery. On the one side are concepts such as classes," + @" objects, fields, inheritance, and .NET APIs. On the other side" + @" are tables, columns, rows, nodes, and separate languages for dealing with" + @" them. Data types often require translation between the two worlds; there are" + @" different standard functions. Because the object world has no notion of query, a" + @" query can only be represented as a string without compile-time type checking or" + @" IntelliSense support in the IDE. Transferring data from SQL tables or XML trees to" + @" objects in memory is often tedious and error-prone."; public int ByLinq() { string searchTerm = "data"; string[] source = text.Split(new char[] { '.', '?', '!', ' ', ';', ':', ',' }, StringSplitOptions.RemoveEmptyEntries); var matchQuery = from word in source where word.Equals(searchTerm, StringComparison.InvariantCultureIgnoreCase) select word; return matchQuery.Count(); }
文字列のカウントとしては少しリッチな感じがする。
実際、ガイドにも次のように書かれている。
文字列に対する操作が単語のカウントのみである場合は、Matches または IndexOf メソッドの使用を検討してください。
Matches
というか正規表現、そしてIndexOf
(ループ)を使うと以下のような感じ。
なお正規表現は今回、GeneratedRegex とそうでないもの両方を記載。
// 正規表現 public int ByRegexCount() { Regex regex = new("data", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); return regex.Count(text); } // 正規表現 (GeneratedRegex) public int ByGenRegexCount() { Regex regex = MyRegex(); return regex.Count(text); } [GeneratedRegex("data", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex MyRegex(); // ループ public int ByLoop() { var span = text.AsSpan(); var searchSpan = "data".AsSpan(); int count = 0; int index; int len = searchSpan.Length; while ((index = span.IndexOf(searchSpan, StringComparison.InvariantCultureIgnoreCase)) >= 0) { ++count; span = span[(index + len)..]; } return count; }
BenchmarkDotNet で比較してみる。
Method | Mean | Error | StdDev | Gen0 | Allocated | ---------------- |------------:|------------:|----------:|-------:|----------:| ByLinq | 10,160.5 ns | 1,941.43 ns | 106.42 ns | 2.0447 | 6416 B | ByRegexCount | 3,580.0 ns | 161.45 ns | 8.85 ns | 0.9308 | 2920 B | ByGenRegexCount | 412.9 ns | 12.11 ns | 0.66 ns | - | - | ByLoop | 2,586.5 ns | 201.40 ns | 11.04 ns | - | - |
今回は GeneratedRegex 版が抜群に速かった。
これは利用した例の関係でStringComparison.InvariantCultureIgnoreCase
相当の処理を必要としていたのがおそらく影響していて、ループ版では内部的にカルチャ(CultureInfo
)での処理に行くが、GeneratedRegex では機械生成コードでより直接的な処理が行われている。
参考:GeneratedRegex 生成コードの一部
// 生成コードの一部。CultureInfo を使わず IndexOfAny('D', 'd') のように判別 if (pos <= inputSpan.Length - 4) { // The pattern begins with a character in the set [Dd]. // Find the next occurrence. If it can't be found, there's no match. ReadOnlySpan<char> span = inputSpan.Slice(pos); for (int i = 0; i < span.Length - 3; i++) { int indexOfPos = span.Slice(i).IndexOfAny('D', 'd'); if (indexOfPos < 0) { goto NoMatchFound; } i += indexOfPos; // The primary set being searched for was found. 2 more sets will be checked so as // to minimize the number of places TryMatchAtCurrentPosition is run unnecessarily. // Make sure they fit in the remainder of the input. if ((uint)(i + 3) >= (uint)span.Length) { goto NoMatchFound; } if (((span[i + 1] | 0x20) == 'a') && ((span[i + 3] | 0x20) == 'a')) { base.runtextpos = pos + i; return true; } } }
【C#】GeneratedRegex が速すぎる - てくメモ
過去にも記事にしているが、生成コードが頑張ってくれて速いので積極的に利用したい感じはする。
文字列を見つける処理は色んなシチュエーションがあるからなんとも言えないけれど、とりあえずカウントだけ考えるなら簡潔なRegex.Count
を基本でいい気がする。