Хабрахабр

[Перевод] Исследуем сопоставление с образцом в C# 7

В C# 7 наконец появилась долгожданная возможность под названием «сопоставление с образцом» (pattern matching). Если вы знакомы с функциональными языками, такими как F#, вы можете быть немного разочарованы этой возможностью в ее текущем виде, но даже сегодня она может упростить ваш код в самых разных сценариях.

Каждая новая возможность чревата опасностью для разработчика, работающего в критическом для производительности приложении. Новые уровни абстракций хороши, но для того, чтобы эффективно использовать их, вы должны знать, что происходит под капотом. Сегодня мы собираемся изучить внутренности сопоставления с образцом, чтобы понять, как это реализовано.
Язык 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.

  1. В операторе switch может использоваться переменная любого типа.
  2. Предложение case может указывать шаблон.
  3. Важен порядок предложений в case. Компилятор выдает ошибку, если предыдущий case соответствует базовому типу, а следующий case – соответствует производному типу.
  4. Все 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); } }
}

Но нужно иметь в виду две вещи:

  1. Компилятор объединяет только последовательные 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;
    

  2. Компилятор делает все возможное, чтобы предотвратить типовые проблемы с неверным порядком 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.
Теги
Показать больше

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

Кнопка «Наверх»
Закрыть