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

Константин Волоховский
Статей: 14

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

C# 11 выходит уже совсем скоро, так что пора детально изучить новые особенности, которые появятся в языке. И хотя их немного, среди них есть довольно интересные: обобщённая математика, исходные строки, модификатор required, параметры типа в атрибутах и прочее.

Обобщённые атрибуты

Теперь нам позволяется объявлять атрибуты с использованием обобщений почти так же, как и в случае с классами и методами. Хоть мы и раньше могли передать тип в параметрах конструктора, никак не используя обобщения, теперь можно явно указать какие типы подлежат передаче через ограничитель типа where. А также не нужно каждый раз использовать оператор typeof.

Посмотрим, как это выглядит на примере простой реализации паттерна "декоратор". Определим сам обобщённый атрибут:

[AttributeUsage(AttributeTargets.Class)]
public class DecorateAttribute<T> : Attribute where T : class
{
    public Type DecoratorType{ get; set; }
    public DecorateAttribute()
    {
        DecoratorType = typeof(T);
    }
}

Далее реализуем как иерархию в соответствии с паттерном, так и фабрику для создания декорированных объектов. Особое внимание обратите на атрибут Decorate:

public interface IWorker
{
    public void Action();
}
public class LoggerDecorator : IWorker
{
    private IWorker _worker;
    public LoggerDecorator(IWorker worker)
    {
        _worker = worker;
    }
    public void Action()
    {
        Console.WriteLine("Log before");
        _worker.Action();
        Console.WriteLine("Log after");
    }
}
[Decorate<LoggerDecorator>]
public class SimpleWorker : IWorker
{
    public void Action()
    {
        Console.WriteLine("Working..");
    }
}

public static class WorkerFactory
{
    public static IWorker CreateWorker()
    {
        IWorker worker = new SimpleWorker();

        if (typeof(SimpleWorker)
            .GetCustomAttribute<DecorateAttribute<LoggerDecorator>>() != null)
        {
            worker = new LoggerDecorator(worker);
        }

        return worker;
    }
}

Пример работы:

var worker = WorkerFactory.CreateWorker();

worker.Action();
// Log before
// Working..
// Log after

Стоит отметить, что ограничения сняты не полностью – тип нужно указывать явно. Использовать параметр типа из класса, например, не получится:

public class GenericAttribute<T> : Attribute { }
public class GenericClass<T>
{
    [GenericAttribute<T>]
    //Error CS8968 'T': an attribute type argument cannot use type parameters
    public void Action()
    {
        // ....
    }
}

Может появиться вопрос: GenericAttribute<int> и GenericAttribute<string> это разные атрибуты, или несколько применений одного? В Microsoft решили, что это один и тот же. Из этого вытекает, что для многократного применения нужно будет установить свойство AllowMultiple в true. Модифицируем первоначальный пример:

[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class DecorateAttribute<T> : Attribute where T : class
{
    // ....
}

Теперь можно декорировать объект несколько раз:

[Decorate<LoggerDecorator>]
[Decorate<TimerDecorator>]
public class SimpleWorker : IWorker
{
    // ....
}

Конечно, ничего глобального это изменение не привнесло, но теперь разработчики библиотек могут предоставлять более понятный и удобный интерфейс при работе с обобщениями в атрибутах.

Обобщённая математика

С новой версией языка нам будут доступны математические операции над обобщёнными типами.

Сначала остановимся на двух универсальных последствиях нововведения:

  • Статический метод (или свойство) интерфейса теперь может иметь модификатор abstract, обязывающий наследников реализовать соответствующий статический метод;
  • Благодаря этому в интерфейсах теперь можно объявлять арифметические операторы.

Да, теперь у нас есть конструкция вида static abstract, что может показаться немного странным, но совсем лишенным смысла я бы это не назвал. Чтобы посмотреть на результат в деле, предлагаю немного надуманный пример со своей реализацией натурального числа, которое можно складывать и создавать из строки:

public interface IAddable<TLeft, TRight, TResult>
    where TLeft : IAddable<TLeft, TRight, TResult>
{
    static abstract TResult operator +(TLeft left, TRight right);
}
public interface IParsable<T> where T : IParsable<T>
{
    static abstract T Parse(string s);
}
public record Natural : IAddable<Natural, Natural, Natural>, IParsable<Natural>
{
    public int Value { get; init; } = 0;
    public static Natural Parse(string s)
    {
        return new() { Value = int.Parse(s) };
    }
    public static Natural operator +(Natural left, Natural right)
    {
        return new() { Value = left.Value + right.Value };
    }
}

Применить в деле заданные операции можно так:

var one = new Natural { Value = 1 };
var two = new Natural { Value = 2 };
var three = one + two;
Console.WriteLine(three);
// Natural { Value = 3 }

var parsed = Natural.Parse("42");
Console.WriteLine(parsed);
// Natural { Value = 42 }

В примере можно увидеть, что наследовать обобщённые интерфейсы для реализации статических абстрактных методов нужно с помощью рекурсивного шаблона вида Natural: IParsable<Natural>. С ним надо быть повнимательнее, чтобы не перепутать параметр типа —Natural : IParsable<OtherParsableType>.

Более наглядно применение статических методов на параметрах типа можно увидеть на таком примере:

public IEnumerable<T> ParseCsvRow<T>(string content) where T : IParsable<T>
    => content.Split(',').Select(T.Parse);
// ....
var row = ParseCsvRow<Natural>("1,5,2");
Console.WriteLine(string.Join(' ', row.Select(x => x.Value)));
// 1 5 2

Раньше обобщение так использовать было нельзя – для вызова статического метода пришлось бы явно указать тип. Теперь же метод будет работать с любым наследником IParsable.

Заданием своей математики, естественно, дело не ограничивается. Был изменён механизм работы базовых типов: 20 из них реализуют соответствующие базовым операциям интерфейсы. Их можно посмотреть в документации.

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

static T Sum<T>(IEnumerable<T> values)
    where T : INumber<T>
{
    T result = T.Zero;
    foreach (var value in values)
    {
        result += T.CreateChecked(value);
    }
    return result;
}

Метод будет работать как с натуральными, так и с действительными числами:

Console.WriteLine(Sum(new int[] { 1, 2, 3, 4, 5 }));
// 15

Console.WriteLine(Sum(new double[] { 0.5, 2.5, 3.0, 4.3, 3.2 }));
// 13.5

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

Я же подведу небольшие итоги. Непосредственно использовать нововведения, скорее всего, придется немногим, ведь не все занимаются разработкой библиотек или работают с другими подходящими для этого задачами. Конструкция static abstract также слишком специфична чтобы бросаться внедрять её в свой проект. Тем не менее, нововведение может сделать нашу жизнь лучше опосредованно – те самые разработчики библиотек смогут тратить меньше ресурсов на поддержку бесчисленного количества перегрузок.

Исходные строковые литералы

В C# у нас появилась возможность создавать "сырые" строки с помощью многократного (от трёх) написания кавычек. Принцип работы похож на буквальный идентификатор @, но есть два важных отличия:

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

Возьмём для примера формирование json строки внутри метода:

string GetJsonForecast(DateTime date, int temperature, string summary)
    => $$"""
    {
        "Date": "{{date.ToString("yyyy-MM-dd")}}", 
        "TemperatureCelsius": "{{temperature}}",
        "Summary": "{{summary}}",
        "Note": ""
    }
    """;

При выводе результата вызова получим строку в виде нормального json:

{
    "Date": "2022-09-16",
    "TemperatureCelsius": 10,
    "Summary": "Windy",
    "Note": ""
}

Отличия от работы идентификатора @ увидеть несложно: исходный литерал действительно был воспринят "буквально" со всеми кавычками и фигурными скобками, а лишних отступов слева у нас нет. Синтаксические правила по работе с "сырыми" строками такие:

  • Строка должна начинаться с 3-х и более кавычек. Таким образом, если нам надо будет разместить в литерале 3 кавычки подряд, то начинать и заканчивать строку нужно 4 символами и так далее;
  • Аналогично это работает с интерполяцией, но уже от одного символа. Так, в примере выше было использовано два знака интерполяции, чтобы можно было записать фигурные скобки для описания структуры json;
  • Исходная строка может быть однострочной. Тогда она должна содержать хотя бы один символ между кавычками;
  • Исходную строку можно сделать многострочной. В этом случае открывающие и закрывающие кавычки должны занимать отдельные строки (добавлять на них текст не позволяется), а отступ у текста от края экрана не может быть меньше, чем у закрывающих кавычек.

Как ни странно, кажется, это одно из самых глобальных нововведений. Теперь можно делать многострочные литералы, не боясь ни за форматирование получившейся строки, ни за опрятность кода. Непонятно лишь одно: зачем теперь работать с текстом при помощи более классического буквального идентификатора?

Перенос на новую строку при интерполяции

Ещё одно, на сей раз небольшое, улучшение при работе со строками: выражение внутри интерполяции теперь можно переносить на новую строчку:

Console.WriteLine(
    $"Roles in {department.Name} department are: {
        string.Join(", ", department.Employees
                                    .Where(x => x.Active)
                                    .Select(x => x.Role)
                                    .OrderBy(x => x)
                                    .Distinct())}");

// Roles in Security department are: Administrator, Manager, PowerUser

Приятная мелочь, если вдруг была потребность помещать длинную цепь операторов (вроде Linq) внутрь интерполяции. Хотя увлекаться переносами не стоит, устроить беспорядок тоже стало немного проще:

Console.WriteLine(
    $"Employee count is {
        department.Employees
                  .Where(x => x.Active)
                  .Count()} in the {department.Name} department");

// Employee count is 20 in the Security department

Модификатор required

С модификатором required появилась возможность обязать инициализировать поля и свойства внутри конструктора или инициализатора. Мотивации у такой возможности две.

Во-первых, при работе с большими иерархиями классов рано или поздно может скопиться boilerplate код, связанный с передачей большого количества данных в родительские конструкторы. Классический пример с фигурами:

class Point
{
    public int X { get; }
    public int Y { get; }
    public Point(int x, int y) 
    { 
        X = x;
        Y = y;
    }
}
// ....
class Textbox : Rectangle
{
    public string Text { get; }
    public Textbox(int x, int y, int width, int height, string text) 
        : base(x, y, width, height)
    {
        Text = text;
    }
}

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

Однако если сделать конструктор без параметров, а свойствам X и Y добавить модификатор required, то это сместит обязательство по инициализации с разработчика типа на его пользователя. Перепишем пример:

class Point
{
    public required int X { get; set; }
    public required int Y { get; set; }
}
// ....
class Textbox : Rectangle
{
    public required string Text { get; set; }
}

Гарантией инициализации выступает факт наследования и теперь этим должен заниматься клиент:

var textbox = new Textbox() { Text = "button" };
// Error CS9035: Required member 'Point.X' be set in the object initializer
// or attribute constructor
// Error CS9035: Required member 'Point.Y' must be set in the object initializer
// or attribute constructor
// ....

Во-вторых, это будет полезно при использовании ORM, обязывающих иметь конструкторы без параметров, ведь, к примеру, раньше нельзя было обязать клиента проинициализировать поле с Id.

Все детали можно найти в документации, я перечислю наиболее интересное:

  • Можно отметить конструктор атрибутом SetsRequiredMembers, если есть гарантия, что все необходимые члены класса инициализируются в нём – тогда использовать инициализатор будет не обязательно;
  • Если конструктор ссылается на другой, отмеченный таким атрибутом (через this() или base()), то он также должен иметь этот атрибут;
  • Пусть инициализация обязательна, её всё ещё можно произвести, присвоив null;

При том, что изменение кажется полезным, можно заметить несколько моментов вызывающих вопросы. Во-первых, предвижу, что новичков будет сбивать с толку одновременное присутствие и модификатора required, и атрибута Required, учитывая, что они выполняют разные задачи. Во-вторых, атрибут SetsRequiredMembers для конструкторов позволяет очень легко оступиться – никто не гарантирует, что разработчик не забыл что-то проинициализировать в конструкторе, но добавил этот атрибут. Например, если забыть про родительский обязательный конструктор (и так уж случилось что у предка есть и пустой конструктор тоже), то можно написать такой код:

class Textbox : Rectangle
{
    public required string Text { get; set; }
    [SetsRequiredMembers]
    public Textbox(string text)
    {
        Text = text;
    }
    public override string ToString()
    {
        return 
            $"{{ X = {X}, Y = {Y}, W = {Width}, H = {Height}, Text = {Text}} }";
    }
}

И всё скомпилируется! И даже отработает:

var textbox = new Textbox("Lorem ipsum dolor sit.");
Console.WriteLine(textbox);
// { X = 0, Y = 0, Width = 0, Height = 0, Text = Lorem ipsum dolor sit. }

Если среди свойств будут ссылочные типы, то хотя бы будет выдано предупреждение о потенциальном null значении, но в этом случае – ничего. Они просто тихо проинициализируются значениями по умолчанию. И это не единственный сценарий – можно забыть добавить в конструктор поле в том же классе, в котором оно было объявлено. В общем, вижу здесь потенциальное пространство для ошибок.

Автоматическая инициализация структур

И ещё про инициализацию. Теперь в конструкторах структур не обязательно инициализировать все члены структур – теперь, как и в случае с классами, они будут проинициализированы значениями по умолчанию при создании экземпляра структуры:

struct Particle
{
    public int X { get; set; }
    public int Y { get; set; }
    public double Angle { get; set; }
    public int Speed { get; set; }

    public Particle(int x, int y) 
    {
        X = x;
        Y = y;
    }
    public override string ToString()
    {
        return 
            $"{{ X = {X}, Y = {Y}, Angle = {Angle}, Speed = {Speed} }}";
    }
}

// ....

var particle = new Particle();
Console.WriteLine(particle);
// { X = 0, Y = 0, Angle = 0, Speed = 0 }

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

Шаблоны списков

Очередное улучшение сопоставления шаблонов – появилась возможность использовать этот механизм по отношению к спискам и массивам:

  • Для отдельных элементов можно использовать любой шаблон чтобы проверить соответствие каким-то условиям;
  • Шаблон пустой переменной (_) соответствует одному элементу в коллекции;
  • Шаблон диапазона (..) может соответствовать числу элементов от нуля и более. Использоваться может только один раз;
  • Шаблон var позволяет захватить один или несколько (с помощью шаблона диапазона) элементов коллекции. Тип можно указывать явно.

Используем все эти возможности для проверки коллекции через оператор is:

var collection = new int[] { 0, 2, 10, 5, 4 };
if (collection is [.., > 5, _, var even] && even % 2 == 0)
{
    Console.WriteLine(even);
    // 4
}

В данном случае было проверено значение третьего (больше, чем 5) и пятого (чётность) элементов массива.

Естественно, шаблоны списков можно использовать и в выражениях switch. Там возможность захватывать элементы выглядит ещё более интересно. Например, теперь можно повеселиться и написать функцию проверки палиндрома таким образом:

bool IsPalindrome (string str) => str switch
{
    [] => true,
    [_] => true,
    [char first, .. string middle, char last]
         => first == last ? IsPalindrome (middle) : false
};

// ....

Console.WriteLine(IsPalindrome("civic"));
// True
Console.WriteLine(IsPalindrome("civil"));
// False

Поддержки двухмерных массивов, к сожалению, нет.

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

Расширенное поле видимости nameof

Небольшое улучшение для оператора nameof: теперь он может захватывать имена параметров при использовании в атрибуте на методе или параметре:

[ScopedParameter(nameof(parameter))]
void Method(string parameter)
// ....
[ScopedParameter(nameof(T))]
void Method<T>()
// ....
void Method([ScopedParameter(nameof(parameter))] int parameter)

Достаточно полезное нововведение для nullable анализа. Теперь при ликвидации предупреждений не нужно полагаться на строки:

[return: NotNullIfNotNull(nameof(path))]
public string? GetEndpoint(string? path)
    => !string.IsNullOrEmpty(path) ? BaseUrl + path : null;

При использовании результата вызова такого метода ненужные предупреждения о null значении не появятся:

var url = GetEndpoint("api/monitoring");
var data = await RequestData(url);

Модификатор доступа file

И ещё одно новое ключевое слово. В этот раз была добавлена область видимости типов, ограниченная файлом, в котором он объявлен. Обусловлено это нуждами кодогенерации, а именно – желанием избежать конфликтов имён.

Теперь, если объявить в разных файлах (но в одном пространстве имён) классы, добавив к первому модификатор file:

// Generated.cs
file class Canvas
{
    public void Render()
    {
        // ....
    }
}

А второй объявить как обычно:

// Canvas.cs
public class Canvas
{
    public void Draw()
    {
        // ....
    }
}

То конфликтов не возникнет. Естественно, никто в использовании этой возможности не ограничивает, так что теперь можно сделать де-факто приватные классы без того, чтобы делать их вложенными.

Если вдруг возник вопрос, почему тогда просто не разрешить использовать модификатор private – то придумать ответ несложно. Так как область видимости применяется именно к файлу (а не пространству имён), такое решение ликвидирует возможное недопонимание.

Небольшие изменения

Есть небольшое количество маленьких изменений, для которых ограничусь упоминанием:

  • Для классов, имена которых находятся в нижнем регистре, теперь выдаются предупреждения. Таким образом, нас хотят защитить от несовместимости при появлении новых ключевых слов;
  • nint и nuint теперь ссылаются на System.IntPtr и System.UIntPtr соответственно;
  • Сопоставление шаблонов теперь работает для типов Span<char> и ReadOnlySpan<char>;
  • При конвертации групп методов (method group conversion) могут использоваться существующие экземпляры делегатов, уже содержащие нужные ссылки;
  • Добавлены строковые литералы в UTF-8 кодировке. Для их задания нужно добавить суффикс u8 к строке. Работать с ними предназначен тип ReadOnlySpan<byte>.

Заключение

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

За всеми подробностями по новой версии C# можно обратиться к официальной документации.

Если вам также хочется ознакомиться с нововведениями в прошлых версиях языка, то приглашаю вас посмотреть наши статьи на эту тему:

А если хотите следить за мной, то предлагаю подписаться на мой Twitter.