Как-то было свободных полчаса перед встречей. Ни туда, ни сюда. Дай, думаю, сниму трейс с приложения. Вдруг что-то интересное найдётся.

А в качестве бонуса: использование var может привести к багам? Узнаем в самом конце ;)

Художественное отступление, шутка на текущую тему. Для того чтобы узнать самое интересное, читать текст под катом совершенно необязательно.

Казалось, что густые серые тучки, висевшие низко над землёй, задевали крыши домов. Тучи и дома располагались ровно по центру, оставляя место чистому горизонту снизу и голубому небу сверху.

Жаркое солнце опустилось за горизонт прямо промеж двух высоток. Теперь солнечные лучи не слепили, попадая прямо в глаза, а оставляли тёплый след на каждой тучке. Снизу оранжевые, аж с краснотой, а сверху серые, почти черные, они стали центральным элементом пейзажа.

На горизонте — рыже-жёлтая заря. Сверху — пока ещё голубое чистое небо. Кучерявость каждой тучки подчеркивалась оранжевыми и красными красками вперемешку с затенёнными, почти черными участками. Теперь каждая из них не просто серая масса, а уникальное и неповторимое буйство красок со своим собственным рельефом на фоне градиента от оранжевого до голубого.

Но это продлится лишь мгновение. Буквально несколько минут именно этого дня отведено на такое завораживающее зрелище. В такие редкие моменты, которые случаются далеко не каждый день и только в определённое время года, точно можно выделить хотя бы несколько минут своей жизни на то, чтобы провести время с чем-то прекрасным.

Разбор трейса

Перейдём сразу к делу. Вот трейс одного приложения с тестовой площадки. Обратимся сразу ко вкладке про аллокации памяти, про создание объектов.

В глаза бросается первая строчка. На неё приходится аж 12% всех аллокаций, она сильно опережает уже третью строчку и все остальные ниже. На ней и сконцентрируемся.

Начнём по порядку. Первая строка — некий IsPrefixOf. И даже понятно откуда — из класса ClusterConfigPath. Кликаем на бирюзовые 37 MB, так мы начнём изучать именно те 37 MB памяти, которые нааллоцировал непосредственно этот метод.

Нам несказанно повезло, этот метод аллоцирует объекты только одного типа: <get_SegmentsAsMemory>d__8. Как мы уже знаем, такие хитрые наименования — это имена автоматически сгенерированных типов. Отправляемся в код смотреть на метод IsPrefixOf.

Изучаем код

Код этого метода предельно простой. Можете пока не вчитываться в него:

private string path;
 
public bool IsPrefixOf(ClusterConfigPath otherPath)
{
    using var segments = SegmentsAsMemory.GetEnumerator();
    using var otherSegments = otherPath.SegmentsAsMemory.GetEnumerator();
    while (true)
    {
        if (!segments.MoveNext()) return true;
        if (!otherSegments.MoveNext()) return false;
        if (!segments.Current.Span.Equals(otherSegments.Current.Span, StringComparison.OrdinalIgnoreCase))
            return false;
    }
}
 
public IEnumerable<ReadOnlyMemory<char>> SegmentsAsMemory
{
    get
    {
        if (string.IsNullOrEmpty(path))
            yield break;
        var pathAsMemory = path.AsMemory();
        var segmentBeginning = 0;
        for (var i = 0; i < path.Length; i++)
        {
            var current = path[i];
            if (current == Separator)
            {
                if (i > segmentBeginning)
                    yield return pathAsMemory.Slice(segmentBeginning, i - segmentBeginning);
                segmentBeginning = i + 1;
            }
        }
        if (segmentBeginning < path.Length)
            yield return pathAsMemory.Slice(segmentBeginning, path.Length - segmentBeginning);
    }
}

Уже известная нам конструкция: код с yield return внутри getter-а SegmentsAsMemory превратился в объект IEnumerable. Когда мы вызываем свойство SegmentsAsMemory, на самом деле мы аллоцируем этот сгенерированный IEnumerable, который получил имя <get_SegmentsAsMemory>d__8. И уже с ним как-то работаем далее, в частности вызываем GetEnumerator. Более никакой аллокации, кроме как этой, в данном коде не видно. Что подтверждается и трейсом.

Код метода IsPrefixOf обладает замечательным свойством — работа с IEnumerable сконцентрирована внутри него: этот IEnumerable не возвращается из него и не передаётся ни в какой другой метод как аргумент. А это намекает нам на то, что необязательно аллоцировать объект на куче. Раз мы не предполагаем того, что в другом методе могут как-то модифицировать наш объект, и нам непременно нужно будет увидеть эти изменения, может быть, мы сможем обойтись структурой?

Изменяем код

И в самом деле. Методу IsPrefixOf нужен просто IEnumerator<ReadOnlyMemory<char>>. Никто не говорит, что им не может быть структура:

private struct SegmentEnumerator : IEnumerator<ReadOnlyMemory<char>>
{
    public bool MoveNext() => ...
    public void Reset() => ...
    public ReadOnlyMemory<char> Current { get } => ... 
    object IEnumerator.Current => Current;
    public void Dispose() => ...
}

Да, совершенно легально заставить структуру реализовывать какой-то интерфейс. Осталось только превратить код getter-а IEnumerator<ReadOnlyMemory<char>> SegmentsAsMemory, написанный с помощью yield return, в код с методами MoveNext и Current. Мы ровно этим уже занимались в этой истории, только делали это в классе, а не структуре, что в нашем случае ни на что не влияет.

Не будем ходить вокруг да около. Вот код получившегося Enumerator-а. Он выглядит эквивалентно предыдущему коду, написанному с помощью yield return (и тесты проходят). Можете изучить его, если хотите, но я опущу бесполезные методы вроде Reset и Dispose для экономии экранного места:

private struct SegmentEnumerator : IEnumerator<ReadOnlyMemory<char>>
{
    private readonly string path;
    private int index = 0;
    private int segmentBeginning = 0;
    private int from = 0;
 
    public SegmentEnumerator(string path) => this.path = path;
 
    public bool MoveNext()
    { 
        if (string.IsNullOrEmpty(path) || index == path.Length)
            return false;
 
        for (; index < path.Length; index++)
        {
            var current = path[index];
            if (current == Separator)
            {
                if (index > segmentBeginning)
                {
                    from = segmentBeginning;
                    segmentBeginning = index + 1;
                    return true;
                }
                segmentBeginning = index + 1;
            }
        }
 
        if (segmentBeginning < path.Length)
        {
            from = segmentBeginning;
            return true;
        }
        return false;
    }
 
    public ReadOnlyMemory<char> Current 
    {
        get => path.AsMemory().Slice(from, index - from);
    } 
}

Осталось использовать его в методе IsPrefixOf: вместо using var segments = SegmentsAsMemory.GetEnumerator() теперь у нас будет using var segments = new SegmentEnumerator(path);. Это единственное отличие. На всякий случай, вот получившийся код:

public bool IsPrefixOf(ClusterConfigPath otherPath)
{
    using var segments = new SegmentEnumerator(path);
    using var otherSegments = new SegmentEnumerator(otherPath.path);
    while (true)
    {
        if (!segments.MoveNext()) return true;
        if (!otherSegments.MoveNext()) return false;
        if (!segments.Current.Span.Equals(otherSegments.Current.Span, StringComparison.OrdinalIgnoreCase))
            return false;
    }
}

Ни один тест не пострадал.

Прибираемся

На самом деле этого не достаточно. Внимательный читатель мог заметить, что getter IEnumerator<ReadOnlyMemory<char>> SegmentsAsMemory был публичный. И нам нужно его поддержать. А дублировать код со сложной логикой, чтобы оставить без изменений имеющуюся реализацию с yield return, очень не хочется. Придётся поддерживать и тестировать две реализации одного и того же.

К счастью, эта задача предельно проста. От нас хотят некий IEnumerator<ReadOnlyMemory<char>>, давайте его и создадим. А внутри будем использовать наш уже готовый IEnumerator<ReadOnlyMemory<char>> (снова уберём лишние методы, чтобы сэкономить экранное место):

private class SegmentEnumerable : IEnumerable<ReadOnlyMemory<char>>
{
    private readonly string path;
 
    public SegmentEnumerable(string path) =>
        this.path = path;
 
    public IEnumerator<ReadOnlyMemory<char>> GetEnumerator() =>
        new SegmentEnumerator(path);
}

Важно понимать, что когда мы делаем return new SegmentEnumerator(path) (в методе GetEnumerator()), то мы возвращаем уже не структуру, а объект. Поскольку метод должен вернуть интерфейс, а мы возвращаем структуру, язык нам «помогает» и делает boxing. То есть оборачивает нашу структуру в сгенерированный класс. Сгенерированный класс содержит структуру как своё поле, а все методы просто проксирует в методы структуры.

Теперь getter выглядит вот так просто:

public IEnumerable<ReadOnlyMemory<char>> SegmentsAsMemory
{
    get
    {
        return new SegmentEnumerable(path);
    }
}

Справедливо задать вопрос: а можно ли и SegmentEnumerable сделать структурой? Конечно же можно, и это бы тоже было полезно, если бы мы где-то создавали SegmentEnumerable и работали с ним в рамках тела одного метода. Но у нас нет ни одного использования IEnumerator<ReadOnlyMemory<char>>, кроме возвращения из публичного getter-а SegmentsAsMemory. А раз это публичный getter, который возвращает интерфейс, то вернуть структуру нельзя. Если бы мы стали возвращать struct SegmentEnumerable : IEnumerator<ReadOnlyMemory<char>>, то .NET всё равно бы сделал boxing и обернул нашу структуру в объект. Вышло бы нисколько не лучше, чем если самостоятельно оперировать сразу объектом (классом).

Бенчмарк

Осталось проверить, принесли ли наши старания пользу. Сравним наши методы с помощью бенчмарка:

  • IsPrefixOf — «оригинальный» код до наших изменений, использующий так же «оригинальный» getter SegmentsAsMemory.

  • IsPrefixOfStruct — новый код метода IsPrefixOf, использующий новый struct SegmentEnumerator.

  • IsPrefixOfBoxed — бонусный метод. Это старая реализация IsPrefixOf, но использующая новую реализацию SegmentsAsMemory (возвращающую новый SegmentEnumerable, использующий внутри новый struct SegmentEnumerator). Этот метод призван продемонстрировать, что мы никак не ухудшили опыт использования публичного SegmentsAsMemory, который мог много где использоваться.

// * Summary *

BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.4061)
11th Gen Intel Core i7-11850H 2.50GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 9.0.200
  [Host]   : .NET 6.0.36 (6.0.3624.51421), X64 RyuJIT AVX2
  .Net 8.0 : .NET 8.0.15 (8.0.1525.16413), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI

Job=.Net 8.0  Runtime=.NET 8.0

| Method           | Path                         | OtherPath                  |      Mean | Allocated |
|------------------|------------------------------|----------------------------|----------:|----------:|
| IsPrefixOfStruct | vostok                       | vostok                     |  15.59 ns |         - |
| IsPrefixOf       | vostok                       | vostok                     |  44.37 ns |     160 B |
| IsPrefixOfBoxed  | vostok                       | vostok                     |  40.94 ns |     128 B |
|------------------|------------------------------|----------------------------|----------:|----------:|
| IsPrefixOfStruct | vostok                       | vostok/datacenters/mapping |  13.78 ns |         - |
| IsPrefixOf       | vostok                       | vostok/datacenters/mapping |  48.55 ns |     160 B |
| IsPrefixOfBoxed  | vostok                       | vostok/datacenters/mapping |  40.11 ns |     128 B |
|------------------|------------------------------|----------------------------|----------:|----------:|
| IsPrefixOfStruct | vostok/datacenters/whitelist | vostok                     |  20.82 ns |         - |
| IsPrefixOf       | vostok/datacenters/whitelist | vostok                     |  58.93 ns |     160 B |
| IsPrefixOfBoxed  | vostok/datacenters/whitelist | vostok                     |  57.16 ns |     128 B |
|------------------|------------------------------|----------------------------|----------:|----------:|
| IsPrefixOfStruct | vostok/datacenters/whitelist | vostok/datacenters/mapping |  52.33 ns |         - |
| IsPrefixOf       | vostok/datacenters/whitelist | vostok/datacenters/mapping | 114.89 ns |     160 B |
| IsPrefixOfBoxed  | vostok/datacenters/whitelist | vostok/datacenters/mapping | 106.74 ns |     128 B |

Бенчмарк не требует комментариев. Как и ожидалось, новая реализация метода не аллоцирует никаких объектов, чего мы и добивались. Вдобавок код получился попросту быстрее в два-три раза, что не может не радовать.

К тому же новая реализация свойства SegmentsAsMemory не только не пострадала, но и стала чуточку быстрее, стала аллоцировать меньше памяти.

Бонус: реализации целиком

Под катом расположен оригинальный код целиком
public IEnumerable<ReadOnlyMemory<char>> SegmentsAsMemory
{
    get
    {
        if (string.IsNullOrEmpty(path))
            yield break;
 
        var pathAsMemory = path.AsMemory();
        var segmentBeginning = 0;
 
        for (var i = 0; i < path.Length; i++)
        {
            var current = path[i];
            if (current == Separator)
            {
                if (i > segmentBeginning)
                    yield return pathAsMemory.Slice(segmentBeginning, i - segmentBeginning);
 
                segmentBeginning = i + 1;
            }
        }
 
        if (segmentBeginning < path.Length)
            yield return pathAsMemory.Slice(segmentBeginning, path.Length - segmentBeginning);
    }
}
 
public bool IsPrefixOf(ClusterConfigPath otherPath)
{
     using var segments = SegmentsAsMemory.GetEnumerator();
     using var otherSegments = otherPath.SegmentsAsMemory.GetEnumerator();
 
     while (true)
     {
         if (!segments.MoveNext())
             return true;
 
         if (!otherSegments.MoveNext())
             return false;
 
         if (!segments.Current.Span.Equals(otherSegments.Current.Span, StringComparison.OrdinalIgnoreCase))
             return false;
     }
}
Под катом расположен новый код целиком
public IEnumerable<ReadOnlyMemory<char>> SegmentsAsMemory
{
    get
    {
        return new SegmentEnumerable(path);
    }
}
 
public bool IsPrefixOf(ClusterConfigPath otherPath)
{
    using var segments = new SegmentEnumerator(path);
    using var otherSegments = new SegmentEnumerator(otherPath.path);
 
    while (true)
    {
        if (!segments.MoveNext())
            return true;
 
        if (!otherSegments.MoveNext())
            return false;
 
        if (!segments.Current.Span.Equals(otherSegments.Current.Span, StringComparison.OrdinalIgnoreCase))
            return false;
    }
}
 
private class SegmentEnumerable : IEnumerable<ReadOnlyMemory<char>>
{
    private readonly string path;
 
    public SegmentEnumerable(string path) =>
        this.path = path;
 
    public IEnumerator<ReadOnlyMemory<char>> GetEnumerator() =>
        new SegmentEnumerator(path);
 
    IEnumerator IEnumerable.GetEnumerator() =>
        GetEnumerator();
}
 
private struct SegmentEnumerator : IEnumerator<ReadOnlyMemory<char>>
{
    private readonly string path;
    private int index = 0;
    private int segmentBeginning = 0;
    private int from = 0;
 
    public SegmentEnumerator(string path) =>
        this.path = path;
 
    public bool MoveNext()
    { 
        if (string.IsNullOrEmpty(path) || index == path.Length)
            return false;
 
        for (; index < path.Length; index++)
        {
            var current = path[index];
            if (current == Separator)
            {
                if (index > segmentBeginning)
                {
                    from = segmentBeginning;
                    segmentBeginning = index + 1;
 
                    return true;
                }
 
                segmentBeginning = index + 1;
            }
        }
 
        if (segmentBeginning < path.Length)
        {
            from = segmentBeginning;
            return true;
        }
 
        return false;
    }
 
    public void Reset()
    {
        index = 0;
        segmentBeginning = 0;
    }
 
    public ReadOnlyMemory<char> Current
    {
        get => path.AsMemory().Slice(from, index - from);
    }
    object IEnumerator.Current =>
        Current;
 
    public void Dispose()
    {
    }
}

Выводы

  • Структуры могут реализовывать интерфейсы, и этим можно пользоваться. Но такие структуры бесполезны для использования в методах, возвращающих интерфейс. Всё равно .NET обернёт её в объект, сделает boxing. Впрочем, так даже безопаснее — это защищает от случайного бага, когда изменения данных, произведенные в одном методе, ожидают видеть в другом методе.

  • В прошлый раз мы делали вывод, что писать 100 строчек своего IEnumerator-а вместо 10 строчек yield return-а ради 11% выгоды — сомнительно. Но вот на практике в реальных приложениях, когда и полезная логика не тривиальна (не 10 строчек, одинаково сложно написать что через yield return, что через MoveNext()), и когда можно реализовать IEnumerator с помощью структуры, выгода оказывается более существенной: отсутствие лишних аллокаций, выигрыш по скорости в 2-3 раза.

  • Особенно выгодно заниматься этим, если кто-то снаружи часто, в циклах, начинает перечислять элементы с помощью IEnumerator-а. Именно это и происходило в нашем настоящем приложении.

  • Знание об устройстве .NET — это не ересь, а необходимый инструмент для достижения лучших результатов в настоящих проектах! Например, таким же способом в некоторых случаях пользуется сам .NET. Обратите внимание на List, его метод GetEnumerator() имеет две сигнатуры. Одна возвращает IEnumerator<T> (бокся структуру Enumerator), а другая возвращает прямо структуру Enumerator, которой можно пользоваться без аллокации дополнительного объекта.

  • Мы посмотрели на один пример, как и когда возникает boxing.

Бонус 2: используешь var – стреляешь себе в ногу!

Давайте представим, что мы пишем код так, что использование MoveNext() и Current одного IEnumerator происходят в разных методах. Ну вот всякое бывает. Допустим, вот так:

private static bool needToStopEnumeration = false;
 
private static void Main(string[] args)
{
    var list = new List<int>();
    for (int i = 0; i < 10000; i++)
        list.Add(i);
 
    var enumerator = list.GetEnumerator();
    while (MoveNextEnumerator(enumerator))
        Console.WriteLine(enumerator.Current);
}
 
private static bool MoveNextEnumerator(IEnumerator<int> enumerator)
{
    if (needToStopEnumeration)
        return false;
    return enumerator.MoveNext();
}

Что напечатает этот метод? Правильно, он будет бесконечно печатать 0.

Чтобы этот код напечатал числа от 0 до 9999 и завершил работу, его нужно написать вот так:

private static bool needToStopEnumeration = false;
 
private static void Main(string[] args)
{
    var list = new List<int>();
    for (int i = 0; i < 10000; i++)
        list.Add(i);
 
    IEnumerator<int> enumerator = list.GetEnumerator();
    while (MoveNextEnumerator(enumerator))
        Console.WriteLine(enumerator.Current);
}
 
private static bool MoveNextEnumerator(IEnumerator<int> enumerator)
{
    if (needToStopEnumeration)
        return false;
    return enumerator.MoveNext();
}

Думаю, уже не нужно объяснять, почему так.

Комментарии (4)


  1. Naf2000
    10.07.2025 05:39

    По поводу последнего момента. Надо сразу переходить к интерфейсам раз уж такое дело

    IList<int> list = new List<int>();


  1. doctorw
    10.07.2025 05:39

    del.


  1. vitesse
    10.07.2025 05:39

    Бонус 2 - обе реализации работают одинаково в dotnetfiddle (.Net 9), так как 'needToStopEnumeration = true', а вот с false уже как вы и пишите.


    1. deniaa Автор
      10.07.2025 05:39

      Не знаю, как случилась эта опечатка. Да, должно быть `needToStopEnumeration = false`. Спасибо, поправил в статье.