>
>
>
Обзор нововведений в C# 13

Валентин Прокофьев
Статей: 2

Обзор нововведений в C# 13

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

Инициализация объекта по индексу "от конца"

Начнём освещение новых изменений с довольно простого по смыслу нововведения — инициализация объекта с помощью неявного оператора индекса "от конца" — ^.

Ранее можно было указывать индексы объекта при инициализации только "от начала":

var initialList = Enumerable.Range(1, 10);
var anotherList = new List<int>(initialList)
{
    [9] = 15
};
Console.WriteLine(string.Join(" ", anotherList));
// 1 2 3 4 5 6 7 8 9 15

В новой версии языка можно указать индекс, отсчитывая номер элемента "от конца":

var initialList = Enumerable.Range(1, 10);
var list = new List<int>(initialList)
{
    [^1] = 15
}; 
Console.WriteLine(string.Join(" ", anotherList));
// 1 2 3 4 5 6 7 8 9 15

Очевидно, что данный функционал поддерживается во всех классах, которые реализуют перегрузку индексатора с аргументом типа структуры Index:

void Check()
{ 
    var initTestCollection = new TestCollection<int>(Enumerable.Range(1, 10));
    var anotherTestCollection = new TestCollection<int>(initTestCollection)
    {
        [^5] = 100
    };
    Console.WriteLine(string.Join(" ", anotherTestCollection));
    // 1 2 3 4 5 100 7 8 9 10
}

class TestCollection<T> : IEnumerable<T>
{
    T[] _items;

    public T this[Index index]
    {
        get =>_items[index];
        set => _items[index] = value;
    }
    // ....
}

Как говорилось ранее, изменение простое и понятное. Обычно инициализатор используется для непрерывного определения значений индексов. Реже используется указание значений конкретных индексов. Есть предположение, что данный кейс будет использоваться ещё реже.

Partial свойства и индексаторы

Новая версия языка предлагает расширить возможность частичного объявления и реализации содержимого классов. До этого можно было указывать на фактор частичности классам, структурам, интерфейсам и методам. Теперь есть возможность указать модификатор partial свойствам и индексаторам. Логика привычная: в одной части указывается объявление, а в другом — реализация.

Для примера, обратимся к ранее объявленной тестовой коллекции TestCollection<T> и немного модифицируем код:

partial class TestCollection<T> : IEnumerable<T>
{
    private T[] _items;

    public partial int Count { get; } // Объявление свойства
    public partial T this[Index index] { get; set; } // Объявление индексатора
    // ....
}
partial class TestCollection<T>
{
    public partial int Count => _items.Length; // Реализация свойства
    public partial T this[Index index] // Реализация индексатора
    {
        get => _items[index];
        set => _items[index] = value;
    }
}

Теперь данный класс имеет объявление индексатора класса и свойства Count в одной части, а реализацию — в другой.

Не секрет, что partial классы используются для генерации исходного кода. Например, при использовании регулярных выражений, скомпилированных во время сборки с помощью атрибута GeneratedRegexAttribute. Или для валидации данных при наследовании от класса ObservableValidator. Данное небольшое нововведение поможет как расширить область применения кодогенерации, так и в бо́льшей степени разграничивать точки объявления и реализации кода в собственных объёмных классах.

Params коллекции

Невероятно, но факт! В C# версии 13 будет добавлена такая желаемая (автором, как минимум) поддержка коллекций при модификаторе params. Теперь методы, для которых мы так усердно переводили коллекции в массивы, станут удобнее в обращении. Кода станет меньше, и повысится читабельность.

Один из понятных случаев передачи коллекций в такие методы — работа с базами данных. Часто необходимо передать некоторую выборку, полученную с помощью LINQ метода Where, или перечень идентификаторов записей с помощью Select. При их использовании, результат представляет из себя коллекцию IEnumerable<T>, которую приходится преобразовывать в массив T[], так как методы с модификатором params в аргументах ограничивают своё использование. В следующем обновлении языка данного действия не потребуется — можно без проблем писать методы, принимающие в качестве аргумента params коллекции.

В дополнение к массивам, для указания типа при ключевом слове params станут доступны: ReadOnlySpan<T>, Span<T> и наследники, реализующие IEnumerable<T> (List<T>, ReadOnlyCollection<T>, ObservableCollection<T> и подобные им).

Интересно взглянуть на приоритет вызова методов при наличии перегрузок. Рассмотрим ситуацию с несколькими перегрузками одного метода и передачей литерала в качестве аргумента:

void Check()
{
    ParamsMethod(1);
}
void ParamsMethod(params int[] values) // До C# 13
{
    // (1)
}
void ParamsMethod(params IEnumerable<int> values) // После C# 13
{
    // (2)
}
void ParamsMethod(params Span<int> values) // После C# 13
{
    // (3)
}
void ParamsMethod(params ReadOnlySpan<int> values) // После C# 13
{
    // (4) <=
}

Выполнение метода Check() приведёт к вызову перегрузки под номером 4, в качестве типа params которого указано ReadOnlySpan<int>. Есть мысль, что тут имеет место быть оптимизационный момент, дабы избежать дополнительного выделения памяти при работе с коллекцией.

Если же ограничить выборку перегрузок методов до Span<int> и ReadOnlySpan<int>, то передача массива приведёт к вызову метода с типом аргумента Span<int>. Такое поведение вызвано тем, что происходит неявное преобразование массива в Span<int>. Если же передать в метод ParamsMethod массив, который инициализируется при вызове, то произойдёт вызов перегрузки ReadOnlySpan<int>, так как фактически мы не имеем ссылок на коллекцию в коде:

void Check()
{
    int[] array = [1, 2, 3];
    ParamsMethod(array);     // (1)
    ParamsMethod([1, 2, 3]); // (2)
}
void ParamsMethod(params Span<int> values) // <= (1)
{
    // ....
}
void ParamsMethod(params ReadOnlySpan<int> values) // <= (2)
{
    // ....
}

При работе с шаблонными методами приоритет практически всегда отдаётся ReadOnlySpan<T>, если нет реализации под конкретный тип. И даже передача List<T> приведёт к вызову метода params ReadOnlySpan<T>, а не IEnumerable<T>, что выглядит весьма неочевидно и потенциально опасно.

Данное расширение функционала довольно существенно, хотя на первый взгляд кажется незначительным. В проектах зачастую приходится прибегать к преобразованию списков в массивы, так как чаще всего работа происходит именно со списками или похожими структурами данных. Применение ToArray() каждый раз ощущалось избыточным, так как фактически эта работа выполняется лишь для того, чтобы код скомпилировался, без какой-либо дополнительной логики. Теперь же можно избавиться от такого чувства и напрямую передавать коллекции в подобные методы.

Атрибут приоритизации перегрузок

Для добавления приоритета одной из перегрузок был добавлен новый атрибут пространства имён System.Runtime.CompilerServices — OverloadResolutionPriorityAttribute. Название громоздкое, но точно отражает суть. Как говорит сам Microsoft, данный атрибут по большей части нужен для разработчиков API, которые хотят "мягко" перевести своих юзеров с одной перегрузки метода на другую, где может быть лучшая реализация.

Принимая во внимание не самый очевидный приоритет выбора компилятором перегрузок, можно явно указать, какой из методов будет использоваться в первую очередь. Например, в контексте двух перегрузок ParamsMethod с типами аргументов ReadOnlySpan<T> и Span<T>, можно указать компилятору на приоритет метода с типом Span<T>:

void Check()
{
    int[] array = [1, 2, 3];
    ParamsMethod(array);     // (1)
    ParamsMethod([1, 2, 3]); // (2)
}
[OverloadResolutionPriority(1)]
void ParamsMethod(params Span<int> values) <= (1)(2)
{
    // ....
}
void ParamsMethod(params ReadOnlySpan<int> values)
{
    // ....
}

Можно заметить, что атрибут принимает одно значение — приоритет перегрузки. Чем выше данное значение, тем приоритетнее метод. Дефолтное значение у каждого из методов — 0.

Важно! Если перегрузки методов находятся в разных классах (например, методы расширения), то необходимо учитывать тот факт, что приоритизация работает лишь внутри собственных классов. То есть приоритет из одного класса методов расширения не будет оказывать влияния на другой.

Вышеописанная особенность области видимости может стать неожиданной проблемой для разработчика. Например, может вызваться устаревший код, с неактуальной логикой, что может повлечь неприятности (особенно в работающем приложении). Для предотвращения подобных ситуаций на рынке существуют инструменты, помогающие разработчикам находить неочевидные ошибки — статические анализаторы кода. Данное нововведение натолкнуло нас на идею добавления нового диагностического правила для нашего C# анализатора PVS-Studio вдобавок к сотням уже существующим.

Новый класс Lock

Работа с синхронизацией потоков была улучшена. На замену object пришёл полноценный класс Lock из пространства имён System.Threading. Данный класс призван сделать код более понятным и эффективным. В дополнение к этому класс имеет следующие методы для работы с ним:

  • Enter() — вход в участок блокировки.
  • TryEnter() — попытка моментального входа в участок блокировки, если это допустимо. Возвращает результат попытки входа в виде bool.
  • EnterScope() — получение структуры Scope, которую можно применить вместе с оператором using.
  • Exit() — выход из участка блокировки.

Также имеется свойство IsHeldByCurrentThread, с помощью которого можно узнать, удерживается ли блокировка текущим потоком.

Если использовать оператор lock в привычном формате, то код принимает следующий вид:

class LockObjectCheck
{
    Lock _lock = new();

    void Do()
    {
        lock (_lock)
        {
            // ....
        }
    }
}

Из примера кода выше можно увидеть, что новая реализация отличается от старой несущественно. При использовании дополнительного функционала нового класса, код принимает следующий вид:

class LockObjectCheck
{
    Lock _lock = new();

    void Do()
    {
        _lock.Enter();
        try
        {
            // ....
        }
        // ....
        finally
        {
            if (_lock.IsHeldByCurrentThread)
                _lock.Exit();
        }
    }
}

Как говорилось ранее, можно использовать экземпляр структуры Scope, полученного с помощью вызова метода EnterScope, чтобы обеспечить правильное использование IDisposable экземпляра. Код в таком случае выглядит следующим образом:

using (var scope = _lock.EnterScope())
{
    // ....
}

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

Новая escape-последовательность

Разработчики языка представили новый символ escape-последовательности \e. Он был добавлен для замены существующего \x1b, который не рекомендуется использовать. Текущая проблема в том, что стоящие далее символы могут интерпретироваться как валидные шестнадцатеричные значения, из-за чего станут частью указанной escape-последовательности. Данный кейс поможет избегать непредвиденных ситуаций.

Улучшение работы групп методов с естественными типами

C# 13 улучшает алгоритм подбора подходящих кандидатов при работе с группами методов и естественными типами. Естественные типы — типы, определённые компилятором (например, с помощью var).

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

Интерфейсы и ref struct

В далёком C# 7.2 были добавлены ref struct, которые в 13 версии языка получили существенные изменения. Напомним, что основной особенностью такой конструкции является исключительное выделение памяти на стеке, без возможности перехода в управляемую кучу. Это можно использовать для повышения безопасности и производительности приложения (подробнее). Ярким представителем таких структур можно назвать знакомый Span<T> и его Readonly-аналог — ReadOnlySpan<T>.

Наследование

До этого момента у ref struct были свои ограничения, в том числе с наследованием от интерфейсов. Ранее это было запрещено во избежание боксинга. При подобной попытке можно было получить ошибку "ref structs cannot implement interfaces". Сейчас же данное ограничение было снято, что позволяет наследоваться от интерфейсов:

interface IExample
{
    string Text { get; set; }
}
ref struct RefStructExample : IExample
{
    public string Text { get; set; }
}

Но не всё так просто. При попытке каста экземпляра структуры к интерфейсу мы получаем ошибку "Cannot convert type 'RefStructExample' to 'IExample' via a reference conversion, boxing conversion, unboxing conversion, wrapping conversion, or null type conversion". Это является одним из новых ограничений при использовании ref struct, обеспечивающее ссылочную безопасность.

Анти-ограничение allows ref struct

Данное анти-ограничение позволяет передавать в шаблонные методы экземпляры ref структур. При попытке передачи подобной структуры в версии C# 12 можно было получить сообщение: "The type 'RefStructExample' may not be a ref struct or a type parameter allowing ref structs in order to use it as parameter 'T' in the generic type or method 'ExampleMethod<T>(T)'". Сейчас же, в месте указания ограничителей метода можно добавить конструкцию, разрешающую использование ref struct:

void Check()
{
    var example = new RefStructExample();
    ExampleMethod(example);
}
void ExampleMethod<T>(T example) where T: IExample, allows ref struct
{
    // ....
}

И да, всё отлично работает. В таком методе можно без проблем описывать всю интересующую обобщённую логику для наследников IExample, включая RefStructExample.

Примечание. allows ref struct — первое анти-ограничение. Ранее подобные конструкции лишь запрещали использование сторонних типов.

Смотря на данное нововведение, можно сказать, что разработчики языка позволяют нам всё больше увлекаться построением иерархий. И это хорошо! Подобные наследования можно использовать как для объединения логики, так и для добавления обязательств реализации для наследуемых сущностей.

Хочется выделить новый тип анти-ограничителей на примере allows ref struct, который меняет подход к указанию спецификации методов. Сама идея включения некоторого функционала выглядит интересно и многообещающе. Интересно будет узнать, какие новые типы анти-ограничителей подготовит для нас в будущем команда разработки C#.

ref и unsafe в итераторах и асинхронных методах

Продолжая тему ref struct нельзя не отметить нововведение, которое расширит места их использования. В асинхронных методах теперь можно объявлять ref локальные переменные и экземпляры ref структур.

Например, при попытке объявления экземпляра структуры ReadOnlySpan<int> в ранней версии C#, компилятор выводит ошибку "Parameters or locals of type 'ReadOnlySpan<int>' cannot be declared in async methods or async lambda expressions". В новой версии языка данной проблемы нет, но стоит учитывать, что осталось ограничение, запрещающее иметь ref в аргументах таких методов.

В методах-итераторах (методы, использующие оператор yield) теперь также можно использовать локальные ref переменные, но есть ограничение по их выводу:

IEnumerable<int> TestIterator()
{
    int[] values = [1, 2, 3, 4, 5];

    ref int firstValue = ref values[0];
    ref int lastValue = ref values[values.Length - 1];
    yield return firstValue;
    yield return lastValue;
    // A 'ref' local cannot be preserved across 'await' or 'yield' boundary
}

Также в новой версии языка итераторы и асинхронные методы станут поддерживать модификатор unsafe, позволяющий производить любые операции с указателями. При этом в итераторах потребуется безопасный контекст для таких конструкций как yield return и yield break.

Заключение

Перечень изменений новой версии языка C# представлен в этот раз большем объёме, чем в прошлом году. Часть из них предоставляет функционал, который ранее был невозможен, а другая часть служит для упрощения жизни разработчиков. В итоге можно сказать, что для рядового специалиста изменений не так много, так как имеется много нишевых нововведений, которых при разработке можно даже не заметить.

Что вы думаете по этому поводу? Идёт ли Microsoft в сторону развития языка или стоит на месте, раскрывая те возможности, которые до этого момента почему-то не были реализованы? Пишите ваше мнение в комментариях.

Документация Microsoft по списку изменений в C# 13 доступна по ссылке. Если же у вас есть желание прочитать наши предыдущие обзорные статьи по нововведениям в языке C#, то вот список всех статей прошлых лет:

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