Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Как собрать в прямом эфире 17 000 зрителей? Значит, рецепт такой. Берем 15 актуальных IT-направлений, зовем зарубежных спикеров, дарим подарки за активность в чате, и вуа-ля — крупнейший в Украине и восточной Европе онлайн-ивент готов. Именно так прошла ежегодная мультитул конференция NIXMultiConf.
Под слоганом «айтишникам — от айтишников» эксперты из Украины, Беларуси, России, Великобритании и Германии поделились опытом и рассказали о новинках индустрии. Полезно было всем — дизайнерам, девелоперам, тестировщикам и менеджерам. И теперь делимся инсайтами с вами.
По мотивам докладов экспертов NIX продолжаем серию статей на самые актуальные темы. На этот раз .NET разработчик в NIX Дмитрий Богдансобрал главные инструменты для C# разработчиков — буквально на все случаи
Хочешь знать больше — смотри конференцию на YouTube-канале.
Привет! Я — Дмитрий Богдан, .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";
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;
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. Удобная фишка, когда необходимо работать не с началом массива, а с концом.
Range
достаточно просто позволяют достать подмассив из общего массива.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
var array = new int[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 }
array[^1]
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()
}
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);
}
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
, класс program
, метод Main
). По сути никакой информативности они не несут, ведь все равно придется весь код писать внутри функции Main
. Когда новичок открывает консольное приложение и видит множество строчек кода, он начинает путаться и не понимает, как они работают. Благо, разработчики .NET решили упростить этот момент. Теперь юзер начинает писать приложение с чистого листа. Не мешают ни namespace
, ни Program
, ни Main
. Не нужно тратить время на поддержание этой громоздкой инфраструктуры. Однако это касается небольших тестовых заданий, не требующих глубокого вникания в платформу.using System;
namespace C9.features
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine(«Hello World»);
}
}
}
using System;
Console.WriteLine(«Hello World»);
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
имеет структурный подход в сравнении объектов. Если у нас два экземпляра класса и мы сравниваем их, то происходит это по ссылкам, не по его свойствам. В то время как с record
они сравниваются по значениям полей, которые находятся внутри. Также при объявлении record
под капотом создается набор методов.Что уже реализовано в типе
record
:- переопределен
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»);
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
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 }
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 }
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 вышло не очень большим. Могу провести такую аналогию: если раньше машину завели, то здесь уже дорабатывают ее отдельные механизмы. Где-то ходовку подкрутили, где-то — двигатель починили. В любом случае эти фичи стоит попробовать на проекте хотя бы ради того, чтоб понять, зайдут или нет.