В 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.

Комментарии (0)