Привет, Хабр!

В экосистеме C# за последние два релиза случилось ровно то, чего многим не хватало для аккуратной работы со списками значений. В C# 12 появились collection expressions — синтаксис вида [1, 2, 3] со spread-элементами .., который конвертируется в массивы, Span, ReadOnlySpan, интерфейсы коллекций и любые правильно устроенные типы. В C# 13 к этому добавили params-коллекции: теперь params может быть не только массивом, а почти любой поддерживаемой коллекцией, включая спаны и неизменяемые контейнеры.

Базовая механика collection expressions

Collection expression — это выражение в квадратных скобках, внутри — элементы и spread-элементы. Примеры назначения по целевому типу:

using System;
using System.Collections.Generic;
using System.Collections.Immutable;

class Demo
{
    // Массив:
    int[] a = [1, 2, 3];

    // List<T> через коллекционный инициализатор:
    List<string> list = ["a", "b", "c"];

    // Span/ReadOnlySpan — компилятор может размещать элементы на стеке:
    Span<byte> bytes = [1, 2, 3, 4];
    ReadOnlySpan<char> letters = ['A', 'B', 'C'];

    // Интерфейсы коллекций:
    IEnumerable<int> seq = [10, 20, 30];

    // Неизменяемые контейнеры:
    ImmutableArray<int> imm = [7, 8, 9];
}

Раскрытие с помощью .. встраивает последовательность внутрь другой:

string[] vowels     = ["a", "e", "i", "o", "u"];
string[] consonants = ["b","c","d","f","g","h","j","k","l","m","n","p","q","r","s","t","v","w","x","z"];
string[] alphabet   = [..vowels, ..consonants, "y"];

Допустимые целевые типы широки: массивы, Span<T> и ReadOnlySpan<T>, типы с коллекционными инициализаторами (Add и итерация), интерфейсы IEnumerable<T>, IReadOnlyList<T> и т.д. Коллекционное выражение всегда порождает конечную коллекцию, даже если целевой тип — интерфейс последовательности. Это не ленивая конструкция как в LINQ. Для Span/ReadOnlySpan компилятор может выбрать стековое хранение. Не работает в контекстах, где требуется compile-time константа.

Разбор конверсий и где подстерегают неоднозначности

Когда есть перегрузки на IEnumerable<T>, ReadOnlySpan<T> и, скажем, массив, коллекционное выражение может подходить сразу ко всем. Правила выбора такие: приоритет у лучшей конверсии элементов и у спан-типов относительно не ref-struct типов; затем — у конкретного типа над интерфейсом. Если компилятор всё равно колеблется, подсказка в виде явного приведения решает вопрос.

static void Use(IEnumerable<int> xs)    => Console.WriteLine("IEnumerable");
static void Use(ReadOnlySpan<int> xs)   => Console.WriteLine("ROS");
static void Use(int[] xs)               => Console.WriteLine("Array");

Use([1, 2, 3]);           // обычно выберет ROS
Use((int[])[1, 2, 3]);    // намеренно массив
Use((IEnumerable<int>)[1,2,3]); // намеренно интерфейс

Для библиотек авторы могут направлять выбор перегрузок атрибутом OverloadResolutionPriority, например повысить приоритет версии с ReadOnlySpan<T>, если она эффективнее.

Как добавить поддержку в свой тип через CollectionBuilder

Свои коллекции тоже можно инициализировать выражениями. Нужно выполнить две вещи: сделать тип итерируемым и указать билдер атрибутом CollectionBuilder. Билдер это статический метод, принимающий ReadOnlySpan<T> и возвращающий экземпляр вашей коллекции.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.CompilerServices;

[CollectionBuilder(typeof(FixedSetBuilder), "Create")]
public sealed class FixedSet<T> : IEnumerable<T>
{
    private readonly T[] _items;

    internal FixedSet(ReadOnlySpan<T> items)
    {
        // примитивная реализация — копируем и «удаляем» дубликаты на глазок
        var tmp = new List<T>(items.Length);
        foreach (var it in items)
            if (!tmp.Contains(it))
                tmp.Add(it);

        _items = tmp.ToArray();
    }

    public IEnumerator<T> GetEnumerator() => ((IEnumerable<T>)_items).GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => _items.GetEnumerator();
}

internal static class FixedSetBuilder
{
    public static FixedSet<T> Create<T>(ReadOnlySpan<T> items) => new FixedSet<T>(items);
}

// Использование:
var s = new FixedSet<int> { };          // старый синтаксис тоже работает
FixedSet<int> s2 = [1, 2, 2, 3, 3, 3];  // теперь и так

Тип должен быть «хорошо устроенным» с точки зрения итерации и добавления элементов, иначе поведение компилятора не определено.

params-коллекции в C# 13

Исторически params можно было ставить только на массивы. Теперь допустимы типы, которые поддерживает коллекционное выражение: спаны, неизменяемые массивы, интерфейсы коллекций, а также типы с билдером. Сигнатура при этом ровно тип параметра, без квадратных скобок. Вызов можно делать и списком аргументов, и одним выражением-коллекцией.

// До:
void Log(params string[] parts) { /* ... */ }

// Теперь:
void Log(params ReadOnlySpan<string> parts) { /* ... */ }
void Emit(params IEnumerable<int> values)   { /* ... */ }

// Оба вызова валидны:
Log("a", "b", "c");
Log(["a", "b", "c"]);

// Для IEnumerable:
Emit(1, 2, 3, 4);
Emit([1, 2, 3, 4]);

Ограничения те же, что и раньше: params — последний параметр и не сочетается с ref, in, out. Существенная деталь реализации — порядок вычисления аргументов и момент построения коллекции. Для params-коллекций это может отличаться от классического params T[].

Шаблон API на ReadOnlySpan без лишних аллокаций

ReadOnlySpan<T> хорош тем, что позволяет принимать данные без копии: от массива, среза, литерала строки, stackalloc-буфера. С params-коллекциями можно описать и рассыпной вызов, и передачу готовой коллекции.

public static class Metrics
{
    // Никаких выделений под вспомогательные массивы при простом вызове
    public static double Average(params ReadOnlySpan<double> values)
    {
        double sum = 0;
        foreach (var v in values)
            sum += v;
        return values.Length == 0 ? 0 : sum / values.Length;
    }
}

// Вызовы:
var avg1 = Metrics.Average(1.0, 2.0, 3.5);
double[] data = { 1, 2, 3, 4, 5 };
var avg2 = Metrics.Average(data);
var avg3 = Metrics.Average([10, 20, 30]); // collection expression

Семантически это ровно тот же params, но тип — спан, поэтому компилятор может обойтись без промежуточного массива.

Когда лучше оставить массив

Если вызывающие часто передают именно массив и обрабатываете вы его как массив, смысла насильно переходить на спан может не быть. Более того, коллекционное выражение при назначении в интерфейсные типы всё равно материализуется во временную коллекцию. Если вам нужна настоящая «ленивая» последовательность — это не про collection expressions. Тут возвращаемся к LINQ или IAsyncEnumerable.

Перегрузки, приоритет и читаемость

Для публичных API удобно держать пару перегрузок:

public sealed class Digest
{
    // Приоритетнее и без аллокаций
    public string Join(params ReadOnlySpan<string> parts)
        => string.Join(":", parts.ToArray()); // осознанная материализация для string.Join

    // Широкая совместимость
    public string Join(IEnumerable<string> parts)
        => string.Join(":", parts);
}

Если у вас появляется конфликт выбора, допускается подсказать компилятору приоритетом. Только применять точечно и документировать, чтобы не удивлять потребителей.

using System.Diagnostics.CodeAnalysis;

public sealed class Overloads
{
    [OverloadResolutionPriority(1)]
    public void Send(params ReadOnlySpan<int> xs) { /* */ }

    public void Send(params int[] xs) { /* */ }
}

Атрибут исключает менее приоритетные методы из набора применимых.

Индексаторы и params-коллекции

params разрешен в индексаторах.

public sealed class Tensor
{
    private readonly Dictionary<(int,int,int), double> _data = new();

    public double this[params ReadOnlySpan<int> idx]
    {
        get
        {
            if (idx.Length != 3) throw new ArgumentException("Need 3 indices");
            return _data.TryGetValue((idx[0], idx[1], idx[2]), out var v) ? v : 0;
        }
        set
        {
            if (idx.Length != 3) throw new ArgumentException("Need 3 indices");
            _data[(idx[0], idx[1], idx[2])] = value;
        }
    }
}

// Вызовы:
var t = new Tensor();
t[0, 1, 2] = 42;
var v = t[[0, 1, 2]];

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

Производительность

Новые конструкции не делают код волшебно быстрым. Главное:

  1. Присваивание в Span/ReadOnlySpan может не аллоцировать — это плюс.

  2. Присваивание в интерфейс коллекции создаст конкретную коллекцию — это минус, если ожидали ленивость.

  3. Внутри метода всё упирается в то, как вы дальше обрабатываете данные: многие BCL-методы всё равно требуют массив. Тогда материализация неизбежна и её лучше делать явно, чтобы не разбрасывать скрытые копии по коду.

Если хотите увидеть разницу в своём кейсе, возьмите BenchmarkDotNet и сравните params int[] против params ReadOnlySpan<int> на типичных входах.

using System;
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

public class ParamsVsSpan
{
    [Params(0, 4, 16, 128)]
    public int N;

    private int[] _arr;

    [GlobalSetup]
    public void Setup() => _arr = Enumerable.Range(1, N).ToArray();

    [Benchmark]
    public int ParamsArray() => SumArray(1, 2, 3, 4);

    [Benchmark]
    public int ParamsArrayExisting() => SumArray(_arr);

    [Benchmark]
    public int ParamsSpan() => SumSpan(1, 2, 3, 4);

    [Benchmark]
    public int ParamsSpanExisting() => SumSpan(_arr);

    static int SumArray(params int[] xs) => xs.Sum();
    static int SumSpan(params ReadOnlySpan<int> xs)
    {
        int s = 0; foreach (var x in xs) s += x; return s;
    }
}

public static class Program
{
    public static void Main() => BenchmarkRunner.Run<ParamsVsSpan>();
}

Не претендуем на абсолютную истину, но будет полезно именно в вашем домене данных.

Примеры дизайна API с использованием обеих фич

Инициализация конфигурации:

public sealed record Rule(string Name, int Level);

public sealed class RuleSet
{
    private readonly List<Rule> _rules = new();

    public static RuleSet From(params IEnumerable<Rule> rules)
    {
        var set = new RuleSet();
        foreach (var r in rules) set._rules.Add(r);
        return set;
    }

    public IReadOnlyList<Rule> Rules => _rules;
}

// Вызовы:
var rs1 = RuleSet.From(
    new Rule("Auth", 1),
    new Rule("Limits", 2)
);

var rs2 = RuleSet.From([new Rule("Auth", 1), new Rule("Limits", 2)]);

Выгрузка данных батчами:

public interface ISink<T> { void Write(ReadOnlySpan<T> batch); }

public static class Export
{
    public static void WriteAll<T>(this ISink<T> sink, params ReadOnlySpan<T> items)
    {
        // Сохраняем инвариант: один проход, без копий
        sink.Write(items);
    }
}

Быстрая сборка неизменяемых структур:

using System.Collections.Immutable;

public static class ImmutableHelpers
{
    public static ImmutableArray<int> Make(params ReadOnlySpan<int> xs)
        => ImmutableArray.Create(xs); // перегрузка под Span есть
}

Что в итоге выбирать

— Хотите короткий литерал в коде: коллекционное выражение, целевой тип — массив или конкретный контейнер. Компилятор сам подберёт оптимальный путь.
— Хотите API без лишних аллокаций: params ReadOnlySpan<T>.
— Нужна шире совместимость с существующим кодом или сторонними коллекциями: params IEnumerable<T> и перегрузка на спан.
— Свой тип должен инициализироваться скобками: CollectionBuilder со статическим Create(ReadOnlySpan<T>).
— Конфликт перегрузок: явное приведение или единичное применение OverloadResolutionPriority.


Продолжая разговор о развитии языка и новых возможностях C#, стоит отметить, что обучение эффективно работает именно тогда, когда есть возможность увидеть практику и живое применение идей. Для этого мы подготовили серию бесплатных открытых уроков по программе C# Developer. Professional, где вы сможете на реальных примерах разобраться в архитектурных подходах, собеседованиях и задачах продвинутого уровня:

Все три занятия открыты и бесплатны. Дополнительно вы можете пройти бесплатное вступительное тестирование — оно помогает объективно оценить ваши текущие знания и навыки, и не связано с самим курсом.

Также рекомендуем ознакомиться с отзывами о курсе C# Developer. Professional, чтобы увидеть, как он воспринимается участниками и какие аспекты обучения они отмечают как наиболее ценные.

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