Написать пост

Что полезного в новых версиях C#

Логотип компании Ozon Tech

Вместе с экспертами Ozon Tech и Route 256 подготовили обзор полезных и неочевидных фич в новых версиях языка С# за последние четыре года.

Обложка поста Что полезного в новых версиях C#

С# продолжает развиваться, и в новых версиях команда разработчиков языка регулярно добавляет в него новые функции и возможности. Вместе с экспертами Ozon Tech и Route 256 подготовили обзор полезных и неочевидных фич, которые появились в языке за последние четыре года. Это не тот C#, который вы учили пять лет назад.

  1. История версий C#
  2. Операторы верхнего уровня — программы без Main методов
  3. Nullable-типы
  4. Типы записи и записи структуры
  5. Индексы и диапазоны
  6. Паттерн-матчинг

История версий C#

Что полезного в новых версиях C# 1

C# был придуман в Microsoft под руководством Андерса Хейлсберга, который до этого работал над языком Delphi. Язык создавался как конкурент Java со свойствами и событиями.

Первая версия C# увидела свет в 2002 году. Затем каждые два или три года Microsoft выпускал новую версию языка. В C# постепенно добавили обобщённые типы и итераторы, Linq, фичи функциональных языков программирования, ключевое слово dynamic для упрощения работы с COM, интеграции с динамическими языками на платформе .NET, async\await, кортежи и другое.

Что полезного в новых версиях C# 2

Начиная с C# 8, разработчики выпускают новую версию языка ежегодно. В них уже нет масштабных изменений, вроде введения Linq или async\await, а некоторые фичи, такие как паттерн-матчинг, развиваются от релиза к релизу. К сожалению, это приводит к тому, что на момент появления многие фичи просто не используются. Программисты, которые изучали C# по книжкам и статьям пятилетней давности, даже не подозревают об их существовании.

В этой статье попытаемся восполнить этот пробел и опишем важные изменения в языке, которые вам стоит использовать в своих программах.

Операторы верхнего уровня — программы без Main методов

Все языки, унаследовавшие синтаксис от языка C, страдают многословностью. C# не стал исключением и перенял у Java необходимость писать следующим образом:

			using System;

namespace HelloWorld
{
    class Program
    {
        static void Main(string[] args)
        {
             Console.WriteLine("Hello World!");
        }
    }
}
		

В C# 9 появилась фича Top-level statements. Теперь тот же самый код возможно переписать короче:

			using System;

Console.WriteLine("Hello World!");
		

Более того, используя implicit usings, using System писать необязательно. Компилятор автоматически добавляет юзинги для популярных пространств имён. Начиная с .NET 6 и C# 10 новое консольное приложение выглядит так:

			Console.WriteLine("Hello World!");
		

Если вам не подходит автоматика, то можете глобально задать юзинги в отдельном .cs файле или в файле проекта.

Код, написанный таким образом, «под капотом» заворачивается в функцию Main. Все функции, которые вы определите, станут локальными и смогут использовать переменные, определённые ранее. Определять типы можно в том же файле, но только после всех top-level statements.

Nullable-типы

В C# 8 появилась фича — Nullable Reference Types, аналогичная Nullable Value Types. В проектах, созданных до .NET 6, фича была отключена по умолчанию. Она работает по следующим правилам:

  • Если есть переменная или параметр ссылочного типа T, то ему необходимо присвоить значение, не являющееся null (non-nullable).
  • Чтобы присвоить null, нужно поставить знак вопроса после имени типа — T? (nullable).
  • Чтобы привести T? к T, необходимо использовать оператор ! после nullable выражения, иначе компилятор выдает ошибку.
  • При обращении к членам T? компилятор статически проверяет, что значение ссылки не равно null.
			string notNull = "Hello";
string? nullable = default;
notNull = nullable!; // null forgiveness
		

В отличие от Nullable Value Types, string и string? — не разные типы. Это один и тот же тип string, подсказка компилятору и набор атрибутов, помогающих проводить статический анализ.

Как следствие, Nullable Reference Types не даёт гарантий за пределами вашего кода. Если в коде функция публичного интерфейса класса принимает string, а не string?, то вам необходимо написать проверку на null, хоть она и выглядит избыточной.

			static void LogMessage(string message)
{
    ArgumentNullException.ThrowIfNull(message);
    Console.WriteLine(message.Length);
}
		

Компилятор, в свою очередь, при нарушении правил Nullable Reference Types генерирует предупреждения, а не ошибки. Но это легко исправить, указав в свойствах проекта, что Nullable предупреждения стоит воспринимать как ошибки.

Что полезного в новых версиях C# 3

Важная вещь, которую стоит помнить, — оператор var всегда создаёт переменную nullable reference типа.

Типы записи и записи структуры в C#

Как появились

Анонимные типы добавили ещё в третью версию C#. Они неизменяемые, имеют структурную, а не ссылочную эквивалентность, и удобное представление в ToString().

			var t1 = new { FirstName = "Vasya", LastName = "Pupkin" };
Console.WriteLine($"Full name is {t1.LastName} {t1.LastName}.");

var t2 = new { FirstName = "Vasya", LastName = "Pupkin" };
Console.WriteLine($"{t1} are equal {t2} = {t1.Equals(t2)}"); // true
Console.WriteLine($"{t1} are same {t2} = {ReferenceEquals(t1, t2)}"); // false
		

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

Такие типы можно создать и «руками», но вам быстро надоест каждый раз переопределять Equals, GetHashCode и ToString, и вы начнёте искать более простые способы.

Один из них — кортежи, которые появились в C# 7.0. Кортежи можно красиво использовать в варианте с именами полей:

			var t = (Sum: 4.5, Count: 3);
Console.WriteLine($"Sum of {t.Count} elements is {t.Sum}.");
(_, var count) = t;
Console.WriteLine($"Count of elements is {count}.");
		

Кортежи тоже имеют структурную эквивалентность и удобное представление в ToString(). Но в отличие от анонимных типов кортежи изменяемые.

Однако, как и Nullable Reference Types, имена кортежей — магия компилятора. «Под капотом» там всегда System.ValueTuple, поля Item1..ItemN, и никакие интерфейсы реализовать не получится.

Как создавать

Начиная с C# 8, можно создавать свои типы, которые объединяют фичи кортежей и анонимных типов, но при этом могут иметь методы и реализовывать интерфейсы. Такие типы называются записями, а для их создания используется ключевое слово record.

			Person p1 = new("Vasya", "Pupkin");
Console.WriteLine($"Full name is {p1.LastName} {p1.LastName}.");
(var firstName, _) = p1;
Console.WriteLine($"First name is {firstName}.");

Person p2 = new("Vasya", "Pupkin");
Console.WriteLine($"{p1} are equal {p2} = {p1 == p2}"); // true
Console.WriteLine($"{p1} are same {p2} = {Object.ReferenceEquals(p1, p2)}"); // false

public record Person(string FirstName, string LastName);
		

Тип, объявленный как record, автоматически реализует деконструктор (не путать с деструктором), Equals, GetHashCode, ToString, а также интерфейс IEquatable и перегружает операторы сравнения. Свойства FirstName и LastName будут неизменяемыми. В остальном это обычный класс. Он может содержать методы, наследовать другие классы, реализовывать интерфейсы.

В примере выше используется фича из C# 9 — Target-typed new operator, когда после new не нужно использовать имя типа, если выражение присваивается переменной или параметру этого типа. Это облегчает использование Nullable Reference Types.

Начиная с C# 11 можно создать тип запись, которая является полным эквивалентом анонимного класса:

			public record Person
{
    public required string FirstName { get; init; }
    public required string LastName { get; init; }
};
		

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

			Person p1 = new() { FirstName = "Vasya", LastName = "Pupkin" };
Console.WriteLine($"Full name is {p1.LastName} {p1.LastName}."); 

Person p2 = new() { FirstName = "Vasya", LastName = "Pupkin" };
Console.WriteLine($"{p1} are equal {p2} = {p1 == p2}"); // true
Console.WriteLine($"{p1} are same {p2} = {ReferenceEquals(p1, p2)}"); // false
		

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

			public record Person(string FirstName, string LastName)
{
    public string[] PhoneNumbers { get; init; } = Array.Empty();
};
		

Записи структуры

В C#10 добавили возможность создавать записи, которые являются структурами, а не классами. В отличие от записей классов записи структуры по умолчанию изменяемы.

			Point p = new(1, 2, 3);
p.X = 2; //нет ошибки

public record struct Point(double X, double Y, double Z);
		

Если вам потребуются неизменяемые записи структуры, добавьте ключевое слово readonly, как и для обычных структур.

			Point p = new(1, 2, 3);
p.X = 2; // ошибка компиляции
 
public readonly record struct Point(double X, double Y, double Z);
		

Оператор with

Для работы с неизменяемыми типами данных в языке появился оператор with. Он копирует запись и изменяет значения свойств.

			Person person1 = new("Nancy", "Davolio") { PhoneNumbers = new[] { "555-1234" } };
Person person2 = person1 with { FirstName = "John" };
person2 = person1 with { PhoneNumbers = new [] { "555-6789" } };

Point p = new(1, 2, 3);
var p2 = p with { Z = 0 };
		

Оператор with работает не только с типами записями, но и с readonly структурами и анонимными типами.

Индексы и диапазоны в C#

В C# 8 разработчики языка добавили возможность писать следующим образом:

			int[] xs = new[] { 0, 10, 20, 30, 40 };
int last = xs[^1]; // вместо xs[xs.Length-1]
Console.WriteLine(last);  // выводит: 40
		

Аналогично можно обратиться ко второму элементу с конца: ^2, к третьему: ^3 и так далее.

Кроме того, появилась возможность извлекать подмассив из массива с помощью индексов, как в языках F#, Python и многих других:

			var xs = new[] { 0, 10, 20, 30, 40 };
var sub = xs[1..^1]; // Массив без первого и последнего элемента
Console.WriteLine($" {{ {string.Join(", ", sub)} }}");  // output: { 10, 20, 30 }
		

Обратите внимание: в операторе диапазона левая граница включается в диапазон, а правая — нет. В диапазонах можно не указывать левую или правую границу, или не указывать обе. В качестве границ могут быть указаны индексы как с конца, так и сначала.

Под капотом оператор диапазона превращается в структуру System.Range:

			var all = ..; //Range.All
var from = 1..; //Range.StartAt(1)
var to = ..2; //Range.EndAt(2)
var sub = 1..^1; //new Range(1, new Index(1, fromEnd: true));
var subFromEnd = ^2..^1; //new Range(new Index(2, fromEnd:true), new Index(1, fromEnd:true))
		

Индексы работают с коллекциями, у которых есть свойство-индексатор с целыми индексами и свойство Length или Count. Диапазоны требуют наличие свойств Length или Count и метода Slice с двумя аргументами. Например, вы можете получить подстроку из строки с помощью оператора диапазона:

			string s = "abc"[1..^1]; //"b"
		

Большинство типов-коллекций автоматически получит поддержку индексов, а для диапазонов нужно будет реализовать метод Slice. Кроме того, вы можете определить свои свойства и методы, которые работают со структурами System.Range и System.Index.

Паттерн-матчинг

Паттерн-матчинг, или сопоставление с шаблоном, — это обобщённое название множества фич, добавленных в компилятор. Попробуем разобрать их все от простого к сложному.

is на стероидах

Оператор is существует с первой версии языка C# и используется для проверки переменной на соответствие определённому типу. До сих пор в статьях и книгах по C# можно встретить такой код:

			object x = "abc";
if (x is string) {
    var s = x as string;
    Console.WriteLine(s.Substring(1, s.Length - 1)); //output: b
}
		

В современном C# нет необходимости проверять тип и приводить к нему в два действия. Можно просто использовать паттерн-матчинг:

			object x = "abc";
if (x is string s) {
    Console.WriteLine(s[1..^1]); //output: b
}
		

Более того, в C# 11 в паттерн-матчинг можно внести вырезание строки:

			object x = "abc";
if (x is string and [_, .. var s, _]) {
    Console.WriteLine(s); //output: b
}
		

Как это читать: выражение после is говорит, что переменная x должна быть строкой (паттерн типа) и может быть разложена на три подстроки (паттерн списка):

— один символ в начале, который мы в дальнейшем никак не используем, так как указали подчёркивание вместо имени переменной (паттерн отбрасывания значения);

— один символ в конце, который мы также игнорируем, а подстроку из любого количества символов в середине присваиваем переменной s и просим компилятор вывести её тип (var-паттерн).

Ключевое слово and создает логический паттерн, когда переменная должна соответствовать обоим паттернам слева и справа от and.

В этом случае, если строка содержит меньше двух символов, то выражение is вычисляется как false и Console.WriteLine не будет выполнено. Если нам хочется, чтобы подстрока s имела длину больше нуля, то необходимо написать ещё одно условие:

			object x = "abc";
if (x is string and [_, .. var s, _] undefinedundefined s is { Length: > 0 }) {
    Console.WriteLine(s); //output: b
}
		

Теперь мы проверяем, чтобы переменная s, полученная в результате сопоставления с шаблоном, соответствовала паттерну (паттерн свойства): свойство Length должно иметь значение больше 0 (паттерн сравнения). Здесь важно не путать логические паттерны и булевы операторы. Это не одно и то же, и они не взаимозаменяемы.

То же самое можно записать в виде логического паттерна внутри лист-паттерна:

			object x = "abc";
if (x is string and [_, .. var s and { Length: > 0 } , _])
{
      Console.WriteLine(s); //output: b
}
		

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

switch и паттерн-матчинг

В выражениях case оператора switсh также можно использовать паттерны:

			void DisplayMeasurements(int a, int b)
{
    switch ((a, b))
    {
        case ( > 0, > 0) when a == b:
            Console.WriteLine($"Both measurements are valid and equal to {a}.");
            break;

        case ( > 0, > 0):
            Console.WriteLine($"First measurement is {a}, second measurement is {b}.");
            break;

        default:
            Console.WriteLine("One or both measurements are not valid.");
            break;
    }
}
		

В примере выше используются паттерн деконструкции (в документации «позиционный паттерн») и паттерны сравнения.

В C# 8 ввели switch expression с более компактным синтаксисом, похожим на функциональные языки:

			static Point Transform(Point point) => point switch
{
    var (x, y) when x < y => new Point(-x, y),
    var (x, y) when x > y => new Point(x, -y),
    var (x, y) => point,
};
public record Point(int X, int Y);
		

В этом и предыдущем примере используется case guard — произвольное булево выражение после ключевого слова when. Оно необходимо в случаях, когда мы не можем выразить необходимые условия в паттернах.

switch expression удобно использовать для создания машины состояний. На одном из форумов была такая задача: для последовательности цифр требуется убедиться, что 1 есть как минимум один раз, 2 — только один раз, 3 — как минимум один раз, а после этого ничего быть не должно.

			var xs = new[] { 1, 1, 1, 2, 3, 3, 3 };

var x = xs.Aggregate(1, (s, x) => (s, x) switch {
    (1, 1) => 1,
    (1, 2) => 2,
    (2, 3) or (3, 3) => 3,
    _ => -1
});
Console.WriteLine(x);
		

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

Проверка на null

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

			var isNull = x is null;
var isNotNull = x is not null;
		

Это связано с тем, что перегруженные операторы == и != могут давать ошибки если левосторонний аргумент равен null.

Заключение

Мы рассмотрели неполный список нововведений, появившихся в новых версиях С#, начиная восьмой версии языка. Однако затронули наиболее важные из них, которые влияют на то, как мы пишем программы.

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

А тем, кому хочется большего, рекомендуем курс Route 256 от Ozon Tech по современным технологиям C#.

Программа рассчитана на разработчиков с опытом от 3 лет. Преподаватели и тьюторы — инженеры Ozon Tech. За два месяца вы сможете подтянуть скилы и освежить знания. На курсе вы научитесь создавать и настраивать микросервисы на ASP.NET Core, эффективно работать с асинхронным кодом, проектировать сложные распределенные системы, создавать REST и gRPC API и другое.

Надеемся, статься оказалась для вас полезной. Остались вопросы? Задайте их в комментариях.

Реклама ООО «Озон технологии» LjN8K484i

Следите за новыми постами
Следите за новыми постами по любимым темам
10К открытий11К показов