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