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
{
// ....
}
Конечно, ничего глобального это изменение не привнесло, но теперь разработчики библиотек могут предоставлять более понятный и удобный интерфейс при работе с обобщениями в атрибутах.
С новой версией языка нам будут доступны математические операции над обобщёнными типами.
Сначала остановимся на двух универсальных последствиях нововведения:
Да, теперь у нас есть конструкция вида 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": ""
}
Отличия от работы идентификатора @ увидеть несложно: исходный литерал действительно был воспринят "буквально" со всеми кавычками и фигурными скобками, а лишних отступов слева у нас нет. Синтаксические правила по работе с "сырыми" строками такие:
Как ни странно, кажется, это одно из самых глобальных нововведений. Теперь можно делать многострочные литералы, не боясь ни за форматирование получившейся строки, ни за опрятность кода. Непонятно лишь одно: зачем теперь работать с текстом при помощи более классического буквального идентификатора?
Ещё одно, на сей раз небольшое, улучшение при работе со строками: выражение внутри интерполяции теперь можно переносить на новую строчку:
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 появилась возможность обязать инициализировать поля и свойства внутри конструктора или инициализатора. Мотивации у такой возможности две.
Во-первых, при работе с большими иерархиями классов рано или поздно может скопиться 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.
Все детали можно найти в документации, я перечислю наиболее интересное:
При том, что изменение кажется полезным, можно заметить несколько моментов вызывающих вопросы. Во-первых, предвижу, что новичков будет сбивать с толку одновременное присутствие и модификатора 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 }
Изменение мало что затрагивает, но и нужно оно было больше для улучшения внутренних механизмов языка (например, для реализации полуавтоматических свойств, которые в этот релиз не вошли).
Очередное улучшение сопоставления шаблонов – появилась возможность использовать этот механизм по отношению к спискам и массивам:
Используем все эти возможности для проверки коллекции через оператор 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: теперь он может захватывать имена параметров при использовании в атрибуте на методе или параметре:
[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:
// Generated.cs
file class Canvas
{
public void Render()
{
// ....
}
}
А второй объявить как обычно:
// Canvas.cs
public class Canvas
{
public void Draw()
{
// ....
}
}
То конфликтов не возникнет. Естественно, никто в использовании этой возможности не ограничивает, так что теперь можно сделать де-факто приватные классы без того, чтобы делать их вложенными.
Если вдруг возник вопрос, почему тогда просто не разрешить использовать модификатор private – то придумать ответ несложно. Так как область видимости применяется именно к файлу (а не пространству имён), такое решение ликвидирует возможное недопонимание.
Есть небольшое количество маленьких изменений, для которых ограничусь упоминанием:
Вот такой вышла новая версия языка. Ожидаемо, не все нововведения одинаково всем пригодятся: какие-то слишком специфичны, какие-то не окажут большого влияния, а некоторые и вовсе просто послужат фундаментом для последующего развития языка. Тем не менее, есть и однозначно полезные. Для меня такими, например, стали поддержка исходных строк и обязательная инициализация. Уверен, и вы сможете найти для себя что-то интересное!
За всеми подробностями по новой версии C# можно обратиться к официальной документации.
Если вам также хочется ознакомиться с нововведениями в прошлых версиях языка, то приглашаю вас посмотреть наши статьи на эту тему:
А если хотите следить за мной, то предлагаю подписаться на мой Twitter.