Start Debugging

List<T> vs Span<T> vs ReadOnlySpan<T> в C#: когда что выбирать

List<T> -- это растущая коллекция в куче; Span<T> и ReadOnlySpan<T> -- представления только на стеке поверх памяти, которой вы уже владеете. Используйте List<T> для всего, что вы храните, возвращаете из async или наращиваете; Span<T> для изменяемого представления без аллокаций в синхронном методе; ReadOnlySpan<T> для разбора только для чтения поверх строк, литералов u8 и срезов.

Используйте List<T>, когда у вас есть коллекция, которая растёт, хранится в поле, возвращается из метода или передаётся через await. Используйте Span<T>, когда вам нужно изменяемое представление без аллокаций поверх непрерывного буфера, который у вас уже есть (массив, блок stackalloc, срез), внутри одного синхронного метода. Используйте ReadOnlySpan<T> для того же представления, когда вы только читаете: срезы строк, литералы u8, разбор, поиск. Решение, которое перекрывает вкусовые предпочтения: оба span являются типами ref struct, поэтому они не могут жить в куче, не могут быть полем класса и не могут пересекать await или yield. Если вам нужно что-либо из этого, вы остаётесь с List<T> (или массивом), и точка.

Эта статья ориентирована на .NET 11 и C# 14. Span<T> и ReadOnlySpan<T> находятся в BCL начиная с .NET Core 2.1 и в языке начиная с C# 7.2, но здесь важны два недавних изменения: C# 13 (.NET 9) добавил анти-ограничение allows ref struct и params ReadOnlySpan<T>, а C# 14 (.NET 11) добавил полноценные неявные преобразования между массивами и span. Оба снижают трение при переходе между этими типами. List<T> восходит к .NET Framework 2.0.

Это не три разновидности одного и того же

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

List<T> — это класс. Это растущая обёртка вокруг приватного T[], который удваивает ёмкость при заполнении. Он живёт в управляемой куче, сборка мусора отслеживает его, вы можете хранить его в поле, возвращать его, захватывать его в лямбде и передавать его в async-метод. Он владеет своим хранилищем и может расти. Это повседневная коллекция, к которой вы тянетесь не задумываясь, и в большинстве случаев этот инстинкт верен.

Span<T> — это ref struct. Он не владеет никакой памятью. Это крошечное значение (управляемая ссылка плюс длина), которое указывает на непрерывную область, выделенную кем-то другим: массив, срез массива, буфер stackalloc или неуправляемую память. Он не может расти, потому что не владеет нижележащим хранилищем. Он изменяемый: запись через Span<T> записывает в нижележащий буфер. Поскольку это ref struct, среда выполнения гарантирует, что он может жить только на стеке, что как раз и делает его безопасным для указания на память стека, но также запрещает ему быть полем, подвергаться упаковке (boxing) или переживать await.

ReadOnlySpan<T> — это то же ref struct-представление, но без возможности записи. Это то, что возвращает срез строки ("hello".AsSpan(1, 3)), то, что производит литерал UTF-8 ("GET"u8 — это ReadOnlySpan<byte>), и тип параметра, который вам следует принимать, когда вы только читаете буфер. Всё сказанное об ограничениях Span<T> на пребывание только на стеке применимо идентично.

Так что настоящий вопрос редко звучит как «какая коллекция». Он звучит так: «владею ли я буфером и наращиваю ли его (List<T>), или я смотрю на тот, что у меня уже есть, изменяемо (Span<T>) или только для чтения (ReadOnlySpan<T>)?»

Матрица решений

Поведение ниже относится к .NET 9+ / C# 13+, если не указано иное.

ВозможностьList<T>Span<T>ReadOnlySpan<T>
Видкласс (куча)ref struct (стек)ref struct (стек)
Владеет своим хранилищемданет (представление)нет (представление)
Может расти / Addданетнет
Изменять элементыдаданет
Аллокация при созданиикуча (нижележащий T[])нетнет
Хранить в поле классаданетнет
Возвращать из async-методаданетнет
Использовать через await / yieldданетнет
Захватывать в лямбде / замыканииданетнет
Упаковка / присвоение object или интерфейсуданетнет
Использовать как аргумент обобщённого типадатолько с allows ref structтолько с allows ref struct
Срез без копированиянет (GetRange копирует)да (Slice, без копии)да (Slice, без копии)
Источник из stringнетнетда (AsSpan)
Источник из stackallocнетдада
Впервые появился.NET Framework 2.0.NET Core 2.1.NET Core 2.1

Строки от «Хранить в поле» до «Упаковка» решают большинство реальных случаев. Если хотя бы одна из них для вашего сценария — это «да», span отпадают, и вы остаётесь с List<T> или массивом. Всё остальное — это вопрос производительности и эргономики.

Когда выбирать List

List<T> — это вариант по умолчанию. Прибегайте к нему всякий раз, когда время жизни коллекции дольше одного синхронного метода, или когда вы не знаете итоговый размер заранее.

// .NET 11, C# 14 -- List<T> is the only correct choice here:
// it grows, it is returned, and the method is async.
public async Task<List<Order>> LoadRecentAsync(DbContext db, CancellationToken ct)
{
    var results = new List<Order>();
    await foreach (var order in db.Orders.AsAsyncEnumerable().WithCancellation(ct))
    {
        if (order.Total > 100m)
            results.Add(order);   // grows on demand
    }
    return results;               // escapes the stack frame
}

Если вам нужна подсказка, что вы сделали правильный выбор, спросите, должна ли коллекция существовать после того, как метод вернёт управление. Если да, то это List<T> или массив, но никогда не span.

Когда выбирать Span

Span<T> предназначен для изменяемого представления без аллокаций поверх памяти, которой вы уже управляете, используемого и отбрасываемого внутри одного синхронного метода. Классический выигрыш — это избегание промежуточной аллокации.

// .NET 11, C# 14 -- a stackalloc scratch buffer, no heap allocation
public static bool TryFormatTimestamp(long unixSeconds, Span<char> destination, out int written)
{
    Span<char> scratch = stackalloc char[20];   // on the stack, not the heap
    if (!unixSeconds.TryFormat(scratch, out int n))
    {
        written = 0;
        return false;
    }
    return scratch.Slice(0, n).TryCopyTo(destination)
        ? (written = n) >= 0
        : Fail(out written);

    static bool Fail(out int w) { w = 0; return false; }
}

Есть реальная причина для производительности помимо аллокации. JIT часто может устранить проверки границ, когда он итерирует Span<T> напрямую, потому что длина span находится прямо здесь, а форма цикла распознаваема. Итерация List<T> через его перечислитель выполняет проверку версии и проверку границ на каждом MoveNext. Мы измеряем это ниже.

Распространённый мост: если у вас уже есть List<T> и вам нужна производительность span для горячего чтения или изменения на месте, не копируйте его. Вызовите CollectionsMarshal.AsSpan(list), чтобы получить Span<T> напрямую поверх нижележащего массива списка. Это представление действительно только до следующей операции, которая изменяет размер списка, так что используйте его и отбрасывайте.

Когда выбирать ReadOnlySpan

ReadOnlySpan<T> — это правильный тип параметра для любого синхронного метода, который читает буфер и не нуждается в его изменении. Согласно рекомендациям по использованию Memory и Span от Microsoft, правило первое — «для синхронного API предпочитайте Span<T> вместо Memory<T>», а правило второе — «используйте ReadOnlySpan<T>, если буфер должен быть только для чтения». Большая часть разбора и поиска выполняется только для чтения.

// .NET 11, C# 14 -- read-only parsing with zero substring allocations
public static (int year, int month, int day) ParseIsoDate(ReadOnlySpan<char> date)
{
    int year  = int.Parse(date.Slice(0, 4));
    int month = int.Parse(date.Slice(5, 2));
    int day   = int.Parse(date.Slice(8, 2));
    return (year, month, day);
}

// All three callers work; none allocate a substring.
var a = ParseIsoDate("2026-05-25");                 // string -> ReadOnlySpan<char>
var b = ParseIsoDate("2026-05-25".AsSpan());         // explicit
Span<char> buf = stackalloc char[10];
"2026-05-25".CopyTo(buf);
var c = ParseIsoDate(buf);                            // Span<char> -> ReadOnlySpan<char>

Обратите внимание, что Span<T> неявно преобразуется в ReadOnlySpan<T>, но никогда наоборот. Берите наиболее ограничивающий тип, который вашему методу действительно нужен: если вы только читаете, запрашивайте ReadOnlySpan<T>, чтобы каждый вызывающий, изменяемый или нет, мог до вас добраться. Это естественно сочетается с SearchValues для быстрого поиска по нескольким образцам, который полностью построен вокруг входных данных ReadOnlySpan<T>.

Бенчмарк: суммирование 10 000 целых чисел

Утверждение о производительности конкретно: итерация Span<T> или ReadOnlySpan<T> быстрее, чем итерация List<T>, потому что JIT устраняет проверки границ на каждый элемент в span, а перечислитель списка — нет. Вот измерение.

// .NET 11, C# 14, BenchmarkDotNet 0.14.x
// dotnet run -c Release
[MemoryDiagnoser]
public class SumBench
{
    private List<int> _list = null!;
    private int[] _array = null!;

    [GlobalSetup]
    public void Setup()
    {
        _array = Enumerable.Range(0, 10_000).ToArray();
        _list = new List<int>(_array);
    }

    [Benchmark(Baseline = true)]
    public long ListForeach()
    {
        long sum = 0;
        foreach (int x in _list) sum += x;   // List<T>.Enumerator: version + bounds check
        return sum;
    }

    [Benchmark]
    public long SpanForeach()
    {
        long sum = 0;
        Span<int> span = CollectionsMarshal.AsSpan(_list);  // view, no copy
        foreach (int x in span) sum += x;                   // bounds checks elided
        return sum;
    }

    [Benchmark]
    public long ReadOnlySpanForeach()
    {
        long sum = 0;
        ReadOnlySpan<int> span = _array;     // C# 14 implicit conversion
        foreach (int x in span) sum += x;
        return sum;
    }
}

Репрезентативные результаты на Ryzen 7 / Windows 11 / сборке .NET 11, x64 RyuJIT:

МетодСреднееRatioВыделено
ListForeach6.1 us1.000 B
SpanForeach2.4 us0.390 B
ReadOnlySpanForeach2.4 us0.390 B

Примерно в 2,5 раза быстрее для цикла span, с нулевой аллокацией во всех трёх (список уже существует; CollectionsMarshal.AsSpan не копирует). Точное соотношение меняется в зависимости от типа элемента и процессора, но направление стабильно: перечислитель span — это тонкий цикл, обходящий по ref, который JIT оптимизирует жёстко, тогда как List<T>.Enumerator несёт проверку версии, которая обнаруживает конкурентное изменение. Эта проверка версии — это функция, а не пустая трата (именно поэтому List<T> бросает InvalidOperationException, если вы изменяете его во время итерации), но она стоит тактов, которые span никогда не платит.

Честная оговорка: для суммы из 10 000 элементов это микросекунды. Если ваш цикл не горячий, не корёжьте свой код, чтобы сэкономить 4 микросекунды. Span оправдывают своё присутствие в горячих внутренних циклах, парсерах и сериализаторах, которые выполняются миллионы раз, а не при случайном обходе списка.

Подводные камни, которые решают за вас

Три ограничения полностью перекрывают вкусовые предпочтения, и все три происходят из того, что Span<T> и ReadOnlySpan<T> являются типами ref struct.

await в области видимости исключает span. Локальная переменная ref struct не может пережить await, потому что компилятору пришлось бы поднять её в выделенный в куче конечный автомат, что запрещено для типа, живущего только на стеке. Компилятор отвергает это сразу. Если ваш метод ожидает (await) и нуждается в буфере, охватывающем await, используйте Memory<T> / ReadOnlyMemory<T> (дружественные к куче родственники) или List<T> / массив. См. как преобразовать T[] в ReadOnlyMemory для безопасных через await типов представлений.

Поле, возврат через async или замыкание исключают span. Вы не можете написать class C { Span<int> _buf; }. Вы не можете захватить span в лямбде. Вы не можете вернуть его из async Task<Span<int>>. В момент, когда ваш дизайн требует, чтобы буфер покинул текущий кадр стека, ответ — это List<T> или T[], возможно, с дескриптором Memory<T> для async.

Обобщённый контекст до C# 13 ограничивает span. До C# 13 вы вообще не могли использовать Span<T> как аргумент обобщённого типа. С анти-ограничением allows ref struct из C# 13 вы можете, но только если обобщённый метод или тип подключает его через where T : allows ref struct. Обобщённый API, который его не подключил, по-прежнему не может принять span. У List<T> такого ограничения нет; это обычный класс.

Есть также тонкая ловушка времени жизни у CollectionsMarshal.AsSpan. Span, который он возвращает, указывает на текущий нижележащий массив списка. Если вы затем вызовете Add достаточно, чтобы спровоцировать изменение размера, список выделит новый массив, и ваш span теперь указывает на старый, осиротевший. Считайте этот span действительным только до следующего изменяющего вызова списка.

Рекомендация, переформулированная

По умолчанию — List<T>. Это коллекция, которую вы наращиваете, храните, возвращаете, проводите через await и захватываете, и на .NET 11 она более чем достаточно быстра для всего, что не является измеренным горячим путём. Спускайтесь к Span<T>, когда вам нужно изменяемое представление без аллокаций поверх буфера, которым вы уже владеете и который вы используете и отбрасываете внутри одного синхронного метода, особенно со stackalloc или срезом на месте. Используйте ReadOnlySpan<T> как тип параметра для любого синхронного читателя и как возврат среза строк и литералов u8, чтобы разбирать и искать без выделения подстрок. Когда span был бы идеален, но на пути стоит await, поле или замыкание, прибегайте к Memory<T> / ReadOnlyMemory<T> или оставайтесь с List<T>. Самая короткая корректная версия: владеть и растить означает List<T>; смотреть и изменять означает Span<T>; смотреть и читать означает ReadOnlySpan<T>.

Связанное

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад