Прошло примерно пол года с момента выхода C# 8, думаем самое время кратко рассмотреть основные нововведения. И начнем мы, пожалуй, по идее, с самого значимого из них, а именно с Nullable reference types.
Nullable reference types
Если бы Microsoft и компания писали бы сочинение по русскому языку в 5-ом классе, то обязательно бы получили минус полбалла за тавтологию, причем заслуженно. Дело в том, что reference types (ссылочные типы) по определению nullable, т.е. зануляемые, таким образом получается масло масляное. Кроме того, название этого функционала пересекается с уже имеющимся функционалом, а именно с Nullable value types. А так как эти возможности имеют мало общего , это приводит к небольшой неразберихе. И даже больше, не только названия пересекаются, но и синтаксис полностью идентичен, что с одной стороны, конечно, хорошо, но вот с другой… Основная цель этого нововведения — решить (а по факту получается «решить») крайне неприятную и старую, как мир, проблему The Billion Dollar Mistake, т.е. всем известную проблему с Null Pointer Exception. В современных и мейнстрим языках, на наш взгляд, подобной проблемы существовать не должно вовсе, но, по тем или иным причинам, большиство популярных языков программирования продолжают гордо нести дух старой школы, а вместе с тем и весь тот груз, с которым каждому разработчику приходится бороться ежедневно, как будто бы других проблем у разработчиков больше нет. Ошибки дизайнеров, допущенные при укладке фундамента в самом начале, и необходимость в обратной совместимости теперь и не дают шанса на исправление подобных ошибок на корню. Из-за чего мы и получаем то, что имеем, как бы грустно это ни было. Напомним, что первая версия C# вышла в далеком 2002 году. С учетом того, что текущая версия вышла в прошлом году, не трудно подсчитать, что на то, чтобы начать (подчеркнем, не побороть, а именно начать) бороться с проблемой, которой быть в принципе не должно, потребовалось невообразимых 17 лет.
Чтобы начать пользоваться Nullable Reference Types, необходимо одним из возможных способов включить эту функцию вручную. И если для старых проектов это оправдано, то почему не включить эту функцию для новых проектов? Остается только гадать. Активировать эту возможность можно как для проекта целиком, в свойствах проекта, так и для отдельно взятых файлов с помощью директивы
#nullable enable . Давайте рассмотрим несколько примеров.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
#nullable enable public class Person { public string name; public string? nickname; } class Program { static void Main(string[] args) { var person = new Person(); person.name = null; person.nickname = null; var nameLen = person.name.Length; var nicknameLen = person.nickname.Length; } } |
В данном фрагменте кода нужно обратить внимание на следующие моменты:
- если скомпилировать этот код, то мы получим два предупреждения (жаль, что по умолчанию компилятор не считает подобный код ошибочным, но это легко исправить): [CS8618] Non-nullable field 'name' is uninitialized. Consider declaring the field as nullable. и [CS8625] Cannot convert null literal to non-nullable reference type. и парочку таких: [CS8602] Dereference of a possibly null reference. . Другими словами, теперь все ссылочные типы по умолчанию не могут принимать значение null . Если необходимо выставить значение в null , нужно использовать знак вопроса после ссылочного типа. Компилятор так же будет ругаться на не инициализированные поля (естественно, если инициализировать поле, то предупреждение исчезнет) и попытки разыменования;
- в отличие от Nullable Value Types, знак вопроса никак не изменяет тип рядом с ним, т.е. в данном случае поле nickname будет иметь тип System.String , а не какой-нибудь Nullable<System.String> . Таким образом, в данном случае единственное, что делает знак вопроса, это добавляет подсказку компилятору, что данное поле можно занулять. По факту, дополнительно добавляется соответствующий атрибут System.Runtime.CompilerServices.NullableAttribute , что-то типа JetBrains.Annotations.CanBeNull со всеми вытекающими, и теперь работа по определению корректности кода в плане NPE перекладывается со стороннего (сторонних) решения (решений) на компилятор.
Исправленный код (не генерирующий предупреждения компилятором) может выглядеть как-то так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
#nullable enable public class Person { public string name; public string? nickname; public Person(string name, string? nickname = null) { this.name = name; this.nickname = nickname; } } class Program { static void Main(string[] args) { var person = new Person("Ivan"); var nameLen = person.name.Length; var nicknameLen = person.nickname != null ? person.nickname.Length : -1; } } |
Само собой разумеется, что стандартная библиотека проаннотирована, и следующий код:
1 |
System.Console.WriteLine(Type.GetType("sdfsdfg").Name); |
сгенерирует предупреждения. И, кажется, вот оно — счастье, наконец-то, после стольких долгих лет боли и страдания! Но. как говорится, слишком рано радоваться. Давайте рассмотрим следующий простейший кусочек кода:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
#nullable enable // ... class Program { private static Person? GetPerson() { return null; } private static void AssertNotNull(Person? value) { if (value == null) throw new ArgumentNullException(nameof(value)); } static void Main(string[] args) { var person = GetPerson(); AssertNotNull(person); System.Console.WriteLine(person.name); } } |
В строчке вывода мы однозначно не можем получить null из-за того, что строчкой выше. В таком случае будет сгенерировано исключение: компилятор (к сожалению, он не настолько умен, как хотелось бы) сгенерирует предупреждение: [CS8602] Dereference of a possibly null reference. Это, конечно же, печально. Но как же быть? Неужто теперь придется в каждом подобном (и куче других) случае добавлять явную (да еще и избыточную) проверку на null ? Да, это один из вариантов решения (возможно даже, не самый худший). Другим вариантом решения является новый Null-Forgiving оператор: ! , который как бы говорит компилятору: «Не парься! Я знаю, что делаю» (ага, как же)). Теперь мы можем переписать наш код следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
#nullable enable // ... class Program { private static Person? GetPerson() { return null; } private static void AssertNotNull(Person? value) { if (value == null) throw new ArgumentNullException(nameof(value)); } static void Main(string[] args) { var person = GetPerson(); AssertNotNull(person); System.Console.WriteLine(person!.name); } } |
и компилятор не будет генерировать исключение. Обратите внимание на восклицательный знак при обращении к имени. Более того, мы даже можем написать следующий код:
1 |
(null as object)!.ToString(); |
и он тоже скомпилируется и упадет с NPE. Интересно, что так же можно использовать несколько операторов подряд:
1 |
person!!!!!!!!!!.name |
как бы «крича» прямо из кода). Также можно объединить оба оператора:
1 |
System.Console.WriteLine(person!?.name); |
а вот обратный вариант:
1 |
System.Console.WriteLine(person?!.name); |
не скомпилируется. Пока существует возможность выстрелить в ногу, ни о какой null -безопасности речи идти не может. Представьте, что в нашем примере по ходу развития программы убрали метод AssertNotNull или проверку в нем; но на восклицательный знак , скорее всего, никто не обратит внимание и мы получим, рано или поздно, свое NPE.
Стоит еще раз отметить, что никаких измений в типах или дополнительных проверок в run-time не происходит. Мы имеем примерно то же самое, что имели раньше, используя JetBrains.Annotations & Rider/ReSharper, только из коробки. Глобальное Nullable reference types, к большому сожалению, проблему не решает.
Default Interface Members
Default Interface Members. пожалуй, самое неоднозначное (хотя чего уж там…) нововведение. Теперь интерфейсы могут иметь методы с реализацией. Default Interface Members еще больше размывает границы между интерфейсами и абстрактными классами. Таким образом, объяснить новые, и довольно сложные, понятия новичкам, тадам! будет еще сложнее. Нам довольно трудно представить, где эта возможность будет, прямо явно полезной. Официальная документация говорит, что Default Interface Members позволяет безопасно добавлять методы в уже выпущенные и используемые интерфейсы. Звучит так себе — довольно неубедительно. Давайте рассмотрим несколько примеров:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
public interface IUser {} public interface IUserProvider { IUser GetUser() { System.Console.WriteLine("IUserInterface.GetUser()"); return new User(); } } public class User : IUser {} public class UserProvider : IUserProvider {} class Program { static void Main(string[] args) { var provider = new UserProvider(); var user = provider.GetUser(); } } |
В данном случае стоит обратить внимание на то, что интерфейс IUserProvider содержит метод GetUser с реализацией и несмотря на то, что наш класс UserProvider реализует интерфейс IUserProvider , явно реализовывать метод GetUser нет необходимости, так как интерфейс содержит реализацию по умолчанию. Можно подумать, что приведенный код скомпилируется и будет работать. Но это не так. Приведенный фрагмент кода генерирует следующую ошибку: [CS1061] 'UserProvider' does not contain a definition for 'GetUser' and no accessible extension method 'GetUser' accepting a first argument of type 'UserProvider' could be found (are you missing a using directive or an assembly reference?) . Да как же так? Может (а, наверное, даже должен) воскликнуть читатель. Дело в том, что, как уже выше говорилось, основной сценарий использования этого нововведения — добавление методов в уже существующие интерфейсы. В таком случае получается, что если бы в классе UserProvider уже был метод GetUser , то произошел бы конфликт. Поэтому, чтобы обратиться к реализованному в интерфейсе методу, необходимо явно откастить к интерфейсу (это примерно аналогично вызову explicit interface implementation). Например, вот так:
1 2 |
IUserProvider provider = new UserProvider(); var user = provider.GetUser(); // IUserInterface.GetUser() |
А что же будет, если у нас появится еще один интерфейс, который будет наследоваться от IUserProvider и также иметь метод GetUser с реализацией по умолчанию? Такой код вообще скомпилируется?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
public interface IUser {} public interface IUserProvider { IUser GetUser() { System.Console.WriteLine("IUserProvider.GetUser()"); return new User(); } } public interface IRemoteUserProvider : IUserProvider { IUser GetUser() { System.Console.WriteLine("IRemoteUserProvider.GetUser()"); return new User(); } } public class User : IUser {} public class UserProvider : IRemoteUserProvider {} class Program { static void Main(string[] args) { var provider = new UserProvider(); ((IUserProvider) provider).GetUser(); ((IRemoteUserProvider) provider).GetUser(); } } |
Да, такой код скомпилируется. А что же будет выведено в консоль при вызове метода GetUser() ? Если один интерфейс наследуется от другого, то можно предположить, что произойдет переопределение метода, в результате чего в консоль попадет две одинаковых строки: IRemoteUserProvider.GetUser() . Но это не так. В консоли мы увидим следующие строки:
1 2 |
IUserProvider.GetUser() IRemoteUserProvider.GetUser() |
На самом деле здесь ничего (для C# разработчиков) неожиданного быть не должно. Поведение точно такое же, как и в подобных случаях при определении перекрывающих методов в обычных классах:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
public class User {} public class UserProvider { public User GetUser() { System.Console.WriteLine("UserProvider.GetUser()"); return new User(); } } public class RemoteUserProvider : UserProvider { public User GetUser() { System.Console.WriteLine("RemoteUserProvider.GetUser()"); return new User(); } } class Program { static void Main(string[] args) { var provider = new RemoteUserProvider(); ((UserProvider) provider).GetUser(); ((RemoteUserProvider) provider).GetUser(); } } |
По запуску этого кода мы также увидим аналогичную картину в консоли:
1 2 |
UserProvider.GetUser() RemoteUserProvider.GetUser() |
Но это поведение, в данном случае, довольно легко исправить. Достаточно объявить метод UserProvider.GetUser() виртуальным и с помощью ключевого слова override переопределить этот метод в RemoteUserProvider . А доступна ли такая же возможность (полиморфизм) для методов интерфейсов по умолчанию? И если доступна, то как ей воспользоваться? Возможно, стоит поступить аналогично примеру с классами? Давайте попробуем:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public interface IUserProvider { virtual IUser GetUser() { System.Console.WriteLine("IUserProvider.GetUser()"); return new User(); } } public interface IRemoteUserProvider : IUserProvider { override IUser GetUser() { System.Console.WriteLine("IRemoteUserProvider.GetUser()"); return new User(); } } |
К сожалению, такой код не скомпилируется и выведет ошибку, наподобие этой: [CS0106] The modifier 'override' is not valid for this item . На самом деле, необходимые нам модификации делаются следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public interface IUserProvider { IUser GetUser() { System.Console.WriteLine("IUserProvider.GetUser()"); return new User(); } } public interface IRemoteUserProvider : IUserProvider { IUser IUserProvider.GetUser() { System.Console.WriteLine("IRemoteUserProvider.GetUser()"); return new User(); } } |
Запустив этот вариант кода, мы получим заветные строчки в консоли:
1 2 |
IRemoteUserProvider.GetUser() IRemoteUserProvider.GetUser() |
Таким образом, полиформизм и наследование работает и на методах в интерфейсах с реализацией по умолчанию.
Думаем, что все прекрасно помнят, что в отличие от классов, наследоваться от нескольких интерфейсов можно. А что же тогда будет, например, в случае, если мы реализуем сразу пару интерфейсов, каждый из которых переопределяет общий для этих интерфейсов метод с реализацией по умолчанию базового интерфейса. Т.е. фактически мы столкнемся с одной из проблем множественного наследования (Diamond inheritance). Давайте так же рассмотрим такой случай на примере:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
public interface IUser {} public interface IUserProvider { IUser GetUser() { System.Console.WriteLine("IUserProvider.GetUser()"); return new User(); } } public interface IRemoteUserProvider : IUserProvider { IUser IUserProvider.GetUser() { System.Console.WriteLine("IRemoteUserProvider.GetUser()"); return new User(); } } public interface ILocalUserProvider : IUserProvider { IUser IUserProvider.GetUser() { System.Console.WriteLine("ILocalUserProvider.GetUser()"); return new User(); } } public class User : IUser {} public class LocalRemoteUserProvider : IRemoteUserProvider, ILocalUserProvider {} class Program { static void Main(string[] args) { IUserProvider provider = new LocalRemoteUserProvider(); var user = provider.GetUser(); } } |
Думаем, здесь так же все очевидно. Такой код не скомпилируется, т.к. компилятор не сможет найти более специфичный (т.е. ниже в иерархии наследования) интрефейс для использования, из-за чего не сможет понять. какой метод вызывать, если кто-то попробует вызвать метод GetUser() .
Таким образом, это не только крайне сомнительная возможность, имеющая мало юз-кейсов (возможно, читатель может привести пример, где подобная возможность была бы как раз кстати?), но и довольно сильно усложняющая и так с каждым годом сложнеющий C#. По нашим ощущениям, крайне маловероятно, что этой возможностью разработчики будут часто пользоваться.
Pattern Matching
Сопоставление с образцом — одна из десятка, если не больше, возможностей, которую разработчики C# плагиатят из F#. Элементы сопоставления с образцом в C# появляются, начиная с версии 7.0. Это, без сомнений, довольно удобная функция, особенно в функциональных языках, таких как F#, т.к. обычно, она идет вкупе с другими возможностями, такими как алгебраические структуры данных, функциональные списки и т.п., которых, на данный момент в C# нет, но возможно они появяться в будущих версиях языка. В 8-ой версии языка добавили возможность match-иться по полям и свойствам структур и классов. Аналогом ключевого слова match в F# стало ключевое слово switch . Т.е. фактически мы теперь имеем аналог обычного switch , но на стероидах. Стоит обратить внимание на то, что это switch expression, а не switch statement, как в случае обычного switch , т.е. этот оператор должен возвращать некоторое значение. Пример:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
class Program { public class Person { public string name; public string nickname; public int age; public Person(string name, string nickname, int age) { this.name = name; this.nickname = nickname; this.age = age; } } static void Main(string[] args) { var person = new Person("Ivan", "SuperMan", 22); var socialGroup = person switch { {age: 14} => "Teenager", {nickname: null} => "Pensioner", {age: 22} => "Worker" }; System.Console.WriteLine($"{person.name} social group: {socialGroup}"); } } |
В консоль будет выведана следующая строка: Ivan social group: Worker . Можно задаться вопросом: а что же произойдет, если никакой из описанных вариантов не сработает? На ум приходит, как минимум, два варианта: 1. возможно, т.к. это выражение, то вернется default значение; 2. выброс исключения. Дизайнеры языка остановились на последнем варианте. И, в таком случае, будет выкинуто исключение: Unmatched value was Program+Person. Также стоит отметить, что, если скомпилировать вышеприведенный код, то будет сгенерировано следующее предупреждение: CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). , которое нам намекает, что мы обработали не все случаи. И этот момент разительно (в положительную сторону) отличается от поведения стандартного switch statement. Само собой, в данном случае мы не можем перечислить все возможные варианты, т.к. их неимоверно много. Здесь нам пригодилось бы ключевое слово default из стандартного свитча. Но, вместо него, как (мы думаем) вы уже догадались, по аналогии с другими вариантами сопоставления с образцом используется нижнее подчеркивание _ . Новое выражение можно использовать не только для обработки полей и свойств, но и в качестве более удобной и лаконичной замены стандартному switch statement:
1 2 3 4 5 6 7 8 9 10 11 |
var color = rainbowColor switch { Rainbow.Red => new RGBColor(0xFF, 0x00, 0x00), Rainbow.Orange => new RGBColor(0xFF, 0x7F, 0x00), Rainbow.Yellow => new RGBColor(0xFF, 0xFF, 0x00), Rainbow.Green => new RGBColor(0x00, 0xFF, 0x00), Rainbow.Blue => new RGBColor(0x00, 0x00, 0xFF), Rainbow.Indigo => new RGBColor(0x4B, 0x00, 0x82), Rainbow.Violet => new RGBColor(0x94, 0x00, 0xD3), _ => throw new ArgumentException(message: "invalid enum value", paramName: nameof(rainbowColor)), }; |
Что будет аналогом следующего кода:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
RGBColor color; switch (rainbowColor) { case Rainbow.Red: color = new RGBColor(0xFF, 0x00, 0x00); case Rainbow.Orange: color = new RGBColor(0xFF, 0x7F, 0x00); case Rainbow.Yellow: color = new RGBColor(0xFF, 0xFF, 0x00); case Rainbow.Green: color = new RGBColor(0x00, 0xFF, 0x00); case Rainbow.Blue: color = new RGBColor(0x00, 0x00, 0xFF); case Rainbow.Indigo: color = new RGBColor(0x4B, 0x00, 0x82); case Rainbow.Violet: color = new RGBColor(0x94, 0x00, 0xD3); default: throw new ArgumentException(message: "invalid enum value", paramName: nameof(colorBand)); }; |
аналогично мы можем делать сопоставление с образцом кортежей:
1 2 3 4 5 6 7 8 9 10 11 |
public static string RockPaperScissors(string first, string second) => (first, second) switch { ("rock", "paper") => "rock is covered by paper. Paper wins.", ("rock", "scissors") => "rock breaks scissors. Rock wins.", ("paper", "rock") => "paper covers rock. Paper wins.", ("paper", "scissors") => "paper is cut by scissors. Scissors wins.", ("scissors", "rock") => "scissors is broken by rock. Rock wins.", ("scissors", "paper") => "scissors cuts paper. Scissors wins.", (_, _) => "tie" }; |
Кроме этого, мы также можем использовать извлеченные из обрабатываемых структур данные и накладывать на них ограничения:
1 2 3 4 5 6 7 8 9 10 |
static Quadrant GetQuadrant(Point point) => point switch { (0, 0) => Quadrant.Origin, var (x, y) when x > 0 && y > 0 => Quadrant.One, var (x, y) when x < 0 && y > 0 => Quadrant.Two, var (x, y) when x < 0 && y < 0 => Quadrant.Three, var (x, y) when x > 0 && y < 0 => Quadrant.Four, var (_, _) => Quadrant.OnBorder, _ => Quadrant.Unknown }; |
Таким образом, сопоставление с образцом — крайне полезная возможность, которая позволяет довольно сильно сократить и упростить некогда сложный код, сделать его более читаем и безопасным из-за полезных предупреждений от компилятора:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class Program { public enum Color { Red, Green, Blue, Black, White } static void Main(string[] args) { var color = GetRandomColor<Color>(); var result = color switch { Color.Red => "Red", Color.Green => "Green", Color.Blue => "Blue", Color.Black => "Black" }; } } |
Сначала перечисление содержало только 4 возможных значения, но через некоторое время был добавлен новый цвет, а в одном из методов забыли добавить обработку нового цвета. Но это не беда, т.к. в данном случае компилятор нам сообщит о проблеме, и она будет исправлена еще до попадания в тест. Но для того, чтобы это все работало и можно было пользоваться помощью компилятора, в случаях подобных этому не стоит использовать _ , чтобы в подобных ситуациях компилятор мог понять, что вновь добавленый цвет не был обработан.
Также может быть интересен следующий момент:
1 2 3 4 5 |
var origin = phoneNumber switch { { Number: 112 } => "Emergency", { Code: 7 } => "RU", { } => "Unknown" }; |
Т.е. можно использовать пустые фигурные скобки в качестве варианта для сопоставления. Такой вариант отловит любой аргумент, который не равен null . Что в таком случае мы можем сказать о коде выше? Корректен ли этот код? Все ли случаи обработаны? Однозначно ответить на этот вопрос не получится, т.к. мы не знаем, что из себя представляет phoneNumber . Если это структура (а структуры не могут принимать значение null ), то да, а если это класс? Вопрос с подвохом! С одной стороны, мы не покрыли вариант с null , а, с другой, ведь есть же nullable reference types! Таким образом, покрытие всех вариантов зависит не только от типа, но и от текущего nullability контекста.
Рекурсивные образцы (Recursive Patterns) позволяют проверять не только поля объектов, но и подполя и поля подполей и т.д.:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
class Program { public class PhoneNumber { public int code; public int number; public PhoneNumber(int code, int number) { this.code = code; this.number = number; } } public class Person { public string name; public string nickname; public int age; public PhoneNumber phoneNumber; public Person(string name, string nickname, int age, PhoneNumber phoneNumber) { this.name = name; this.nickname = nickname; this.age = age; } } static void Main(string[] args) { var person = new Person("Ivan", "SuperMan", 22, new PhoneNumber(+7, 1234567890)); var origin = person switch { { phoneNumber: {code: +7}} => "Russian", { phoneNumber: {number: 1234567890}} => "Test number", { phoneNumber: {code: var code}} => $"No idea. Code: {code}" }; } } |
Этот пример показывает, как можно осуществлять сопоставление с образцом с учетом данных во вложенных объектах. Кроме того, мы также можем извлекать полученные данные и использовать их при необходимости. Подобная возможность придется кстати в случаях, когда необходимо комплексно обработать некоторые данные с разным уровнем вложенности. Например, эту возможность можно использовать при валидации.
Сопоставление с образцом можно использовать не только в switch выражениях, но и в других местах, как, собственно, это было в предыдущих версиях C#. Ну и не стоит забывать про деконструкцию, ее также можно использовать в качестве варианта в проверках. Однозначно, новая возможность сопоставления с образцом — довольно полезная вещь. Жаль, что она появилась так поздно.
Indices and ranges
Были добавлены два новых типа: Index и Range . Для индексов и диапазонов соответственно. Основная цель — лаконичный доступ к элементу или диапазону последовательностей (sequences). Сюда относятся не только последовательности IEnumerable , но и массивы, строки, списки и любые, в том числе и кастомные, коллекции. Также были добавлены новые операторы: ^ и .. . Думаем, что нетрудно догадаться, что делает второй оператор — создает диапазон. Это — привычный и понятный для большинства синтаксис. Чего, конечно, не скажешь о первом операторе. Попробуйте догадаться, для чего он нужен)). Было бы гораздо лучше, если бы этот оператор использовался для возведения в степень, как это делается в некоторых языках. Но в данном случае он используется для создания индекса с конца коллекции. Рассмотрим несколько примеров:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
using System; using System.Collections.Generic; class Program { static void Write(int[] array, List<int> list, string @string, Index index) { var strIndex = index.ToString().PadLeft(2); Console.WriteLine( $"array[{strIndex}] = {array[index]}; list[{strIndex}] = {list[index]}; string[{strIndex}] = {@string[index]}"); } static void Main(string[] args) { Index myIndex = 0; Index anotherIndex = new Index(1, false); Index indexFromEnd = new Index(2, true); Index anotherIndexFromEnd = ^1; var array = new[] {1, 2, 3, 4}; var list = new List<int> {1, 2, 3, 4}; var @string = "1234"; Write(array, list, @string, myIndex); Write(array, list, @string, anotherIndex); Write(array, list, @string, indexFromEnd); Write(array, list, @string, anotherIndexFromEnd); } } |
Вывод в консоль будет следующим:
1 2 3 4 |
array[ 0] = 1; list[ 0] = 1; string[ 0] = 1 array[ 1] = 2; list[ 1] = 2; string[ 1] = 2 array[^2] = 3; list[^2] = 3; string[^2] = 3 array[^1] = 4; list[^1] = 4; string[^1] = 4 |
Стоит обратить внимание, что нумерация с конца начинается с 1! Т.к. запись list[^0] аналогична записи list[list.Length] . Другими словами, в стандартных коллекциях при обращении по индексу ^0 мы получим исключение. В своих же коллекциях мы можем обработать и этот случай, и вернуть, возможно, полезные данные.
Диапазон Range представляет из себя линейный, строго возрастающий диапазон индексов с шагом 1. При этом старт диапазона включается, а конец исключается. Рассмотрим на примерах:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
using System; class Program { static void Write(int[] array, Range range) { var rangeStr = range.Start.ToString().PadLeft(2) + ".." + range.End.ToString().PadLeft(2); Console.WriteLine($"list[{rangeStr}] = {string.Join(", ", array[range])}"); } static void Main(string[] args) { var array = new[] {1, 2, 3, 4}; Write(array, 0..4); Write(array, 0..); Write(array, ..^0); Write(array, 0..^0); Write(array, ..); Write(array, ^2..^0); Write(array, 0..3); Write(array, ..3); } } |
Вывод будет следующим:
1 2 3 4 5 6 7 8 |
list[ 0.. 4] = 1, 2, 3, 4 list[ 0..^0] = 1, 2, 3, 4 list[ 0..^0] = 1, 2, 3, 4 list[ 0..^0] = 1, 2, 3, 4 list[ 0..^0] = 1, 2, 3, 4 list[^2..^0] = 3, 4 list[ 0.. 3] = 1, 2, 3 list[ 0.. 3] = 1, 2, 3 |
Вроде бы, все очевидно. Но есть и печаль, как уже выше было сказано: диапазон должен быть строго возрастающим, т.е. если попробовать получить диапазон array[3..0] , то мы получим исключение: System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. . По аналогии с предущим типом, здесь также можно учесть такие диапазоны в своих коллекциях. Т.е. всегда необходимо проверять, возрастающий ли диапазон или нет, при обращении к стандартным коллекциям (еще одно потенциальное слабое место). Другой неприятный момент — это шаг. Нельзя указать шаг, отличный от единицы, что также печалит, т.к. в том же F# мы можем без проблем указать шаг.
Readonly members
Теперь при объявлении методов и свойств в структурах можно использовать модификатор readonly , который будет означать, что метод не изменяет состояние данных структуры.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public struct Person { public string name; public string surname; public Person( string name, string surname) { this.name = name; this.surname = surname; } public readonly string Dump() => $"Person({name}, {surname})"; } class Program { static void Main(string[] args) { var person = new Person("Ivan", "Ivanov"); System.Console.WriteLine(person.Dump()); // Person(Ivan, Ivanov) } } |
При этом, если в таком методе попробовать изменить данные, то мы получим ошибку компиляции:
1 |
public readonly string Dump() => $"Person({name = "NewName"}, {surname})"; |
[CS1604] Cannot assign to 'name' because it is read-only . При этом, если мы добавим обращение к геттеру без модификатора readonly , то мы получим уже предупреждение:
1 2 3 |
public string fullName => $"{name}, {surname}"; public readonly string Dump() => $"Person({fullName})"; |
[CS8656] Call to non-readonly member 'Person.fullName.get' from a 'readonly' member results in an implicit copy of 'this'. И это — несмотря на то, что геттер, в данном случае, никак не изменяет данные. Т.е. в таком случае будет создана копия структуры и вызов геттера будет с использованием копии. Это — потенциально узкое место по производительности. И, видимо, поэтому (т.к. в итоге оригинальная структура не изменяется) генерируется предупреждение, а не ошибка, как в предыдущем случае. Чтобы избавиться от этого предупреждения, необходимо добавить модификатор readonly . Таким образом, модификатор только на чтение необходимо указывать и в свойствах только на чтение (геттерах). В случаях авто-реализуемых свойств (auto-implemented properities), указывать модифкатор не нужно, т.к. компилятор будет считать соответствующий геттер не изменяющим данные.
Довольно полезное нововведение, в некоторых случаях, которое позволяет явно указывать намерение (наглядно) для методов структур и осуществлять корректность этих намерений компилятором. Жаль, что такой же возможности нет для методов классов.
Using declarations
Небольшой синтаксический сахар над уже имеющейся возможностью:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
using System; public class Resource : IDisposable { public void Dispose() => Console.WriteLine("Disposed"); } class Program { static void Main(string[] args) { using var myResource = new Resource(); Console.WriteLine("My resource: " + myResource); } } |
Коды выше выведет в консоль, как нетрудно догадаться:
1 2 |
My resource: Resource Disposed |
Эта запись полностью аналогична следующей:
1 2 3 4 |
using (var myResource = new Resource()) { Console.WriteLine("My resource: " + myResource); } |
Единственная польза от этого нововведения — некоторое уменьшение вложенности.
Static local functions
Теперь с помощью модификатора static можно запретить захватывать внешний контекст в локальных функциях:
1 2 3 4 5 6 7 8 9 10 11 12 |
public class MyClass { public void MyMethod(string myValue) { static bool IsCorrectValue() => string.IsNullOrEmpty(myValue); if (IsCorrectValue()) System.Console.WriteLine($"My value: {myValue}"); else System.Console.WriteLine("Incorrect value"); } } |
В результате компиляции этого кода будет ошибка:
1 |
[CS8421] A static local function cannot contain a reference to 'myValue'. |
Впринципе, в некоторых случаях данная возможность может быть полезной.
Disposable ref structs
Как известно, структуры, объявленные с модификатором ref , не могут реализовывать интерфейсы и, как сделствие, нельзя реализовать IDisposable . Но теперь ref структуры могут быть освобождаемыми. Для этого такие структуры должны иметь метод void Dispose() . Эта возможность так же работает и с readonly ref структурами.
Asynchronous streams
Наконец-то асинхронные стримы добрались и до C#.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
using System; using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; class Program { public static async IAsyncEnumerable<int> Generate(int from, int to) { for (var i = from; i <= to; ++i) { await Task.Delay(100); yield return i; } } static async Task Main(string[] args) { var sw = new Stopwatch(); sw.Start(); await foreach (var number in Generate(1, 10)) { Console.WriteLine($"Elapsed({sw.ElapsedMilliseconds}) - {number}"); } sw.Stop(); } } |
В консоли мы увидим что-то наподобие такого:
1 2 3 4 5 6 7 8 9 10 |
Elapsed(116) - 1 Elapsed(227) - 2 Elapsed(333) - 3 Elapsed(446) - 4 Elapsed(555) - 5 Elapsed(663) - 6 Elapsed(771) - 7 Elapsed(880) - 8 Elapsed(988) - 9 Elapsed(1096) - 10 |
Довольно полезная возможность, которая позволяет, при умелом использовании, много разного и удобного.
Null-coalescing assignment
Был добавлен новый null-coalescing оператор к уже имеющемуся ?? — ??= , позволяющий кроме проверки на null , еще и присвоить значение:
1 2 3 4 5 |
List<int> list = null; list ??= new List<int>(); System.Console.WriteLine(string.Join(", ", list)); |
Т.е. фактически это — краткая запись следующего кода:
1 |
list = list ?? new List<int>(); |
Полезный, и теперь консистентный, оператор, позволяющий несколько сократить код и относительно безболезненно (в некоторых местах) работать с ссылочными типами. Почему было не предоставить этот оператор сразу вместе с предыдущим, непонятно. Наверное, слишком сложен в реализации)
Enhancement of interpolated verbatim strings
Теперь порядок токенов $ и @ может быть любым. Оба варианта корректны:
1 2 |
$@"..." @$"..." |
Мы не охватили еще пару новых возможностей, а именно Unmanaged constructed types и Stackalloc in nested expressions, о которых можно почитать по соответствующим ссылкам.
Заключение
Новый релиз C# принес как полезные вещи, так и довольно бесполезные в повседневной жизни. Многие изменения можно отнести к косметическим и доделкам разного рода косяков, почему-то не реализованных сразу. Часть изменений довольно сильно усложняет и так далеко не самый простой язык. Есть, конечно, и пара полезных нововведений, которые могут быть использованы на ежедневной основе. Какие нововведения, на ваш взгляд, можно к ним отнести? Обязательно пишите о них в комментариях. Есть еще одна печаль, которая напрямую связана с нововведениями, требующими изменений в виртуальной машине (например, default interface methods), из-за чего невозможно перейти на новую версию C# в Unity, пока все эти нововведения не будут обработаны на стороне Unity. Таким образом, использовать C# 8 в Unity можно будет хорошо, если в этом году. Естественно, вины разработчиков C# в этом нет, чего не скажешь о разработчиках из Unity.
Спасибо за внимание.