Pattern matching в C# 7 +15


В C# 7 наконец-то появилась долгожданная функция под названием «сопоставление с образцом». Если вы знакомы с функциональными языками, такими как F#, то эта функция в том виде, в котором она существует на данный момент, может вас слегка разочаровать. Но даже сегодня она способна упростить код в самых разных случаях. Подробнее под катом!



Каждая новая функция может быть опасна для разработчика, создающего приложение, для которого критически важна производительность. Новые уровни абстракций хороши, но чтобы эффективно их использовать, нужно понимать, как они работают на самом деле. В этой статье рассматривается функция сопоставления с образцом и принципы ее работы.

Образец в C# может использоваться в выражении is, а также в блоке case оператора switch.
Существует три типа образцов:

  • образецконстанты;
  • образецтипа;
  • образецпеременной.

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

При использовании сопоставления с образцом в выражениях is стоит обратить внимание на несколько интересных моментов:

  • Переменная, введенная оператором if, отправляется во внешнюю область видимости.
  • Переменная, введенная оператором if, явно назначается, только когда образецсовпадает.
  • Текущая реализация сопоставления с образцом константы в выражениях 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, явно назначается, только когда предикат имеет значение true. Это значит, что переменная n во втором выражении if не назначена в правом операнде, но поскольку она уже объявлена, мы можем использовать ее как переменную out в методе int.TryParse.

Третий момент, упомянутый выше, является наиболее важным. Рассмотрим следующий пример:

public void BoxTwice(int n)
{
    if (n is 42) Console.WriteLine("n is 42");
}

В большинстве случаев выражение is преобразуется в object.Equals(константа, переменная) [хотя в характеристиках говорится, что для простых типов следует использовать оператор ==]:

public void BoxTwice(int n)
{
    if (object.Equals(42, n))
    {
        Console.WriteLine("n is 42");
    }
}

Этот код вызывает два процесса «упаковка-преобразование», которые могут значительно повлиять на производительность, если использовать их на критическом пути приложения. Раньше выражение o is null вызывало упаковку, если переменная о имела тип, допускающий значение null (см. Suboptimal code for e is null («Неоптимальный код для e is null»)), но есть надежда, что это будет исправлено (вот соответствующий запрос на github).

Если переменная n относится к типу object, то выражение o is 42 вызовет один процесс «упаковка-преобразование» (для литерала 42), хотя похожий код на основе оператора switch не привел бы к этому.

Образец переменной в выражении is


Образец переменной — это особый вид образца типа с одним большим отличием: образец будет соответствовать любому значению, даже null.

public void IsVar(object o)
{
    if (o is var x) Console.WriteLine($"x: {x}");
}

Выражение o is object примет значение true, если o не равна null, однако выражение o is var x всегда будет принимать значение true. Поэтому компилятор в режиме выпуска* полностью исключает выражения if и просто покидает вызов метода Console. К сожалению, компилятор не предупреждает о недоступности кода в следующем случае: if (!(o is var x)) Console.WriteLine(«Unreachable»). Есть надежда, что и это тоже исправят.

* Неясно, почему поведение отличается только в режиме выпуска. Представляется, что корень всех проблем один: первоначальная реализация функции неоптимальна. Впрочем, судя по этому комментарию Нила Гафтера (Neal 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 и оператор Elvis


Есть еще один случай, который может оказаться полезным. Образецтипа соответствует значению, только когда оно не равно null. Мы можем использовать эту логику «фильтрации» с оператором, распространяющим null, чтобы сделать код более удобным для чтения:

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);
    }
}

Обратите внимание, что один и тот же образецможно использовать как для типов значения, так и для ссылочных типов.

Сопоставление с образцом в блоках case


В 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. Компилятор выдаст ошибку, если предыдущее предложение соответствует базовому типу, а последующее — производному.
  4. Нестандартные предложения неявно проверяются на значение null**. В приведенном выше примере последнее предложение case является действительным, так как совпадает, только когда аргумент не равен null.

** В последнем предложении case показана еще одна функция, добавленная в C# 7, — образцы пустой переменной. Специальное имя _ сообщает компилятору, что переменная не нужна. Образцу типа в предложении 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;
    }
}

Это странная версия задачи FizzBuzz, в которой обрабатывается объект, а не просто число.

Оператор 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. Компилятор делает все возможное, чтобы избежать типичных проблем упорядочения.

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;
}

Однако компилятор не может определить, что один предикат сильнее другого, и эффективно замещает следующие предложения case:

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;
}

Кратко о сопоставлении с образцом


  • В C# 7 появились следующие образцы: образец константы, образец типа, образец переменной и образец пустой переменной.
  • Образцы можно использовать в выражениях is и в блоках case.
  • Реализация образца константы в выражении is для типов значения далека от идеала с точки зрения производительности.
  • Образцы переменной всегда совпадают, с ними надо быть осторожными.
  • Оператор switch можно использовать для набора проверок типа с дополнительными предикатами в предложениях when.

Событие по Unity в Москве — Unity Moscow Meetup 2018.1


11 октября, в четверг, в ВШБИ состоится Unity Moscow Meetup 2018.1. Это первая в этом сезоне встреча Unity разработчиков в Москве. Темой первого митапа будет AR/VR. Вас ждут интересные доклады, общение с профессионалами индустрии, а так же специальная демо-зона от MSI.

Подробности




К сожалению, не доступен сервер mySQL