Привет, Хабр!
В экосистеме 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]];
Спецификация фиксирует порядок вычисления таких аргументов и момент создания коллекции при сложных выражениях, чтобы избежать сюрпризов.
Производительность
Новые конструкции не делают код волшебно быстрым. Главное:
-
Присваивание в
Span/ReadOnlySpanможет не аллоцировать — это плюс. -
Присваивание в интерфейс коллекции создаст конкретную коллекцию — это минус, если ожидали ленивость.
-
Внутри метода всё упирается в то, как вы дальше обрабатываете данные: многие 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, где вы сможете на реальных примерах разобраться в архитектурных подходах, собеседованиях и задачах продвинутого уровня:
27 августа в 20:00 — «От N‑Layer к Clean Architecture: Эволюция проектирования.NET приложений».
11 сентября в 20:00 — «Подготовка к лайв‑код интервью. Не leetcode’ом единым».
15 сентября в 20:00 — «Senior C# собеседование: Разбираем сложные вопросы по коду, алгоритмам, памяти и системному дизайну».
Все три занятия открыты и бесплатны. Дополнительно вы можете пройти бесплатное вступительное тестирование — оно помогает объективно оценить ваши текущие знания и навыки, и не связано с самим курсом.
Также рекомендуем ознакомиться с отзывами о курсе C# Developer. Professional, чтобы увидеть, как он воспринимается участниками и какие аспекты обучения они отмечают как наиболее ценные.
ссылка на оригинал статьи https://habr.com/ru/articles/938200/
Добавить комментарий