﻿<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0">
	<channel>
		<title>.net, C# and programming</title>
		<link>https://blog.rogatnev.net/</link>
		<description>.net, C# and programming</description>
		<copyright>Copyright © 2026</copyright>
		<managingEditor>Sergey Rogatnev</managingEditor>
		<pubDate>Thu, 09 Apr 2026 13:49:34 GMT</pubDate>
		<lastBuildDate>Thu, 09 Apr 2026 13:49:34 GMT</lastBuildDate>
		<item>
			<title>Collections search</title>
			<link>https://blog.rogatnev.net/posts/en/2026/04/Collections-search.html</link>
			<description>When O(N) is faster than O(1).</description>
			<author>Sergey Rogatnev</author>
			<guid>https://blog.rogatnev.net/posts/en/2026/04/Collections-search.html</guid>
			<pubDate>Thu, 09 Apr 2026 00:00:00 GMT</pubDate>
			<content:encoded>&lt;p&gt;During interviews, we often hear — or say ourselves — that searching in an array is slower than in a hashtable.
Some might even recall that array search has linear complexity, or O(n), while a hash table has constant complexity, O(1).
But does this hold true in practice? What if there are situations where searching in an array turns out to be faster?
Let's not rush to conclusions.&lt;/p&gt;
&lt;!--more--&gt;
&lt;p&gt;So, let's compare searching in an &lt;code&gt;array&lt;/code&gt; and a &lt;code&gt;HashSet&lt;/code&gt; on small collections, using different types: a primitive &lt;code&gt;int&lt;/code&gt; and a reference type &lt;code&gt;string&lt;/code&gt;.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Full benchmark code. Click to expand.&lt;/summary&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;using System.Collections.Generic;
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;

namespace Benchmarks
{
[ShortRunJob(RuntimeMoniker.Net60)]
[ShortRunJob(RuntimeMoniker.Net80)]
[ShortRunJob(RuntimeMoniker.Net10_0)]
[HideColumns("Job", "Error", "StdDev", "Gen0", "Median")]
[DisassemblyDiagnoser(printSource: true, maxDepth: 2)]
public class HashSetContainsBenchmark
{
[Params(1, 10, 100, 1000)]
public int Size { get; set; }

        private int[] _arrayInt;
        private HashSet&amp;lt;int&amp;gt; _hashSetInt;
        private int _missingInt;

        private string[] _arrayString;
        private HashSet&amp;lt;string&amp;gt; _hashSetString;
        private string _missingString;

        [GlobalSetup]
        public void Setup()
        {
            // --- Int Setup ---
            var intData = Enumerable.Range(0, Size).ToArray();
            _arrayInt = intData;
            _hashSetInt = new HashSet&amp;lt;int&amp;gt;(intData);
            _missingInt = Size + 1;

            // --- String Setup ---
            var stringData = Enumerable.Range(0, Size).Select(i =&amp;gt; $"value-{i}").ToArray();
            _arrayString = stringData;
            _hashSetString = new HashSet&amp;lt;string&amp;gt;(stringData);
            _missingString = "value-a";
        }

        // --- Int Benchmarks ---
        [Benchmark(Description = "Array.Contains (Int)")]
        public bool Int_Array() =&amp;gt; _arrayInt.Contains(_missingInt);

        [Benchmark(Description = "HashSet.Contains (Int)")]
        public bool Int_HashSet() =&amp;gt; _hashSetInt.Contains(_missingInt);

        // --- String Benchmarks ---
        [Benchmark(Description = "Array.Contains (String)")]
        public bool String_Array() =&amp;gt; _arrayString.Contains(_missingString);

        [Benchmark(Description = "HashSet.Contains (String)")]
        public bool String_HashSet() =&amp;gt; _hashSetString.Contains(_missingString);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;pre&gt;&lt;code class="language-markdown"&gt;| Method                      | Runtime   | Size |     Mean | Code Size |
|-----------------------------|-----------|------|---------:|----------:|
|  Array.Contains (Int)       | .NET 6.0  | 1    | 3.443 ns |      50 B |
|  HashSet.Contains (Int)     | .NET 6.0  | 1    | 2.052 ns |     447 B |
|  Array.Contains (String)    | .NET 6.0  | 1    | 8.621 ns |      57 B |
|  HashSet.Contains (String)  | .NET 6.0  | 1    | 4.741 ns |     737 B |
|  Array.Contains (Int)       | .NET 8.0  | 1    | 2.676 ns |      40 B |
|  HashSet.Contains (Int)     | .NET 8.0  | 1    | 1.954 ns |     385 B |
|  Array.Contains (String)    | .NET 8.0  | 1    | 9.639 ns |      51 B |
|  HashSet.Contains (String)  | .NET 8.0  | 1    | 4.599 ns |     351 B |
|  Array.Contains (Int)       | .NET 10.0 | 1    | 1.193 ns |     669 B |
|  HashSet.Contains (Int)     | .NET 10.0 | 1    | 1.666 ns |     377 B |
|  Array.Contains (String)    | .NET 10.0 | 1    | 2.285 ns |     548 B |
|  HashSet.Contains (String)  | .NET 10.0 | 1    | 3.698 ns |     233 B |
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Intuitively, it seems that iterating over one element in an array should be faster than the more complex mechanism of hash-based searching.
As we can see, even when searching in a collection of a single int, the array loses to HashSet up until .NET 10:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.rogatnev.net/img/collections-search/one_element_int.png" alt="Search time in ns, size = 1"&gt;&lt;/p&gt;
&lt;p&gt;And even then, the advantage is quite small.&lt;/p&gt;
&lt;p&gt;We observe the same picture for strings:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.rogatnev.net/img/collections-search/one_element_string.png" alt="Search time in ns, size = 1"&gt;&lt;/p&gt;
&lt;p&gt;Notice how in .NET 10, array search for strings (2.285 ns) even beats HashSet (3.698 ns) — we'll explain why later.&lt;/p&gt;
&lt;p&gt;Alright, collections of one element are weird. Let's take the larger size — 10 elements. Surely, the advantage of HashSet should become more obvious there:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-markdown"&gt;| Method                      | Runtime   | Size |      Mean | Code Size |
|-----------------------------|-----------|------|----------:|----------:|
|  Array.Contains (Int)       | .NET 6.0  | 10   |  4.059 ns |      50 B |
|  HashSet.Contains (Int)     | .NET 6.0  | 10   |  2.901 ns |     447 B |
|  Array.Contains (String)    | .NET 6.0  | 10   | 33.465 ns |      57 B |
|  HashSet.Contains (String)  | .NET 6.0  | 10   |  5.807 ns |     737 B |
|  Array.Contains (Int)       | .NET 8.0  | 10   |  3.137 ns |      40 B |
|  HashSet.Contains (Int)     | .NET 8.0  | 10   |  2.497 ns |     397 B |
|  Array.Contains (String)    | .NET 8.0  | 10   | 31.164 ns |      51 B |
|  HashSet.Contains (String)  | .NET 8.0  | 10   |  5.937 ns |     377 B |
|  Array.Contains (Int)       | .NET 10.0 | 10   |  1.639 ns |     729 B |
|  HashSet.Contains (Int)     | .NET 10.0 | 10   |  2.230 ns |     377 B |
|  Array.Contains (String)    | .NET 10.0 | 10   | 18.063 ns |     548 B |
|  HashSet.Contains (String)  | .NET 10.0 | 10   |  4.583 ns |     233 B |
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Do you see the oddities already? Here they are:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.rogatnev.net/img/collections-search/ten_elements_int.png" alt="Search time in ns, size = 10"&gt;&lt;/p&gt;
&lt;p&gt;The search time in HashSet remains roughly the same regardless of the framework version, but array search keeps getting better and better.
Moreover, in .NET 10, the array overtakes &lt;code&gt;HashSet&lt;/code&gt;! How come?
Does array search also work in constant time now?
It seems we are on the verge of a grand discovery. Let's test our hypothesis on collections of 100 elements:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-markdown"&gt;| Method                      | Runtime   | Size |       Mean | Code Size |
|-----------------------------|-----------|------|-----------:|----------:|
|  Array.Contains (Int)       | .NET 6.0  | 100  |  13.073 ns |      50 B |
|  HashSet.Contains (Int)     | .NET 6.0  | 100  |   2.116 ns |     447 B |
|  Array.Contains (String)    | .NET 6.0  | 100  | 168.924 ns |      57 B |
|  HashSet.Contains (String)  | .NET 6.0  | 100  |   8.306 ns |     737 B |
|  Array.Contains (Int)       | .NET 8.0  | 100  |   4.381 ns |      40 B |
|  HashSet.Contains (Int)     | .NET 8.0  | 100  |   1.857 ns |     385 B |
|  Array.Contains (String)    | .NET 8.0  | 100  |  93.214 ns |      51 B |
|  HashSet.Contains (String)  | .NET 8.0  | 100  |   7.938 ns |     377 B |
|  Array.Contains (Int)       | .NET 10.0 | 100  |   3.401 ns |     736 B |
|  HashSet.Contains (Int)     | .NET 10.0 | 100  |   1.774 ns |     377 B |
|  Array.Contains (String)    | .NET 10.0 | 100  |  71.121 ns |     562 B |
|  HashSet.Contains (String)  | .NET 10.0 | 100  |   6.806 ns |     233 B |
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Unfortunately, a miracle didn't happen. Now the &lt;code&gt;array&lt;/code&gt; consistently loses to &lt;code&gt;HashSet&lt;/code&gt; in searching.
But the difference for the int type is still not that significant:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.rogatnev.net/img/collections-search/compare_ints.png" alt="Search time in ns, size = 100"&gt;&lt;/p&gt;
&lt;p&gt;Let's look at the growth of search time in an array relative to its size.
For clarity, let's add an array of 1000 elements:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.rogatnev.net/img/collections-search/compare_ints_2.png" alt="Search time in ns, size = 1000"&gt;&lt;/p&gt;
&lt;p&gt;As we can see, only in .NET 6 do we begin to observe linear growth after 100 elements.
And in versions 8 and 10, the growth, if not logarithmic, is heavily smoothed out.&lt;/p&gt;
&lt;p&gt;It's time to dive deeper, specifically into exactly how array search is performed.
Calling the Contains method on an array ultimately leads to calling the static Array.IndexOf method.
But this is where the differences begin.&lt;/p&gt;
&lt;h1 id="net-6"&gt;.NET 6&lt;/h1&gt;
&lt;p&gt;Using some loop-unrolling optimizations and fast access to array elements, .NET checks each element for equality:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;...
while (length &amp;gt;= 8)
{
    length -= 8;

    if (value.Equals(Unsafe.Add(ref searchSpace, index)))
        goto Found;
    if (value.Equals(Unsafe.Add(ref searchSpace, index + 1)))
        goto Found1;
    if (value.Equals(Unsafe.Add(ref searchSpace, index + 2)))
        goto Found2;
    if (value.Equals(Unsafe.Add(ref searchSpace, index + 3)))
        goto Found3;
    if (value.Equals(Unsafe.Add(ref searchSpace, index + 4)))
        goto Found4;
    if (value.Equals(Unsafe.Add(ref searchSpace, index + 5)))
        goto Found5;
    if (value.Equals(Unsafe.Add(ref searchSpace, index + 6)))
        goto Found6;
    if (value.Equals(Unsafe.Add(ref searchSpace, index + 7)))
        goto Found7;

    index += 8;
}
// the remainder of the array (less than 8 elements) is checked next
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As you can see, we sequentially check each element and get our honest &lt;code&gt;O(N)&lt;/code&gt; complexity. Nothing unusual so far.&lt;/p&gt;
&lt;h1 id="net-8"&gt;.NET 8&lt;/h1&gt;
&lt;p&gt;Starting with this version, the situation changes drastically.
The &lt;code&gt;SpanHelpers.NonPackedIndexOfValueType&lt;/code&gt; method comes into play, which, in addition to loop unrolling, contains a powerful hardware optimization—vectorization:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;if (Vector512.IsHardwareAccelerated &amp;amp;&amp;amp; length &amp;gt;= Vector512&amp;lt;TValue&amp;gt;.Count)
{
    Vector512&amp;lt;TValue&amp;gt; left = Vector512.Create&amp;lt;TValue&amp;gt;(value);
    //...
}
else if (Vector256.IsHardwareAccelerated &amp;amp;&amp;amp; length &amp;gt;= Vector256&amp;lt;TValue&amp;gt;.Count)
{
    Vector256&amp;lt;TValue&amp;gt; left = Vector256.Create&amp;lt;TValue&amp;gt;(value);
    //...
}
else
{
    Vector128&amp;lt;TValue&amp;gt; left = Vector128.Create&amp;lt;TValue&amp;gt;(value);
    //...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can see the use of new types: &lt;code&gt;Vector128&lt;/code&gt;, &lt;code&gt;Vector256&lt;/code&gt;, &lt;code&gt;Vector512&lt;/code&gt; — collections for working with vector data.
And pay attention to the &lt;code&gt;IsHardwareAccelerated&lt;/code&gt; property being checked in every condition — this is where the magic lies.
It determines whether your processor supports working with vectors of 128, 256, and 512 bits in length, respectively.&lt;/p&gt;
&lt;p&gt;Using the &lt;code&gt;Vector512&lt;/code&gt; class allows operating on 16 int array elements at once.
512 bits = 64 bytes, meaning the vector holds 16 values of type int at 4 bytes each.&lt;/p&gt;
&lt;p&gt;Unfortunately, we now have to dive into assembler to better understand how this works:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-asm"&gt;cmp r8d,4
jl near ptr M01_L03          ; If elements &amp;lt; 4, process one by one
cmp r8d,10h                  ; 0x10 = 16 decimal
jl near ptr M01_L18          ; If elements &amp;lt; 16, switch to processing using a 256-bit register (ymm)
vpbroadcastd zmm0,edx        ; Load the target value edx into the 512-bit register zmm0. Now zmm0 looks like [edx, edx, edx, ..., edx]
...
vmovups zmm1,[rcx]           ; Load the first 16 elements from memory into the 512-bit register zmm1
vpcmpeqd k1,zmm1,zmm0        ; Compare zmm1 and zmm0 for equality
kortestw k1,k1               ; Check if there is at least one match
...
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;vpcmpeqd&lt;/code&gt; is an instruction that compares two vectors element-by-element and saves the result into a special register &lt;code&gt;k1&lt;/code&gt;. If the fifth element matches, the fifth bit in &lt;code&gt;k1&lt;/code&gt; will be set to 1.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;kortestw&lt;/code&gt; checks this bitmask. If it equals 0, it means the target number is not present in the first 16 elements.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;At first glance, it looks complicated, but essentially, this is a continuation of the loop unrolling concept, just at the hardware level.
And the new framework versions hide all the complexity of such optimizations from us.
Ultimately, there is no magic, and array search is still &lt;code&gt;O(N)&lt;/code&gt;. Even if it is &lt;code&gt;O(N/16)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This magic only works on modern processors that support &lt;code&gt;AVX-512&lt;/code&gt; instructions (or &lt;code&gt;AVX2&lt;/code&gt; for 256-bit).
If you run this code on older hardware, .NET will gracefully fall back to &lt;code&gt;Vector128&lt;/code&gt; or a regular loop.&lt;/p&gt;
&lt;h1 id="net-10"&gt;.NET 10&lt;/h1&gt;
&lt;p&gt;Let me remind you that in this version, the results for an array of length 10 got even better and even surpassed &lt;code&gt;HashSet&lt;/code&gt;.
At the same time, the search algorithm itself hasn't changed; the code is exactly the same.
Registers larger than 512 bits haven't been invented yet. So, what's the deal? I assume that &lt;a href="https://en.wikipedia.org/wiki/Profile-guided_optimization"&gt;PGO&lt;/a&gt; (Profile-Guided Optimization) kicked in, and the compiler decided that searches will most often occur in medium-sized arrays (8–15 elements), rearranged the comparison blocks to avoid unnecessary jumps, and saved the processor a couple more clock cycles.&lt;/p&gt;
&lt;p&gt;And remember how earlier we made a mental note of why string array search became so good in .NET 10? It's time to figure that out as well.&lt;/p&gt;
&lt;p&gt;Let's look at the benchmark once again:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-markdown"&gt;| Method                  | Runtime   | Size |     Mean | Code Size |
|-------------------------|-----------|------|---------:|----------:|
| Array.Contains (String) | .NET 8.0  | 1    | 9.639 ns |      51 B |
| Array.Contains (String) | .NET 10.0 | 1    | 2.285 ns |     548 B |
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Besides the nearly fourfold difference in speed, we also see a multiple-fold difference in Code Size.
For .NET 8, the compiler didn't think twice and redirected the &lt;code&gt;Contains&lt;/code&gt; method call to the generic &lt;code&gt;Array.IndexOf&lt;/code&gt; method, which calls the virtual &lt;code&gt;bool Equals(object obj)&lt;/code&gt; method for each array element.
It's reliable, but not fast.&lt;/p&gt;
&lt;p&gt;However, in .NET 10, the compiler applied a new technique called "devirtualization" — more specifically, &lt;strong&gt;Guarded Devirtualization&lt;/strong&gt;.
It is understood that our collection is an array of strings and only strings.
This means we can skip all those generic calls and bypass the virtual method table dispatch, and simply inline the entire string comparison loop.
That is why we get 548 bytes of code versus 51. But! We get specific code for searching a string in an array, which works much faster. To see this, let's look at the assembly code again:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-asm"&gt;...
mov r8,offset MT_System.String
cmp [rcx],r8                ; Verify that we definitely have a string. Hence Guarded Devirtualization.
jne short M00_L08           ; Otherwise, compare via classic Equals
...
mov r8d,[rcx+8]             ; Read the length of the current string
cmp r8d,[rsi+8]             ; Compare it with the length of the target string
je short M00_L07            ; If lengths match - compare the strings themselves byte by byte
...
M00_L07:
lea rax,[rcx+0C]
...
lea rdx,[rsi+0C]            ; Get a pointer to the byte array where strings are stored
mov rcx,rax
call qword ptr [7FFD2A46C330] ; System.SpanHelpers.SequenceEqual - call the method that compares 2 byte arrays
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At the same time, &lt;code&gt;SequenceEqual&lt;/code&gt; also supports vector operations and hardware acceleration.
Wow! That's powerful! I didn't even suspect what a tremendous amount of work goes on behind a simple string search in an array.&lt;/p&gt;
&lt;h1 id="conclusions"&gt;Conclusions&lt;/h1&gt;
&lt;p&gt;Once again, the obvious conclusion suggests itself: simply updating .NET will yield a performance boost (and therefore resource savings).
Also, there is no magic; &lt;code&gt;O(N)&lt;/code&gt; is still &lt;code&gt;O(N)&lt;/code&gt;, which is clearly noticeable at large values. However, for small values (5–10 elements), things might not be so straightforward.&lt;/p&gt;
&lt;p&gt;But can we do better? To avoid O(N), even with hardware acceleration, but also without constantly paying the hashing overhead? We'll see...&lt;/p&gt;
&lt;h1 id="analyzer"&gt;Analyzer&lt;/h1&gt;
&lt;p&gt;I've added a &lt;a href="https://github.com/Backs/Collections.Analyzer/blob/master/Documentation/CI0008.md"&gt;rule&lt;/a&gt; to my &lt;a href="https://github.com/Backs/Collections.Analyzer"&gt;analyzer&lt;/a&gt; that will suggest replacing an array (or list) with a HashSet if Contains is called on it.
This is useful for .NET 8 and below, and remains relevant for .NET 10 with collections larger than ~15 elements. For finer tuning, you can adjust the trigger threshold in &lt;code&gt;.editorconfig&lt;/code&gt; — for example, only starting from five elements:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dotnet_diagnostic.CI0008.min_items_count = 5
&lt;/code&gt;&lt;/pre&gt;
</content:encoded>
			<comments xmlns="http://purl.org/rss/1.0/modules/slash/">0</comments>
		</item>
		<item>
			<title>Collection initializer: List&lt;T&gt;</title>
			<link>https://blog.rogatnev.net/posts/en/2025/11/List-initializer.html</link>
			<description>Evaluate the impact of list size pre-initialization on performance and memory usage in your application.</description>
			<author>Sergey Rogatnev</author>
			<guid>https://blog.rogatnev.net/posts/en/2025/11/List-initializer.html</guid>
			<pubDate>Thu, 27 Nov 2025 00:00:00 GMT</pubDate>
			<content:encoded>&lt;p&gt;We're all used to write &lt;code&gt;new List&amp;lt;int&amp;gt; { 1, 2, 3, 4 }&lt;/code&gt; or &lt;code&gt;new int[] { 1, 2, 3, 4}&lt;/code&gt; to initialize collections with
values. Syntactically, they look similar, but the behavior is different, and you should be careful if you are worried
about
performance.&lt;/p&gt;
&lt;!--more--&gt;
&lt;h1 id="array"&gt;Array&lt;/h1&gt;
&lt;p&gt;As we know, &lt;a href="https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/arrays"&gt;arrays&lt;/a&gt; contain a
sequence of fixed-size elements. Once created, their size cannot be changed for the entire lifetime of the array.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var array = new int[] { 1, 2, 3, 4};
&lt;/code&gt;&lt;/pre&gt;
&lt;h1 id="listt"&gt;List&amp;lt;T&amp;gt;&lt;/h1&gt;
&lt;p&gt;When we don't know the final size of a collection, or we need to add/remove elements during its life cycle, using &lt;a href="https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.list-1?view=net-8.0"&gt;&lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt;&lt;/a&gt; type is appropriate.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var list = new List&amp;lt;int&amp;gt;();
list.Add(1);
list.Add(2);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At first glance, it might seem like we can always use a list instead of an array — it has all the capabilities of an array, but it can also be dynamically resized.
But to decide whether to use a list, we need to understand more about its internal structure.&lt;/p&gt;
&lt;p&gt;Part of the source code:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public class List&amp;lt;T&amp;gt; : IList&amp;lt;T&amp;gt;, IList, IReadOnlyList&amp;lt;T&amp;gt;
{
    private const int DefaultCapacity = 4;

    internal T[] _items;
    internal int _size;

    private static readonly T[] s_emptyArray = new T[0];

    // Constructs a List. The list is initially empty and has a capacity
    // of zero. Upon adding the first element to the list the capacity is
    // increased to DefaultCapacity, and then increased in multiples of two
    // as required.
    public List()
    {
        _items = s_emptyArray;
    }
    ...

    public int Capacity
    {
        get =&amp;gt; _items.Length;
        set {...}
    }

    public int Count =&amp;gt; _size;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;_items&lt;/code&gt; - internal array for storing elements;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_size&lt;/code&gt; - the number of elements in the array and the total size of the list;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Capacity&lt;/code&gt; - the size of the &lt;code&gt;_items&lt;/code&gt; array and the maximum number of elements that can fit into it without resizing.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="resizing-a-list"&gt;Resizing a list&lt;/h2&gt;
&lt;p&gt;In simple terms, we can say that a list is an array that can be resized as needed.&lt;/p&gt;
&lt;p&gt;Every time we try to add another item, the list checks if there is enough free space in &lt;code&gt;_items&lt;/code&gt;, otherwise it sets the new capacity:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;internal void Grow(int capacity)
{
    ...
    int newCapacity = _items.Length == 0 ? DefaultCapacity : 2 * _items.Length;
    ...
    Capacity = newCapacity;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let's take a closer look at the property &lt;code&gt;Capacity&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;// Gets and sets the capacity of this list.  The capacity is the size of
// the internal array used to hold items.  When set, the internal
// array of the list is reallocated to the given capacity.
public int Capacity
{
    get =&amp;gt; _items.Length;
    set
    {
        if (value &amp;lt; _size)
        {
            ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.value, ExceptionResource.ArgumentOutOfRange_SmallCapacity);
        }

        if (value != _items.Length)
        {
            if (value &amp;gt; 0)
            {
                T[] newItems = new T[value];
                if (_size &amp;gt; 0)
                {
                    Array.Copy(_items, newItems, _size);
                }
                _items = newItems;
            }
            else
            {
                _items = s_emptyArray;
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So, what do we see? Initially, each list is created with an empty internal array. After the first element is added,
the list creates a new array with 4 elements (&lt;code&gt;DefaultCapacity&lt;/code&gt; is 4). And when the current array is exhausted, a new one is created
with double the size, and all elements are copied.&lt;/p&gt;
&lt;h1 id="performance"&gt;Performance&lt;/h1&gt;
&lt;p&gt;What happens when we create a new list and initialize it with values?&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var list = new List&amp;lt;int&amp;gt; { 1, 2, 3, 4, 5 };
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This looks like array initialization, but it works completely differently.
According to the &lt;a href="https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/object-and-collection-initializers#collection-initializers"&gt;documentation&lt;/a&gt;, any type that implements &lt;code&gt;IEnumerable&lt;/code&gt; and has an &lt;code&gt;Add&lt;/code&gt; method can be used with a collection initializer.&lt;/p&gt;
&lt;p&gt;So, the previous example is just a shorthand form of successive calls to the &lt;code&gt;Add&lt;/code&gt; method:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var list = new List&amp;lt;int&amp;gt;();
list.Add(1);
list.Add(2);
list.Add(3);
list.Add(4);
list.Add(5);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The compiler simply transforms the short collection initializer and automatically adds the necessary calls. This can result in a performance penalty.&lt;/p&gt;
&lt;p&gt;Let's take a closer look at the process of adding 5 elements:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Before the first call to &lt;code&gt;Add&lt;/code&gt; the list is empty, the internal array is empty.&lt;/li&gt;
&lt;li&gt;The first element added creates a new internal array with 4 elements.&lt;/li&gt;
&lt;li&gt;Elements 2, 3, 4 do not change anything when added.&lt;/li&gt;
&lt;li&gt;When we add the fifth element, the internal array is full and needs to be resized.
A new array of size 8 is created, all elements are copied from the previous array, and the fifth element is added.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So, we end up with a list with 5 elements and an internal array with 8 elements. We've created two arrays, and the final one is wasting 37.5% of its space.
As you might have guessed, creating new arrays and copying elements results in memory allocation and takes additional time.&lt;/p&gt;
&lt;p&gt;This could be an unpleasant surprise in critical areas. Do we have a solution? Yes!&lt;/p&gt;
&lt;h2 id="capacity"&gt;Capacity&lt;/h2&gt;
&lt;p&gt;If we know or guess the finite size of the list, we can create it with an initial capacity (&lt;code&gt;Capacity&lt;/code&gt;).&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var list = new List&amp;lt;int&amp;gt;(5);
list.Add(1);
list.Add(2);
list.Add(3);
list.Add(4);
list.Add(5);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var list = new List&amp;lt;int&amp;gt;(5) { 1, 2, 3, 4, 5 };
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now we immediately create one internal array of 5 elements, and there are no more unnecessary memory allocations.&lt;/p&gt;
&lt;h2 id="benchmark"&gt;Benchmark&lt;/h2&gt;
&lt;p&gt;Let's compare creating lists with and without initial capacity.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Full benchmark code. Click to expand.&lt;/summary&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;using System.Collections.Generic;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;

namespace ListBenchmark
{
    [ShortRunJob(RuntimeMoniker.Net48)]
    [ShortRunJob(RuntimeMoniker.NetCoreApp31)]
    [ShortRunJob(RuntimeMoniker.Net80)]
    [ShortRunJob(RuntimeMoniker.Net10_0)]
    [MemoryDiagnoser]
    [HideColumns("Job", "Error", "StdDev", "Gen0")]
    public class InitListBenchmark
    {
        [BenchmarkCategory("One")]
        [Benchmark]
        public List&amp;lt;int&amp;gt; InitList1()
        {
            return new List&amp;lt;int&amp;gt; {1};
        }

        [BenchmarkCategory("One")]
        [Benchmark]
        public List&amp;lt;int&amp;gt; InitListWithSize1()
        {
            return new List&amp;lt;int&amp;gt;(1) {1};
        }

        [BenchmarkCategory("Five")]
        [Benchmark]
        public List&amp;lt;int&amp;gt; InitList5()
        {
            return new List&amp;lt;int&amp;gt; {1, 2, 3, 4, 5};
        }
        
        [BenchmarkCategory("Five")]
        [Benchmark]
        public List&amp;lt;int&amp;gt; InitListWithSize5()
        {
            return new List&amp;lt;int&amp;gt;(5) {1, 2, 3, 4, 5};
        }
        
        [BenchmarkCategory("Ten")]
        [Benchmark]
        public List&amp;lt;int&amp;gt; InitList10()
        {
            return new List&amp;lt;int&amp;gt; {1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
        }
        
        [BenchmarkCategory("Ten")]
        [Benchmark]
        public List&amp;lt;int&amp;gt; InitListWithSize10()
        {
            return new List&amp;lt;int&amp;gt;(10) {1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;pre&gt;&lt;code class="language-markdown"&gt;BenchmarkDotNet v0.15.6, Windows 10 (10.0.19045.6456/22H2/2022Update)
AMD Ryzen 7 7840H with Radeon 780M Graphics 3.80GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 10.0.100
[Host]                      : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v4
ShortRun-.NET 10.0          : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v4
ShortRun-.NET 8.0           : .NET 8.0.22 (8.0.22, 8.0.2225.52707), X64 RyuJIT x86-64-v4
ShortRun-.NET Core 3.1      : .NET Core 3.1.32 (3.1.32, 4.700.22.55902), X64 RyuJIT VectorSize=256
ShortRun-.NET Framework 4.8 : .NET Framework 4.8.1 (4.8.9310.0), X64 RyuJIT VectorSize=256

IterationCount=3 LaunchCount=1 WarmupCount=3

| Method             | Runtime            |      Mean | Allocated |
|--------------------|--------------------|----------:|----------:|
| InitList1          | .NET Framework 4.8 | 13.904 ns |      80 B |
| InitListWithSize1  | .NET Framework 4.8 |  6.658 ns |      72 B |
| InitList1          | .NET Core 3.1      | 11.091 ns |      72 B |
| InitListWithSize1  | .NET Core 3.1      |  7.407 ns |      64 B |
| InitList1          | .NET 8.0           | 10.084 ns |      72 B |
| InitListWithSize1  | .NET 8.0           |  6.838 ns |      64 B |
| InitList1          | .NET 10.0          |  8.170 ns |      72 B |
| InitListWithSize1  | .NET 10.0          |  6.638 ns |      64 B |
| InitList5          | .NET Framework 4.8 | 31.298 ns |     136 B |
| InitListWithSize5  | .NET Framework 4.8 | 12.013 ns |      88 B |
| InitList5          | .NET Core 3.1      | 26.466 ns |     128 B |
| InitListWithSize5  | .NET Core 3.1      |  9.446 ns |      80 B |
| InitList5          | .NET 8.0           | 23.714 ns |     128 B |
| InitListWithSize5  | .NET 8.0           | 15.587 ns |      80 B |
| InitList5          | .NET 10.0          | 20.002 ns |     128 B |
| InitListWithSize5  | .NET 10.0          |  8.712 ns |      80 B |
| InitList10         | .NET Framework 4.8 | 53.488 ns |     225 B |
| InitListWithSize10 | .NET Framework 4.8 | 18.185 ns |     104 B |
| InitList10         | .NET Core 3.1      | 44.371 ns |     216 B |
| InitListWithSize10 | .NET Core 3.1      | 12.496 ns |      96 B |
| InitList10         | .NET 8.0           | 38.707 ns |     216 B |
| InitListWithSize10 | .NET 8.0           | 12.024 ns |      96 B |
| InitList10         | .NET 10.0          | 33.854 ns |     216 B |
| InitListWithSize10 | .NET 10.0          | 15.822 ns |      96 B |
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Setting the initial capacity prevents unnecessary memory allocations and results in better performance.
And there's no excess memory traffic. The more elements we add to the list, the greater the difference we see in the benchmarks.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.rogatnev.net/img/list/memory-allocation.png" alt="Difference in memory used"&gt;&lt;/p&gt;
&lt;h2 id="analyzer"&gt;Analyzer&lt;/h2&gt;
&lt;p&gt;If performance is critical for your project, you should pay attention to these situations. Automatic diagnostics can help.
I maintain a set of diagnostic tools based on Roslyn – &lt;a href="https://www.nuget.org/packages/Collections.Analyzer"&gt;Collections.Analyzer&lt;/a&gt;, and now it can detect
lists with a collection initializer and without an initial capacity.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.rogatnev.net/img/list/list-initializer.gif" alt="Automatically specify the initial list size"&gt;&lt;/p&gt;
&lt;p&gt;It's important to understand that the analyzer only sets the initial collection size to avoid unnecessary allocations during the initial collection phase.
He cannot predict how the size of the collection will change in the future.&lt;/p&gt;
&lt;h1 id="recommendations"&gt;Recommendations&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;If you need a static collection that you won't modify (add or remove elements), use arrays.&lt;/li&gt;
&lt;li&gt;If you are creating a list and exactly know its future size, set the initial capacity to that size.&lt;/li&gt;
&lt;li&gt;If you are creating a list and don't know its future size, set the &lt;strong&gt;expected&lt;/strong&gt; size.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1 id="links"&gt;Links&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/object-and-collection-initializers#collection-initializers"&gt;Collection initializers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Backs/Collections.Analyzer"&gt;Collections.Analyzer&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded>
			<comments xmlns="http://purl.org/rss/1.0/modules/slash/">0</comments>
		</item>
		<item>
			<title>Удаляем пробелы из строки</title>
			<link>https://blog.rogatnev.net/posts/ru/2025/11/Remove-Whitespaces.html</link>
			<description>Рассматриваем разные подходы к удалению пробельных символов из строк C# - сравнение методов.</description>
			<author>Сергей Рогатнев</author>
			<guid>https://blog.rogatnev.net/posts/ru/2025/11/Remove-Whitespaces.html</guid>
			<pubDate>Thu, 20 Nov 2025 00:00:00 GMT</pubDate>
			<content:encoded>&lt;p&gt;Еще одна популярная задача при работе со строками — удалить из них пробельные символы.
Можно представить, что нам нужно очистить пользовательский ввод (удалить пробелы вначале и конце строк в имени) или минифицировать json-объект.
.NET предоставляет нам несколько возможностей для решения этой задачи, давайте рассмотрим самые популярные и попробуем найти наиболее эффективные.
Заодно проверим, какие изменения произошли в новой версии .NET 10.&lt;/p&gt;
&lt;!--more--&gt;
&lt;p&gt;Для нашего примера будем считать пробельными символами сам пробел (&lt;code&gt; &lt;/code&gt;), табуляцию (&lt;code&gt;\t&lt;/code&gt;) и перевод строки (&lt;code&gt;\n&lt;/code&gt;).
На самом деле их больше, но для нашей задачи ограничимся только этими как самыми популярными.&lt;/p&gt;
&lt;p&gt;Для бенчмарка я буду использовать самые новые и одни из самых старых версий фреймворка.
Вряд ли для новых проектов выберут .NET Framework 4.8 для старта нового проекта, но интересно увидеть, как развивалась платформа во времени и что можно получить с переходом на новые версии.&lt;/p&gt;
&lt;h1 id="replace"&gt;Replace&lt;/h1&gt;
&lt;p&gt;Начнем с самого простого метода — &lt;code&gt;Replace&lt;/code&gt;. Он может заменить нам все вхождения какого-то символа или подстроки на другой символ или подстроку.
Для наших целей мы можем заменить пробельные символы на пустую строку (&lt;code&gt;string.Empty&lt;/code&gt;). Но есть большой недостаток - этот метод не может заменить все пробельные символы сразу, поэтому мы вынуждены вызывать его для каждого пробельного символа:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;private static string Replace(string s)
{
    return s.Replace(" ", string.Empty).Replace("\t", string.Empty).Replace("\n", string.Empty);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Посмотрим, как этот метод ведет себя в разных версиях .NET.&lt;/p&gt;
&lt;p&gt;Полный код бенчмарка и все результаты вы найдете в конце статьи.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-markdown"&gt;| Method           | Runtime            |        Mean |      Median | Allocated |
|------------------|--------------------|------------:|------------:|----------:|
| Replace          | .NET Framework 4.8 | 230.9093 ns | 227.7122 ns |     233 B |
| Replace          | .NET Core 3.1      | 158.6589 ns | 158.1439 ns |     216 B |
| Replace          | .NET 8.0           | 178.9700 ns | 177.7641 ns |     216 B |
| Replace          | .NET 10.0          | 157.4850 ns | 156.3763 ns |     216 B |
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Если посмотреть на размер итоговой строки, то мы тратим примерно в 3 раза больше памяти.
Очевидно, из-за того, что мы 3 раза вызываем метод &lt;code&gt;Replace()&lt;/code&gt;, который создаёт новую строку. Из неё мы опять удаляем символы, что опять создаёт новую строку.
И мы видим незначительное улучшение с каждой новой версией .NET.&lt;/p&gt;
&lt;p&gt;И если в версии .NET 4.8 реализация метода &lt;code&gt;Replace()&lt;/code&gt; находится внутри Common Language Runtime (CLR):&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;[SecuritySafeCritical]
[MethodImpl(MethodImplOptions.InternalCall)]
private extern string ReplaceInternal(string oldValue, string newValue);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;То уже начиная с .NET Core 3.1 используется собственная реализация на указателях, а позже — с использованием типа &lt;code&gt;Span&lt;/code&gt; и векторных инструкций.&lt;/p&gt;
&lt;p&gt;К недостатку этого метода можно отнести то, что нужно явно перечислить все пробельные символы. А их, на самом деле, не так мало.
Каждый новый символ - это лишний вызов метода &lt;code&gt;Replace&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Наверняка, должны быть более оптимальные решения.&lt;/p&gt;
&lt;h1 id="regex"&gt;Regex&lt;/h1&gt;
&lt;p&gt;Регулярные выражения — универсальный способ решения для многих задач. И здесь он тоже применим.
Как я говорил в предыдущем методе, мы не можем с помощью метода &lt;code&gt;Replace()&lt;/code&gt; заменить все пробельные символы. А вот с помощью регулярных выражений можем:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;private static readonly Regex EmptySpacesCompiled = new Regex(@"\s+", RegexOptions.Compiled);

private static string RegexCompiled(string s)
{
    return EmptySpacesCompiled.Replace(s, string.Empty);
}

private static readonly Regex EmptySpacesGenerated = GenerateRegex();

[GeneratedRegex(@"\s+")]
private static partial Regex GenerateRegex();

private static string RegexGenerated(string s)
{
    return EmptySpacesGenerated.Replace(s, string.Empty);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;\s&lt;/code&gt; - специальное выражение, которое объединяет широкий набор пробельных символов, в том числе &lt;code&gt;\t&lt;/code&gt;, &lt;code&gt;\n&lt;/code&gt;, &lt;code&gt; &lt;/code&gt;.
Поэтому расширим задачу и дальше переходим к более общему варианту, где под пробельными понимаются все символы, которые &lt;code&gt;char.IsWhiteSpace&lt;/code&gt; или &lt;code&gt;\s&lt;/code&gt; считают пробельными.
Таким образом, мы не пропустим ничего, как в случае с методом &lt;code&gt;Replace&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Мы уже знаем по &lt;a href="xref:2025-06-07-String-of-Digits"&gt;предыдущей статье&lt;/a&gt;, опция &lt;code&gt;Compiled&lt;/code&gt; положительно сказывается на производительности, будем указывать её.
Заодно проверим, как работает &lt;code&gt;GeneratedRegex&lt;/code&gt; в этом случае.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;| Method         | Runtime            |        Mean |      Median | Allocated |
|----------------|--------------------|------------:|------------:|----------:|
| RegexCompiled  | .NET Framework 4.8 | 872.0395 ns | 856.0633 ns |    1115 B |
| RegexGenerated | .NET Framework 4.8 |         N/A |         N/A |       N/A |
| RegexCompiled  | .NET Core 3.1      | 709.4504 ns | 696.5562 ns |     896 B |
| RegexGenerated | .NET Core 3.1      |         N/A |         N/A |       N/A |
| RegexCompiled  | .NET 8.0           | 202.2732 ns | 202.0966 ns |      64 B |
| RegexGenerated | .NET 8.0           | 191.2238 ns | 188.5849 ns |      64 B |
| RegexCompiled  | .NET 10.0          | 183.2023 ns | 181.1374 ns |      64 B |
| RegexGenerated | .NET 10.0          | 138.8752 ns | 138.8915 ns |      64 B |
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Если в старых версиях фреймворка это решение не выдерживает сравнения даже с примитивным &lt;code&gt;Replace()&lt;/code&gt;, то в 8-й и 10-й версии мы видим значительное улучшение.&lt;/p&gt;
&lt;p&gt;А самое интересное — обратите внимание на потребление памяти. Всего 64 байта — итоговая строка. Это минимум, который можно получить в данном случае.
Дело в том, что в новых версиях .NET &lt;code&gt;Regex&lt;/code&gt; собирает итоговую строку из "кусочков" исходной без использования промежуточных состояний.
За счет этого удаётся избежать дополнительной аллокации.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.rogatnev.net/img/remove-whitespaces/replace-regex.png" alt="Сравнение методов Replace и Regex"&gt;&lt;/p&gt;
&lt;p&gt;Удивительно, насколько оптимизированными стали регулярные выражения - мы видим кратный рост производительности и уменьшение использования памяти.&lt;/p&gt;
&lt;p&gt;Но попробуем поискать более быстрый способ очистки строк.&lt;/p&gt;
&lt;h1 id="split-concat"&gt;Split + Concat&lt;/h1&gt;
&lt;p&gt;Еще один из способов удалить все пробелы — выделить из строки только непрерывные последовательности не пробельных символов, а потом собрать их в новую строку.&lt;/p&gt;
&lt;p&gt;Это можно сделать с помощью метода &lt;code&gt;Split&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;private static string SplitConcat(string s)
{
    var parts = s.Split((char[])null, StringSplitOptions.RemoveEmptyEntries);
    return string.Concat(parts);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Передавая &lt;code&gt;null&lt;/code&gt; в качестве первого параметра мы как раз хотим разделить нашу исходную строку по пробельным символам с использованием метода &lt;code&gt;char.IsWhiteSpace&lt;/code&gt;.
Этот метод работает с более широким набором пробельных символов, чем те, что мы описывали вначале.
И указываем опцию &lt;code&gt;RemoveEmptyEntries&lt;/code&gt; чтобы в результирующем массиве &lt;code&gt;parts&lt;/code&gt; не оказалось пустых строк.&lt;/p&gt;
&lt;p&gt;Проведем бенчмарк и сравним с предыдущими результатами. Для наглядности оставим только последние версии фреймворка.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-markdown"&gt;| Method         | Runtime            |        Mean |      Median | Allocated |
|----------------|--------------------|------------:|------------:|----------:|
| Replace        | .NET 8.0           | 178.9700 ns | 177.7641 ns |     216 B |
| RegexCompiled  | .NET 8.0           | 202.2732 ns | 202.0966 ns |      64 B |
| RegexGenerated | .NET 8.0           | 191.2238 ns | 188.5849 ns |      64 B |
| SplitConcat    | .NET 8.0           |  97.6117 ns |  97.6992 ns |     376 B |
| Replace        | .NET 10.0          | 157.4850 ns | 156.3763 ns |     216 B |
| RegexCompiled  | .NET 10.0          | 183.2023 ns | 181.1374 ns |      64 B |
| RegexGenerated | .NET 10.0          | 138.8752 ns | 138.8915 ns |      64 B |
| SplitConcat    | .NET 10.0          |  90.2844 ns |  90.6640 ns |     376 B |
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src="https://blog.rogatnev.net/img/remove-whitespaces/split-concat.png" alt="Сравнение всех методов со Split+Concat"&gt;&lt;/p&gt;
&lt;p&gt;На данный момент этот способ является самым производительным во всех фреймворках.
Но, к сожалению, он потребляет неприлично много памяти. Даже больше, чем трехкратный вызов метода &lt;code&gt;Replace()&lt;/code&gt;.
И большая часть этих затрат приходится на вызов метода &lt;code&gt;Split()&lt;/code&gt;. Как мы уже знаем, результирующая строка занимает всего 64 байта, а значит 312 байт мы тратим на массив &lt;code&gt;parts&lt;/code&gt;.
Это большой симптом того, что мы выбрали некорректный способ для реализации задачи. Ведь действительно, сам по себе массив &lt;code&gt;parts&lt;/code&gt; нам не нужен.
Это только промежуточный результат, который мы потом должны объединить в строку, а сам массив отбросить.&lt;/p&gt;
&lt;h1 id="linq"&gt;LINQ&lt;/h1&gt;
&lt;p&gt;Мы можем ускориться и по возможности не создавать лишних объектов.
Еще один популярный вариант:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;private static string Linq(string s)
{
    return new string(s.Where(c =&amp;gt; !char.IsWhiteSpace(c)).ToArray());
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Берем из нашей исходной строки только не пробельные символы и создаём из них новую строку.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-markdown"&gt;| Method           | Runtime            |        Mean |      Median | Allocated |
|------------------|--------------------|------------:|------------:|----------:|
| Linq             | .NET Framework 4.8 | 455.1868 ns | 457.0586 ns |     449 B |
| Linq             | .NET Core 3.1      | 331.2851 ns | 331.8324 ns |     448 B |
| Linq             | .NET 8.0           | 166.4657 ns | 165.9890 ns |     448 B |
| Linq             | .NET 10.0          |  70.9343 ns |  71.1085 ns |     192 B |
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;В общей сводной таблице он есть, но в рекомендациях не рассматривается из‑за аллокаций.
На это влияет необходимость создания какого-то промежуточного массива для символов без пробелов.&lt;/p&gt;
&lt;h1 id="array-buffer"&gt;Array buffer&lt;/h1&gt;
&lt;p&gt;Если нам всё равно нужно создавать какой-то массив, сделаем это сами заранее.
Подготовим массив символов длинной исходной строки, заполним его символами без пробелов и создадим из него новую строку:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;private static string Buffer(string s)
{
    var buffer = new char[s.Length];
    var index = 0;

    foreach (var c in s)
    {
        if (!char.IsWhiteSpace(c))
        {
            buffer[index++] = c;
        }
    }

    return new string(buffer, 0, index);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;И посмотрим на результаты:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-markdown"&gt;| Method           | Runtime            |        Mean |      Median | Allocated |
|------------------|--------------------|------------:|------------:|----------:|
| Buffer           | .NET Framework 4.8 |  72.8121 ns |  73.0219 ns |     168 B |
| Buffer           | .NET Core 3.1      |  56.3518 ns |  55.7937 ns |     160 B |
| Buffer           | .NET 8.0           |  38.5226 ns |  38.5226 ns |     160 B |
| Buffer           | .NET 10.0          |  31.0592 ns |  31.1676 ns |     160 B |
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Выглядит впечатляюще - лучший метод по производительности на данный момент:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.rogatnev.net/img/remove-whitespaces/array-buffer.png" alt="Производительность метода Buffer"&gt;&lt;/p&gt;
&lt;p&gt;А главное, мы видим стабильное потребление памяти: только затраты на буфер и новую строку.&lt;/p&gt;
&lt;p&gt;Но есть еще один трюк, который нам поможет улучшить результаты.&lt;/p&gt;
&lt;h1 id="stackalloc-array-buffer"&gt;Stackalloc array buffer&lt;/h1&gt;
&lt;p&gt;Как мы знаем, такие типы данных как массивы, хранятся в управляемой куче (как раз эти данные попадают в метрику &lt;code&gt;Allocated&lt;/code&gt; в бенчмарках).
И доступ к этим данным несколько медленнее, чем к тем, что хранятся на стеке.&lt;/p&gt;
&lt;p&gt;Обычно мы не управляем тем, где будут размещаться данные, но в .NET есть специальное выражение &lt;a href="https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/stackalloc"&gt;&lt;code&gt;stackalloc&lt;/code&gt;&lt;/a&gt;, с помощью него можно выделить блок памяти прямо на стеке:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;private static unsafe string StackallocBuffer(string s)
{
    var buffer = stackalloc char[s.Length];
    var index = 0;

    foreach (var c in s)
    {
        if (!char.IsWhiteSpace(c))
        {
            buffer[index++] = c;
        }
    }

    return new string(buffer, 0, index);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;И запустим бенчмарк:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-markdown"&gt;| Method           | Runtime            |        Mean |      Median | Allocated |
|------------------|--------------------|------------:|------------:|----------:|
| StackallocBuffer | .NET Framework 4.8 |  72.5095 ns |  70.6909 ns |      72 B |
| StackallocBuffer | .NET Core 3.1      |  50.3178 ns |  49.4475 ns |      64 B |
| StackallocBuffer | .NET 8.0           |  32.9556 ns |  32.7873 ns |      64 B |
| StackallocBuffer | .NET 10.0          |  26.7175 ns |  26.4192 ns |      64 B |
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Теперь наш временный массив &lt;code&gt;buffer&lt;/code&gt; создаётся не в общей куче, от чего получается еще небольшая прибавка в производительности и минимальное потребление памяти.&lt;/p&gt;
&lt;p&gt;Но нужно учитывать, что размер стека ограничен и очень большие массивы создать не получится - получим &lt;code&gt;StackOverflowException&lt;/code&gt;. Размер стека по-умолчанию равен 1 МБ и нужно учитывать это ограничение.
Для коротких строк (десятки-сотни символов) это безопасно, для потенциально больших строк лучше оставить вариант с кучей.&lt;/p&gt;
&lt;h1 id="section"&gt;Заключение&lt;/h1&gt;
&lt;p&gt;Как обычно мы рассмотрели самые популярные способы очистки строк от пробельных символов. Все они дают одинаковый результат, но используют разные механизмы.
Если эта операция является частотной в вашем сценарии, то посмотрите, насколько оптимальный вариант вы используете.
Виден значительный прогресс в каждой версии .NET и, в отличие от проверки строки на числа, здесь нет каких-то подводных камней.&lt;/p&gt;
&lt;p&gt;Еще в общем бенчмарке есть вариант с использованием &lt;code&gt;StringBuilder&lt;/code&gt;, но фактически он очень похож на решение с &lt;code&gt;Buffer&lt;/code&gt; - также проходит строку один раз и накапливает символы без пробелов во внутренний буфер, поэтому подробно я про него не пишу, но можно посмотреть на результаты в таблице.&lt;/p&gt;
&lt;p&gt;Рекомендации:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Если для вас важна простота решения и его читабельность, то подходят разные варианты &lt;code&gt;Regex&lt;/code&gt; и метод &lt;code&gt;Replace&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Для "горячих" мест используйте &lt;code&gt;Buffer&lt;/code&gt; или &lt;code&gt;StackallocBuffer&lt;/code&gt; в зависимости от ограничений на длину строки. Обычно это какие-то парсеры, логирование, нормализация входящих запросов и т.п.&lt;/li&gt;
&lt;li&gt;Для старых версий фреймворка избегайте использования регулярных выражений для простых шаблонов - это будет не эффективно.&lt;/li&gt;
&lt;/ul&gt;
&lt;details&gt;
&lt;summary&gt;Полный код бенчмарка. Нажмите, чтобы развернуть.&lt;/summary&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;using System;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;

namespace WhitespacesBenchmark
{
[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net10_0)]
[SimpleJob(RuntimeMoniker.Net80)]
[SimpleJob(RuntimeMoniker.NetCoreApp31)]
[SimpleJob(RuntimeMoniker.Net48)]
[HideColumns("Job", "Error", "StdDev", "Gen0")]
public partial class RemoveWhitespacesBenchmark
{
        private const string Value = " \tString  \t with \n\n\t\t whitespaces \t";

        [Benchmark]
        public string Replace()
        {
            return Replace(Value);
        }

        private static string Replace(string s)
        {
            return s.Replace(" ", string.Empty).Replace("\t", string.Empty).Replace("\n", string.Empty);
        }

        [Benchmark]
        public string SplitConcat()
        {
            return SplitConcat(Value);
        }

        private static string SplitConcat(string s)
        {
            var parts = s.Split((char[])null, StringSplitOptions.RemoveEmptyEntries);
            return string.Concat(parts);
        }

        [Benchmark]
        public string Linq()
        {
            return Linq(Value);
        }

        private static string Linq(string s)
        {
            return new string(s.Where(c =&amp;gt; !char.IsWhiteSpace(c)).ToArray());
        }

        private static readonly Regex EmptySpacesCompiled = new Regex(@"\s+", RegexOptions.Compiled);

#if NET7_0_OR_GREATER
        private static readonly Regex EmptySpacesGenerated = GenerateRegex();

        [GeneratedRegex(@"\s+")]
        private static partial Regex GenerateRegex();
#endif

        [Benchmark]
        public string RegexCompiled()
        {
            return RegexCompiled(Value);
        }

        private static string RegexCompiled(string s)
        {
            return EmptySpacesCompiled.Replace(s, string.Empty);
        }

        [Benchmark]
        public string RegexGenerated()
        {
            return RegexGenerated(Value);
        }

        private static string RegexGenerated(string s)
        {
#if NET7_0_OR_GREATER
            return EmptySpacesGenerated.Replace(s, string.Empty);
#else
            return s;
#endif
        }

        [Benchmark]
        public string StringBuilder()
        {
            return StringBuilder(Value);
        }

        private static string StringBuilder(string s)
        {
            var sb = new StringBuilder(s.Length);

            foreach (var c in s)
            {
                if (!char.IsWhiteSpace(c))
                {
                    sb.Append(c);
                }
            }

            return sb.ToString();
        }

        [Benchmark]
        public string Buffer()
        {
            return Buffer(Value);
        }

        private static string Buffer(string s)
        {
            var buffer = new char[s.Length];
            var index = 0;

            foreach (var c in s)
            {
                if (!char.IsWhiteSpace(c))
                {
                    buffer[index++] = c;
                }
            }

            return new string(buffer, 0, index);
        }

        [Benchmark]
        public string StackallocBuffer()
        {
            return StackallocBuffer(Value);
        }

        private static unsafe string StackallocBuffer(string s)
        {
            var buffer = stackalloc char[s.Length];
            var index = 0;

            foreach (var c in s)
            {
                if (!char.IsWhiteSpace(c))
                {
                    buffer[index++] = c;
                }
            }

            return new string(buffer, 0, index);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;details&gt;
&lt;summary&gt;Все результаты бенчмарков. Нажмите, чтобы развернуть.&lt;/summary&gt;
&lt;pre&gt;&lt;code&gt;BenchmarkDotNet v0.15.6, Windows 10 (10.0.19045.6456/22H2/2022Update)
AMD Ryzen 7 7840H with Radeon 780M Graphics 3.80GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 10.0.100
[Host]             : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v4
.NET 10.0          : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v4
.NET 8.0           : .NET 8.0.22 (8.0.22, 8.0.2225.52707), X64 RyuJIT x86-64-v4
.NET Core 3.1      : .NET Core 3.1.32 (3.1.32, 4.700.22.55902), X64 RyuJIT VectorSize=256
.NET Framework 4.8 : .NET Framework 4.8.1 (4.8.9310.0), X64 RyuJIT VectorSize=256


| Method           | Runtime            |        Mean |      Median | Allocated |
|------------------|--------------------|------------:|------------:|----------:|
| Replace          | .NET Framework 4.8 | 230.9093 ns | 227.7122 ns |     233 B |
| SplitConcat      | .NET Framework 4.8 | 164.1033 ns | 161.6289 ns |     610 B |
| Linq             | .NET Framework 4.8 | 455.1868 ns | 457.0586 ns |     449 B |
| RegexCompiled    | .NET Framework 4.8 | 872.0395 ns | 856.0633 ns |    1115 B |
| RegexGenerated   | .NET Framework 4.8 |         N/A |         N/A |       N/A |
| StringBuilder    | .NET Framework 4.8 | 115.0854 ns | 115.5498 ns |     217 B |
| Buffer           | .NET Framework 4.8 |  72.8121 ns |  73.0219 ns |     168 B |
| StackallocBuffer | .NET Framework 4.8 |  72.5095 ns |  70.6909 ns |      72 B |
| Replace          | .NET Core 3.1      | 158.6589 ns | 158.1439 ns |     216 B |
| SplitConcat      | .NET Core 3.1      | 122.7668 ns | 120.7053 ns |     376 B |
| Linq             | .NET Core 3.1      | 331.2851 ns | 331.8324 ns |     448 B |
| RegexCompiled    | .NET Core 3.1      | 709.4504 ns | 696.5562 ns |     896 B |
| RegexGenerated   | .NET Core 3.1      |         N/A |         N/A |       N/A |
| StringBuilder    | .NET Core 3.1      |  88.3582 ns |  88.6698 ns |     208 B |
| Buffer           | .NET Core 3.1      |  56.3518 ns |  55.7937 ns |     160 B |
| StackallocBuffer | .NET Core 3.1      |  50.3178 ns |  49.4475 ns |      64 B |
| Replace          | .NET 8.0           | 178.9700 ns | 177.7641 ns |     216 B |
| SplitConcat      | .NET 8.0           |  97.6117 ns |  97.6992 ns |     376 B |
| Linq             | .NET 8.0           | 166.4657 ns | 165.9890 ns |     448 B |
| RegexCompiled    | .NET 8.0           | 202.2732 ns | 202.0966 ns |      64 B |
| RegexGenerated   | .NET 8.0           | 191.2238 ns | 188.5849 ns |      64 B |
| StringBuilder    | .NET 8.0           |  50.1718 ns |  50.4215 ns |     208 B |
| Buffer           | .NET 8.0           |  38.5226 ns |  38.5226 ns |     160 B |
| StackallocBuffer | .NET 8.0           |  32.9556 ns |  32.7873 ns |      64 B |
| Replace          | .NET 10.0          | 157.4850 ns | 156.3763 ns |     216 B |
| SplitConcat      | .NET 10.0          |  90.2844 ns |  90.6640 ns |     376 B |
| Linq             | .NET 10.0          |  70.9343 ns |  71.1085 ns |     192 B |
| RegexCompiled    | .NET 10.0          | 183.2023 ns | 181.1374 ns |      64 B |
| RegexGenerated   | .NET 10.0          | 138.8752 ns | 138.8915 ns |      64 B |
| StringBuilder    | .NET 10.0          |  37.4249 ns |  37.6158 ns |     208 B |
| Buffer           | .NET 10.0          |  31.0592 ns |  31.1676 ns |     160 B |
| StackallocBuffer | .NET 10.0          |  26.7175 ns |  26.4192 ns |      64 B |
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Отметка &lt;code&gt;N/A&lt;/code&gt; ставится для методов, которые не существуют в данной версии фреймворка.&lt;/li&gt;
&lt;/ul&gt;
&lt;/details&gt;
</content:encoded>
			<comments xmlns="http://purl.org/rss/1.0/modules/slash/">0</comments>
		</item>
		<item>
			<title>Инициализатор коллекций: List&lt;T&gt;</title>
			<link>https://blog.rogatnev.net/posts/ru/2025/09/List-initializer.html</link>
			<description>Оцените влияние предварительной инициализации списка на производительность и работу с памятью в приложении.</description>
			<author>Сергей Рогатнев</author>
			<guid>https://blog.rogatnev.net/posts/ru/2025/09/List-initializer.html</guid>
			<pubDate>Sat, 27 Sep 2025 00:00:00 GMT</pubDate>
			<content:encoded>&lt;p&gt;Мы все привыкли писать &lt;code&gt;new List&amp;lt;int&amp;gt; { 1, 2, 3, 4 }&lt;/code&gt; или &lt;code&gt;new int[] { 1, 2, 3, 4}&lt;/code&gt;, чтобы инициализировать коллекции какими-то значениями. Синтаксически это выглядит похоже, но поведение отличается, и вам следует быть осторожными, если вы заботитесь о производительности.&lt;/p&gt;
&lt;!--more--&gt;
&lt;h1 id="section"&gt;Массив&lt;/h1&gt;
&lt;p&gt;Как мы знаем, &lt;a href="https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/arrays"&gt;массивы&lt;/a&gt; содержат последовательность элементов фиксированного размера. После создания размер нельзя изменить в течение всего времени жизни массива.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var array = new int[] { 1, 2, 3, 4};
&lt;/code&gt;&lt;/pre&gt;
&lt;h1 id="listt"&gt;List&amp;lt;T&amp;gt;&lt;/h1&gt;
&lt;p&gt;Когда мы не знаем конечный размер коллекции или нам нужно добавлять/удалять элементы в течение ее жизненного цикла, подходит использование тип &lt;a href="https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.list-1?view=net-8.0"&gt;&lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var list = new List&amp;lt;int&amp;gt;();
list.Add(1);
list.Add(2);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;На первый взгляд может показаться, что мы всегда можем использовать список вместо массива — у него есть все возможности массива, но его также можно динамически изменять.
Но чтобы решить, использовать ли список, нам нужно больше узнать о его внутренней структуре.&lt;/p&gt;
&lt;p&gt;Часть исходного кода:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public class List&amp;lt;T&amp;gt; : IList&amp;lt;T&amp;gt;, IList, IReadOnlyList&amp;lt;T&amp;gt;
{
    private const int DefaultCapacity = 4;

    internal T[] _items;
    internal int _size;

    private static readonly T[] s_emptyArray = new T[0];

    // Constructs a List. The list is initially empty and has a capacity
    // of zero. Upon adding the first element to the list the capacity is
    // increased to DefaultCapacity, and then increased in multiples of two
    // as required.
    public List()
    {
        _items = s_emptyArray;
    }
    ...

    public int Capacity
    {
        get =&amp;gt; _items.Length;
        set {...}
    }

    public int Count =&amp;gt; _size;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;_items&lt;/code&gt; - внутренний массив для хранения элементов;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_size&lt;/code&gt; - количество элементов в массиве и общий размер списка;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Capacity&lt;/code&gt; - размер массива &lt;code&gt;_items&lt;/code&gt; и максимальное количество элементов, которые могут в него поместиться без изменения размера.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="section-1"&gt;Изменение размера списка&lt;/h2&gt;
&lt;p&gt;Проще говоря, можно сказать, что список — это массив, который может изменять размер при необходимости.&lt;/p&gt;
&lt;p&gt;Каждый раз, когда мы пытаемся добавить еще один элемент, список проверяет, достаточно ли в &lt;code&gt;_items&lt;/code&gt; свободного места, в противном случае он устанавливает новую емкость:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;internal void Grow(int capacity)
{
    ...
    int newCapacity = _items.Length == 0 ? DefaultCapacity : 2 * _items.Length;
    ...
    Capacity = newCapacity;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Давайте посмотрим поближе на свойство &lt;code&gt;Capacity&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;// Gets and sets the capacity of this list.  The capacity is the size of
// the internal array used to hold items.  When set, the internal
// array of the list is reallocated to the given capacity.
public int Capacity
{
    get =&amp;gt; _items.Length;
    set
    {
        if (value &amp;lt; _size)
        {
            ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.value, ExceptionResource.ArgumentOutOfRange_SmallCapacity);
        }

        if (value != _items.Length)
        {
            if (value &amp;gt; 0)
            {
                T[] newItems = new T[value];
                if (_size &amp;gt; 0)
                {
                    Array.Copy(_items, newItems, _size);
                }
                _items = newItems;
            }
            else
            {
                _items = s_emptyArray;
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Итак, что мы видим? Изначально каждый список создается с пустым внутренним массивом. После добавления первого элемента список создает новый массив на 4 элемента (&lt;code&gt;DefaultCapacity&lt;/code&gt; равно 4). И когда текущий массив исчерпан, создается новый с удвоенным размером, и все элементы копируются.&lt;/p&gt;
&lt;h1 id="section-2"&gt;Производительность&lt;/h1&gt;
&lt;p&gt;Что происходит, когда мы создаем новый список и инициализируем его значениями?&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var list = new List&amp;lt;int&amp;gt; { 1, 2, 3, 4, 5 };
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Это выглядит как инициализация массива, но работает совершенно по-другому. Согласно &lt;a href="https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/object-and-collection-initializers#collection-initializers"&gt;документации&lt;/a&gt;, любой тип, который реализует &lt;code&gt;IEnumerable&lt;/code&gt; и имеет метод &lt;code&gt;Add&lt;/code&gt; может использоваться с инициализатором коллекции.&lt;/p&gt;
&lt;p&gt;Таким образом, предыдущий пример — это просто краткая форма последовательных вызовов метода &lt;code&gt;Add&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var list = new List&amp;lt;int&amp;gt;();
list.Add(1);
list.Add(2);
list.Add(3);
list.Add(4);
list.Add(5);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Компилятор просто преобразует короткий инициализатор коллекции и автоматически добавляет необходимые вызовы. И это может вызвать снижение производительности.&lt;/p&gt;
&lt;p&gt;Давайте подробнее рассмотрим процесс добавления 5 элементов:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Перед первым вызовом &lt;code&gt;Add&lt;/code&gt; список пуст, внутренний массив пуст.&lt;/li&gt;
&lt;li&gt;Первый добавленный элемент создает новый внутренний массив на 4 элемента.&lt;/li&gt;
&lt;li&gt;Элементы 2, 3, 4 при добавлении ничего не меняют.&lt;/li&gt;
&lt;li&gt;Когда мы добавляем пятый элемент, внутренний массив заполнен и требует изменения размера. Создается новый массив размером 8, все элементы копируются из предыдущего массива, и добавляется пятый элемент.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;В итоге у нас есть список с 5 элементами и внутренний массив на 8 элементов. При этом мы создали 2 массива, и конечный тратит 37.5% своего пространства впустую.
Как вы могли догадаться, создание новых массивов и копирование элементов приводит к выделению памяти и занимает дополнительное время.&lt;/p&gt;
&lt;p&gt;Это может стать неприятным сюрпризом в критически важных местах. Есть ли у нас решение? Да!&lt;/p&gt;
&lt;h2 id="capacity"&gt;Capacity&lt;/h2&gt;
&lt;p&gt;Если мы знаем или предполагаем конечный размер списка, мы можем создать его с начальной емкостью (&lt;code&gt;Capacity&lt;/code&gt;).&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var list = new List&amp;lt;int&amp;gt;(5);
list.Add(1);
list.Add(2);
list.Add(3);
list.Add(4);
list.Add(5);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Или&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var list = new List&amp;lt;int&amp;gt;(5) { 1, 2, 3, 4, 5 };
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Теперь мы сразу создаем один внутренний массив на 5 элементов и больше нет никаких ненужных выделений памяти.&lt;/p&gt;
&lt;h2 id="section-3"&gt;Бенчмарк&lt;/h2&gt;
&lt;p&gt;Давайте сравним создание списков с начальной емкостью и без нее.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Код бенчмарка. Нажмите, чтобы развернуть.&lt;/summary&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;using System.Collections.Generic;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;

namespace ListBenchmark
{
    [ShortRunJob(RuntimeMoniker.Net48)]
    [ShortRunJob(RuntimeMoniker.NetCoreApp31)]
    [ShortRunJob(RuntimeMoniker.Net80)]
    [ShortRunJob(RuntimeMoniker.Net10_0)]
    [MemoryDiagnoser]
    [HideColumns("Job", "Error", "StdDev", "Gen0")]
    public class InitListBenchmark
    {
        [BenchmarkCategory("One")]
        [Benchmark]
        public List&amp;lt;int&amp;gt; InitList1()
        {
            return new List&amp;lt;int&amp;gt; {1};
        }

        [BenchmarkCategory("One")]
        [Benchmark]
        public List&amp;lt;int&amp;gt; InitListWithSize1()
        {
            return new List&amp;lt;int&amp;gt;(1) {1};
        }

        [BenchmarkCategory("Five")]
        [Benchmark]
        public List&amp;lt;int&amp;gt; InitList5()
        {
            return new List&amp;lt;int&amp;gt; {1, 2, 3, 4, 5};
        }
        
        [BenchmarkCategory("Five")]
        [Benchmark]
        public List&amp;lt;int&amp;gt; InitListWithSize5()
        {
            return new List&amp;lt;int&amp;gt;(5) {1, 2, 3, 4, 5};
        }
        
        [BenchmarkCategory("Ten")]
        [Benchmark]
        public List&amp;lt;int&amp;gt; InitList10()
        {
            return new List&amp;lt;int&amp;gt; {1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
        }
        
        [BenchmarkCategory("Ten")]
        [Benchmark]
        public List&amp;lt;int&amp;gt; InitListWithSize10()
        {
            return new List&amp;lt;int&amp;gt;(10) {1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;pre&gt;&lt;code class="language-markdown"&gt;BenchmarkDotNet v0.15.6, Windows 10 (10.0.19045.6456/22H2/2022Update)
AMD Ryzen 7 7840H with Radeon 780M Graphics 3.80GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 10.0.100
[Host]                      : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v4
ShortRun-.NET 10.0          : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v4
ShortRun-.NET 8.0           : .NET 8.0.22 (8.0.22, 8.0.2225.52707), X64 RyuJIT x86-64-v4
ShortRun-.NET Core 3.1      : .NET Core 3.1.32 (3.1.32, 4.700.22.55902), X64 RyuJIT VectorSize=256
ShortRun-.NET Framework 4.8 : .NET Framework 4.8.1 (4.8.9310.0), X64 RyuJIT VectorSize=256

IterationCount=3  LaunchCount=1  WarmupCount=3

| Method             | Runtime            |      Mean | Allocated |
|--------------------|--------------------|----------:|----------:|
| InitList1          | .NET Framework 4.8 | 13.904 ns |      80 B |
| InitListWithSize1  | .NET Framework 4.8 |  6.658 ns |      72 B |
| InitList1          | .NET Core 3.1      | 11.091 ns |      72 B |
| InitListWithSize1  | .NET Core 3.1      |  7.407 ns |      64 B |
| InitList1          | .NET 8.0           | 10.084 ns |      72 B |
| InitListWithSize1  | .NET 8.0           |  6.838 ns |      64 B |
| InitList1          | .NET 10.0          |  8.170 ns |      72 B |
| InitListWithSize1  | .NET 10.0          |  6.638 ns |      64 B |
| InitList5          | .NET Framework 4.8 | 31.298 ns |     136 B |
| InitListWithSize5  | .NET Framework 4.8 | 12.013 ns |      88 B |
| InitList5          | .NET Core 3.1      | 26.466 ns |     128 B |
| InitListWithSize5  | .NET Core 3.1      |  9.446 ns |      80 B |
| InitList5          | .NET 8.0           | 23.714 ns |     128 B |
| InitListWithSize5  | .NET 8.0           | 15.587 ns |      80 B |
| InitList5          | .NET 10.0          | 20.002 ns |     128 B |
| InitListWithSize5  | .NET 10.0          |  8.712 ns |      80 B |
| InitList10         | .NET Framework 4.8 | 53.488 ns |     225 B |
| InitListWithSize10 | .NET Framework 4.8 | 18.185 ns |     104 B |
| InitList10         | .NET Core 3.1      | 44.371 ns |     216 B |
| InitListWithSize10 | .NET Core 3.1      | 12.496 ns |      96 B |
| InitList10         | .NET 8.0           | 38.707 ns |     216 B |
| InitListWithSize10 | .NET 8.0           | 12.024 ns |      96 B |
| InitList10         | .NET 10.0          | 33.854 ns |     216 B |
| InitListWithSize10 | .NET 10.0          | 15.822 ns |      96 B |
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Когда мы устанавливаем начальную емкость, это не приводит к ненужным выделениям памяти, и мы видим лучшую производительность.
И нет избыточного трафика памяти. Чем больше элементов мы добавляем в список, тем большую разницу мы видим в бенчмарках.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.rogatnev.net/img/list/memory-allocation.png" alt="Разница в используемой памяти"&gt;&lt;/p&gt;
&lt;h2 id="section-4"&gt;Анализатор&lt;/h2&gt;
&lt;p&gt;Если производительность критична для вашего проекта, вы должны обращать внимание на эти ситуации. Автоматическая диагностика может вам помочь.
Я поддерживаю набор диагностических инструментов на основе Roslyn - &lt;a href="https://www.nuget.org/packages/Collections.Analyzer"&gt;Collections.Analyzer&lt;/a&gt;, и теперь он может обнаруживать списки с инициализатором коллекции и без начальной емкости.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.rogatnev.net/img/list/list-initializer.gif" alt="Автоматическое указание первоначального размера List"&gt;&lt;/p&gt;
&lt;p&gt;Важно понимать, что анализатор устанавливает только начальный размер коллекции, чтобы избежать лишних аллокаций на начальном этапе заполнения коллекции.
Он не может предсказать, как изменится размер коллекции в будущем.&lt;/p&gt;
&lt;h1 id="section-5"&gt;Рекомендации&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;Если вам нужна статическая коллекция, которую вы не будете изменять (добавлять или удалять элементы) — используйте массивы.&lt;/li&gt;
&lt;li&gt;Если вы создаете список и точно знаете его будущий размер — установите начальную емкость с этим размером.&lt;/li&gt;
&lt;li&gt;Если вы создаете список и не знаете его будущий размер — установите ожидаемый размер.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1 id="section-6"&gt;Ссылки&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/object-and-collection-initializers#collection-initializers"&gt;Collection initializers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Backs/Collections.Analyzer"&gt;Collections.Analyzer&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded>
			<comments xmlns="http://purl.org/rss/1.0/modules/slash/">0</comments>
		</item>
		<item>
			<title>Dapper: How caching can be harmful</title>
			<link>https://blog.rogatnev.net/posts/en/2025/09/Dapper.html</link>
			<description>Investigating the problem of high memory consumption when using caching in Dapper</description>
			<author>Sergey Rogatnev</author>
			<guid>https://blog.rogatnev.net/posts/en/2025/09/Dapper.html</guid>
			<pubDate>Sat, 06 Sep 2025 00:00:00 GMT</pubDate>
			<content:encoded>&lt;p&gt;Dapper is a popular library that allows mapping objects from a database to C# types.
Unlike Entity Framework, it is not a full-fledged ORM, but it is very popular due to its minimalism.
In this article, I will explain how the default behavior can lead to a significant increase in memory consumption.&lt;/p&gt;
&lt;!--more--&gt;
&lt;h1 id="profiling"&gt;Profiling&lt;/h1&gt;
&lt;p&gt;This story began with one application at work. Metrics showed that over time it consumed more and more memory, although there were no obvious reasons for it.
I collected an application dump and opened it in a profiler.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.rogatnev.net/img/dapper/memory_profiler.png" alt="Memory profiler"&gt;
The problem was visible almost immediately — the application was holding 2 gigabytes of some strings in memory, related to Dapper.
The strings themselves contained the body of the SQL query. Let's study what objects in memory they are associated with.&lt;/p&gt;
&lt;h1 id="dapper-internals"&gt;Dapper internals&lt;/h1&gt;
&lt;p&gt;The profiler points to the &lt;code&gt;SqlMapper.Identity&lt;/code&gt; type and the associated &lt;code&gt;SqlMapper.CacheInfo&lt;/code&gt;. From the name, it's obvious that it is about caching.
Let's take a closer look at the creation of the &lt;code&gt;SqlMapper.Identity&lt;/code&gt; type during query execution:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;  private static async Task&amp;lt;int&amp;gt; ExecuteImplAsync(
    IDbConnection cnn,
    CommandDefinition command,
    object? param)
  {
    SqlMapper.CacheInfo cacheInfo = SqlMapper.GetCacheInfo(new SqlMapper.Identity(command.CommandText, new CommandType?(command.CommandTypeDirect), cnn, (Type) null, param?.GetType()), param, command.AddToCache);
    // ...
  }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As we can see, at the beginning of the method, we try to get something from the cache using &lt;code&gt;SqlMapper.Identity&lt;/code&gt; as a key, which contains the full text of the query in &lt;code&gt;command.CommandText&lt;/code&gt; and other parameters.&lt;/p&gt;
&lt;p&gt;The type stored in the cache is &lt;code&gt;SqlMapper.CacheInfo&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;  private class CacheInfo
  {
    private int hitCount;
    public SqlMapper.DeserializerState Deserializer { get; set; }
    public Func&amp;lt;DbDataReader, object&amp;gt;[]? OtherDeserializers { get; set; }
    public Action&amp;lt;IDbCommand, object?&amp;gt;? ParamReader { get; set; }
    public int GetHitCount() =&amp;gt; Interlocked.CompareExchange(ref this.hitCount, 0, 0);
    public void RecordHit() =&amp;gt; Interlocked.Increment(ref this.hitCount);
  }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a set of different deserializers associated with a specific SQL query, which allows Dapper to reuse objects for similar queries, speeding up data materialization.&lt;/p&gt;
&lt;p&gt;A fairly reasonable optimization. But why is our application holding such a large cache?&lt;/p&gt;
&lt;h1 id="sql-json"&gt;SQl + json = 🚩&lt;/h1&gt;
&lt;p&gt;The problem is how we construct the SQL query. For example, the method for getting users by a list of identifiers looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public async Task&amp;lt;ICollection&amp;lt;string&amp;gt;&amp;gt; SearchAsync(
    ThemeSearchRequest searchRequest, 
    int skip = 0,
    int take = int.MaxValue, 
    TimeSpan? timeout = null)
{
    var selectScript = @$"SELECT theme_base 
                          FROM [dbo].Themes 
                          {searchRequest.ToWhereExpression()}
                          GROUP BY theme_base
                          ORDER BY MAX(count_base) DESC, theme_base
                          OFFSET {skip} ROWS 
                          FETCH NEXT {take} ROWS ONLY";
    
        await using var connection = new SqlConnection(this.connectionString);
        
        var result = (await connection.QueryAsync&amp;lt;string&amp;gt;(
            selectScript,
            searchRequest.ToParametersWithValues(),
            commandTimeout: (int)(timeout?.TotalSeconds ?? DefaultTimeout.TotalSeconds))).AsList();
        
        return result;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;SearchAsync&lt;/code&gt; method forms a &lt;strong&gt;dynamic&lt;/strong&gt; query to the database — some search conditions, pagination. And that's the problem.
Almost every query will be unique, and according to Dapper's basic logic, it will be cached for fast materialization in the future.&lt;/p&gt;
&lt;p&gt;The same is written in the &lt;a href="https://github.com/DapperLib/Dapper#limitations-and-caveats"&gt;documentation&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Dapper caches information about every query it runs, this allows it to materialize objects quickly and process parameters quickly. The current implementation caches this information in a ConcurrentDictionary object. Statements that are only used once are routinely flushed from this cache. Still, if you are generating SQL strings on the fly without using parameters it is possible you may hit memory issues.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1 id="dapper-caching"&gt;Dapper caching&lt;/h1&gt;
&lt;p&gt;Let's take a closer look at the caching mechanism in Dapper. We have already found out that each SQL query is used as a key and stores additional information for fast materialization of objects.
Will this cache grow indefinitely? Let's look at the &lt;code&gt;SqlMapper&lt;/code&gt; code:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;private static bool TryGetQueryCache(Identity key, [NotNullWhen(true)] out CacheInfo? value)
{
    if (_queryCache.TryGetValue(key, out value!))
    {
        value.RecordHit();
        return true;
    }
    value = null;
    return false;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;TryGetQueryCache&lt;/code&gt; method searches for data in the cache and, if successful, increments the hit counter — the number of times the data has been returned for this key.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;private const int COLLECT_PER_ITEMS = 1000;
private static void SetQueryCache(Identity key, CacheInfo value)
{
    if (Interlocked.Increment(ref collect) == COLLECT_PER_ITEMS)
    {
        CollectCacheGarbage();
    }
    _queryCache[key] = value;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;SetQueryCache&lt;/code&gt; method tries to add data to the cache until it reaches a size of 1000 elements.
When this limit is reached, the &lt;code&gt;CollectCacheGarbage()&lt;/code&gt; method is called:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;private const int COLLECT_HIT_COUNT_MIN = 0;
private static void CollectCacheGarbage()
{
    try
    {
        foreach (var pair in _queryCache)
        {
            if (pair.Value.GetHitCount() &amp;lt;= COLLECT_HIT_COUNT_MIN)
            {
                _queryCache.TryRemove(pair.Key, out var _);
            }
        }
    }
    finally
    {
        Interlocked.Exchange(ref collect, 0);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It tries to remove all cached items that have never been accessed. In this process, the &lt;code&gt;GetHitCount()&lt;/code&gt; method resets the internal hit counter:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public int GetHitCount() { return Interlocked.CompareExchange(ref hitCount, 0, 0); }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Thus, after &lt;code&gt;CollectCacheGarbage()&lt;/code&gt; is run, the value of all &lt;code&gt;hitCount&lt;/code&gt;s is reset, which allows finding unused elements during the next cleanup.
But in the case of active use of dynamic SQL queries, it is highly likely that all queries will be used once and will be constantly evicted from the cache.&lt;/p&gt;
&lt;p&gt;Using one-time queries not only hits your application's memory but also creates an additional load on the SQL server.
It will have to parse, compile, and build an execution plan for each new query.&lt;/p&gt;
&lt;h1 id="solution"&gt;Solution&lt;/h1&gt;
&lt;p&gt;The correct solution here would be to use parameterized queries so that a single query will be reused many times, including on the SQL server:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public async Task&amp;lt;ICollection&amp;lt;TaskRules&amp;gt;&amp;gt; GetTaskRulesAsync(long taskId)
{
    var sql = $@"SELECT * FROM TaskRules WHERE TaskID = @taskId";
    await using var connection = new SqlConnection(this.connectionString);
    await connection.OpenAsync();
    var result = await connection
        .QueryAsync&amp;lt;TaskRules&amp;gt;(sql, new { taskId }, commandTimeout: 180);

    return result.AsList();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;GetTaskRulesAsync&lt;/code&gt; method uses a parameterized SQL query, where data is selected by &lt;code&gt;@taskId&lt;/code&gt;.
This allows using caching at all levels — in the application and on the SQL server.&lt;/p&gt;
&lt;p&gt;A quick solution might be to disable caching for a specific query — this can be done by managing &lt;code&gt;CommandFlags&lt;/code&gt; when creating the command:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;new CommandDefinition(sql, CommandFlags.NoCache)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This way, "one-time" queries will not get into the cache and clutter up the memory.&lt;/p&gt;
&lt;h1 id="conclusion"&gt;Conclusion&lt;/h1&gt;
&lt;p&gt;Many solutions try to optimize frequently used scenarios through strict rules or some of their own heuristics.
Such important rules are usually described somewhere prominently. In Dapper's case — right in the Readme.md.&lt;/p&gt;
&lt;h1 id="recommendations"&gt;Recommendations&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;Perform periodic profiling, see how applications use memory. For example, using &lt;a href="https://www.jetbrains.com/dotmemory/"&gt;dotMemory&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Use parameterized queries for working with SQL.&lt;/li&gt;
&lt;li&gt;If any solution uses caching, make sure it is useful for you.&lt;/li&gt;
&lt;/ul&gt;
</content:encoded>
			<comments xmlns="http://purl.org/rss/1.0/modules/slash/">0</comments>
		</item>
		<item>
			<title>Паттерны: Спецификация</title>
			<link>https://blog.rogatnev.net/posts/ru/2025/08/Patterns-Specification.html</link>
			<description>Реализация паттерна спецификация на C# и Entity Framework. Централизация бизнес-логики, улучшение тестируемости и независимость от слоя хранения.</description>
			<author>Сергей Рогатнев</author>
			<guid>https://blog.rogatnev.net/posts/ru/2025/08/Patterns-Specification.html</guid>
			<pubDate>Fri, 29 Aug 2025 00:00:00 GMT</pubDate>
			<content:encoded>&lt;p&gt;Паттерн Спецификация (Specification) объединяет в себе доменный подход к построению приложений и Entity Framework.
Этот паттерн создан для управления бизнес-правилами и соединяет наш код с ними. Эта статья показывает пример реализации этого паттерна на базе Entity Framework.&lt;/p&gt;
&lt;!--more--&gt;
&lt;p&gt;Для начала опишем нашу модель:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public sealed class User
{
    public User()
    {
        Contacts = new List&amp;lt;Contact&amp;gt;();
    }
    public int Id { get; set; }

    public string Login { get; set; }

    public bool IsActive { get; set; }

    public ICollection&amp;lt;Contact&amp;gt; Contacts { get; }
}

public abstract class Contact
{
    public int Id { get; set; }

    public bool IsActive { get; set; }
}

public class Phone : Contact
{
    public string PhoneCode { get; set; }
    public string PhoneNumber { get; set; }
}

public class Email : Contact
{
    public string Value { get; set; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Она достаточно простая, но подходит нам в качестве примера.
Теперь добавим немного магии Entity Framework для создания контекста данных:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public sealed class Context : DbContext
{
    public Context(DbContextOptions builderOptions)
        :base(builderOptions)
    {
        
    }
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity&amp;lt;User&amp;gt;().HasMany(o =&amp;gt; o.Contacts).WithOne();
        modelBuilder.Entity&amp;lt;Contact&amp;gt;()
            .HasDiscriminator&amp;lt;int&amp;gt;(&amp;quot;ContactType&amp;quot;)
            .HasValue&amp;lt;Phone&amp;gt;(1)
            .HasValue&amp;lt;Email&amp;gt;(2);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Теперь мы можем использовать SQL Server в качестве хранилища.&lt;/p&gt;
&lt;p&gt;Хорошо спроектированные приложения разделяют хранилище и доменную модель, поэтому мы добавим репозиторий для нашего класса &lt;code&gt;User&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public interface IUserRepository
{
    void Add(User user);
    User Get(int id);
}

public sealed class UserRepository : IUserRepository
{
    private readonly Context _context;

    public UserRepository(Context context)
    {
        _context = context;
    }

    public void Add(User user)
    {
        _context.Set&amp;lt;User&amp;gt;().Add(user);
    }

    public User Get(int id)
    {
        return _context.Set&amp;lt;User&amp;gt;().FirstOrDefault(o =&amp;gt; o.Id == id);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Сейчас это выглядит как стандартное приложение, ничего особенного.
Самое интересное начинается, когда нам требуется реализовать различные запросы в наше хранилище.
Давайте рассмотрим самые распространенные способы.&lt;/p&gt;
&lt;h1 id="section"&gt;Фильтры&lt;/h1&gt;
&lt;p&gt;Для примера давайте создадим несколько фильтров:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Найти всех пользователей с активными телефонами&lt;/li&gt;
&lt;li&gt;Найти всех пользователей по префиксу логина&lt;/li&gt;
&lt;li&gt;Найти всех пользователей по телефонному номеру&lt;/li&gt;
&lt;/ul&gt;
&lt;h1 id="iqueryable"&gt;Путь IQueryable&lt;/h1&gt;
&lt;p&gt;Самый простой способ - предоставить метод репозитория, который возвращает &lt;code&gt;IQueryable&amp;lt;User&amp;gt;&lt;/code&gt; и позволить разработчикам запрашивать любые данные:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public interface IUserRepository
{
    void Add(User user);
    User Get(int id);
    IQueryable&amp;lt;User&amp;gt; Query();
}

public sealed class UserRepository : IUserRepository
{
    private readonly Context _context;

    public UserRepository(Context context)
    {
        _context = context;
    }

    public void Add(User user)
    {
        _context.Set&amp;lt;User&amp;gt;().Add(user);
    }

    public User Get(int id)
    {
        return _context.Set&amp;lt;User&amp;gt;().FirstOrDefault(o =&amp;gt; o.Id == id);
    }

    public IQueryable&amp;lt;User&amp;gt; Query()
    {
        return _context.Set&amp;lt;User&amp;gt;();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;С методом &lt;code&gt;Query&lt;/code&gt; мы можем реализовать наши фильтры:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public static void Search(IUserRepository repository)
{
    var users1 = repository.Query()
        .Where(o =&amp;gt; o.Contacts.Any(c =&amp;gt; c is Phone &amp;amp;&amp;amp; c.IsActive))
        .ToArray();

    var users2 = repository.Query()
        .Where(o =&amp;gt; o.Login.StartsWith(&amp;quot;log&amp;quot;))
        .ToArray();

    var users3 = repository.Query()
        .Where(o =&amp;gt; o.Contacts.Any(c =&amp;gt; c is Phone &amp;amp;&amp;amp; (c as Phone).PhoneNumber == &amp;quot;111-22-33&amp;quot;))
        .ToArray();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Это быстрое и простое решение, но имеющее серьезные недостатки:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Нам нужно повторять код фильтра, если нужно его переиспользовать в другом месте. Можно сделать изменение в одном месте и забыть в другом — вот они, недостатки дублирования кода.&lt;/li&gt;
&lt;li&gt;Бизнес-логика (наши фильтры) распределены по коду. Мы не можем просто выписать все правила, т.к. они находятся в разных частях приложения.&lt;/li&gt;
&lt;li&gt;Сложно тестировать. Мы не можем проверить фильтры отдельно от хранилища.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1 id="section-1"&gt;Методы расширения&lt;/h1&gt;
&lt;p&gt;Можно попробовать устранить недостатки предыдущего решения и собрать все фильтры в одном классе в виде методов расширений:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public static class UserQueryExtensions
{
    public static IEnumerable&amp;lt;User&amp;gt; FindWithActivePhones(this IQueryable&amp;lt;User&amp;gt; query)
    {
        return query.Where(o =&amp;gt; o.Contacts.Any(c =&amp;gt; c is Phone &amp;amp;&amp;amp; c.IsActive))
            .AsEnumerable();
    }

    public static IEnumerable&amp;lt;User&amp;gt; FindWithLoginPrefix(this IQueryable&amp;lt;User&amp;gt; query, string prefix)
    {
        return query.Where(o =&amp;gt; o.Login.StartsWith(prefix))
            .AsEnumerable();
    }

    public static IEnumerable&amp;lt;User&amp;gt; FindWithPhoneNumber(this IQueryable&amp;lt;User&amp;gt; query, string phoneNumber)
    {
        return query.Where(o =&amp;gt; o.Contacts.Any(c =&amp;gt; c is Phone &amp;amp;&amp;amp; (c as Phone).PhoneNumber == phoneNumber))
            .AsEnumerable();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public static void Search(IUserRepository repository)
{
    var users1 = repository.Query().FindWithActivePhones();

    var users2 = repository.Query().FindWithLoginPrefix(&amp;quot;log&amp;quot;);

    var users3 = repository.Query().FindWithPhoneNumber(&amp;quot;111-22-33&amp;quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Выглядит лучше, теперь вся бизнес-логика (фильтры) находятся в одном месте и их можно протестировать:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;[Test]
public void FindWithActivePhonesTest()
{
    var user = new User
    {
        Id = 1,
        Login = &amp;quot;login&amp;quot;
    };
    user.Contacts.Add(new Phone { IsActive = true, PhoneNumber = &amp;quot;123-321&amp;quot; });
    
    var queryable = new[] { user }.AsQueryable();

    var result = queryable.FindWithActivePhones();

    CollectionAssert.AreEquivalent(new[] { 1 }, result.Select(o =&amp;gt; o.Id));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Но наш репозиторий всё еще содержит метод &lt;code&gt;Query&lt;/code&gt;, что нехорошо, это может привести к неконтролируемому использованию.
Можно переместить всю логику внутрь репозитория:&lt;/p&gt;
&lt;h1 id="section-2"&gt;Фильтры в репозитории&lt;/h1&gt;
&lt;p&gt;Добавим все фильтр-методы в сам репозиторий:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public interface IUserRepository
{
    void Add(User user);
    User Get(int id);
    IEnumerable&amp;lt;User&amp;gt; FindWithActivePhones();
    IEnumerable&amp;lt;User&amp;gt; FindWithLoginPrefix(string prefix);
    IEnumerable&amp;lt;User&amp;gt; FindWithPhoneNumber(string phoneNumber);
}

public sealed class UserRepository : IUserRepository
{
    private readonly Context _context;

    public UserRepository(Context context)
    {
        _context = context;
    }

    public void Add(User user)
    {
        _context.Set&amp;lt;User&amp;gt;().Add(user);
    }

    public User Get(int id)
    {
        return _context.Set&amp;lt;User&amp;gt;().FirstOrDefault(o =&amp;gt; o.Id == id);
    }

    public IEnumerable&amp;lt;User&amp;gt; FindWithActivePhones()
    {
        return _context.Set&amp;lt;User&amp;gt;().Where(o =&amp;gt; o.Contacts.Any(c =&amp;gt; c is Phone &amp;amp;&amp;amp; c.IsActive))
            .AsEnumerable();
    }

    public IEnumerable&amp;lt;User&amp;gt; FindWithLoginPrefix(string prefix)
    {
        return _context.Set&amp;lt;User&amp;gt;().Where(o =&amp;gt; o.Login.StartsWith(prefix))
            .AsEnumerable();
    }

    public IEnumerable&amp;lt;User&amp;gt; FindWithPhoneNumber(string phoneNumber)
    {
        return _context.Set&amp;lt;User&amp;gt;().Where(o =&amp;gt; o.Contacts.Any(c =&amp;gt; c is Phone &amp;amp;&amp;amp; (c as Phone).PhoneNumber == phoneNumber))
            .AsEnumerable();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public static void Search(IUserRepository repository)
{
    var users1 = repository.FindWithActivePhones();

    var users2 = repository.FindWithLoginPrefix(&amp;quot;log&amp;quot;);

    var users3 = repository.FindWithPhoneNumber(&amp;quot;111-22-33&amp;quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Мы избавились от опасного метода &lt;code&gt;Query&lt;/code&gt;. Но всё еще есть сложности с тестированием — методы сильно связаны с хранилищем и Entity Framework.
Так же, наши фильтры могут использоваться только для доступа к хранилищу. А что, если мы хотим проверить вновь создаваемые сущности этими бизнес-правилами?
Сейчас это невозможно.&lt;/p&gt;
&lt;p&gt;На самом деле, мы бы могли остановиться на этом решении, его достаточно в 99% случаев.&lt;/p&gt;
&lt;p&gt;Но если вы используете Domain-Driven development (DDD), Entity Framework (или другую ORM) и активно используете юнит-тесты, то вам может подойти следующий подход.&lt;/p&gt;
&lt;h1 id="section-3"&gt;Спецификации&lt;/h1&gt;
&lt;p&gt;Вначале давайте опишем требования, которые для нас важны:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Хранить бизнес-правила в одном месте и не дублировать их&lt;/li&gt;
&lt;li&gt;Фильтры отделены от хранилища (могут быть использованы вне репозитория)&lt;/li&gt;
&lt;li&gt;Фильтры можно тестировать отдельно&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Ключевая сущность паттерна - это &lt;code&gt;спецификация&lt;/code&gt;, специальный объект с бизнес-правилом.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public interface ISpecification&amp;lt;T&amp;gt;
{
    Expression&amp;lt;Func&amp;lt;T, bool&amp;gt;&amp;gt; IsSatisfiedBy { get; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Где &lt;code&gt;T&lt;/code&gt; - наша бизнес сущность (&lt;code&gt;User&lt;/code&gt; как в примерах выше).
В репозиторий добавляется метод, который работает со спецификацией:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public interface IUserRepository
{
    void Add(User user);
    User Get(int id);
    IEnumerable&amp;lt;User&amp;gt; Get(ISpecification&amp;lt;User&amp;gt; specification);
}

public sealed class UserRepository : IUserRepository
{
    private readonly Context _context;

    public UserRepository(Context context)
    {
        _context = context;
    }

    public void Add(User user)
    {
        _context.Set&amp;lt;User&amp;gt;().Add(user);
    }

    public User Get(int id)
    {
        return _context.Set&amp;lt;User&amp;gt;().FirstOrDefault(o =&amp;gt; o.Id == id);
    }

    public IEnumerable&amp;lt;User&amp;gt; Get(ISpecification&amp;lt;User&amp;gt; specification)
    {
        return _context.Set&amp;lt;User&amp;gt;()
            .Where(specification.IsSatisfiedBy)
            .AsEnumerable();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Entity Framework позволяет нам использовать концепцию LINQ to SQL через методы расширения.
Нам интересен метод &lt;code&gt;Where&lt;/code&gt; с предикатом фильтра:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public static IQueryable&amp;lt;TSource&amp;gt; Where&amp;lt;TSource&amp;gt;(this IQueryable&amp;lt;TSource&amp;gt; source, Expression&amp;lt;Func&amp;lt;TSource, bool&amp;gt;&amp;gt; predicate)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Реализуем наши бизнес-правила (спецификации):&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public class ActivePhonesSpecification : ISpecification&amp;lt;User&amp;gt;
{
	public Expression&amp;lt;Func&amp;lt;User, bool&amp;gt;&amp;gt; IsSatisfiedBy =&amp;gt;
		o =&amp;gt; o.Contacts.Any(c =&amp;gt; c is Phone &amp;amp;&amp;amp; c.IsActive);
}

public class LoginPrefixSpecification : ISpecification&amp;lt;User&amp;gt;
{
	public LoginPrefixSpecification(string prefix)
	{
		IsSatisfiedBy = o =&amp;gt; o.Login.StartsWith(prefix);
	}
	public Expression&amp;lt;Func&amp;lt;User, bool&amp;gt;&amp;gt; IsSatisfiedBy { get; }
}

public class PhoneNumberSpecification:ISpecification&amp;lt;User&amp;gt;
{
	public PhoneNumberSpecification(string phoneNumber)
	{
		IsSatisfiedBy = o =&amp;gt; o.Contacts.Any(c =&amp;gt; c is Phone &amp;amp;&amp;amp; (c as Phone).PhoneNumber == phoneNumber);
	}
	public Expression&amp;lt;Func&amp;lt;User, bool&amp;gt;&amp;gt; IsSatisfiedBy { get; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Мы описали все правила в одном месте и они отделены от хранилища - работают только с доменной сущностью.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public static void Search(IUserRepository repository)
{
	var users1 = repository.Get(new ActivePhonesSpecification());

	var users2 = repository.Get(new LoginPrefixSpecification(&amp;quot;log&amp;quot;));

	var users3 = repository.Get(new PhoneNumberSpecification(&amp;quot;111-22-33&amp;quot;));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Тестирование:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;[Test]
public void FindWithActivePhonesTest()
{
	var user = new User
	{
		Id = 1,
		Login = &amp;quot;login&amp;quot;
	};
	user.Contacts.Add(new Phone { IsActive = true, PhoneNumber = &amp;quot;123-321&amp;quot; });


	var queryable = new[] { user }.AsQueryable();

	var specification = new ActivePhonesSpecification();
	var result = queryable.Where(specification.IsSatisfiedBy);

	CollectionAssert.AreEquivalent(new[] { 1 }, result.Select(o =&amp;gt; o.Id));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Работа с правилами без хранилища:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;[Test]
public void FindWithActivePhonesTest()
{
	var user = new User
	{
		Id = 1,
		Login = &amp;quot;login&amp;quot;
	};
	user.Contacts.Add(new Phone { IsActive = true, PhoneNumber = &amp;quot;123-321&amp;quot; });

	var specification = new ActivePhonesSpecification();

	Assert.IsTrue(specification.IsSatisfiedBy.Compile().Invoke(user));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Как улучшение, мы можем скомпилировать метод из выражения &lt;code&gt;IsSatisfiedBy&lt;/code&gt;.&lt;/p&gt;
&lt;h1 id="section-4"&gt;Заключение&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;DDD описывает модели, репозитории и правила&lt;/li&gt;
&lt;li&gt;Entity Framework и LINQ позволяют организовать простой доступ к данным&lt;/li&gt;
&lt;li&gt;Спецификации хранят правила отдельно от уровня работы с данными&lt;/li&gt;
&lt;/ul&gt;
&lt;h1 id="section-5"&gt;Ссылки&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.microsoft.com/en-us/ef/core/"&gt;Entity Framework Core&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.microsoft.com/en-us/previous-versions/msp-n-p/ff649690(v=pandp.10)"&gt;The Repository Pattern&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/ddd-oriented-microservice"&gt;Design a DDD-oriented microservice&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded>
			<comments xmlns="http://purl.org/rss/1.0/modules/slash/">0</comments>
		</item>
		<item>
			<title>Dapper: как кеширование может принести вред</title>
			<link>https://blog.rogatnev.net/posts/ru/2025/08/Dapper.html</link>
			<description>Исследование проблемы высокого потребления памяти при использовании кеширования в Dapper</description>
			<author>Сергей Рогатнев</author>
			<guid>https://blog.rogatnev.net/posts/ru/2025/08/Dapper.html</guid>
			<pubDate>Sun, 24 Aug 2025 00:00:00 GMT</pubDate>
			<content:encoded>&lt;p&gt;Dapper — популярная библиотека, которая позволяет делать маппинг объектов из базы данных в типы C#.
В отличие от Entity Framework не является полноценной ORM, но пользуется большой популярностью за счет своей минималистичности.
В этой статье я расскажу, как поведение по-умолчанию может привести к значительному росту потребления памяти.&lt;/p&gt;
&lt;!--more--&gt;
&lt;h1 id="section"&gt;Профилирование&lt;/h1&gt;
&lt;p&gt;Эта история началась с одного приложения на работе. Метрики показывали, что со временем оно потребляло всё больше и больше памяти, хотя явных причин на то не было.
Я собрал дамп приложения и открыл его в профайлере.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.rogatnev.net/img/dapper/memory_profiler.png" alt="Memory profiler"&gt;&lt;/p&gt;
&lt;p&gt;Проблема была видна практически сразу — приложение держало в памяти 2 гигабайта каких-то строк, связанных с Dapper.
Сами строки содержали в себе тело SQL-запроса. Давайте посмотрим, с какими объектами в памяти они ассоциированы.&lt;/p&gt;
&lt;h1 id="dapper"&gt;Внутренности Dapper&lt;/h1&gt;
&lt;p&gt;Профайлер указывает нам на тип &lt;code&gt;SqlMapper.Identity&lt;/code&gt; и связанынй с ним &lt;code&gt;SqlMapper.CacheInfo&lt;/code&gt;. По названию очевидно, что это своего рода кеширование.
Давайте ближе взглянем на создание типа &lt;code&gt;SqlMapper.Identity&lt;/code&gt; в процессе выполнения запроса:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;  private static async Task&amp;lt;int&amp;gt; ExecuteImplAsync(
    IDbConnection cnn,
    CommandDefinition command,
    object? param)
  {
    SqlMapper.CacheInfo cacheInfo = SqlMapper.GetCacheInfo(new SqlMapper.Identity(command.CommandText, new CommandType?(command.CommandTypeDirect), cnn, (Type) null, param?.GetType()), param, command.AddToCache);
    // ...
  }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Как мы видим, в самом начале метода мы пытаемся получить что-то из кэша используя для этого в качестве ключа &lt;code&gt;SqlMapper.Identity&lt;/code&gt;,
который в свою очередь содержит полный текст запроса &lt;code&gt;command.CommandText&lt;/code&gt; и другие параметры.&lt;/p&gt;
&lt;p&gt;Хранится в кеше тип &lt;code&gt;SqlMapper.CacheInfo&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;  private class CacheInfo
  {
    private int hitCount;
    public SqlMapper.DeserializerState Deserializer { get; set; }
    public Func&amp;lt;DbDataReader, object&amp;gt;[]? OtherDeserializers { get; set; }
    public Action&amp;lt;IDbCommand, object?&amp;gt;? ParamReader { get; set; }
    public int GetHitCount() =&amp;gt; Interlocked.CompareExchange(ref this.hitCount, 0, 0);
    public void RecordHit() =&amp;gt; Interlocked.Increment(ref this.hitCount);
  }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Это набор разных десериализаторов ассоциированных с конкретным SQL-запросом, что позволяет Dapper переиспользовать объекты для однотипных запросов ускоряя материализацию данных.&lt;/p&gt;
&lt;p&gt;Довольно разумная оптимизация. Но почему же наше приложение держит такой большой кэш?&lt;/p&gt;
&lt;h1 id="sql-json"&gt;SQl + json = 🚩&lt;/h1&gt;
&lt;p&gt;Проблема в том, как именно мы составляем SQL запрос. Например, метод получения пользователей по списку идентификаторов выглядит так:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public async Task&amp;lt;ICollection&amp;lt;string&amp;gt;&amp;gt; SearchAsync(
    ThemeSearchRequest searchRequest, 
    int skip = 0,
    int take = int.MaxValue, 
    TimeSpan? timeout = null)
{
    var selectScript = @$"SELECT theme_base 
                          FROM [dbo].Themes 
                          {searchRequest.ToWhereExpression()}
                          GROUP BY theme_base
                          ORDER BY MAX(count_base) DESC, theme_base
                          OFFSET {skip} ROWS 
                          FETCH NEXT {take} ROWS ONLY";
    
        await using var connection = new SqlConnection(this.connectionString);
        
        var result = (await connection.QueryAsync&amp;lt;string&amp;gt;(
            selectScript,
            searchRequest.ToParametersWithValues(),
            commandTimeout: (int)(timeout?.TotalSeconds ?? DefaultTimeout.TotalSeconds))).AsList();
        
        return result;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Метод &lt;code&gt;SearchAsync&lt;/code&gt; формирует &lt;strong&gt;динамический&lt;/strong&gt; запрос к базе — какие-то поисковые условия, пагинация. И в этом и есть проблема.
Почти каждый запрос будет уникальным, и согласно базовой логике Dapper он будет кешироваться для быстрой материализации в будущем.&lt;/p&gt;
&lt;p&gt;Об этом же написано в &lt;a href="https://github.com/DapperLib/Dapper#limitations-and-caveats"&gt;документации&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Dapper caches information about every query it runs, this allows it to materialize objects quickly and process parameters quickly. The current implementation caches this information in a ConcurrentDictionary object. Statements that are only used once are routinely flushed from this cache. Still, if you are generating SQL strings on the fly without using parameters it is possible you may hit memory issues.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1 id="dapper-1"&gt;Кеширование Dapper&lt;/h1&gt;
&lt;p&gt;Давайте подробнее рассмотрим механизм кеширование в Dapper. Мы уже выяснили, что каждый SQL-запрос используется в качестве ключа и сохраняет дополнительную информацию для быстрой материализации объектов.
Будет ли этот кэш расти бесконечно? Давайте взглянем на код &lt;code&gt;SqlMapper&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;private static bool TryGetQueryCache(Identity key, [NotNullWhen(true)] out CacheInfo? value)
{
    if (_queryCache.TryGetValue(key, out value!))
    {
        value.RecordHit();
        return true;
    }
    value = null;
    return false;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Метод &lt;code&gt;TryGetQueryCache&lt;/code&gt; ищет данные в кэше и в случае успеха увеличивает счетчик попаданий — количество раз, котоыре данные возвращались по этому ключу.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;private const int COLLECT_PER_ITEMS = 1000;
private static void SetQueryCache(Identity key, CacheInfo value)
{
    if (Interlocked.Increment(ref collect) == COLLECT_PER_ITEMS)
    {
        CollectCacheGarbage();
    }
    _queryCache[key] = value;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Метод &lt;code&gt;SetQueryCache&lt;/code&gt; пытается добавить данные в кэш, пока он не достиг размера в 1000 элементов.
В случае его достижения вызывается метод &lt;code&gt;CollectCacheGarbage()&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;private const int COLLECT_HIT_COUNT_MIN = 0;
private static void CollectCacheGarbage()
{
    try
    {
        foreach (var pair in _queryCache)
        {
            if (pair.Value.GetHitCount() &amp;lt;= COLLECT_HIT_COUNT_MIN)
            {
                _queryCache.TryRemove(pair.Key, out var _);
            }
        }
    }
    finally
    {
        Interlocked.Exchange(ref collect, 0);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Он пытается удалить все закешированные элементы, к которым ни разу не обращались. При этом, метод &lt;code&gt;GetHitCount()&lt;/code&gt; обнуляет внутренний счетчик попаданий:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public int GetHitCount() { return Interlocked.CompareExchange(ref hitCount, 0, 0); }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Таким образом, после запуска &lt;code&gt;CollectCacheGarbage()&lt;/code&gt; значение всех &lt;code&gt;hitCount&lt;/code&gt; обнуляется, что позволяет найти неиспользуемые элементы при следующей очистке.
Но в случае активного использования динамических SQL-запросов велика вероятность, что все запросы будут использоваться по 1 разу и постоянно будут вытесняться из кэша.&lt;/p&gt;
&lt;p&gt;Использование одноразовых запросов бьет не только по памяти вашего приложения, но и создаёт дополнительную нагрузку на SQL-сервер.
Ему придется парсить, компилировать и строить план выполнения для каждого нового запроса.&lt;/p&gt;
&lt;h1 id="section-1"&gt;Решение&lt;/h1&gt;
&lt;p&gt;Корректным вариантом решения здесь было бы использование параметризованных, тогда один запрос будет переиспользоваться множество раз, в том числе и на SQL-сервере:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public async Task&amp;lt;ICollection&amp;lt;TaskRules&amp;gt;&amp;gt; GetTaskRulesAsync(long taskId)
{
    var sql = $@"SELECT * FROM TaskRules WHERE TaskID = @taskId";
    await using var connection = new SqlConnection(this.connectionString);
    await connection.OpenAsync();
    var result = await connection
        .QueryAsync&amp;lt;TaskRules&amp;gt;(sql, new { taskId }, commandTimeout: 180);

    return result.AsList();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;В методе &lt;code&gt;GetTaskRulesAsync&lt;/code&gt; используется параметризованный SQL-запрос, где выбираются данные по &lt;code&gt;@taskId&lt;/code&gt;.
Это позволяет эффективно использовать кеширование на всех уровнях — в приложении и SQL-сервере.&lt;/p&gt;
&lt;p&gt;Быстрым решением может быть отключение кеширования для конкретного запроса — это можно сделать через управление &lt;code&gt;CommandFlags&lt;/code&gt; при создании команды:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;new CommandDefinition(sql, CommandFlags.NoCache)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Таким образом, "одноразовые" запросы не будут попадать в кэш и засорять память.&lt;/p&gt;
&lt;h1 id="section-2"&gt;Заключение&lt;/h1&gt;
&lt;p&gt;Многие решения пытаются оптимизировать часто-используемые сценарии за счет строгих правил или каких-то своих эвристик.
Такие важные правила обычно описаны где-то на видном месте. В случае в Dapper — сразу в Readme.md.&lt;/p&gt;
&lt;h1 id="section-3"&gt;Рекомендации&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;Проводите периодическое профилирование, смотрите, как приложения используют память. Например, с помощью &lt;a href="https://www.jetbrains.com/dotmemory/"&gt;dotMemory&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Для работы с SQL используйте параметризованные запросы&lt;/li&gt;
&lt;li&gt;Если какое-либо решение использует кэширование, убедитесь, что оно полезно для вас.&lt;/li&gt;
&lt;/ul&gt;
</content:encoded>
			<comments xmlns="http://purl.org/rss/1.0/modules/slash/">0</comments>
		</item>
		<item>
			<title>Пагинация: как правильно поделить данные по страницам</title>
			<link>https://blog.rogatnev.net/posts/ru/2025/07/Paging.html</link>
			<description>Элегантный способ подсчета количества страниц для организации пагинации</description>
			<author>Сергей Рогатнев</author>
			<guid>https://blog.rogatnev.net/posts/ru/2025/07/Paging.html</guid>
			<pubDate>Sat, 12 Jul 2025 00:00:00 GMT</pubDate>
			<content:encoded>&lt;p&gt;Работая с большими списками данных, мы разбиваем их на страницы — так и пользователю удобнее, и не нагружает нашу систему.
А значит вы наверняка решали задачу определения общего количества страниц. Давайте покажу вам один простой и элегантный способ.&lt;/p&gt;
&lt;!--more--&gt;
&lt;h1 id="section"&gt;Пагинация&lt;/h1&gt;
&lt;p&gt;В классическом виде задача звучит так: у нас есть всего &lt;code&gt;TotalCount&lt;/code&gt; записей в базе данных, нужно вывести их постранично по &lt;code&gt;BatchSize&lt;/code&gt; на страницу.
Нужно определить количество страниц - &lt;code&gt;PageCount&lt;/code&gt;. Для этого нужно поделить &lt;code&gt;TotalCount&lt;/code&gt; на &lt;code&gt;BatchSize&lt;/code&gt; и если результат получился дробным, то округлить его вверх.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;| TotalCount | BatchSize | TotalCount/BatchSize | PageCount |
|------------|-----------|----------------------|----------:|
| 90         | 10        | 9                    |         9 |
| 99         | 10        | 9.9                  |        10 |
| 100        | 10        | 10                   |        10 |
| 101        | 10        | 10.1                 |        11 |
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="math.ceiling"&gt;Math.Ceiling&lt;/h3&gt;
&lt;p&gt;В C# можно воспользоваться методом &lt;a href="https://learn.microsoft.com/ru-ru/dotnet/api/system.math.ceiling?view=net-9.0"&gt;&lt;code&gt;Math.Ceiling&lt;/code&gt;&lt;/a&gt; - он округляет вещественное число до ближайшего целого, которое больше либо равно переданному аргументу.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var PageCount = (int)Math.Ceiling((double)TotalCount / BatchSize);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Мы получим корректное значение. Но как вы заметили, нам вначале нужно преобразовать наши целые числа &lt;code&gt;TotalCount&lt;/code&gt;, &lt;code&gt;BatchSize&lt;/code&gt; в вещественные, вызвать метод, а потом обратно преобразовать результат в целочисленный.&lt;/p&gt;
&lt;h3 id="section-1"&gt;Остаток от деления&lt;/h3&gt;
&lt;p&gt;Можно заметить, если &lt;code&gt;TotalCount&lt;/code&gt; делится нацело на &lt;code&gt;BatchSize&lt;/code&gt;, то результатом является просто частное &lt;code&gt;TotalCount/BatchSize&lt;/code&gt;, иначе нужно добавить 1 к результату.
Преобразуем нашу формулу:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var PageCount = TotalCount / BatchSize + (TotalCount % BatchSize == 0 ? 0 : 1);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Мы избавились от преобразований типа и вызова метода, но добавился один тернарный оператор. Можно ли сделать еще проще?
Оказывается, можно.&lt;/p&gt;
&lt;h3 id="section-2"&gt;Целочисленное деление&lt;/h3&gt;
&lt;p&gt;Как мы знаем, целочисленное деление всегда даёт округление результата к меньшему либо равному значению.
Давайте воспользуемся этой особенностью и рассмотрим следующее выражение:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var Add = (BatchSize - 1) / BatchSize;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Числитель у нас всегда меньше знаменателя, поэтому результат целочисленного деления всегда будет равен &lt;code&gt;0&lt;/code&gt;.
Добавим это выражение к частному &lt;code&gt;TotalCount / BatchSize&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var PageCount = TotalCount / BatchSize + (BatchSize - 1) / BatchSize;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;По правилам целочисленной арифметики второе слагаемое всегда равно &lt;code&gt;0&lt;/code&gt; и оно не влияет на сумму.
Давайте внесем его под общий знаменатель:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var PageCount = (TotalCount + BatchSize - 1) / BatchSize;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Это и есть наша элегантная формула деления с округлением вверх. Почему она работает?&lt;/p&gt;
&lt;p&gt;Давайте рассмотрим 2 случая:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;TotalCount&lt;/code&gt; делится нацело на &lt;code&gt;BatchSize&lt;/code&gt;, тогда &lt;code&gt;TotalCount / BatchSize&lt;/code&gt; даст нужный нам &lt;code&gt;PageCount&lt;/code&gt;, а &lt;code&gt;(BatchSize - 1) / BatchSize&lt;/code&gt; даст &lt;code&gt;0&lt;/code&gt; и не повлияет на результат.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TotalCount&lt;/code&gt; при делении на &lt;code&gt;BatchSize&lt;/code&gt; даёт остаток &lt;code&gt;R&lt;/code&gt;, тогда &lt;code&gt;TotalCount / BatchSize&lt;/code&gt; даст значение &lt;code&gt;PageCount - 1&lt;/code&gt;. И у нас остаётся выражение &lt;code&gt;(R + BatchSize - 1) / BatchSize&lt;/code&gt;, где &lt;code&gt;R&lt;/code&gt; по определению всегда больше &lt;code&gt;1&lt;/code&gt; и меньше &lt;code&gt;BatchSize&lt;/code&gt;.
При любых значениях &lt;code&gt;R&lt;/code&gt; результат целочисленного деления будет равен &lt;code&gt;1&lt;/code&gt;. Таким образом, в итоге получим значение &lt;code&gt;PageCount&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="section-3"&gt;Бенчмарк&lt;/h3&gt;
&lt;p&gt;Давайте ряди любопытства сравним все 3 способа.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Код бенчмарка. Нажмите, чтобы развернуть.&lt;/summary&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;using BenchmarkDotNet.Attributes;

namespace Paging;

public class Benchmark
{
    [Params(99)]
    public int TotalCount;

    [Params(10)]
    public int BatchSize;

    [Params(10000)]
    public int Iterations;

    [Benchmark]
    public int MathCeiling()
    {
        var result = 0;
        for (int i = 0; i &amp;lt; Iterations; i++)
            result += (int)Math.Ceiling((double)TotalCount / BatchSize);
        return result;
    }

    [Benchmark]
    public int Mod()
    {
        var result = 0;
        for (int i = 0; i &amp;lt; Iterations; i++)
            result += TotalCount / BatchSize + (TotalCount % BatchSize == 0 ? 0 : 1);
        return result;
    }

    [Benchmark]
    public int Div()
    {
        var result = 0;
        for (int i = 0; i &amp;lt; Iterations; i++)
            result += (TotalCount + BatchSize - 1) / BatchSize;
        return result;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;p&gt;Результаты:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;BenchmarkDotNet v0.15.2, Windows 10 (10.0.19045.6093/22H2/2022Update)
AMD Ryzen 7 7840H with Radeon 780M Graphics 3.80GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 8.0.412
  [Host]     : .NET 8.0.18 (8.0.1825.31117), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  DefaultJob : .NET 8.0.18 (8.0.1825.31117), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI


| Method      | TotalCount | BatchSize | Iterations |     Mean |     Error |    StdDev |
|-------------|------------|-----------|------------|---------:|----------:|----------:|
| MathCeiling | 99         | 10        | 10000      | 2.298 us | 0.0042 us | 0.0037 us |
| Mod         | 99         | 10        | 10000      | 4.517 us | 0.0597 us | 0.0559 us |
| Div         | 99         | 10        | 10000      | 2.285 us | 0.0455 us | 0.0425 us |
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Еще одно подтверждение бессмысленности этих измерений — это необходимость нескольких тысяч итераций, иначе значения неотличимы от нуля.
Тем не менее удивительно, что первый и третий варианты показали практически идентичный результат.&lt;/p&gt;
&lt;h1 id="section-4"&gt;Заключение&lt;/h1&gt;
&lt;p&gt;Данная формула — хорошая демонстрация особенностей целочисленной арифметики, которая немного отличается от обычной.
Можно пользоваться этими возможностями, но не в ущерб понятности.&lt;/p&gt;
&lt;p&gt;И да, важно, что данная формула работает только для положительных чисел.&lt;/p&gt;
</content:encoded>
			<comments xmlns="http://purl.org/rss/1.0/modules/slash/">0</comments>
		</item>
		<item>
			<title>String of Digits</title>
			<link>https://blog.rogatnev.net/posts/en/2025/07/String-of-Digits.html</link>
			<description>We consider different approaches to checking digital strings in C# - comparison of methods, efficiency and ready-made examples.</description>
			<author>Sergey Rogatnev</author>
			<guid>https://blog.rogatnev.net/posts/en/2025/07/String-of-Digits.html</guid>
			<pubDate>Sat, 05 Jul 2025 00:00:00 GMT</pubDate>
			<content:encoded>&lt;p&gt;A popular task is to check if a string contains only digits. For example, you need to check if the user entered the
correct phone number, index or a tax code of an organization.
There are several ways to solve this task, which differ in their efficiency. Let's take a look at the most popular ones.&lt;/p&gt;
&lt;!--more--&gt;
&lt;h1 id="regex"&gt;Regex&lt;/h1&gt;
&lt;p&gt;Probably the most popular way to solve this task is to use a regular expression.
It's straightforward and easy to use an expression &lt;code&gt;^[0-9]*$&lt;/code&gt; (or &lt;code&gt;^\d*$&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;Below is a naive implementation of a regular expression check:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;Regex regex = new Regex("^[0-9]*$");
var value = "123456789000";
var isValid = regex.IsMatch(value);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I'm sure someone sees the problem. This implementation is only suitable for a one-time launch.
In production code, when you check thousands of strings, this solution will not be efficient.
.NET provides the ability to compile a regular expression at runtime, by using the &lt;code&gt;RegexOptions.Compiled&lt;/code&gt; option:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;Regex regex = new Regex("^[0-9]*$", RegexOptions.Compiled);
var value = "123456789000";
var isValid = regex.IsMatch(value);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Calling a constructor with this option will generate IL code that will be called through &lt;code&gt;DynamicMethod&lt;/code&gt; inside
&lt;code&gt;Regex.IsMatch&lt;/code&gt;, which will be faster than the usual regular expression processing.
The downside is that it takes longer to create a &lt;code&gt;Regex&lt;/code&gt; object due to the time spent on compilation at runtime, but
this quickly pays off with repeated use.&lt;/p&gt;
&lt;p&gt;Let's compare both solutions.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Benchmark code. Click to expand.&lt;/summary&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net90)]
[SimpleJob(RuntimeMoniker.Net60)]
[SimpleJob(RuntimeMoniker.NetCoreApp31)]
[SimpleJob(RuntimeMoniker.Net48)]
[HideColumns("Job", "Error", "StdDev", "Gen0")]
public partial class DigitsBenchmarks
{
    private static string value = "123456789000";

    private static readonly Regex regex = new Regex("^[0-9]*$");
    private static readonly Regex compiledRegex = new Regex("^[0-9]*$", RegexOptions.Compiled);

    [Benchmark]
    public bool Regex()
    {
        return regex.IsMatch(value);
    }
    
    [Benchmark]
    public bool CompiledRegex()
    {
        return compiledRegex.IsMatch(value);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;p&gt;Results:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Mean&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Median&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Allocated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Regex&lt;/td&gt;
&lt;td&gt;.NET Framework 4.8&lt;/td&gt;
&lt;td style="text-align: right;"&gt;165.4417 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;166.2537 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CompiledRegex&lt;/td&gt;
&lt;td&gt;.NET Framework 4.8&lt;/td&gt;
&lt;td style="text-align: right;"&gt;115.9377 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;115.9720 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regex&lt;/td&gt;
&lt;td&gt;.NET Core 3.1&lt;/td&gt;
&lt;td style="text-align: right;"&gt;118.1540 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;118.1887 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CompiledRegex&lt;/td&gt;
&lt;td&gt;.NET Core 3.1&lt;/td&gt;
&lt;td style="text-align: right;"&gt;89.7392 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;89.6514 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regex&lt;/td&gt;
&lt;td&gt;.NET 6.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;57.8247 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;57.8031 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CompiledRegex&lt;/td&gt;
&lt;td&gt;.NET 6.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;21.2952 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;21.2616 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regex&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;47.2579 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;47.3506 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CompiledRegex&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;24.2419 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;24.2547 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src="https://blog.rogatnev.net/img/string-of-digits/regex_compiled.png" alt="Regex &amp;amp; Compiled Regex"&gt;&lt;/p&gt;
&lt;p&gt;The advantage of using compiled expressions is quite obvious. Also, with each new version of .NET, the contribution of
developers to performance is more visible. This is another argument in favor of upgrading to modern versions of the .NET
Framework.&lt;/p&gt;
&lt;h1 id="regex-source-generators"&gt;Regex source generators&lt;/h1&gt;
&lt;p&gt;Again, compiling regular expressions has one drawback - creating a &lt;code&gt;Regex&lt;/code&gt; object at runtime will take some time. Is it
possible to get rid of this?&lt;/p&gt;
&lt;p&gt;Starting with .NET 7, this capability is available
through &lt;a href="https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/#source-generators"&gt;source generators&lt;/a&gt;.
Strictly speaking, source generators appeared in .NET 5, but the regular expression solution was implemented only in the
seventh version.&lt;/p&gt;
&lt;p&gt;Source generators allow you to create C# code at &lt;strong&gt;compile&lt;/strong&gt; time, which means you can view and debug it as if it was
your own code.
And regular expressions can be converted to C# code at compile time! .NET has a special attribute for this -
&lt;code&gt;GeneratedRegex&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;namespace DigitBenchmark
{
    public partial class DigitsBenchmarks
    {
        private static readonly Regex generatedRegex = GenerateRegex();
        
        [GeneratedRegex("^[0-9]*$")]
        private static partial Regex GenerateRegex();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let's figure out what's going on here.&lt;/p&gt;
&lt;p&gt;First, we need to mark our &lt;code&gt;DigitsBenchmarks&lt;/code&gt; class as &lt;code&gt;partial&lt;/code&gt;, since some of the generated code for this class will
be in another file.
Next we need to create a &lt;code&gt;partial&lt;/code&gt; method that will return an object of type &lt;code&gt;Regex&lt;/code&gt; and mark it with the
&lt;code&gt;GeneratedRegex&lt;/code&gt; attribute specifying the regular expression pattern.
You don't need to specify the &lt;code&gt;RegexOptions.Compiled&lt;/code&gt; option, it will be ignored.&lt;/p&gt;
&lt;p&gt;The implementation of the &lt;code&gt;GenerateRegex&lt;/code&gt; method will be in another file. You can find it in the project and view the
source code:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;namespace DigitBenchmark
{
    partial class DigitsBenchmarks
    {
        /// &amp;lt;remarks&amp;gt;
        /// Pattern:&amp;lt;br/&amp;gt;
        /// &amp;lt;code&amp;gt;^[0-9]*$&amp;lt;/code&amp;gt;&amp;lt;br/&amp;gt;
        /// Explanation:&amp;lt;br/&amp;gt;
        /// &amp;lt;code&amp;gt;
        /// ○ Match if at the beginning of the string.&amp;lt;br/&amp;gt;
        /// ○ Match a character in the set [0-9] atomically any number of times.&amp;lt;br/&amp;gt;
        /// ○ Match if at the end of the string or if before an ending newline.&amp;lt;br/&amp;gt;
        /// &amp;lt;/code&amp;gt;
        /// &amp;lt;/remarks&amp;gt;
        [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Text.RegularExpressions.Generator", "8.0.12.21506")]
        private static partial global::System.Text.RegularExpressions.Regex GenerateRegex() =&amp;gt; global::System.Text.RegularExpressions.Generated.GenerateRegex_0.Instance;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As you can see, the file with the same class was automatically created and contains the implementation of our method for
generating a regular expression.&lt;/p&gt;
&lt;p&gt;And then we can use this &lt;code&gt;Regex&lt;/code&gt; object as usual. Since this is real C# code, there is no need to generate anything at
runtime, so there is no point in specifying the &lt;code&gt;RegexOptions.Compiled&lt;/code&gt; option.&lt;/p&gt;
&lt;p&gt;What benefit do we get from this? My benchmarks do not include .NET 7 and 8, let's compare the performance of the latest
one at the moment:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Mean&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Median&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Allocated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Regex&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;47.2579 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;47.3506 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CompiledRegex&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;24.2419 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;24.2547 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GeneratedRegex&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;17.2548 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;17.2603 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;We see that the time has been reduced by almost 30%! The compiler has much more opportunity to optimize the source
code at the compilation stage than at runtime.&lt;/p&gt;
&lt;h1 id="char.isdigit"&gt;char.IsDigit&lt;/h1&gt;
&lt;p&gt;Another popular way is to use the static &lt;code&gt;char.IsDigit&lt;/code&gt; method in combination with the LINQ &lt;code&gt;All&lt;/code&gt; method:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var value = "123456789000";
var isValid = value.All(char.IsDigit);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let's check how good this method is in terms of performance.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Benchmark code. Click to expand.&lt;/summary&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net90)]
[SimpleJob(RuntimeMoniker.Net60)]
[SimpleJob(RuntimeMoniker.NetCoreApp31)]
[SimpleJob(RuntimeMoniker.Net48)]
[HideColumns("Job", "Error", "StdDev", "Gen0")]
public partial class DigitsBenchmarks
{
    [Benchmark]
    public bool LinqCharIsDigit()
    {
        return value.All(char.IsDigit);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;p&gt;Results:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Mean&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Median&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Allocated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LinqCharIsDigit&lt;/td&gt;
&lt;td&gt;.NET Framework 4.8&lt;/td&gt;
&lt;td style="text-align: right;"&gt;92.1679 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;92.2549 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;96 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinqCharIsDigit&lt;/td&gt;
&lt;td&gt;.NET Core 3.1&lt;/td&gt;
&lt;td style="text-align: right;"&gt;72.0987 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;72.6419 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;96 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinqCharIsDigit&lt;/td&gt;
&lt;td&gt;.NET 6.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;74.2609 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;74.4256 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;96 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinqCharIsDigit&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;31.0294 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;31.0501 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;32 B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;And let's compare this method with previous solutions.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.rogatnev.net/img/string-of-digits/regex_isdigit.png" alt="Regex vs char.IsDigit"&gt;&lt;/p&gt;
&lt;p&gt;If in older versions of the framework, LINQ validation and the &lt;code&gt;IsDigit&lt;/code&gt; method has an advantage over regular
expressions, then later we see that such an implementation starts to lose.&lt;/p&gt;
&lt;p&gt;Also, please note that each call results in the allocation of additional memory. This value consists of two parts:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Creating a lambda expression in a method parameter &lt;code&gt;All(c =&amp;gt; char.IsDigit(c))&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Creating an iterator inside the &lt;code&gt;All&lt;/code&gt; method.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;It is noteworthy that .NET 9 allocates three times less memory.
Before .NET 9, the &lt;code&gt;All&lt;/code&gt; method consisted of a &lt;code&gt;foreach&lt;/code&gt; loop with a condition:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public static bool All&amp;lt;TSource&amp;gt;(this IEnumerable&amp;lt;TSource&amp;gt; source, Func&amp;lt;TSource, bool&amp;gt; predicate)
{
  //...
  foreach (TSource source1 in source)
  {
    if (!predicate(source1))
      return false;
  }
  return true;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But in .NET 9, an important optimization was added:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public static bool All&amp;lt;TSource&amp;gt;(this IEnumerable&amp;lt;TSource&amp;gt; source, Func&amp;lt;TSource, bool&amp;gt; predicate)
{
  //...
  ReadOnlySpan&amp;lt;TSource&amp;gt; span;
  if (source.TryGetSpan&amp;lt;TSource&amp;gt;(out span))
  {
    ReadOnlySpan&amp;lt;TSource&amp;gt; readOnlySpan = span;
    for (int index = 0; index &amp;lt; readOnlySpan.Length; ++index)
    {
      TSource source1 = readOnlySpan[index];
      if (!predicate(source1))
        return false;
    }
  }
  else
  {
    foreach (TSource source2 in source)
    {
      if (!predicate(source2))
        return false;
    }
  }
  return true;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Instead of an unconditional &lt;code&gt;foreach&lt;/code&gt; loop, the &lt;code&gt;All&lt;/code&gt; method tries to get a &lt;code&gt;ReadOnlySpan&lt;/code&gt; - a read-safe contiguous
block of memory - from the source.
And then a simple &lt;code&gt;for&lt;/code&gt; loop is used, which does not lead to the creation of an iterator, reducing the amount of
additional memory.
You can get rid of this completely by rewriting the &lt;code&gt;All&lt;/code&gt; method to a regular loop:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public bool ForIsDigit()
{
    for (var i = 0; i &amp;lt; value.Length; i++)
    {
        if (!char.IsDigit(value[i]))
            return false;
    }

    return true;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In addition to the absence of unnecessary memory traffic, this solution is rapid.
&lt;img src="https://blog.rogatnev.net/img/string-of-digits/linq_for.png" alt="LINQ vs for"&gt;&lt;/p&gt;
&lt;h1 id="what-is-a-digit"&gt;What is a digit?&lt;/h1&gt;
&lt;p&gt;It seems that we have found the optimal solution for checking a string for the presence of only digits.
But try to guess what the following code will output:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;Console.WriteLine(char.IsDigit('0'));
Console.WriteLine(char.IsDigit('a'));
Console.WriteLine(char.IsDigit('٨'));
Console.WriteLine(char.IsDigit('৯'));
&lt;/code&gt;&lt;/pre&gt;
&lt;details&gt;
&lt;summary&gt;Click to find out the answer.&lt;/summary&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;Console.WriteLine(char.IsDigit('0')); //True
Console.WriteLine(char.IsDigit('a')); //False
Console.WriteLine(char.IsDigit('٨')); //True
Console.WriteLine(char.IsDigit('৯')); //True
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;p&gt;I think the result surprises you. But there is nothing unusual about it, the &lt;code&gt;IsDigit&lt;/code&gt; method considers as
digits not only the usual symbols from the set &lt;code&gt;0-9&lt;/code&gt;, but also all other symbols that are related to digits in the
Unicode encoding. And there are actually a lot of them.
This can be a problem if you rely on such a check in your business code.&lt;/p&gt;
&lt;p&gt;I think this is the reason for the new &lt;code&gt;char.IsAsciiDigit&lt;/code&gt; method to appear starting with .NET 7. Now it really only
checks characters from the &lt;code&gt;0-9&lt;/code&gt; set.&lt;/p&gt;
&lt;p&gt;Its implementation is very similar to manually checking each character in the loop, let's compare both solutions:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;[Benchmark]
public bool ForCompare()
{
    for (var i = 0; i &amp;lt; value.Length; i++)
    {
        if (value[i] &amp;lt; '0' || value[i] &amp;gt; '9')
            return false;
    }

    return true;
}

[Benchmark]
public bool ForIsAsciiDigit()
{
    for (var i = 0; i &amp;lt; value.Length; i++)
    {
        if (!char.IsAsciiDigit(value[i]))
            return false;
    }

    return true;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Mean&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Median&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Allocated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ForCompare&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;4.8587 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;4.8656 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ForIsAsciiDigit&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;4.7515 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;4.4976 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Both methods show equivalent results.&lt;/p&gt;
&lt;h1 id="conclusion"&gt;Conclusion&lt;/h1&gt;
&lt;p&gt;We've looked at several different ways to check whether a string consists only of digits or not.
And it's important to understand the specifics of how some of them work, because in addition to trivial performance
issues, you can get errors in your business logic if you don't check your input data well enough.&lt;/p&gt;
&lt;p&gt;Recommendations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If you write your applications for .NET 7 or higher, then use the generated regular expressions. Otherwise, specify
the &lt;code&gt;RegexOptions.Compiled&lt;/code&gt; option.&lt;/li&gt;
&lt;li&gt;If you write your applications for .NET 7, use the &lt;code&gt;char.IsAsciiDigit&lt;/code&gt; method to check characters. Otherwise, it is
better to write the check yourself.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1 id="links"&gt;Links&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/dotnet/standard/base-types/compilation-and-reuse-in-regular-expressions"&gt;Compilation and reuse in regular expressions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/dotnet/api/system.char.isdigit?view=net-9.0"&gt;char.IsDigit&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/dotnet/api/system.char.isasciidigit?view=net-9.0"&gt;char.IsAsciiDigit&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;details&gt;
&lt;summary&gt;Full benchmark code. Click to expand.&lt;/summary&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;using System.Linq;
using System.Text.RegularExpressions;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;

namespace DigitBenchmark
{
    [MemoryDiagnoser]
    [SimpleJob(RuntimeMoniker.Net90)]
    [SimpleJob(RuntimeMoniker.Net60)]
    [SimpleJob(RuntimeMoniker.NetCoreApp31)]
    [SimpleJob(RuntimeMoniker.Net48)]
    [HideColumns("Job", "Error", "StdDev", "Gen0")]
    public partial class DigitsBenchmarks
    {
        private static string value = "123456789000";

        private static readonly Regex regex = new Regex("^[0-9]*$");
        private static readonly Regex compiledRegex = new Regex("^[0-9]*$", RegexOptions.Compiled);
        private static readonly Regex generatedRegex = GenerateRegex();

        [GeneratedRegex("^[0-9]*$")]
        private static partial Regex GenerateRegex();

        [Benchmark]
        public bool Regex()
        {
            return regex.IsMatch(value);
        }
        
        [Benchmark]
        public bool CompiledRegex()
        {
            return compiledRegex.IsMatch(value);
        }

        [Benchmark]
        public bool GeneratedRegex()
        {
            return generatedRegex.IsMatch(value);
        }

        [Benchmark]
        public bool LinqCharIsDigit()
        {
            return value.All(char.IsDigit);
        }

        [Benchmark]
        public bool ForCompare()
        {
            for (var i = 0; i &amp;lt; value.Length; i++)
            {
                if (value[i] &amp;lt; '0' || value[i] &amp;gt; '9')
                    return false;
            }

            return true;
        }

        [Benchmark]
        public bool ForIsDigit()
        {
            for (var i = 0; i &amp;lt; value.Length; i++)
            {
                if (!char.IsDigit(value[i]))
                    return false;
            }

            return true;
        }

        [Benchmark]
        public bool ForIsAsciiDigit()
        {
            for (var i = 0; i &amp;lt; value.Length; i++)
            {
                if (!char.IsAsciiDigit(value[i]))
                    return false;
            }

            return true;
        }

        [Benchmark]
        public bool LinqCharIsAsciiDigit()
        {
            return value.All(char.IsAsciiDigit);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;details&gt;
&lt;summary&gt;Full results. Click to expand.&lt;/summary&gt;
&lt;pre&gt;&lt;code&gt;BenchmarkDotNet v0.15.0, Windows 10 (10.0.19045.5917/22H2/2022Update)
AMD Ryzen 7 7840H with Radeon 780M Graphics 3.80GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 9.0.204
  [Host]             : .NET 9.0.5 (9.0.525.21509), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  .NET 6.0           : .NET 6.0.36 (6.0.3624.51421), X64 RyuJIT AVX2
  .NET 9.0           : .NET 9.0.5 (9.0.525.21509), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  .NET Core 3.1      : .NET Core 3.1.32 (CoreCLR 4.700.22.55902, CoreFX 4.700.22.56512), X64 RyuJIT AVX2
  .NET Framework 4.8 : .NET Framework 4.8.1 (4.8.9310.0), X64 RyuJIT VectorSize=256
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Mean&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Median&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Allocated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Regex&lt;/td&gt;
&lt;td&gt;.NET Framework 4.8&lt;/td&gt;
&lt;td style="text-align: right;"&gt;165.4417 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;166.2537 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CompiledRegex&lt;/td&gt;
&lt;td&gt;.NET Framework 4.8&lt;/td&gt;
&lt;td style="text-align: right;"&gt;115.9377 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;115.9720 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GeneratedRegex&lt;/td&gt;
&lt;td&gt;.NET Framework 4.8&lt;/td&gt;
&lt;td style="text-align: right;"&gt;N/A&lt;/td&gt;
&lt;td style="text-align: right;"&gt;N/A&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinqCharIsDigit&lt;/td&gt;
&lt;td&gt;.NET Framework 4.8&lt;/td&gt;
&lt;td style="text-align: right;"&gt;92.1679 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;92.2549 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;96 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ForCompare&lt;/td&gt;
&lt;td&gt;.NET Framework 4.8&lt;/td&gt;
&lt;td style="text-align: right;"&gt;4.8703 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;4.8602 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ForIsDigit&lt;/td&gt;
&lt;td&gt;.NET Framework 4.8&lt;/td&gt;
&lt;td style="text-align: right;"&gt;8.3920 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;8.3952 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ForIsAsciiDigit&lt;/td&gt;
&lt;td&gt;.NET Framework 4.8&lt;/td&gt;
&lt;td style="text-align: right;"&gt;N/A&lt;/td&gt;
&lt;td style="text-align: right;"&gt;N/A&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinqCharIsAsciiDigit&lt;/td&gt;
&lt;td&gt;.NET Framework 4.8&lt;/td&gt;
&lt;td style="text-align: right;"&gt;N/A&lt;/td&gt;
&lt;td style="text-align: right;"&gt;N/A&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regex&lt;/td&gt;
&lt;td&gt;.NET Core 3.1&lt;/td&gt;
&lt;td style="text-align: right;"&gt;118.1540 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;118.1887 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CompiledRegex&lt;/td&gt;
&lt;td&gt;.NET Core 3.1&lt;/td&gt;
&lt;td style="text-align: right;"&gt;89.7392 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;89.6514 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GeneratedRegex&lt;/td&gt;
&lt;td&gt;.NET Core 3.1&lt;/td&gt;
&lt;td style="text-align: right;"&gt;N/A&lt;/td&gt;
&lt;td style="text-align: right;"&gt;N/A&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinqCharIsDigit&lt;/td&gt;
&lt;td&gt;.NET Core 3.1&lt;/td&gt;
&lt;td style="text-align: right;"&gt;72.0987 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;72.6419 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;96 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ForCompare&lt;/td&gt;
&lt;td&gt;.NET Core 3.1&lt;/td&gt;
&lt;td style="text-align: right;"&gt;5.3070 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;5.3077 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ForIsDigit&lt;/td&gt;
&lt;td&gt;.NET Core 3.1&lt;/td&gt;
&lt;td style="text-align: right;"&gt;9.0998 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;9.1106 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ForIsAsciiDigit&lt;/td&gt;
&lt;td&gt;.NET Core 3.1&lt;/td&gt;
&lt;td style="text-align: right;"&gt;N/A&lt;/td&gt;
&lt;td style="text-align: right;"&gt;N/A&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinqCharIsAsciiDigit&lt;/td&gt;
&lt;td&gt;.NET Core 3.1&lt;/td&gt;
&lt;td style="text-align: right;"&gt;N/A&lt;/td&gt;
&lt;td style="text-align: right;"&gt;N/A&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regex&lt;/td&gt;
&lt;td&gt;.NET 6.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;57.8247 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;57.8031 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CompiledRegex&lt;/td&gt;
&lt;td&gt;.NET 6.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;21.2952 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;21.2616 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GeneratedRegex&lt;/td&gt;
&lt;td&gt;.NET 6.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;N/A&lt;/td&gt;
&lt;td style="text-align: right;"&gt;N/A&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinqCharIsDigit&lt;/td&gt;
&lt;td&gt;.NET 6.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;74.2609 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;74.4256 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;96 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ForCompare&lt;/td&gt;
&lt;td&gt;.NET 6.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;5.6198 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;5.5922 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ForIsDigit&lt;/td&gt;
&lt;td&gt;.NET 6.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;10.3160 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;10.1789 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ForIsAsciiDigit&lt;/td&gt;
&lt;td&gt;.NET 6.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;N/A&lt;/td&gt;
&lt;td style="text-align: right;"&gt;N/A&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinqCharIsAsciiDigit&lt;/td&gt;
&lt;td&gt;.NET 6.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;N/A&lt;/td&gt;
&lt;td style="text-align: right;"&gt;N/A&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regex&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;47.2579 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;47.3506 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CompiledRegex&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;24.2419 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;24.2547 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GeneratedRegex&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;17.2548 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;17.2603 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinqCharIsDigit&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;31.0294 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;31.0501 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;32 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ForCompare&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;4.8587 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;4.8656 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ForIsDigit&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;7.1207 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;7.7792 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ForIsAsciiDigit&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;4.7515 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;4.4976 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinqCharIsAsciiDigit&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;32.6861 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;32.5483 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;32 B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;N/A&lt;/code&gt; mark is given to methods that do not exist in the current version of the framework.&lt;/li&gt;
&lt;/ul&gt;
&lt;/details&gt;
</content:encoded>
			<comments xmlns="http://purl.org/rss/1.0/modules/slash/">0</comments>
		</item>
		<item>
			<title>Строка из чисел</title>
			<link>https://blog.rogatnev.net/posts/ru/2025/06/String-of-Digits.html</link>
			<description>Рассматриваем разные подходы к проверке цифровых строк в C# - сравнение методов, эффективность и готовые примеры.</description>
			<author>Сергей Рогатнев</author>
			<guid>https://blog.rogatnev.net/posts/ru/2025/06/String-of-Digits.html</guid>
			<pubDate>Sat, 07 Jun 2025 00:00:00 GMT</pubDate>
			<content:encoded>&lt;p&gt;Популярная задача — определить, состоит ли строка только из числовых символов. Например, нужно проверить, что пользователь правильно ввел номер телефона, индекс или ИНН организации.
Сделать это можно несколькими способами, которые отличаются своей эффективностью. Давайте рассмотрим самые популярные из них.&lt;/p&gt;
&lt;!--more--&gt;
&lt;h1 id="regex"&gt;Regex&lt;/h1&gt;
&lt;p&gt;Наверное, это наиболее часто встречающийся способ. И действительно, что может быть проще использования регулярного выражения вида &lt;code&gt;^[0-9]*$&lt;/code&gt; (или &lt;code&gt;^\d*$&lt;/code&gt;)?&lt;/p&gt;
&lt;p&gt;Ниже представлена наивная реализация проверки с помощью регулярного выражения:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;Regex regex = new Regex("^[0-9]*$");
var value = "123456789000";
var isValid = regex.IsMatch(value);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Возможно, вы уже видите здесь проблему. Такая реализация годится только для одноразового запуска. В промышленном коде, где вы проверяете сотни тысяч строк, такое решение будет не эффективным.
.NET предоставляет возможность скомпилировать регулярное выражение во время выполнения при вызове конструктора, для это нужно использовать опцию &lt;code&gt;RegexOptions.Compiled&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;Regex regex = new Regex("^[0-9]*$", RegexOptions.Compiled);
var value = "123456789000";
var isValid = regex.IsMatch(value);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;При вызове конструктора с этой опцией будет сгенерирован IL-код, который будет вызываться через &lt;code&gt;DynamicMethod&lt;/code&gt; внутри &lt;code&gt;Regex.IsMatch&lt;/code&gt;, что будет быстрее, чем обычная обработка регулярного выражения.
Минусом же будет более долгое создание объекта &lt;code&gt;Regex&lt;/code&gt; за счет затрат времени на компиляцию в рантайме, но это быстро окупается при многократном использовании.&lt;/p&gt;
&lt;p&gt;Давайте сравним производительность двух вариантов.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Код бенчмарка. Нажмите, чтобы развернуть.&lt;/summary&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net90)]
[SimpleJob(RuntimeMoniker.Net60)]
[SimpleJob(RuntimeMoniker.NetCoreApp31)]
[SimpleJob(RuntimeMoniker.Net48)]
[HideColumns("Job", "Error", "StdDev", "Gen0")]
public partial class DigitsBenchmarks
{
    private static string value = "123456789000";

    private static readonly Regex regex = new Regex("^[0-9]*$");
    private static readonly Regex compiledRegex = new Regex("^[0-9]*$", RegexOptions.Compiled);

    [Benchmark]
    public bool Regex()
    {
        return regex.IsMatch(value);
    }
    
    [Benchmark]
    public bool CompiledRegex()
    {
        return compiledRegex.IsMatch(value);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;p&gt;Результаты:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Mean&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Median&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Allocated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Regex&lt;/td&gt;
&lt;td&gt;.NET Framework 4.8&lt;/td&gt;
&lt;td style="text-align: right;"&gt;165.4417 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;166.2537 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CompiledRegex&lt;/td&gt;
&lt;td&gt;.NET Framework 4.8&lt;/td&gt;
&lt;td style="text-align: right;"&gt;115.9377 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;115.9720 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regex&lt;/td&gt;
&lt;td&gt;.NET Core 3.1&lt;/td&gt;
&lt;td style="text-align: right;"&gt;118.1540 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;118.1887 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CompiledRegex&lt;/td&gt;
&lt;td&gt;.NET Core 3.1&lt;/td&gt;
&lt;td style="text-align: right;"&gt;89.7392 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;89.6514 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regex&lt;/td&gt;
&lt;td&gt;.NET 6.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;57.8247 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;57.8031 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CompiledRegex&lt;/td&gt;
&lt;td&gt;.NET 6.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;21.2952 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;21.2616 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regex&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;47.2579 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;47.3506 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CompiledRegex&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;24.2419 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;24.2547 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src="https://blog.rogatnev.net/img/string-of-digits/regex_compiled.png" alt="Regex &amp;amp; Compiled Regex"&gt;&lt;/p&gt;
&lt;p&gt;Преимущество использования скомпилированных выражений довольно наглядно. Так же с каждой новой версией .NET виден вклад разработчиков в производительность.
Это еще один аргумент в пользу обновления на современные версии фреймворка.&lt;/p&gt;
&lt;h1 id="regex-source-generators"&gt;Regex source generators&lt;/h1&gt;
&lt;p&gt;Повторюсь, что компиляция регулярных выражений имеет один недостаток — создание объекта &lt;code&gt;Regex&lt;/code&gt; в рантайме будет занимать какое-то время. Можно ли от этого избавиться?
Начиная с .NET 7 такая возможность появляется благодаря &lt;a href="https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/#source-generators"&gt;генераторам кода&lt;/a&gt;. Строго говоря, они появились в .NET 5, но решение для регулярных выражений было реализовано только в седьмой версии.
Генераторы кода позволяют создавать C#-код на этапе &lt;strong&gt;компиляции&lt;/strong&gt;. А значит его можно просматривать и дебажить так, словно это ваш собственный код.
И регулярные выражения можно превратить в C#-код на этапе компиляции! В .NET для этого реализован специальный атрибут &lt;code&gt;GeneratedRegex&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;namespace DigitBenchmark
{
    public partial class DigitsBenchmarks
    {
        private static readonly Regex generatedRegex = GenerateRegex();
        
        [GeneratedRegex("^[0-9]*$")]
        private static partial Regex GenerateRegex();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Давайте разберемся, что здесь происходит.
Во-первых, нам нужно пометить наш класс &lt;code&gt;DigitsBenchmarks&lt;/code&gt; как &lt;code&gt;partial&lt;/code&gt;, т.к. часть сгенерированного кода для этого класса будет находиться в другом файле.
Дальше нам нужно создать &lt;code&gt;partial&lt;/code&gt;-метод, который будет возвращать объект типа &lt;code&gt;Regex&lt;/code&gt; и пометить его атрибутом &lt;code&gt;GeneratedRegex&lt;/code&gt; с указанием шаблона регулярного выражения.
Опцию &lt;code&gt;RegexOptions.Compiled&lt;/code&gt; указывать не нужно, она будет проигнорирована. Далее поймете почему.&lt;/p&gt;
&lt;p&gt;Реализация метода &lt;code&gt;GenerateRegex&lt;/code&gt; будет находиться в другом файле. Его можно найти в проекте и посмотреть исходный код:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;namespace DigitBenchmark
{
    partial class DigitsBenchmarks
    {
        /// &amp;lt;remarks&amp;gt;
        /// Pattern:&amp;lt;br/&amp;gt;
        /// &amp;lt;code&amp;gt;^[0-9]*$&amp;lt;/code&amp;gt;&amp;lt;br/&amp;gt;
        /// Explanation:&amp;lt;br/&amp;gt;
        /// &amp;lt;code&amp;gt;
        /// ○ Match if at the beginning of the string.&amp;lt;br/&amp;gt;
        /// ○ Match a character in the set [0-9] atomically any number of times.&amp;lt;br/&amp;gt;
        /// ○ Match if at the end of the string or if before an ending newline.&amp;lt;br/&amp;gt;
        /// &amp;lt;/code&amp;gt;
        /// &amp;lt;/remarks&amp;gt;
        [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Text.RegularExpressions.Generator", "8.0.12.21506")]
        private static partial global::System.Text.RegularExpressions.Regex GenerateRegex() =&amp;gt; global::System.Text.RegularExpressions.Generated.GenerateRegex_0.Instance;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Как видите, автоматически был создан файл с тем же классом и содержащий реализацию нашего метода по генерации регулярного выражения.
А дальше мы можем пользоваться этим объектом &lt;code&gt;Regex&lt;/code&gt; как обычно. Так как это настоящий C#-код, то генерировать в рантайме ничего не нужно, поэтому и указывать опцию &lt;code&gt;RegexOptions.Compiled&lt;/code&gt; нет смысла.&lt;/p&gt;
&lt;p&gt;Какое преимущество мы от этого получим? В моих бенчмарках нет версий .NET 7 и 8, сравним производительность по последней на данный момент:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Mean&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Median&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Allocated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Regex&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;47.2579 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;47.3506 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CompiledRegex&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;24.2419 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;24.2547 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GeneratedRegex&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;17.2548 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;17.2603 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Видим, что время сократилось почти на 30%! Компилятор имеет намного больше возможностей для оптимизации исходного кода на этапе компиляции, чем в рантайме.&lt;/p&gt;
&lt;h1 id="char.isdigit"&gt;char.IsDigit&lt;/h1&gt;
&lt;p&gt;Еще один популярный способ — использование статического метода &lt;code&gt;char.IsDigit&lt;/code&gt; в сочетании с LINQ-методом &lt;code&gt;All&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;var value = "123456789000";
var isValid = value.All(char.IsDigit);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Давайте проверим, насколько хорош этот метод с точки зрения производительности.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Код бенчмарка. Нажмите, чтобы развернуть.&lt;/summary&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net90)]
[SimpleJob(RuntimeMoniker.Net60)]
[SimpleJob(RuntimeMoniker.NetCoreApp31)]
[SimpleJob(RuntimeMoniker.Net48)]
[HideColumns("Job", "Error", "StdDev", "Gen0")]
public partial class DigitsBenchmarks
{
    [Benchmark]
    public bool LinqCharIsDigit()
    {
        return value.All(char.IsDigit);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;p&gt;Результаты:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Mean&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Median&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Allocated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LinqCharIsDigit&lt;/td&gt;
&lt;td&gt;.NET Framework 4.8&lt;/td&gt;
&lt;td style="text-align: right;"&gt;92.1679 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;92.2549 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;96 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinqCharIsDigit&lt;/td&gt;
&lt;td&gt;.NET Core 3.1&lt;/td&gt;
&lt;td style="text-align: right;"&gt;72.0987 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;72.6419 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;96 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinqCharIsDigit&lt;/td&gt;
&lt;td&gt;.NET 6.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;74.2609 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;74.4256 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;96 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinqCharIsDigit&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;31.0294 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;31.0501 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;32 B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;И сравним этот способ с предыдущими решениями.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.rogatnev.net/img/string-of-digits/regex_isdigit.png" alt="Regex vs char.IsDigit"&gt;&lt;/p&gt;
&lt;p&gt;Если в старых версиях фреймворка проверки через LINQ и метод &lt;code&gt;IsDigit&lt;/code&gt; имеют преимущество над регулярными выражениями, то позже мы видим, что такая реализация начинает проигрывать.&lt;/p&gt;
&lt;p&gt;Так же, обратите внимание, что каждый вызов такого метода приводит к выделению какого-то количества дополнительной памяти. Это значение складывается из 2-х составляющих:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Создание лямбда-выражения в параметре метода &lt;code&gt;All(c =&amp;gt; char.IsDigit(c))&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Создание итератора внутри метода &lt;code&gt;All&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Примечательно, что в версии .NET 9 выделяется в 3 раза меньше памяти.
До .NET 9 метод &lt;code&gt;All&lt;/code&gt; был очень простым и состоял из цикла &lt;code&gt;foreach&lt;/code&gt; с условием:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public static bool All&amp;lt;TSource&amp;gt;(this IEnumerable&amp;lt;TSource&amp;gt; source, Func&amp;lt;TSource, bool&amp;gt; predicate)
{
  //...
  foreach (TSource source1 in source)
  {
    if (!predicate(source1))
      return false;
  }
  return true;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Но в версии .NET 9 была добавлена важная оптимизация:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public static bool All&amp;lt;TSource&amp;gt;(this IEnumerable&amp;lt;TSource&amp;gt; source, Func&amp;lt;TSource, bool&amp;gt; predicate)
{
  //...
  ReadOnlySpan&amp;lt;TSource&amp;gt; span;
  if (source.TryGetSpan&amp;lt;TSource&amp;gt;(out span))
  {
    ReadOnlySpan&amp;lt;TSource&amp;gt; readOnlySpan = span;
    for (int index = 0; index &amp;lt; readOnlySpan.Length; ++index)
    {
      TSource source1 = readOnlySpan[index];
      if (!predicate(source1))
        return false;
    }
  }
  else
  {
    foreach (TSource source2 in source)
    {
      if (!predicate(source2))
        return false;
    }
  }
  return true;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Вместо безусловного цикла &lt;code&gt;foreach&lt;/code&gt; метод &lt;code&gt;All&lt;/code&gt; пробует получить из источника &lt;code&gt;ReadOnlySpan&lt;/code&gt; - безопасный для чтения непрерывный блок памяти.
И дальше используется простой цикл &lt;code&gt;for&lt;/code&gt;, который не приводит к созданию итератора. Тем самым уменьшая количество дополнительной памяти.
Полностью избавиться от этого можно переписав метод &lt;code&gt;All&lt;/code&gt; на обычный цикл:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;public bool ForIsDigit()
{
    for (var i = 0; i &amp;lt; value.Length; i++)
    {
        if (!char.IsDigit(value[i]))
            return false;
    }

    return true;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Помимо отсутствия лишнего memory-traffic данное решение является очень быстрым.
&lt;img src="https://blog.rogatnev.net/img/string-of-digits/linq_for.png" alt="LINQ vs for"&gt;&lt;/p&gt;
&lt;h1 id="section"&gt;Что такое число?&lt;/h1&gt;
&lt;p&gt;Казалось бы, мы нашли оптимальное решение для проверки строки на наличие только чисел.
Но предлагаю вам попробовать угадать, что выведет следующий код:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;Console.WriteLine(char.IsDigit('0'));
Console.WriteLine(char.IsDigit('a'));
Console.WriteLine(char.IsDigit('٨'));
Console.WriteLine(char.IsDigit('৯'));
&lt;/code&gt;&lt;/pre&gt;
&lt;details&gt;
&lt;summary&gt;Нажмите, чтобы узнать ответ.&lt;/summary&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;Console.WriteLine(char.IsDigit('0')); //True
Console.WriteLine(char.IsDigit('a')); //False
Console.WriteLine(char.IsDigit('٨')); //True
Console.WriteLine(char.IsDigit('৯')); //True
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;p&gt;Думаю, вы удивлены результатом. Но в этом нет ничего необычного, метод &lt;code&gt;IsDigit&lt;/code&gt; считает числами не только привычные нам символы из множества &lt;code&gt;0-9&lt;/code&gt;, но и все остальные символы, которые в кодировке Unicode относятся к числам. А их на самом деле много.
Это может быть проблемой, если вы опираетесь на такую проверку в своём бизнес-коде.&lt;/p&gt;
&lt;p&gt;Думаю, это послужило причиной появления нового метода &lt;code&gt;char.IsAsciiDigit&lt;/code&gt; начиная с .NET 7. Вот он уже действительно проверяет только символы из множества &lt;code&gt;0-9&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Его реализация очень похожа на ручную проверку каждого символа в цикле, давай сравним оба варианта:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;[Benchmark]
public bool ForCompare()
{
    for (var i = 0; i &amp;lt; value.Length; i++)
    {
        if (value[i] &amp;lt; '0' || value[i] &amp;gt; '9')
            return false;
    }

    return true;
}

[Benchmark]
public bool ForIsAsciiDigit()
{
    for (var i = 0; i &amp;lt; value.Length; i++)
    {
        if (!char.IsAsciiDigit(value[i]))
            return false;
    }

    return true;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Mean&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Median&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Allocated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ForCompare&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;4.8587 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;4.8656 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ForIsAsciiDigit&lt;/td&gt;
&lt;td&gt;.NET 9.0&lt;/td&gt;
&lt;td style="text-align: right;"&gt;4.7515 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;4.4976 ns&lt;/td&gt;
&lt;td style="text-align: right;"&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Оба метода показывают эквивалентные результаты.&lt;/p&gt;
&lt;h1 id="section-1"&gt;Заключение&lt;/h1&gt;
&lt;p&gt;Мы рассмотрели несколько разных способов проверить строку, состоит ли она только из чисел или нет.
И важно понимать особенности работы некоторых из них, так как помимо банальных проблем производительности можно получить ошибки в бизнес-логике, если недостаточно качественно проверять входные данные.&lt;/p&gt;
&lt;p&gt;Рекомендации:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Если вы пишете свои приложения под .NET 7 или выше, то используйте сгенерированные регулярные выражения. В противном случае указывайте опцию &lt;code&gt;RegexOptions.Compiled&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Если вы пишете свои приложения под .NET 7 используйте метод &lt;code&gt;char.IsAsciiDigit&lt;/code&gt; для проверки символов. В противном случае лучше написать проверку самому.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1 id="section-2"&gt;Ссылки&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/dotnet/standard/base-types/compilation-and-reuse-in-regular-expressions"&gt;Компиляция и генерация регулярных выражений&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/dotnet/api/system.char.isdigit?view=net-9.0"&gt;char.IsDigit&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/dotnet/api/system.char.isasciidigit?view=net-9.0"&gt;char.IsAsciiDigit&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;details&gt;
&lt;summary&gt;Полный код бенчмарка. Нажмите, чтобы развернуть.&lt;/summary&gt;
&lt;pre&gt;&lt;code class="language-csharp"&gt;using System.Linq;
using System.Text.RegularExpressions;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;

namespace DigitBenchmark
{
    [MemoryDiagnoser]
    [SimpleJob(RuntimeMoniker.Net90)]
    [SimpleJob(RuntimeMoniker.Net60)]
    [SimpleJob(RuntimeMoniker.NetCoreApp31)]
    [SimpleJob(RuntimeMoniker.Net48)]
    [HideColumns("Job", "Error", "StdDev", "Gen0")]
    public partial class DigitsBenchmarks
    {
        private static string value = "123456789000";

        private static readonly Regex regex = new Regex("^[0-9]*$");
        private static readonly Regex compiledRegex = new Regex("^[0-9]*$", RegexOptions.Compiled);
        private static readonly Regex generatedRegex = GenerateRegex();

        [GeneratedRegex("^[0-9]*$")]
        private static partial Regex GenerateRegex();

        [Benchmark]
        public bool Regex()
        {
            return regex.IsMatch(value);
        }
        
        [Benchmark]
        public bool CompiledRegex()
        {
            return compiledRegex.IsMatch(value);
        }

        [Benchmark]
        public bool GeneratedRegex()
        {
            return generatedRegex.IsMatch(value);
        }

        [Benchmark]
        public bool LinqCharIsDigit()
        {
            return value.All(char.IsDigit);
        }

        [Benchmark]
        public bool ForCompare()
        {
            for (var i = 0; i &amp;lt; value.Length; i++)
            {
                if (value[i] &amp;lt; '0' || value[i] &amp;gt; '9')
                    return false;
            }

            return true;
        }

        [Benchmark]
        public bool ForIsDigit()
        {
            for (var i = 0; i &amp;lt; value.Length; i++)
            {
                if (!char.IsDigit(value[i]))
                    return false;
            }

            return true;
        }

        [Benchmark]
        public bool ForIsAsciiDigit()
        {
            for (var i = 0; i &amp;lt; value.Length; i++)
            {
                if (!char.IsAsciiDigit(value[i]))
                    return false;
            }

            return true;
        }

        [Benchmark]
        public bool LinqCharIsAsciiDigit()
        {
            return value.All(char.IsAsciiDigit);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;details&gt;
&lt;summary&gt;Все результаты бенчмарков. Нажмите, чтобы развернуть.&lt;/summary&gt;
&lt;pre&gt;&lt;code&gt;BenchmarkDotNet v0.15.0, Windows 10 (10.0.19045.5917/22H2/2022Update)
AMD Ryzen 7 7840H with Radeon 780M Graphics 3.80GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 9.0.204
  [Host]             : .NET 9.0.5 (9.0.525.21509), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  .NET 6.0           : .NET 6.0.36 (6.0.3624.51421), X64 RyuJIT AVX2
  .NET 9.0           : .NET 9.0.5 (9.0.525.21509), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  .NET Core 3.1      : .NET Core 3.1.32 (CoreCLR 4.700.22.55902, CoreFX 4.700.22.56512), X64 RyuJIT AVX2
  .NET Framework 4.8 : .NET Framework 4.8.1 (4.8.9310.0), X64 RyuJIT VectorSize=256

| Method               | Runtime            |        Mean |      Median | Allocated |
|----------------------|--------------------|------------:|------------:|----------:|
| Regex                | .NET Framework 4.8 | 165.4417 ns | 166.2537 ns |         - |
| CompiledRegex        | .NET Framework 4.8 | 115.9377 ns | 115.9720 ns |         - |
| GeneratedRegex       | .NET Framework 4.8 |         N/A |         N/A |         - |
| LinqCharIsDigit      | .NET Framework 4.8 |  92.1679 ns |  92.2549 ns |      96 B |
| ForCompare           | .NET Framework 4.8 |   4.8703 ns |   4.8602 ns |         - |
| ForIsDigit           | .NET Framework 4.8 |   8.3920 ns |   8.3952 ns |         - |
| ForIsAsciiDigit      | .NET Framework 4.8 |         N/A |         N/A |         - |
| LinqCharIsAsciiDigit | .NET Framework 4.8 |         N/A |         N/A |         - |
| Regex                | .NET Core 3.1      | 118.1540 ns | 118.1887 ns |         - |
| CompiledRegex        | .NET Core 3.1      |  89.7392 ns |  89.6514 ns |         - |
| GeneratedRegex       | .NET Core 3.1      |         N/A |         N/A |         - |
| LinqCharIsDigit      | .NET Core 3.1      |  72.0987 ns |  72.6419 ns |      96 B |
| ForCompare           | .NET Core 3.1      |   5.3070 ns |   5.3077 ns |         - |
| ForIsDigit           | .NET Core 3.1      |   9.0998 ns |   9.1106 ns |         - |
| ForIsAsciiDigit      | .NET Core 3.1      |         N/A |         N/A |         - |
| LinqCharIsAsciiDigit | .NET Core 3.1      |         N/A |         N/A |         - |
| Regex                | .NET 6.0           |  57.8247 ns |  57.8031 ns |         - |
| CompiledRegex        | .NET 6.0           |  21.2952 ns |  21.2616 ns |         - |
| GeneratedRegex       | .NET 6.0           |         N/A |         N/A |         - |
| LinqCharIsDigit      | .NET 6.0           |  74.2609 ns |  74.4256 ns |      96 B |
| ForCompare           | .NET 6.0           |   5.6198 ns |   5.5922 ns |         - |
| ForIsDigit           | .NET 6.0           |  10.3160 ns |  10.1789 ns |         - |
| ForIsAsciiDigit      | .NET 6.0           |         N/A |         N/A |         - |
| LinqCharIsAsciiDigit | .NET 6.0           |         N/A |         N/A |         - |
| Regex                | .NET 9.0           |  47.2579 ns |  47.3506 ns |         - |
| CompiledRegex        | .NET 9.0           |  24.2419 ns |  24.2547 ns |         - |
| GeneratedRegex       | .NET 9.0           |  17.2548 ns |  17.2603 ns |         - |
| LinqCharIsDigit      | .NET 9.0           |  31.0294 ns |  31.0501 ns |      32 B |
| ForCompare           | .NET 9.0           |   4.8587 ns |   4.8656 ns |         - |
| ForIsDigit           | .NET 9.0           |   7.1207 ns |   7.7792 ns |         - |
| ForIsAsciiDigit      | .NET 9.0           |   4.7515 ns |   4.4976 ns |         - |
| LinqCharIsAsciiDigit | .NET 9.0           |  32.6861 ns |  32.5483 ns |      32 B |
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Отметка &lt;code&gt;N/A&lt;/code&gt; ставится для методов, которые не существуют в данной версии фреймворка.&lt;/li&gt;
&lt;/ul&gt;
&lt;/details&gt;
</content:encoded>
			<comments xmlns="http://purl.org/rss/1.0/modules/slash/">0</comments>
		</item>
	</channel>
</rss>