В C# 7 наконец появилась долгожданная возможность под названием «сопоставление с образцом» (pattern matching). Если вы знакомы с функциональными языками, такими как F#, вы можете быть немного разочарованы этой возможностью в ее текущем виде, но даже сегодня она может упростить ваш код в самых разных сценариях.
Каждая новая возможность чревата опасностью для разработчика, работающего в критическом для производительности приложении. Новые уровни абстракций хороши, но для того, чтобы эффективно использовать их, вы должны знать, что происходит под капотом. Сегодня мы собираемся изучить внутренности сопоставления с образцом, чтобы понять, как это реализовано.
Язык C# ввел понятие образца, которое может использоваться в is-выражении и внутри блока case оператора switch.
Существует 3 типа шаблонов:
- Шаблон const
- Шаблон типа
- Шаблон var
Сопоставление с образцом в is-выражениях
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | public void IsExpressions(object o) { // Alternative way checking for null if (o is null) Console.WriteLine("o is null"); // Const pattern can refer to a constant value const double value = double.NaN; if (o is value) Console.WriteLine("o is value"); // Const pattern can use a string literal if (o is "o") Console.WriteLine("o is \"o\""); // Type pattern if (o is int n) Console.WriteLine(n); // Type pattern and compound expressions if (o is string s && s.Trim() != string.Empty) Console.WriteLine("o is not blank"); } |
is-выражение может проверить, равно ли значение константе, а проверка типа может дополнительно создавать переменную образца (pattern variable).
Я нашел несколько интересных аспектов, связанных с сопоставлением с образцом в is-выражениях:
- Переменная, введенная в оператор if, поднимается во внешнюю область видимости.
- Переменная, введенная в оператор if, полностью определена (definitely assigned) только тогда, когда образец сопоставляется.
- Текущая реализация сопоставления const-образцу в is-выражениях не очень эффективна.
Сначала проверим первые два случая:
1 2 3 4 5 6 7 8 9 10 11 12 13 | public void ScopeAndDefiniteAssigning(object o) { if (o is string s && s.Length != 0) { Console.WriteLine("o is not empty string"); } // Can't use 's' any more. 's' is already declared in the current scope. if (o is int n || (o is string s2 && int.TryParse(s2, out n))) { Console.WriteLine(n); } } |
Первый оператор if вводит переменную s, и переменная видна внутри всего метода. Это разумно, но усложнит логику, если другие if-операторы в том же блоке будут пытаться повторно использовать одно и то же имя еще раз. В этом случае вам нужно использовать другое имя, чтобы избежать коллизий.
Переменная, введенная в is-выражении, полностью определена только тогда, когда предикат является истинным. Это означает, что переменная n во втором операторе if не определена в правом операнде, но поскольку эта переменная уже объявлена, мы можем использовать ее как переменную out в методе int.TryParse.
Третий аспект, упомянутый выше, является наиболее важным. Рассмотрим следующий код:
1 2 3 4 | public void BoxTwice(int n) { if (n is 42) Console.WriteLine("n is 42"); } |
В большинстве случаев, is-выражение преобразуется в object.Equals (constValue, variable) (даже если спецификация говорит, что оператор == должен использоваться для примитивных типов):
1 2 3 4 5 6 7 | public void BoxTwice(int n) { if (object.Equals(42, n)) { Console.WriteLine("n is 42"); } } |
Этот код вызывает 2 упаковки (boxing), которые могут весьма серьезно повлиять на производительность, если они используются в критическом пути приложения. Когда-то выражение o is null так же вызывало упаковку (см. Suboptimal code for e is null) и, я надеюсь, что текущее поведение так же будет исправлено в скором времени (вот соответствующий тикет на гитхабе).
Если n-переменная имеет тип object, то o is 42 приведет к одному выделению памяти (для упаковки литерала 42), хотя подобный код на основе switch не приводит к выделениям памяти.
var pattern в is- выражениях
Образец var является частным случаем образца типа с одним ключевым отличием: образец будет соответствовать любому значению, даже если значение равно null.
1 2 3 4 | public void IsVar(object o) { if (o is var x) Console.WriteLine($"x: {x}"); } |
o is object истинно, когда o не null, но o is var x всегда истинно. Компилятор знает об этом и в режиме Release (*) полностью удаляет конструкцию if и просто оставляет вызов консольного метода. К сожалению, компилятор не предупреждает, что код недостижим в следующем случае:
if (!(o is var x)) Console.WriteLine(«Unreachable»). Надеюсь, это тоже будет исправлено.
(*) Непонятно, почему поведение отличается только в режиме Release. Но я думаю, что все проблемы имеют одну природу: первоначальная реализация фичи неоптимальна. Но на основе этого комментария Нила Gafter это изменится: «Плохой код, соответствующий сопоставлению с образцом, переписывается с нуля (для поддержки рекурсивных шаблонов тоже). Я ожидаю, что большинство улучшений, которые вы ищете здесь, будут «бесплатными» в новом коде.».
Отсутствие проверки на null делает этот случай очень особенным и потенциально опасным. Но если вы знаете, что именно происходит, вы можете найти этот вариант сопоставления полезным. Его можно использовать для введения временной переменной внутри выражения:
1 2 3 4 5 6 7 8 | public void VarPattern(IEnumerable s) { if (s.FirstOrDefault(o => o != null) is var v && int.TryParse(v, out var n)) { Console.WriteLine(n); } } |
Is-expression и «Элвис»-оператор
Есть другой случай, который я нашел очень полезным. Образец типа соответствует значению, только если значение не равно null. Мы можем использовать эту «фильтрующую» логику с null-propagating оператором, чтобы сделать код более читабельным:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | public void WithNullPropagation(IEnumerable s) { if (s?.FirstOrDefault(str => str.Length > 10)?.Length is int length) { Console.WriteLine(length); } // Similar to if (s?.FirstOrDefault(str => str.Length > 10)?.Length is var length2 && length2 != null) { Console.WriteLine(length2); } // And similar to var length3 = s?.FirstOrDefault(str => str.Length > 10)?.Length; if (length3 != null) { Console.WriteLine(length3); } } |
Обратите внимание, что один и тот же шаблон может использоваться как для типов значений, так и для ссылочных типов.
Сопоставление с образцом блоках switch
C# 7 расширяет оператор switch для использования образцов в case-блоках:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | public static int Count(this IEnumerable e) { switch (e) { case ICollection c: return c.Count; case IReadOnlyCollection c: return c.Count; // Matches concurrent collections case IProducerConsumerCollection pc: return pc.Count; // Matches if e is not null case IEnumerable _: return e.Count(); // Default case is handled when e is null default: return 0; } } |
В примере показан первый набор изменений в операторе switch.
- В операторе switch может использоваться переменная любого типа.
- Предложение case может указывать шаблон.
- Важен порядок предложений в case. Компилятор выдает ошибку, если предыдущий case соответствует базовому типу, а следующий case – соответствует производному типу.
- Все case-блоки содержат неявную проверку на null (**). В предыдущем примере, последний case-блок правилен, поскольку он будет срабатывать только тогда, когда аргумент не равен null.
(**) В последнем case-блоке показана еще одна возможность, добавленная в C# 7, называемая шаблоном «discard». Имя _ является специальным и сообщает компилятору, что переменная не нужна. Шаблон типа в предложении case требует имени переменной, и если вы не собираетесь ее использовать, то вы можете ее проигнорировать с помощью _.
Следующий фрагмент показывает еще одну особенность сопоставления с образцом на основе switch — возможность использования предикатов:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | public static void FizzBuzz(object o) { switch (o) { case string s when s.Contains("Fizz") || s.Contains("Buzz"): Console.WriteLine(s); break; case int n when n % 5 == 0 && n % 3 == 0: Console.WriteLine("FizzBuzz"); break; case int n when n % 5 == 0: Console.WriteLine("Fizz"); break; case int n when n % 3 == 0: Console.WriteLine("Buzz"); break; case int n: Console.WriteLine(n); break; } } |
Switch может иметь более одного case-блока с одним и тем же типом. В этом случае, компилятор объединяет все проверки типов в один блок, чтобы избежать избыточных вычислений:
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 | public static void FizzBuzz(object o) { // All cases can match only if the value is not null if (o != null) { if (o is string s && (s.Contains("Fizz") || s.Contains("Buzz"))) { Console.WriteLine(s); return; } bool isInt = o is int; int num = isInt ? ((int)o) : 0; if (isInt) { // The type check and unboxing happens only once per group if (num % 5 == 0 && num % 3 == 0) { Console.WriteLine("FizzBuzz"); return; } if (num % 5 == 0) { Console.WriteLine("Fizz"); return; } if (num % 3 == 0) { Console.WriteLine("Buzz"); return; } Console.WriteLine(num); } } } |
Но нужно иметь в виду две вещи:
- Компилятор объединяет только последовательные case-блоки с одинаковым типом, и если вы будете смешивать блоки для разных типов, компилятор будет генерировать менее оптимальный код:
1 2 3 4 5 6 7 8 9 10
switch (o) { // The generated code is less optimal: // If o is int, then more than one type check and unboxing operation // may happen. case int n when n == 1: return 1; case string s when s == "": return 2; case int n when n == 2: return 3; default: return -1; }
Компилятор преобразует его следующим образом:
1 2 3 4
if (o is int n && n == 1) return 1; if (o is string s && s == "") return 2; if (o is int n2 && n2 == 2) return 3; return -1;
- Компилятор делает все возможное, чтобы предотвратить типовые проблемы с неверным порядком case-блоков.
1 2 3 4 5 6
switch (o) { case int n: return 1; // Error: The switch case has already been handled by a previous case. case int n when n == 1: return 2; }
Но компилятор не знает, что один предикат сильнее другого и, по сути, делает следующие блоки недостижимыми:
1 2 3 4 5 6
switch (o) { case int n when n > 0: return 1; // Will never match, but the compiler won't warn you about it case int n when n > 1: return 2; }
Соответствие шаблону 101
- В C# 7 были введены следующие образцы: шаблон const, шаблон типа, шаблон var и шаблон discard.
- Образцы могут использоваться в is-выражениях и в case блоках.
- Реализация шаблона const в is-выражениях для типов значений далека от совершенства с точки зрения производительности.
- Образцу var соответствует любое значение, и вы должны быть с ним осторожны.
- Оператор switch может использоваться для набора проверок типа с дополнительными предикатами в предложениях when.