В C# 7 наконец появилась долгожданная возможность под названием «сопоставление с образцом» (pattern matching). Если вы знакомы с функциональными языками, такими как F#, вы можете быть немного разочарованы этой возможностью в ее текущем виде, но даже сегодня она может упростить ваш код в самых разных сценариях.
Каждая новая возможность чревата опасностью для разработчика, работающего в критическом для производительности приложении. Новые уровни абстракций хороши, но для того, чтобы эффективно использовать их, вы должны знать, что происходит под капотом. Сегодня мы собираемся изучить внутренности сопоставления с образцом, чтобы понять, как это реализовано.
Язык C# ввел понятие образца, которое может использоваться в is-выражении и внутри блока case оператора switch.
Существует 3 типа шаблонов:
is-выражение может проверить, равно ли значение константе, а проверка типа может дополнительно создавать переменную образца (pattern variable).
Я нашел несколько интересных аспектов, связанных с сопоставлением с образцом в is-выражениях:
Сначала проверим первые два случая:
Первый оператор if вводит переменную s, и переменная видна внутри всего метода. Это разумно, но усложнит логику, если другие if-операторы в том же блоке будут пытаться повторно использовать одно и то же имя еще раз. В этом случае вам нужно использовать другое имя, чтобы избежать коллизий.
Переменная, введенная в is-выражении, полностью определена только тогда, когда предикат является истинным. Это означает, что переменная n во втором операторе if не определена в правом операнде, но поскольку эта переменная уже объявлена, мы можем использовать ее как переменную out в методе int.TryParse.
Третий аспект, упомянутый выше, является наиболее важным. Рассмотрим следующий код:
В большинстве случаев, is-выражение преобразуется в object.Equals (constValue, variable) (даже если спецификация говорит, что оператор == должен использоваться для примитивных типов):
Этот код вызывает 2 упаковки (boxing), которые могут весьма серьезно повлиять на производительность, если они используются в критическом пути приложения. Когда-то выражение o is null так же вызывало упаковку (см. Suboptimal code for e is null) и, я надеюсь, что текущее поведение так же будет исправлено в скором времени (вот соответствующий тикет на гитхабе).
Если n-переменная имеет тип object, то o is 42 приведет к одному выделению памяти (для упаковки литерала 42), хотя подобный код на основе switch не приводит к выделениям памяти.
Образец var является частным случаем образца типа с одним ключевым отличием: образец будет соответствовать любому значению, даже если значение равно null.
o is object истинно, когда o не null, но o is var x всегда истинно. Компилятор знает об этом и в режиме Release (*) полностью удаляет конструкцию if и просто оставляет вызов консольного метода. К сожалению, компилятор не предупреждает, что код недостижим в следующем случае:
if (!(o is var x)) Console.WriteLine(«Unreachable»). Надеюсь, это тоже будет исправлено.
(*) Непонятно, почему поведение отличается только в режиме Release. Но я думаю, что все проблемы имеют одну природу: первоначальная реализация фичи неоптимальна. Но на основе этого комментария Нила Gafter это изменится: «Плохой код, соответствующий сопоставлению с образцом, переписывается с нуля (для поддержки рекурсивных шаблонов тоже). Я ожидаю, что большинство улучшений, которые вы ищете здесь, будут „бесплатными“ в новом коде.».
Отсутствие проверки на null делает этот случай очень особенным и потенциально опасным. Но если вы знаете, что именно происходит, вы можете найти этот вариант сопоставления полезным. Его можно использовать для введения временной переменной внутри выражения:
Есть другой случай, который я нашел очень полезным. Образец типа соответствует значению, только если значение не равно null. Мы можем использовать эту «фильтрующую» логику с null-propagating оператором, чтобы сделать код более читабельным:
Обратите внимание, что один и тот же шаблон может использоваться как для типов значений, так и для ссылочных типов.
C# 7 расширяет оператор switch для использования образцов в case-блоках:
В примере показан первый набор изменений в операторе switch.
(**) В последнем case-блоке показана еще одна возможность, добавленная в C# 7, называемая шаблоном «discard». Имя _ является специальным и сообщает компилятору, что переменная не нужна. Шаблон типа в предложении case требует имени переменной, и если вы не собираетесь ее использовать, то вы можете ее проигнорировать с помощью _.
Следующий фрагмент показывает еще одну особенность сопоставления с образцом на основе switch — возможность использования предикатов:
Switch может иметь более одного case-блока с одним и тем же типом. В этом случае, компилятор объединяет все проверки типов в один блок, чтобы избежать избыточных вычислений:
Но нужно иметь в виду две вещи:
Каждая новая возможность чревата опасностью для разработчика, работающего в критическом для производительности приложении. Новые уровни абстракций хороши, но для того, чтобы эффективно использовать их, вы должны знать, что происходит под капотом. Сегодня мы собираемся изучить внутренности сопоставления с образцом, чтобы понять, как это реализовано.
Язык C# ввел понятие образца, которое может использоваться в is-выражении и внутри блока case оператора switch.
Существует 3 типа шаблонов:
- Шаблон const
- Шаблон типа
- Шаблон var
Сопоставление с образцом в is-выражениях
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-выражениях не очень эффективна.
Сначала проверим первые два случая:
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.
Третий аспект, упомянутый выше, является наиболее важным. Рассмотрим следующий код:
public void BoxTwice(int n)
{
if (n is 42) Console.WriteLine("n is 42");
}
В большинстве случаев, is-выражение преобразуется в object.Equals (constValue, variable) (даже если спецификация говорит, что оператор == должен использоваться для примитивных типов):
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.
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 делает этот случай очень особенным и потенциально опасным. Но если вы знаете, что именно происходит, вы можете найти этот вариант сопоставления полезным. Его можно использовать для введения временной переменной внутри выражения:
public void VarPattern(IEnumerable<string> s)
{
if (s.FirstOrDefault(o => o != null) is var v
&& int.TryParse(v, out var n))
{
Console.WriteLine(n);
}
}
Is-expression и «Элвис»-оператор
Есть другой случай, который я нашел очень полезным. Образец типа соответствует значению, только если значение не равно null. Мы можем использовать эту «фильтрующую» логику с null-propagating оператором, чтобы сделать код более читабельным:
public void WithNullPropagation(IEnumerable<string> 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-блоках:
public static int Count<T>(this IEnumerable<T> e)
{
switch (e)
{
case ICollection<T> c: return c.Count;
case IReadOnlyCollection<T> c: return c.Count;
// Matches concurrent collections
case IProducerConsumerCollection<T> pc: return pc.Count;
// Matches if e is not null
case IEnumerable<T> _: 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 — возможность использования предикатов:
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-блока с одним и тем же типом. В этом случае, компилятор объединяет все проверки типов в один блок, чтобы избежать избыточных вычислений:
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-блоки с одинаковым типом, и если вы будете смешивать блоки для разных типов, компилятор будет генерировать менее оптимальный код:
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; }
Компилятор преобразует его следующим образом:
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-блоков.
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; }
Но компилятор не знает, что один предикат сильнее другого и, по сути, делает следующие блоки недостижимыми:
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.