Params-коллекции и collection expressions в C#

от автора

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

В экосистеме 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, чтобы увидеть, как он воспринимается участниками и какие аспекты обучения они отмечают как наиболее ценные.


ссылка на оригинал статьи https://habr.com/ru/articles/938200/