Как стать автором
Обновить

Про новинки в .NET 5 и C# 9.0

Время на прочтение 21 мин
Количество просмотров 85K
Добрый день.

В нашей компании .NET используется с самого его рождения. У нас в продуктиве работают решения, написанные на всех версиях фреймворка: от самой первой и до последней на сегодняшний день .NET Core 3.1.

История .NET, за которой мы пристально следим всё это время, творится на глазах: версия .NET 5, которую планируют релизить в ноябре, только что вышла в виде Release Candidate 2. Нас давно предупреждали, что пятая версия будет эпохальной: с нею закончится .NET-шизофрения, когда существовали две ветки фреймворка: классический и Core. Теперь они сольются в экстазе, и будет один сплошной .NET.

Вышедший RC2 уже можно начинать полноценно использовать – никаких новых изменений перед релизом больше не ожидается, будет только фикс найденных багов. Более того: на RC2 уже работает официальный сайт, посвящённый .NET.

А мы представляем вам обзор новшеств в .NET 5 и C# 9. Вся информация с примерами кода взята из официального блога разработчиков платформы .NET (а также ещё из массы источников) и проверена лично.

Новые нативные и просто новые типы


В C# и .NET одновременно добавили нативные типы:

  • nint и nuint для C#
  • соответствующие им System.IntPtr и System.UIntPtr в BCL

Смысл для добавления этих типов — операции с низкоуровневыми API. А фишка в том, что реальный размер этих типов определяется уже во время выполнения и зависит от разрядности системы: на 32-разрядных их размер будет 4 байта, а на 64-разрядных, соответственно, 8 байт.

С большой вероятностью вы не столкнётесь с этими типами в реальной работе. Как, впрочем, и с ещё одним новым типом: Half. Этот тип существует только в BCL, аналога в C# для него пока нет. Это 16-битный тип для значений с плавающей точкой. Он может пригодиться для тех случаев, когда адская точность не требуется, и можно выиграть немного памяти для хранения значений, ведь типы float и double занимают 4 и 8 байт. Самое интересное, что для этого типа вообще пока не определены арифметические операции, и вы не сможете даже сложить две переменные типа Half без явного приведения их к float или double. То есть, назначение этого типа сейчас чисто утилитарное — экономия места. Впрочем, арифметику к нему планируют добавить в следующем релизе .NET и C#. Через год.

Атрибуты у локальных функций


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

#nullable enable
private static void Process(string?[] lines, string mark)
{
    foreach (var line in lines)
    {
        if (IsValid(line))
        {
            // Processing logic...
        }
    }

    bool IsValid([NotNullWhen(true)] string? line)
    {
        return !string.IsNullOrEmpty(line) && line.Length >= mark.Length;
    }
}

Статические лямбда-выражения


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

Чтобы избежать таких ошибок, лямбда-выражения теперь можно помечать ключевым словом static. И в этом случае они теряют доступ к любому локальному контексту: от локальных переменных до this и base.

Вот довольно исчерпывающий пример использования:

static void SomeFunc(Func<int, int> f)
{
    Console.WriteLine(f(5));
}

static void Main(string[] args)
{
    int y1 = 10;
    const int y2 = 10;
    SomeFunc(i => i + y1);          // выведет 15
    SomeFunc(static i => i + y1);   // ошибка компиляции: y1 не видна в лямбде
    SomeFunc(static i => i + y2);   // выведет 15
}

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

GetEnumerator как метод расширения


Теперь метод GetEnumerator может быть методом расширения, что позволит перебирать через foreach даже то, что раньше перебрать было нельзя. Например — кортежи.

Вот пример, когда через foreach становится возможно перебрать ValueTuple с помощью написанного для него метода расширения:

static class Program
{
    public static IEnumerator<T> GetEnumerator<T>(this ValueTuple<T, T, T, T, T> source)
    {
        yield return source.Item1;
        yield return source.Item2;
        yield return source.Item3;
        yield return source.Item4;
        yield return source.Item5;
    }

    static void Main(string[] args)
    {
        foreach(var item in (1,2,3,4,5))
        {
            System.Console.WriteLine(item);
        }
    }
}

Этот код выводит в консоль числа от 1 до 5.

Discard pattern в параметрах лямбда-выражений и анонимных функций


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

Func<int, int, int> someFunc1 = (_, _) => {return 5;};
Func<int, int, int> someFunc2 = (int _, int _) => {return 5;};
Func<int, int, int> someFunc3 = delegate (int _, int _) {return 5;};

Инструкции верхнего уровня в C#


Речь идёт об упрощённой структуре кода на C#. Теперь написание простейшего кода действительно выглядит просто:

using System;

Console.WriteLine("Hello World!");

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

Кстати, в будущем разработчики C# думают развить тему с упрощением синтаксиса и попробовать избавиться от конструкции using System; в очевидных случаях. А пока можете избавиться от неё, просто написав вот так:

System.Console.WriteLine("Hello World!");

И это действительно будет рабочая программа из одной строки.

Можно использовать более сложные варианты:

using System;
using System.Runtime.InteropServices;

Console.WriteLine("Hello World!");
FromWhom();
Show.Excitement("Top-level programs can be brief, and can grow as slowly or quickly in complexity as you'd like", 8);

void FromWhom()
{
    Console.WriteLine($"From {RuntimeInformation.FrameworkDescription}");
}

internal class Show
{
    internal static void Excitement(string message, int levelOf)
    {
        Console.Write(message);

        for (int i = 0; i < levelOf; i++)
        {
            Console.Write("!");
        }

        Console.WriteLine();
    }
}

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

Разумеется, у этой фичи есть ограничения. Главное из них — так можно делать только в одном файле проекта. Как правило, это имеет смысл делать в том файле, где вы раньше создавали точку входа в программу в виде функции Main(string[] args). При этом саму функцию Main определять там же нельзя — это второе ограничение. Фактически, сам такой файл с упрощённым синтаксисом и есть функция Main, и в нём даже уже присутствует неявно переменная args, являющаяся массивом с параметрами. То есть, вот такой код тоже скомпилируется и выведет длину массива:

System.Console.WriteLine(args.Length);

В общем, фича не самая важная, но для демонстрационных и обучающих целей вполне себе подходит. Детали тут.

Pattern matching в операторе if


Представьте, что вам надо проверить переменную-объект на то, что она не принадлежит определённому типу. До сих пор надо было писать вот так:

if (!(vehicle is Car)) { ... }

Но с C# 9.0 можно писать по-человечески:

if (vehicle is not Car) { ... }

Также появилась возможность компактной записи некоторых проверок:

if (context is {IsReachable: true, Length: > 1 })
{
    Console.WriteLine(context.Name);
}

Эта новая форма записи эквивалентна старой доброй вот такого вида:

if (context is object && context.IsReachable && context.Length > 1 )
{
    Console.WriteLine(context.Name);
}

Или ещё можно записать то же самое относительно по-новому (но это уже вчерашний день):

if (context?.IsReachable == true && context?.Length > 1 )
{
    Console.WriteLine(context.Name);
}

В новом синтаксисе можно также использовать логические операторы and, or и not, плюс, скобки для расстановки приоритетов:

if (context is {Length: > 0 and (< 10 or 25) })
{
    Console.WriteLine(context.Name);
}

И это только улучшения pattern matching в обычном if. Что добавили в pattern matching для switch expression – читайте далее.

Улучшенный pattern matching в switch expression


В switch expression (не путать с оператором switch) добавили огромные улучшения в плане pattern matching. Рассмотрим на примерах из официальной документации. Примеры посвящены расчёту платы за проезд какого-то транспорта в некоторое время. Вот первый пример:

public decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car c           => 2.00m,
    Taxi t          => 3.50m,
    Bus b           => 5.00m,
    DeliveryTruck t => 10.00m,
    { }             => throw new ArgumentException("Unknown vehicle type", nameof(vehicle)),
    null            => throw new ArgumentNullException(nameof(vehicle))
};

Две последние строки в switch-выражении — нововведения. Фигурные скобки означают любой объект, не равный null. А для сопоставления с null теперь можно использовать соответствующее ключевое слово.

Это не всё. Обратите внимание, что для каждого сопоставления с объектом вы вынуждены создавать переменную: c для Car, t для Taxi и так далее. Но эти переменные не используют. В таких случаях уже сейчас в C# 8.0 можно использовать discard pattern:

public decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car _           => 2.00m,
    Taxi _          => 3.50m,
    Bus _           => 5.00m,
    DeliveryTruck _ => 10.00m,
    // ...
};

Но начиная с девятой версии C# можно вообще ничего не писать в таких случаях:

public decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car             => 2.00m,
    Taxi            => 3.50m,
    Bus             => 5.00m,
    DeliveryTruck   => 10.00m,
    // ...
};

Улучшения в switch expression на этом далеко не заканчиваются. Теперь легче стало писать более сложные выражения. Например, часто возвращаемый результат должен зависеть от значений свойств переданного объекта. Теперь это можно записать короче и удобнее, чем комбинацией if'ов:

public static decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car { Passengers: 0 } => 2.00m + 0.50m,
    Car { Passengers: 1 } => 2.0m,
    Car { Passengers: 2 } => 2.0m - 0.50m,
    Car => 2.00m - 1.0m,
    // ...
};

Обратите внимание на первые три строки в switch: по факту проверяется значение свойства Passengers и в случае равенства возвращается соответствующий результат. Если же не будет ни одного совпадения, то будет возвращено значение для общего варианта (четвёртая строка внутри switch). Кстати, значения свойств проверяются только в том случае, если переданный объект vehicle не равен null и является экземпляром класса Car. То есть, бояться Null Reference Exception при проверках не стоит.

Но и это ещё не всё. Теперь в switch expression можно даже писать выражения для более удобного сопоставления:

public static decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Bus b when ((double)b.Riders / (double)b.Capacity) < 0.50 => 5.00m + 2.00m,
    Bus b when ((double)b.Riders / (double)b.Capacity) > 0.90 => 5.00m - 1.00m,
    Bus => 5.00m,

    DeliveryTruck t when (t.GrossWeightClass >= 5000) => 10.00m + 5.00m,
    DeliveryTruck t when (t.GrossWeightClass >= 3000 && t.GrossWeightClass < 5000) => 10.00m,
    DeliveryTruck => 8.00m,
    // ...
};

И это тоже ещё не всё. Синтаксис switch expression расширили до вложенных switch expression, чтобы нам было ещё проще описывать сложные условия:

public static decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car c => c.Passengers switch
    {
        0 => 2.00m + 0.5m,
        1 => 2.0m,
        2 => 2.0m - 0.5m,
        _ => 2.00m - 1.0m
    },
    // ...
};

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

public static decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car c => c.Passengers switch
    {
        0 => 2.00m + 0.5m,
        1 => 2.0m,
        2 => 2.0m - 0.5m,
        _ => 2.00m - 1.0m
    },

    Taxi t => t.Fares switch
    {
        0 => 3.50m + 1.00m,
        1 => 3.50m,
        2 => 3.50m - 0.50m,
        _ => 3.50m - 1.00m
    },

    Bus b when ((double)b.Riders / (double)b.Capacity) < 0.50 => 5.00m + 2.00m,
    Bus b when ((double)b.Riders / (double)b.Capacity) > 0.90 => 5.00m - 1.00m,
    Bus => 5.00m,

    DeliveryTruck t when (t.GrossWeightClass >= 5000) => 10.00m + 5.00m,
    DeliveryTruck t when (t.GrossWeightClass >= 3000 && t.GrossWeightClass < 5000) => 10.00m,
    DeliveryTruck => 8.00m,

    null => throw new ArgumentNullException(nameof(vehicle)),
    _ => throw new ArgumentException(nameof(vehicle))
};

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

private enum TimeBand
{
    MorningRush,
    Daytime,
    EveningRush,
    Overnight
}

private static TimeBand GetTimeBand(DateTime timeOfToll) =>
    timeOfToll.Hour switch
    {
        < 6 or > 19 => TimeBand.Overnight,
        < 10 => TimeBand.MorningRush,
        < 16 => TimeBand.Daytime,
        _ => TimeBand.EveningRush,
    };

Как видите, в C# 9.0 ещё появилась возможность использовать при сопоставлении операторы сравнения <, >, <=, >=, а также логические операторы and, or и not.

Но и это, чёрт побери, ещё не конец. В switch expression теперь можно использовать… кортежи. Вот полный пример кода, который вычисляет некий коэффициент к плате за проезд, в зависимости от дня недели, времени суток и направления движения (в город/из города):

private enum TimeBand
{
    MorningRush,
    Daytime,
    EveningRush,
    Overnight
}

private static bool IsWeekDay(DateTime timeOfToll) =>
    timeOfToll.DayOfWeek switch
{
    DayOfWeek.Saturday => false,
    DayOfWeek.Sunday => false,
    _ => true
};

private static TimeBand GetTimeBand(DateTime timeOfToll) =>
    timeOfToll.Hour switch
{
    < 6 or > 19 => TimeBand.Overnight,
    < 10 => TimeBand.MorningRush,
    < 16 => TimeBand.Daytime,
    _ => TimeBand.EveningRush,
};

public static decimal PeakTimePremiumFull(DateTime timeOfToll, bool inbound) =>
    (IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
{
    (true, TimeBand.MorningRush, true) => 2.00m,
    (true, TimeBand.MorningRush, false) => 1.00m,
    (true, TimeBand.Daytime, true) => 1.50m,
    (true, TimeBand.Daytime, false) => 1.50m,
    (true, TimeBand.EveningRush, true) => 1.00m,
    (true, TimeBand.EveningRush, false) => 2.00m,
    (true, TimeBand.Overnight, true) => 0.75m,
    (true, TimeBand.Overnight, false) => 0.75m,
    (false, TimeBand.MorningRush, true) => 1.00m,
    (false, TimeBand.MorningRush, false) => 1.00m,
    (false, TimeBand.Daytime, true) => 1.00m,
    (false, TimeBand.Daytime, false) => 1.00m,
    (false, TimeBand.EveningRush, true) => 1.00m,
    (false, TimeBand.EveningRush, false) => 1.00m,
    (false, TimeBand.Overnight, true) => 1.00m,
    (false, TimeBand.Overnight, false) => 1.00m,
};

В методе PeakTimePremiumFull как раз для сопоставления используются кортежи, и это стало возможным в новой версии C# 9.0. Кстати, если посмотреть внимательно на код, то напрашиваются две оптимизации:

  • последние восемь строк возвращают одно и то же значение;
  • дневной и ночной трафик имеют один и тот же коэффициент.

В итоге код метода можно сильно сократить с помощью discard pattern:

public static decimal PeakTimePremiumFull(DateTime timeOfToll, bool inbound) =>
    (IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
{
    (true, TimeBand.MorningRush, true)  => 2.00m,
    (true, TimeBand.MorningRush, false) => 1.00m,
    (true, TimeBand.Daytime,     _)     => 1.50m,
    (true, TimeBand.EveningRush, true)  => 1.00m,
    (true, TimeBand.EveningRush, false) => 2.00m,
    (true, TimeBand.Overnight,   _)     => 0.75m,
    (false, _,                   _)     => 1.00m,
};

Ну а если ещё более внимательно присмотреться, то можно сократить и этот вариант, вынеся в общий случай коэффициент 1.0:

public static decimal PeakTimePremiumFull(DateTime timeOfToll, bool inbound) =>
    (IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
{
    (true, TimeBand.Overnight, _) => 0.75m,
    (true, TimeBand.Daytime, _) => 1.5m,
    (true, TimeBand.MorningRush, true) => 2.0m,
    (true, TimeBand.EveningRush, false) => 2.0m,
    _ => 1.0m,
};

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

Кортежи в switch expression можно использовать и в C# 8.0. Никчемный разработчик, который писал эту статью, стал немножечко умнее.


Ну и напоследок вот ещё один сумасшедший пример, демонстрирующий новый синтакcис сопоставлений и с кортежами, и по свойствам объектов:

public static bool IsAccessOkOfficial(Person user, Content content, int season) => 
    (user, content, season) switch 
{
    ({Type: Child}, {Type: ChildsPlay}, _)          => true,
    ({Type: Child}, _, _)                           => false,
    (_ , {Type: Public}, _)                         => true,
    ({Type: Monarch}, {Type: ForHerEyesOnly}, _)    => true,
    (OpenCaseFile f, {Type: ChildsPlay}, 4) when f.Name == "Sherlock Holmes"  => true,
    {Item1: OpenCaseFile {Type: var type}, Item2: {Name: var name}} 
        when type == PoorlyDefined && name.Contains("Sherrinford") && season >= 3 => true,
    (OpenCaseFile, var c, 4) when c.Name.Contains("Sherrinford")              => true,
    (OpenCaseFile {RiskLevel: >50 and <100 }, {Type: StateSecret}, 3) => true,
    _                                               => false,
};

Это всё выглядит достаточно непривычно. Для полного понимания рекомендую заглянуть в первоисточник, там приведён полный пример кода.

Новый new, а также в принципе улучшенный target typing


Давным-давно в C# появилась возможность писать var вместо названия типа, ибо сам тип можно было определить из контекста (собственно, это и называется target typing). То есть, вместо вот такой записи:

SomeLongNamedType variable = new SomeLongNamedType();

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

var variable = new SomeLongNamedType()

И о типе переменной variable компилятор догадается сам. Спустя годы реализовали обратный синтаксис:

SomeLongNamedType variable = new ();

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

var result = SomeMethod(new (2020,10,01));

//...

public Car SomeMethod(DateTime p)
{
    //...

    return new() { Passengers = 2 };
}

В данном примере при вызове SomeMethod параметр типа DateTime создаётся сокращённым синтаксисом. Возвращаемое из метода значение создаётся таким же способом.

Где действительно будет выгода от такого синтаксиса, так это при определении коллекций:

List<DateTime> datesList = new()
{
    new(2020, 10, 01),
    new(2020, 10, 02),
    new(2020, 10, 03),
    new(2020, 10, 04),
    new(2020, 10, 05)
};

Car[] cars = 
{
    new() {Passengers = 2},
    new() {Passengers = 3},
    new() {Passengers = 4}
};

Отсутствие необходимости писать полное название типа при перечислении элементов коллекции делает код чуть-чуть чище.

Target typed операторы ?? и ?:


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

int? result = b ? 0 : null; // nullable value type

Раньше требовалось явное приведение типов у нуля к int?.. Теперь это не является необходимым.

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

Person person = student ?? customer; // Shared base type

Типы customer и student, хоть и являются производными от Person, но формально являются разными. Предыдущая версия языка не разрешала вам использовать такую конструкцию без явного приведения типа. Теперь компилятор прекрасно понимает, что имеется в виду.

Переопределение возвращаемого типа методов


В C# 9.0 разрешили переопределять возвращаемый тип у перекрываемых методов. Требование одно: новый тип должен быть наследуемым от оригинального (ковариантным). Вот пример:

abstract class Animal
{
    public abstract Food GetFood();
    ...
}
class Tiger : Animal
{
    public override Meat GetFood() => ...;
}

В классе Tiger возвращаемое значение у метода GetFood переопределено с Food на Meat. Теперь это нормально если Meat является производным типом от Food.

init-свойства — это не совсем readonly-члены


В новой версии языка появилась интересная фишка: init-свойства. Это свойства, значение которых можно установить только при начальной инициализации объекта. Казалось бы, что для этого существуют readonly-члены класса, но на самом деле это разные вещи, позволяющие решать разные задачи. Чтобы понять, в чём разница, и в чём прелесть init-свойств, вот пример:

Person employee = new () {
    Name = "Paul McCartney",
    Company = "High Technologies Center",
    CompanyAddress = new () {
        Country = "Russia",
        City = "Izhevsk",
        Line1 = "246, Karl Marx St."
    }
}

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

class Person {
    //...
    public string Name {get; set;}
    public string Company {get; set;}
    public Address CompanyAddress {get; set;}
    //...
}

Однако, по факту свойство Name является неизменяемым. В настоящее время есть только единственный способ сделать это свойство read-only – объявить приватный сеттер:

class Person {
    //...
    public string Name {get; private set;}
    //...
}

Но в этом случае мы сразу же лишаемся возможности пользоваться удобным синтаксисом объявления экземпляра класса через присваивание значений свойствам внутри фигурных скобок. И мы можем установить значение свойства Name только передав его в параметры конструктору класса. Теперь представьте, что свойство CompanyAddress, вообще-то, тоже неизменяемое по смыслу. В общем, в такую ситуацию я попадал очень много раз, и всегда приходилось выбирать из двух зол:

  • навороченные конструкторы с кучей параметров, зато все свойства класса read-only;
  • удобный синтаксис создания объекта, но зато все свойства класса read-write, а я должен об этом помнить и случайно не поменять их где-то.

В этом месте кто-то может вспомнить про readonly-члены класса и предложить оформить класс Person так:

class Person {
    //...
    public readonly string Name;
    public readonly string Company;
    public readonly string CompanyAddress;
    //...
}

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

Но в C# 9.0 эта проблема решена: если вы определите свойство, как init-свойство, вы получите и удобный синтаксис создания объекта, и реально неизменяемое в дальнейшем свойство:

class Person {
    public string Name { get; init; }
    public string Company { get; init; }
    public Address CompanyAddress { get; init; }
}

Кстати, в init-свойствах, как и в конструкторе, можно инициализировать readonly-члены класса, и вы можете писать вот так:

public class Person
{
    private readonly string name;
       
    public string Name
    { 
        get => name; 
        init => name = (value ?? throw new ArgumentNullException(nameof(Name)));
    }
}

Record — это узаконенная DTO'шка


Продолжая тему неизменяемых свойств, мы подошли к главному, на мой взгляд, нововведению языка: типу record. Этот тип предназначен для удобного создания целых неизменяемых структур, а не только свойств. Причина для появления отдельного типа проста: работая по всем канонам, мы постоянно создаём DTO'шки для изоляции разных уровней приложения. DTO'шки обычно представляют собой просто набор полей, без всякой бизнес-логики. И, как правило, значения этих полей не изменяются в течение времени жизни этой DTO'шки.

Лирическое отступление.

DTO – Data Transfer Object. Такой объект служит только для передачи данных между различными слоями внутри приложения (DAL, BL, PL) или подсистемами внутри какой-то системы приложений. Ещё мы их часто называем «модельки». Обычно мы возвращаем такие модельки-DTO'шки из уровня DAL в уровень BL, там обогащаем полученные данные, оборачиваем их в новую DTO-модельку, выкидываем на уровень представления, а там уже вполне можем преобразовать ещё раз в очередную DTO-модельку, чтобы выкинуть клиенту в каком-то виде (в HTML-вьюшке или JSON-строке).

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

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

В общем, с DTO-модельками дело имеют все.

Так, спустя много-много лет, разработчики C# добрались-таки до реально нужного улучшения: они легализовали DTO-модельки в виде отдельного типа record.

До сих пор все DTO-модельки, что мы создавали (а мы создавали их массово) представляли собой обычные классы. Для компилятора и для рантайма они ничем не отличались от всех прочих классов, хотя не являлись таковыми в классическом смысле. Мало кто использовал для DTO-моделек структуры (struct) — это не всегда было приемлемо по различным причинам.

Теперь же мы можем определить record (далее — запись) — особую структуру, которая предназначена для создания неизменяемых DTO-моделек. Запись занимает промежуточное место между структурам и классами в их обычном понимании. Это и недокласс и сверхструктура. Запись — всё ещё ссылочный тип со всеми вытекающими последствиями. Записи почти всегда ведут себя как обычный класс, могут содержать методы, допускают наследование (но только от других записей, не от объектов, хотя если запись явно не наследуется ни от чего, то она так же неявно наследуется от object, как и всё в C#), могут реализовывать интерфейсы. Более того, вы вообще не обязаны делать записи полностью неизменяемыми. А где же тогда смысл и в чём разница?

Давайте просто создадим запись:

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

    public Person(string first, string last) => (FirstName, LastName) = (first, last);
}

А теперь вот вам пример использования:

Person p1 = new ("Paul", "McCartney");
Person p2 = new ("Paul", "McCartney");

System.Console.WriteLine(p1 == p2);

Этот пример выведет в консоль true. Если бы Person был классом, то в консоль было бы выведено false, поскольку объекты сравниваются по ссылке: две ссылочные переменные равны только если ссылаются на один и тот же объект. Но это не так с записями. Записи сравниваются по значению всех их полей, включая приватные.

Продолжая предыдущий пример, посмотрим на этот код:

System.Console.WriteLine(p1);

В случае с классом мы бы получили в консоль полное название класса. Но в случае с записями мы увидим в консоли вот что:

Person { LastName = McCartney, FirstName = Paul}

Дело в том, что для записей метод ToString() неявно переопределяется и выводит не название типа, а полный список публичных полей со значениями. Точно так же для записей неявно переопределены операторы == и !=, что даёт возможность менять логику сравнения.

Поиграем с наследованием записей:

public record Teacher : Person
{
    public string Subject { get; }

    public Teacher(string first, string last, string sub)
        : base(first, last) => Subject = sub;
}

Теперь создадим две записи разных типов и сравним их:

Person p = new("Paul", "McCartney");
Teacher t = new("Paul", "McCartney", "Programming");

System.Console.WriteLine(p == t);

Хотя запись Teacher и унаследована от Person, переменные p и t не будут равны, в консоль выведется false. Это потому что сравнение производится не только по всем полям записей, но и по типам, а типы тут явно разные.

И хотя сравнение наследуемых типов записей разрешено (но бессмысленно), сравнение вообще разных типов записей недопустимо в принципе:

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

    public Person(string first, string last) => (FirstName, LastName) = (first, last);
}

public record Person2
{
    public string LastName { get; }
    public string FirstName { get; }

    public Person2(string first, string last) => (FirstName, LastName) = (first, last);
}

// ...

Person p = new("Paul", "McCartney");
Person2 p2 = new("Paul", "McCartney");
System.Console.WriteLine(p == p2);    // ошибка компиляции

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

Ещё одна приятная особенность записей — ключевое слово with, с помощью которого легко создавать модификации ваших DTO-моделек. Посмотрите на пример:

Person me = new("Steve", "Brown");
Person brother = me with { FirstName = "Paul" };

В этом примере у записи brother значения всех полей будут заполнены из записи me, кроме поля FirstName – оно будет изменено на Paul.

До сих пор вы видели классический способ создания записей — с полным определением конструкторов, свойств и так далее. Но теперь появился ещё и лаконичный способ:

public record Person(string FirstName, string LastName);

public record Teacher(string FirstName, string LastName,
    string Subject)
    : Person(FirstName, LastName);

public sealed record Student(string FirstName,
    string LastName, int Level)
    : Person(FirstName, LastName);

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

public record Pet(string Name)
{
    public void ShredTheFurniture() =>
        Console.WriteLine("Shredding furniture");
}

public record Dog(string Name) : Pet(Name)
{
    public void WagTail() =>
        Console.WriteLine("It's tail wagging time");

    public override string ToString()
    {
        StringBuilder s = new();
        base.PrintMembers(s);
        return $"{s.ToString()} is a dog";
    }
}

Свойства и конструктор у записей в этом случае также будут созданы автоматически. Всё меньше и меньше boilerplate-кода, но только применимо к записям. Для классов и структур это не работает.

Кроме всего уже сказанного, компилятор также умеет автоматически создавать деконструктор для записей:

var person = new Person("Bill", "Wagner");

var (first, last) = person; // Этот деконструктор создан автоматически
Console.WriteLine(first);
Console.WriteLine(last);

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

А пока — все переписываем DTO-модельки с классов на записи.

.NET Source Generators


Source Generator (далее — просто генератор) — это довольно интересная фишка. Генератор является куском кода, который выполняется на этапе компиляции, имеет возможность проанализировать уже скомпилированный код, и может сгенерировать дополнительный код, который так же будет скомпилирован. Если не совсем понятно, то вот один довольно актуальный пример, когда генератор может быть востребован.

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

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

Генераторы кода — тема новая и слишком необычная, чтобы уместить её в рамках этого поста. Дополнительно можно ознакомиться с примером простейшего «Hello, world!» генератора в этом обзоре.

С генераторами кода связаны две новые фичи, про которые написано далее.

Частичные методы (partial method)


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

public partial class MyClass
{
    public partial int DoSomeWork(out string p);
}
public partial class MyClass
{
    public partial int DoSomeWork(out string p)
    {
        p = "test";
        System.Console.WriteLine("Partial method");
        return 5;
    }
}

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

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

Методы-инициализаторы (module initializers)


Есть три повода для внедрения этого функционала:

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

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

using System.Runtime.CompilerServices;
class C
{
    [ModuleInitializer]
    internal static void M1()
    {
        // ...
    }
}

На метод накладываются некоторые ограничения:

  • он должен быть статическим;
  • он не должен иметь параметров;
  • он не должен ничего возвращать;
  • он не должен работать с обобщениями (generics);
  • он должен быть доступен из содержащего его модуля, то есть:
    • он должен быть internal или public
    • он не должен быть локальным методом

А работает это так: как только компилятор находит все методы, помеченные атрибутом ModuleInitializer, он генерирует специальный код, который их все вызывает. Порядок вызова методов-инициализаторов указать нельзя, но он будет один и тот же при каждой компиляции.

Заключение


Уже опубликовав пост, мы заметили, что он больше посвящён новинкам в языке C# 9.0, чем новинкам самого .NET. Но и так неплохо получилось.
Теги:
Хабы:
+131
Комментарии 152
Комментарии Комментарии 152

Публикации

Истории

Работа

.NET разработчик
66 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн
PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн