Expert JS React Developers for TUI wanted. Join Ciklum and get a $4000 sign-on bonus!
×Закрыть

Какие фичи C# 9 упростят тебе жизнь. Шпаргалка .NET разработчику

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Привет! Я — Дмитрий Богдан, .NET разработчик в NIX и спикер NIXMulticonf.

Эта статья — своеобразная «шпаргалка» для девелоперов по самым полезным фичам C# 9, а также нескольким функциям из предыдущих версий. С каждой новой версией C# разработчики стремятся сделать весь процесс программирования удобным и лаконичным. На этот раз больше всего внимания уделили изменениям свойств объектов, новому типу Record и не только. Но обо всем по-порядку.

C# — язык программирования, который Microsoft изначально создали для своих проектов. Его синтаксические возможности перекликаются с Java и С++. В 2000 году инженеры компании разработали технологию активных серверных страниц ASP.NET, которая позволяла подвязывать к веб-приложениям базы данных. Саму ASP.NET написали на C#. Возможность строить гибкие и легко масштабируемые в будущем приложения — одно из крутых преимуществ C#. Продукты тоже могут быть самые разные — от игр до веб-сервисов.

С 2017 года разработчики из года в год анонсируют новую версию С#. Если раньше он преподносился как исключительно объектно-ориентированный язык, то в последние годы к нему стали добавлять возможности из функционального подхода. Тем самым у девелоперов появилось больше вариативности в решении задач.

В статье мы разберем новинки C# 9 и вспомним старые фичи, которые тоже могут быть полезными.

Init-only setter

Этого сеттера давно не хватало. Его добавили, чтобы пользователь не был ограничен в возможностях создания объектов. Init-only setter позволяет инициализировать свойства только в конструкторе класса или использовать блок инициализации объектов. Ни один из представленных ранее сеттеров не мог реализовать подобный функционал.

public string FirstName { get; init; }
 
public User(string firstName)
{
    this.FirstName = firstName;
}
 
public void ChangeName(string name)
{
    //Error: CS8852
    this.FirstName = firstName;
}
 
var user = new User() { FirstName = "Name" };
 
//Error: CS8852
user.FirstName = "NewName";

Что еще важного дает Init-only setter? Если ваш объект будет иметь, например, вот такие свойства, он не будет изменяемым. То есть вы сможете менять объект только на этапе его создания. Инициализаторы объектов и конструкторы хороши для создания вложенных объектов, где целое дерево объектов создается за один раз. Они освобождают юзера от написания большого количества шаблонных конструкций. Достаточно прописать определенные свойства.

Deconstruct feature

Деконструктор подразумевает разложение объекта. Он позволяет сразу разложить объект в одной строчке на несколько переменных, объявить их в области видимости и присвоить определенные значения. Как реализовать эту функцию? В классе, в котором вы хотите, чтобы проявилась такая фича, определите метод деконструктора. Затем установите значения out параметрам в этих методах и перекиньте их в область видимости, которая вызвала этот деконстракт. Деконструкторы можно переопределять. Они могут быть с двумя и более параметрами.

var user = new User() { FirstName = "FirstName", MiddleName = "MiddleName", LastName = "LastName" };
 
var (firstName, lastName) = user; 
public void Deconstruct(out string firstName, out string lastName)
    {
    	(firstName, lastName) = (FirstName, LastName);
    }
 
    public void Deconstruct(out string firstName, out string middleName, out string lastName)
    {
    	(firstName, middleName, lastName) = (FirstName, MiddleName, LastName);
    }
 
var (firstName, _, lastName) = user;

Представим ситуацию: есть один деконстрактор с тремя аргументами, а нам нужен только первый и последний. Есть два варианта, как их достать. Первый — перегрузить деконструктор и сделать его с двумя аргументами. Второй — воспользоваться оператором ( _ ). Он позволит выделить переменную, которую мы не передаем во внешний контекст. Таким образом, у нас будет возможность достать только необходимые данные и не перегружать деконструкторы.

Indices and Ranges

Индекс относительно конца массива кода добавился еще в версии C# 7. Удобная фишка, когда необходимо работать не с началом массива, а с концом. Ranges достаточно просто позволяют достать подмассив из общего массива.

var array = new int[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 }

 array[^1] // 0  

Range range = ..6; // 0,1,2,3,4,5 

Range range = 6..; //6,7,8,9,0 

Range range = ^2..^0; //9,0

new Operator

При создании объекта у нас есть левая и правая часть выражения. В левой — объявляем тип и название переменной, а в правой — непосредственно создаем объект. С помощью оператора var в левой части мы можем не указывать тип. В процессе компиляции он подтянется из правой части и заменит var на нужный тип данных. Вот, как работает var. Теперь с новым оператором new мы указываем ожидаемый тип данных только в левой части выражения и упускаем его в правой.

Допустим, у нас есть юзер, который создается тремя разными конструкторами: пустым, с двумя аргументами и с использованием блока инициализации кода. Если укажем имя типа в левой части выражения, можем воспользоваться оператором new() чтобы не указывать тип в правой части.

Также смотрите, как теперь стало проще создавать Dictionary через оператора new:

User xu = new();
User yu = new("FirstName", "LastName");
User zu = new() { FirstName = "FirstName", LastName = "LastName" };
 
Dictionary<int, User> lookup = new()
{
   	[1] = new(),
   	[2] = new(),
   	[3] = new(),
   	[4] = new()
}

Local functions

Локальные функции появились в C# 8. Их можно объявлять внутри функции, в выражении или конструкторе. С этими функциями удобно работать в рекурсии, когда нужно посчитать степень, факториал или найти число Фибоначчи. В новой версии локальные функции немного обновили. Теперь вместе с ними можно использовать атрибуты.

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

public void Get(User[] users)
{
    //Processing logic ...
 
    var result = FactorialCalc(somenumber);
 
    //Processing logic ...
 
    int FactorialCalc(int number) => number == 1 ? 1 : number * FactorialCalc(number-1);
}

Из личного опыта, мне пока сложно представить применение локальных функций в масштабе проекта. Думаю, при разработке нужно держать проект в едином стиле. Если со временем он начнет расти, локальные функции могут усложнить читаемость кода. В небольших и изолированных решениях, может быть, эти фичи пригодятся. Например, в Azure Function, AWS Lambda, background worker. Но не вижу проблем, чтобы сделать private method или extension method вместо локальной функции.

Top level statement

Позволяет убрать нагромождение лишнего кода. При создании консольного приложения у нас возникает program cs файл с стандартным набором кода (масив using, namespace, class program, method Main). По сути никакой информативности они не несут, ведь все равно придется весь код писать внутри Main функции. Когда новичок открывает консольное приложение и видит множество строчек кода, он начинает путаться и не понимает, как они работают. Благо, разработчики .NET решили упростить этот момент. Теперь юзер начинает писать приложение с чистого листа. Не мешают ни namespaces, ни Programs, ни Main. Не нужно тратить время на поддержание этой громоздкой инфраструктуры. Однако это касается небольших тестовых заданий, не требующих глубокого вникания в платформу.

using System;
 
namespace C9.features
{
    class Program
    {
    	static void Main(string[] args)
    	{
        	     Console.WriteLine("Hello World");
    	}
    }
}
 
 
 
using System;
Console.WriteLine("Hello World");

Record type feature — основная фича C# 9

Это новый тип данных, который позаимствовал несколько особенностей у значимых и ссылочных типов. По сути Record — ссылочный тип. Однако во время присвоения ссылка на объект не передается. Происходит копирование объекта, как у значимых типов. Ключевое слово record наделяет этот класс дополнительным поведением. Главное отличие — Record type имеет структурный подход в сравнении объектов. Если у нас два экземпляра класса и мы сравниваем их, то происходит это по ссылкам, не по его свойствам. В то время как с Record они сравниваются по значениям полей, которые находятся внутри. Также при объявлении Record под капотом создается набор методов.

Что уже реализовано в Record type:

  • переопределен GetHashCode и методы Copy и Clone;
  • переопределен ToString;
  • имеют короткий способ записи
  • по умолчанию имеют Deconstruction
  • есть возможность использовать при копировании новое ключевое слово with

Records initialization

Посмотрим, как выглядит объявление Record. Есть идентификатор доступа — public. Вместо класса теперь указано ключевое слово recond, затем его имя. Напоминает конструктор, в котором передаются два аргумента. Но во что все это превратится потом? У нас появятся два поля FirstName и LastName, которые будут иметь геттеры и Init-only setters. В этом случае мы не можем пользоваться блоком инициализации объекта, потому что под капотом переопределили конструктор. То есть дефолтный конструктор мы больше не может использовать. Теперь нужно его объявить или воспользоваться конструктором со значением по умолчанию.

public record User(string FirstName, string LastName);
 
public record User
{
    public string FirstName { get; init; }
    public string LastName { get; init; } 

public User(string firstName, string lastName) => (FirstName, LastName) = (firstName, lastName);
 
}
 
var user = new User("FirstName", "LastName");
 
 
//CS7036 There is no argument given that corresponds to the required.
var user = new User { FirstName = "FirstName", LastName = "LastName" };
 
 
 
public record User(string FirstName = null, string LastName = null);
 
var user1 = new User("FirstName", "LastName");

Давайте сравним два рекорда. Вы видите, что FirstName и LastName у этих объектов одинаковые. Поэтому при сравнении выдает true. Это происходит на основе сравнения значений полей, а не ссылок, как было в классах. Создадим такой же класс user. При сравнении двух объектов с разными ссылками и одинаковыми значениями получаем false.

public record User(string FurstName = null, string LastName = null);
 
var user1 = new User("FirstName", "LastName");
var user2 = new User("FirstName", "LastName");
 
Console.WriteLine(user1 == user2); //return true;
 
class User
{
    public string FirstName { get; init; }
    public string LastName { get; init; }
 
    public User(string firstName, string lastName) => (FirstName, LastName) = (firstName, lastName);
}
 
var user1 = new User("FirstName", "LastName");
var user2 = new User("FirstName", "LastName");
 
Console.WriteLine(user1 == user2); //return false

Так как теперь переопределен ToString: при выводе Record получается конструкция со всеми «внутренностями» объекта: имя типа, property, и уже ничего не нужно переопределять.

public enum CustomEnum
    {
    	State1,
    	State2
    }
 
    public record Record(string Name, string Description, CustomEnum CustomEnum);
 
    var record1 = new Record("Record Name", "Record Description", CustomEnum.State2);
 
	Console.WriteLine(record1); // Record { Name = Record Name, Description = Record Description, CustomEnum = State2 }

Записи намеренно создаются неизменяемыми. Вместо этого мы создаем новый экземпляр с другими значениями. Здесь уже подключаются With-expressions.

With-expressions

Когда мы создаем Record юзера с помощью краткого типа, все наши свойства имеют геттеры и Init-only setters. О чем это говорит? Это значит, что свойства будут изменяться только во время создания объекта. Обычно не получается так, что один объект все время живет себе спокойно в приложении. Нам нужно в нем что-то менять. Для этого и создали конструкцию With-expressions. Они используют синтаксис инициализатора объекта и показывают, что конкретно отличается в новом объекте от старого.По сути своей With-expression — это копирование объекта как у значимых типов. Во время копирования дает возможность изменить значения некоторых свойств объекта, которые должны применяться к новой переменной, в тоже время не затрагивая значения уже существующей переменной. Эта функция позволяет нам изменять поля и записывать их в новый объект.

public record User(string FirstName, string LastName);
 
var user = new User("FirstName", "LastName");
 
var newUser = user with { FirstName = "FirstName" };
 
Consolt.WriteLine(newUser); // User {FirstName = New Name, LastName = LastName }  

В этом случае у нас есть юзер с двумя полями — FirstName и LastName. Присвоили им какие-то значения и теперь хотим юзера записать/скопировать в новую переменную и следом внести изменения. Для этого после юзера пишем ключевое слово With и далее можем менять любые поля.

По сравнению с предыдущими двумя версиями, обновление C# 9 вышло не очень большим. Могу провести такую аналогию: если раньше машину завели, то здесь уже дорабатывают ее отдельные механизмы. Где-то ходовку подкрутили, где-то — двигатель починили. В любом случае эти фичи стоит попробовать на проекте хотя бы ради того, чтоб понять, зайдут или нет.

👍НравитсяПонравилось7
В избранноеВ избранном4
Подписаться на тему «C#»
LinkedIn

Похожие статьи

Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Record type feature

Главное чтобы не получилось как со структурами в свое время :)
github.com/...​otnet/runtime/issues/8028

Из личного опыта, мне пока сложно представить применение локальных функций в масштабе проекта. Думаю, при разработке нужно держать проект в едином стиле. Если со временем он начнет расти, локальные функции могут усложнить читаемость кода. В небольших и изолированных решениях, может быть, эти фичи пригодятся. Например, в Azure Function, AWS Lambda, background worker. Но не вижу проблем, чтобы сделать private method или extension method вместо локальной функции.

Для парсинга удобно, и для conditional logic тоже.

Что из полезного пропущено в статье, это булевы keywords, стыренные с VB, F#
and, or, not

Приятнее условия читать как
c is (>= ’a’ and <= ’z’) or (>= ’A’ and <= ’Z’) or ’.’ or ’,’

Приятнее условия читать как
c is (>= ’a’ and <= ’z’) or (>= ’A’ and <= ’Z’) or ’.’ or ’,’

Спасибо, я лучше себе кислоты в глаза залью.

Init-only setter

Полезная вещь. Синтаксис ИМХО чуть корявый, но если привыкнуть — вполне себе ОК

new Operator

Синтаксис уровня «поломай себе глаза»

Local functions

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

Record type feature

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

new Operator
Синтаксис уровня «поломай себе глаза»

С моей точки зрения он несогласуется с var

Есть две возможности
var a = new Type(){}
или
Typa a = new(){}

В любом случае придется вводить первые буквы типа, чтобы IDE подобрала тип
С другой стороны var полезен когда тип трудно написать, например как результат linq query
Т.е. удобно
var a = collection.Select(...) что-там

Если же начать вдобавок пользоваться и новой фишкой, то получится инконсистенси

var a = ...
Type1 b = new()
var c = ...
Type2 d = new()
Разнобой
Обычно в editor.config прописано либо явный тип везде, либо var везде

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

Если ты синьор повидавший говнеца и оверинжиниринга то да.

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

По-моєму вони загрались вже в це насипання фічей в C#, типу, головне не зупинятися

На самом деле язык развивается очень системно — в c# добавляют весьма аккуратно и разумно вещи, что бы исправить первоначальный корявый дизайн характерный Java-подобным ооп языкам — убирают дефолтный ref types semantics для моделирования домена(nullable ref types, records), делают удобней инструментарий для работы с экспрешинами(pattern matching/deconstruct), постепенно розвивают adt инструментарий, иммутабельные структуры поддержку в язык долают — static local functions, records etc.. С точки зрения безопасности и продвинутости системы типов ему уже не догнать f#, но включив все эти плюшки код можно писать более безопасный, чем например на версии языка 6 это точно.

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

Не троллинга ради. Не понятно в чем суть создания «более безопасного C#». Когда я его юзал еще в 2010 году, с безопасностью было все в порядке (самыми «опасными» вещами считалось использование IDisposable типа без using и использование непотокобезопасных типов в многопоточном приложении без синхроанизации). Да, это по сути Java, на 10 лет современнее, но это никак не вредило популярности языка.

Я примерно понимаю как в теории новые фичи могут позволить писать код, который будет более быстрым, но во-первых, такого рода фичи нужно использовать очень прямыми руками под чутким присмотром профайлера, иначе в лучшем случае все эти потуги не дадут существенного влияния на перформанс и будут источником холиваров на code review.
Во-вторых, часть из этих новых фич требует использования unsafe C#, что, учитывая целевую аудиторию языка, прямое приглашение выстрелить себе в ногу.

Если бы я выбрал C# для нового проекта, я бы его выбрал не потому что там добавили эти свистоперделки для перформанса, а потому что на него можно посадить какого-то 23-летнего кодерка после месячных курсов по C# и быть уверенным, что он не сможет сильно напортачить.

Вместо этих непонятных потуг моденизировать C#, MSFT нужно было запилить новый язык, в котором все эти фичи для перформанса будут встроены с day 1, или еще лучше — контрибьютить в язык, в котором эти фичи уже есть (*cough*Rust*cough*).

контрибьютить в язык, в котором эти фичи уже есть (*cough*Rust*cough*).

Ну так Майки ж уже в Rust foundation, не?

Таки да. Но фичи в C# уже добавлены и их оттуда не выпилишь.

Честно говоря, я с тобой не согласен по поводу новых фич в шарпе. Они практически все не о performance кода, а о производительности труда разработчика. По поводу unsafe — напомни что такое недавно завозили? Бо навскидку не припоминаю.

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

Span и производные как раз таки что б unsafe было проще писать — если почитаешь все эти типы идут с грифом type/memory safe и либо прибиты гвоздями к стеку(как span) , либо убираются gc(как memory). В целом речь вообще не о них в посте выше, а например о nullable ref types(теперь класс становиться non nullable) и только на уровне полей/свойства ты определяешь это явно, куда ближе к option pattern, решает фундаментальную дизайн проблему и имеет полную обратную совместимость. Аналогично с records — structural equality, иммутабельность, не надо мучатся с boilerplate по сравнении ссылок, иммутабельны и позволяют делать infheritance(за не именем union types) в отличии от структур. Static local func — тоже ненароком не изменят то что не должны, все это выглядит как куда более разумные решение отсутствующие у ближайших конкурентов(речь не про rust), чем добавление Элвисов и тому подобная ерунда, например как было года 4 назад.

Span и производные как раз таки что б unsafe было проще писать

Ну я ж пишу, как-то упустил момент, когда проблема написания unsafe кода в C# стала настолько большой, что пришлось добавлять эти костыли для упрощения написания unsafe кода.

Вот детально описана мотивация
github.com/...​ob/master/docs/roadmap.md
один из первых документов на сей счет, что появился лет 5 назад и описывал тоже в общем дизайн проблемы clr/bcl решаемые внедрением этого апи.
Логично что бы обновить все звенья в том числе для сторонних библиотек, кроме полного обновления апи system пришлось дать удобный инструмент разработчикам либ для работы с low alloc кодом(i\o драйвера, сериализаторы, компрессоры, парсеры) — они это и сделали впринципе.

Подписаться на комментарии