Привет, Хабр! Меня зовут Митя, я инженер-программист в Контуре. Во время написания кода анализаторы иногда предлагают заменить привычные конструкции на pattern matching. Однако то, что призвано повысить читаемость, нередко делает код более трудным для восприятия, особенно, если не понимать, во что именно разворачиваются те или иные конструкции. И в один из дней я задался вопросом: а есть ли какие-нибудь подводные камни при использовании pattern matching и во что именно его преобразует компилятор? В этой статье — мои мысли и заметки.
Дисклеймер! Для анализа кода я использую сервис SharpLab. Поэтому к каждому примеру приложил ссылку на оригинальный код SharpLab. В самой статье код может быть переформатирован для лучшей читаемости.
Declaration and type patterns
Declaration pattern — это паттерн вида T x, который делает сразу две вещи:
проверяет, что runtime-значение совместимо с типом
T,если проверка прошла, объявляет переменную
xи кладёт туда значение, приведенное кT.
Мотивация добавления
Упростить часто повторяющуюся конструкцию «проверка типа + приведение + объявление переменной».
До C# 7.0 типичный код выглядел так:var v = expr as T; if (v != null) ... или if (obj is T) { var t = (T)obj; ... }.
Declaration pattern сводит к одной конструкции:if (expr is T v) ..., объединяя проверку типа и извлечение значения.Сделать код короче.
Когда переменная появляется прямо внутри условия, её удобно сразу использовать в&&/whenбез отдельного блока и без повторных приведений/кастов:if (storage is UsbKey usb && usb.IsPluggedIn) ....Повысить безопасность за счёт flow analysis.
Переменная паттерна доступна только там, где матч гарантированно произошёл (условно: definitely assigned when true). Это снижает риск случайно использовать значение «не того типа» или до присваивания.Встроить паттерны как общий механизм «проверить форму данных и извлечь части».
Исторически это было частью более широкой идеи: сделать в языке "framework patterns". Эта мотивация явно прослеживается в design notes и материалах по C# 7.0: patterns — новый элемент языка, который планировали расширять. Собственно, так и получилось.
Пример (SharpLab)
if (obj is Employee employee)
{
employee.DoWork(); // опять работа ;(
}По смыслу obj is Employee employee — это “проверка + приведение + присваивание”. Для ссылочных типов это обычно можно читать как эквивалент:
var employee = obj as Employee;
if (employee != null)
{
employee.DoWork();
}Для value-type’ов as уже не подходит, но pattern matching продолжает работать (например, через unboxing):
object obj = 42;
if (obj is int x)
{
Console.WriteLine(x + 1);
}Логически это похоже на SharpLab:
if (obj is int)
{
int x = (int)obj;
Console.WriteLine(x + 1);
}Nullable-сценарий: матчится только когда есть значение. Пример:
int? maybe = GetNumber();
if (maybe is int value)
{
Console.WriteLine(value); // value точно не null
}Это читается как компактный аналог (SharpLab):
if (maybe.HasValue)
{
var value = maybe.Value;
Console.WriteLine(value);
}Итоги
Declaration / type patterns позволяют:
объединить проверку типа и извлечение значения в одной конструкции
(T x),писать более компактные
if/switch,безопасно работать с
null,Nullable<T>и unboxing за счёт flow analysis.
Constant patterns
Constant patterns позволяют сравнивать значение с фиксированной константой (числом, строкой, null, значением enum и т.д.) при помощи оператора is.
Что такое constant patterns
Constant pattern сопоставляет выражение с конкретным значением. Например, можно проверить, что число равно 5:
if (x is 5)
{
Console.WriteLine("x == 5");
}Или что строка равна "hello":
if (s is "hello")
{
...
}Как компилятор разворачивает constant pattern (SharpLab)
if (x == 5)
{
...
}
if (s == "hello")
{
...
}
if (d == 5.0)
{
...
}В constant pattern справа можно использовать constant expression следующих видов:
целочисленные и вещественные числовые литералы,
символьные литералы (
char),строковые литералы,
булевы значения
trueиfalse,значения
enum,имена объявленных
const-полей илиconst-локальных переменных,null.
Выражение должно иметь тип, который можно неявно преобразовать к типу константы. Но есть одно исключение: выражение типа Span<char> или ReadOnlySpan<char> можно сопоставить со строковыми константами. В таком случае компилятор будет использовать MemoryExtensions.SequenceEqual (SharpLab).
Ещё одним важным уточнением будет то, что constant patterns не вызывает пользовательские перегрузки оператора == или метода Equals. Это происходит, потому что constant pattern является частью системы pattern matching, которая опирается только на предсказуемые операции:
встроенные сравнения примитивов (IL-инструкция
ceq, проверки диапазонов);reference equality для ссылочных типов,
структурное сравнение значений для примитивов и enum,
спец-поведение, заранее известное компилятору (interned строки, enum и т.п.).
Использование пользовательских перегрузок == или Equals здесь невозможно по нескольким причинам:
Паттерны должны быть детерминированными и заранее анализируемыми. Roslyn вычисляет их структуру во время компиляции: группирует константы в диапазоны, оптимизирует
or-выражения и формирует компактные булевы деревья.
Если бы сравнение зависело от произвольной логики перегруженного==, подобные оптимизации просто не работали бы.Перегруженный
==может вести себя слишком произвольно.
Он может бросать исключения, менять состояние, быть асимметричным или требовать нестандартных преобразований типов.
Всё это несовместимо с тем, что pattern matching задуман как "чистый" и безопасный синтаксический сахар.Constant pattern должен вести себя одинаково для всех типов.
Паттерны участвуют в exhaustiveness-анализе, в switch- выражениях, в логических композициях (or,and).
Чтобы эта система оставалась корректной, сравн��ние с константой обязано быть структурным, а не основанным на пользовательской перегрузке операторов.
Итоги
Constant patterns — базовый и очень понятный механизм, который формирует фундамент для многих других видов pattern matching:
switch-выражений,logical patterns (
or,and),list patterns,
property patterns и т.д. Несмотря на простоту, у него есть два важных ограничения:
constant pattern всегда работает только со значениями, известными на этапе компиляции — никакие пользовательские структуры или объекты здесь участвовать не могут;
сравнение выполняется по встроенным правилам языка, без вызова перегруженного
==илиEquals.
Поэтому constant pattern — это не просто ==, но другими символами, а отдельный инструмент.
Хоть я прекрасно знаю, что в моём проекте оператор
==нигде не перегружен и вряд ли когда-либо будет, я всё же предпочитаю писать проверку наnullчерезis. Так я хотя бы уверен, что сравнение всегда будет выполняться предсказуемо, и я точно не получуNullReferenceException.
Relational patterns
Relational patterns используется, чтобы сопоставить/сравнить результат выражения с константой при помощи <, >, <=, >=. Чаще всего их используют в is и в switch/switch expression вместе с and/or/not.
Пример:
Console.WriteLine(Classify(13)); // вывод: Too high
Console.WriteLine(Classify(double.NaN)); // вывод: Unknown
Console.WriteLine(Classify(2.4)); // вывод: Acceptable
static string Classify(double measurement) => measurement switch
{
< -4.0 => "Too low",
> 10.0 => "Too high",
double.NaN => "Unknown",
_ => "Acceptable",
};Важно: правая часть — обязательно constant expression. То есть, x is > min не скомпилируется, для других случаев необходимо использовать when или обычный if.
Как компилятор разворачивает relational pattern
В простом случае это превращается в обычные сравнения, а and/or — в логические операторы:
_ = x is >= 0 and <= 100;
примерно эквивалентно (SharpLab):
_ = x >= 0 && x <= 100;
Но если на вход подан nullable или object, то компилятор добавит проверки так, чтобы не словить исключения.
Например:
int? n = GetNullable();
_ = n is > 0;Примерно эквивалентно (SharpLab):
_ = n.HasValue && n.Value > 0;
Logical patterns
Logical patterns позволяют комбинировать другие паттерны так же, как логические операторы комбинируют условия — но внутри самого pattern matching.
Есть три комбинатора:
P or Q— матчится, если матчится хотя бы один подпаттерн,P and Q— матчится, если матчатся оба подпеттерна,not P— матчится, если не матчитсяP.
Приоритет операторов и скобки
У logical patterns есть приоритет (как у !, &&, ||): not > and > or Поэтому выражение:
c is >= 'a' and <= 'z' or >= 'A' and <= 'Z'
парсится как:
(c is (>= 'a' and <= 'z')) or (>= 'A' and <= 'Z')
Как компилятор разворачивает logical-pattern
Пример кода:
var c = 'a';
if (c is >= 'a' and <= 'z' or >= 'A' and <= 'Z')
{
}Примерно эквивалентно (SharpLab):
bool flag;
if (c >= 'a')
{
flag = c <= 'z';
}
else
{
flag = c >= 'A' && c <= 'Z';
}
if (flag)
{
// тело if
}Забавная оптимизация компилятора
Когда готовил материал, удалось наткнуться на забавную оптимизацию компилятора:
Изначальный код C#:
private static bool Sample1(short version)
=> version == 2
|| version == 768
|| version == 769
|| version == 770
|| version == 771
|| version == 772;
private static bool Sample2(short version) => version is 2
or 768
or 769
or 770
or 771
or 772;
private static bool Sample3(short version) => version switch
{
2 or 768 or 769 or 770 or 771 or 772 => true,
_ => false
};На первый взгляд кажется, что компилятор должен генерировать одинаковый код, ведь логика у всех трёх функций идентична. Но если заглянуть в IL и JIT ASM, нас ждёт маленький сюрприз.
Сгенерированный C# компилятором код
Вот во что Roslyn переписал наши методы:
private static bool Sample1(short version)
{
return version == 2 || version == 768 || version == 769 || version == 770 || version == 771 || version == 772;
}
private static bool Sample2(short version)
{
if (version == 2 || (uint)(version - 768) <= 4u)
{
return true;
}
return false;
}
private static bool Sample3(short version)
{
if (version == 2 || (uint)(version - 768) <= 4u)
{
return true;
}
return false;
}Ссылки на SharpLab:
Что мы видим?
Sample1 Компилятор сгенерировал шесть независимых сравнений.
В JIT ASM это отчётливо видно: шесть cmp подряд.
Sample2 и Sample3 Roslyn заметил, что значения 0x300–0x304 лежат в непрерывном диапазоне, и оптимизировал проверку до:
(uint)(version - 768) <= 4
То есть вместо пяти сравнений он сделал одно вычитание и одно сравнение.
JIT превратил это в компактную ASM-конструкцию:
add eax, 0xfffffd00 ; eax -= 768
cmp eax, 4
jbe <match>Почему так произошло?
Pattern matching даёт компилятору более структурированное представление условия (набор кейсов/паттернов для одного входа), поэтому Roslyn может безопасно распознавать диапазоны и генерировать range-check. Для произвольного || Roslyn такие преобразования обычно не делает и оставляет выражение как есть, рассчитывая на оптимизации JIT.
Property patterns
Property patterns позволяют выполнять сопоставление значений по свойствам (или полям) объектов, то есть проверять не просто тип или константу, а форму объекта: что он того типа, и что его свойства удовлетворяют нужным условиям.
Что такое property patterns
Property patterns использует синтаксис, где после is SomeType { ... } указывается набор проверок по свойствам:
if (obj is SomeType { Prop1: 42, Prop2: "hello" })
{
...
}Такое выражение означает:
objдолжно быть неnull.objдолжно быть экземпляромSomeType(или совместимого типа),у этого объекта свойства
Prop1иProp2должны иметь указанные значения, иначе сопоставление не удастся.
Как компилятор разворачивает property-pattern (упрощённо)
Рассмотрим пример:
if (diag is { Severity: Severity.Error, Fixes: { Count: > 0 }})
{
Console.WriteLine($"Diagnostic #{diag.DiagId} is critical.");
foreach (var fix in diag.Fixes!)
{
Console.WriteLine($" - {fix}");
}
}Упрощённо такой код можно представить как (SharpLab):
var tmp = diag;
if (tmp is not null
&& tmp.Severity == Severity.Error
&& tmp.Fixes is not null
&& tmp.Fixes.Count > 0)
{
Console.WriteLine($"Diagnostic #{tmp.DiagId} is critical. Apply fixes:");
foreach (var fix in tmp.Fixes)
{
Console.WriteLine($" - {fix}");
}
}То есть property pattern превращается в последовательность:
проверка на
null,проверка типа (
is Diagnostic),поочерёдные проверки значений свойств.
В реальности сгенерированный код будет чуть сложнее (оптимизации, переиспользование временных переменных и т.п.), но с точки зрения семантики это примерно то, что делает компилятор.
Итоги
Property patterns позволяют:
в одном выражении проверять сразу несколько свойств объекта,
компактно описывать форму данных, включая вложенные свойства,
избегать большого количества временных переменных и большого числа ветвлений в коде.
Positional patterns
Positional patterns позволяют сопоставлять объект “по позициям” так же, как вы это делаете при деконструкции (var (x, y) = point;). Идея простая: если объект можно деконструировать в набор значений, то эти значения можно матчить паттерном вида (…, …).
Что такое positional patterns
Positional pattern записывается в круглых скобках и содержит подпaттерны для каждого “элемента” деконструкции:
var p = new Point(10, 20);
_ = p is (10, 20); // оба значения совпали
_ = p is (> 0, > 0); // оба положительные
_ = p is (var x, 20); // извлекли x и проверили y
_ = p is (_, 0); // первый игнорируем, второй равен 0Часто можно встретить эту конструкцию с switch/switch expression: Пример:
static string Quadrant(Point p) => p switch
{
(0, 0) => "Origin",
(> 0, > 0) => "I",
(< 0, > 0) => "II",
(< 0, < 0) => "III",
(> 0, < 0) => "IV",
_ => "Axis"
};Как компилятор разворачивает positional-pattern:
Ключевой момент: positional pattern опирается на деконструкцию. То есть компилятор:
(для ссылочных типов) проверяет
null,вызывает
Deconstruct(...)и получает компоненты,применяет подпаттерны к полученным значениям.
Простой пример:
var p = new Point(1, 2);
_ = p is (1, 2);Что будет после компиляции:
int num = 0;
if ((object)point != null) {
double X;
double Y;
point.Deconstruct(out X, out Y);
if (X == 1.0) {
num = ((Y == 2.0) ? 1 : 0);
return;
}
num = 0;
}Какие типы поддерживают positional patterns?
Любой тип с методом
Deconstruct.
Итоги
Этот вид pattern matching поддерживает любой тип с реализованным методом
Deconstruct.Positional patterns очень удобно применять в switch и switch expressions.
Tuple patterns
Tuple patterns — это по сути тот же positional pattern, но применяемый к кортежам (ValueTuple).
Ключевое отличие: не вызывается Deconstruct, потому что у кортежей уже есть “позиционные” элементы Item1, Item2, … (а имена вроде x: и y: — это лишь синтаксический сахар).
То есть (x: 1, y: 2) is (1, 2) матчится по позициям, а не по именам.
Как компилятор разворачивает tuple-pattern
ValueTuple — это struct с полями Item1, Item2, … (и/или “именами” на уровне синтаксиса). Поэтому матчинг обычно превращается в простые проверки полей.
Пример:
var t = (1, 2);
_ = t is (1, 2);Что будет после компиляции Ссылка на SharpLab
int num;
if (t.Item1 == 1) {
if (t.Item2 == 2) {
num = 1;
}
}
else {
num = 0;
}Итоги
Tuple patterns позволяет матчить кортежи (a, b, c) по позициям, используя обычные паттерны внутри (_, or, диапазоны, var и т.д.).
var pattern
var pattern используется, чтобы сопоставить любое выражение (включая null) и присвоить его результат новой локальной переменной, как в примере:
static bool IsAcceptable(int id, int absLimit) =>
SimulateDataFetch(id) is var results
&& results.Min() >= -absLimit
&& results.Max() <= absLimit;
static int[] SimulateDataFetch(int id)
{
var rand = new Random();
return Enumerable
.Range(start: 0, count: 5)
.Select(s => rand.Next(minValue: -10, maxValue: 11))
.ToArray();
}Как компилятор разворачивает var-pattern
is var results компилятор по смыслу превращает просто в присваивание. Эквивалентная запись будет такой (SharpLab):
static bool IsAcceptable(int id, int absLimit)
{
var results = SimulateDataFetch(id);
return results.Min() >= -absLimit
&& results.Max() <= absLimit;
}var pattern полезен, когда нужна временная переменная внутри булевого выражения, чтобы сохранить результат промежуточных вычислений. Также var pattern можно использовать, когда нужно выполнить дополнительные проверки в when-гардах switch-выражения или switch-оператора, как показано в следующем примере:
public record Point(int X, int Y);
static Point Transform(Point point) => point switch
{
var (x, y) when x < y => new Point(-x, y),
var (x, y) when x > y => new Point(x, -y),
var (x, y) => new Point(x, y),
};
static void TestTransform()
{
Console.WriteLine(Transform(new Point(1, 2))); // вывод: Point { X = -1, Y = 2 }
Console.WriteLine(Transform(new Point(5, 2))); // вывод: Point { X = 5, Y = -2 }
}Есть вот такая очень крутая статья Stephen Cleary про использования
whenв логировании ошибок, рекомендую ознакомиться.
Discard pattern
Discard pattern (_) — это некая заглушка, которая совпадает с любым значением (включая null), но ничего не сохраняет и не создаёт переменных. Обычно используется как значение по умолчанию, чтобы покрыть все случаи и, например, не получить исключение во время использования switch expression.
Пример:
static decimal GetDiscountInPercent(DayOfWeek? dayOfWeek) => dayOfWeek switch
{
DayOfWeek.Monday => 0.5m,
DayOfWeek.Tuesday => 12.5m,
DayOfWeek.Wednesday => 7.5m,
DayOfWeek.Thursday => 12.5m,
DayOfWeek.Friday => 5.0m,
DayOfWeek.Saturday => 2.5m,
DayOfWeek.Sunday => 2.0m,
_ => 0.0m, // всё остальное (включая null и “неизвестные” значения)
};Как компилятор разворачивает discard pattern
Логически _ — это просто ветка default (SharpLab).
Примерный код:
static decimal GetDiscountInPercent(DayOfWeek? dayOfWeek)
{
return dayOfWeek switch
{
DayOfWeek.Monday => 0.5m,
DayOfWeek.Tuesday => 12.5m,
DayOfWeek.Wednesday => 7.5m,
DayOfWeek.Thursday => 12.5m,
DayOfWeek.Friday => 5.0m,
DayOfWeek.Saturday => 2.5m,
DayOfWeek.Sunday => 2.0m,
_ => 0.0m
};
}В отличие от switch expression, в is и в switch statement _ как паттерн использовать нельзя.
Итоги
Discard pattern (_) полезен, когда нужно:
добавить “ветку по умолчанию” в
switch expression;гарантировать, что
switchобработает все значения (включаяnullи “неизвестные” enum-значения);сделать код короче и очевиднее, чем
default: в классическомswitch.
Parenthesized pattern
Parenthesized pattern позволяет заключить в скобки любой паттерн. Обычно это делают, чтобы подчеркнуть выражение или изменить приоритет операций в логических паттернах, как показано в следующем примере:
if ( input is not (float or double)) {
return ;
}List patterns
List patterns позволяет проверять структуру массивов и коллекций, а также извлекать элементы через сопоставление.
Позволяет сопоставить выражение с последовательностью элементов. Например, можно проверить, что массив состоит ровно из трёх заданных значений:
int[] numbers = { 1, 2, 3 };
Console.WriteLine(numbers is [1, 2, 3]); // True
Console.WriteLine(numbers is [1, 2, 4]); // False
Console.WriteLine(numbers is [1, 2, 3, 4]); // False
Console.WriteLine(numbers is [0 or 1, <= 2, >= 3]); // TrueКак компилятор разворачивает list-pattern
_ = arr != null
&& arr.Length == 3
&& arr[0] == 1
&& arr[1] == 2
&& arr[2] == 3;То есть паттерн превращается в обычный набор проверок длины и обращения по индексам.
Какие коллекции поддерживают List patterns?
Одна из ключевых особенностей list patterns — то, что тип не обязан реализовывать интерфейсы IList, ICollection или подобные. Вместо этого используется подход, напоминающий duck typing. Это означает, что если у типа есть нужные свойства и методы, то он будет считаться совместимым.
Для совместимости достаточно обеспечить наличие:
свойства длины (
LengthилиCount);индексатора
this[int];необязательно — индексатора
this[Range], если вы хотите поддерживать slice-паттерны([..rest]).
Пример кастомной коллекции, совместимой с list patterns:
public class MyCoolCollection
{
private readonly List<int> _items = new();
public void Add(int item) => _items.Add(item);
public int Length => _items.Count;
public int this[Index index] => _items[index];
public ReadOnlySpan<int> this[Range range]
=> CollectionsMarshal.AsSpan(_items)[range];
}Такой тип будет корректно сопоставляться с конструкциями вроде:
var result = myCoolCollection is [1, _, .. var tail];
Итоги
List patterns позволяет:
проверять содержимое массивов и списков,
извлекать отдельные элементы и фрагменты коллекций,
просто описывать структуру данных с помощью
[a, b, ..rest]. А благодаря duck typing любой пользовательский тип можно сделать совместимым — достаточно реализовать несколько необходимых свойств и индексаторов.
У Контура выходила статья про сравнение Any() и Count. Для себя я выбрал использовать конструкцию
is []для коллекций, которые поддерживают list patterns, так ��ак она не только лаконичнее, но и автоматически включает проверку наnull. Благодаря этому код не может упасть сNullReferenceExceptionпри обращении к пустой ссылке.
Slice Patterns
Slice patterns расширяют возможности list patterns, позволяя сопоставлять произвольные фрагменты коллекций с помощью специального оператора ... Они позволяют извлекать срезы, игнорировать часть коллекции или фиксировать её начало/конец, при этом не требуя точного соответствия длины.
Slice pattern позволяет обозначить: "здесь может быть любое количество элементов".
Например, можно проверить, что коллекция начинается с двух значений, а затем содержит произвольный хвост:
var arr = new[] { 1, 2, 3, 4, 5 };
_ = arr is [1, 2, ..];Также можно извлечь этот хвост при помощи уже известного var pattern matching:
_ = arr is [1, 2, .. var tail];
tail в этом случае будет содержать элементы [3, 4, 5].
Как компилятор разворачивает slice pattern
Возьмём пример:
var arr = new[] { 1, 2, 3, 4, 5 };
_ = arr is [1, 2, ..];Запись [1, 2, ..] означает: массив не null, его длина минимум 2, первый элемент равен 1, второй элемент равен 2, а дальше может быть что угодно.
Компилятор сведёт это к последовательности простых проверок (SharpLab).
bool ok = arr is not null
&& arr.Length >= 2
&& arr[0] == 1
&& arr[1] == 2;Если добавить сохранение головы, хвоста или средней части массива, тогда будет использован срез (SharpLab).
var arr = new[] { 1, 2, 3, 4, 5 };
_ = arr is [1, 2, .. var tail];Упрощенно это будет выглядеть так:
int[] tail;
bool ok = arr is not null
&& arr.Length >= 2
&& arr[0] == 1
&& arr[1] == 2
&& (tail = arr[2..]) is not null; // присваивание хвостаarr[2..] компилятор раскроет через RuntimeHelpers.GetSubArray(arr, range) (этот же механизм используется и у range-оператора для массивов).
Какие коллекции поддерживают slice patterns?
Slice patterns накладывают те же требования, что и list patterns, но с одним дополнительным условием: тип должен поддерживать доступ по диапазону (this[Range]). Требуется наличие:
свойства длины (
LengthилиCount),индексатора
this[int],индексатора
this[Range] — обязателен для использования..внутри паттерна.
Пример кастомного типа, совместимого со slice patterns:
(в данном случае он совпадает с типом из примера для list patterns, т. к. уже реализует нужные индексаторы):
public class MyCoolCollection
{
private readonly List<int> _items = new();
public void Add(int item) => _items.Add(item);
public int Length => _items.Count;
public int this[Index index] => _items[index];
public ReadOnlySpan<int> this[Range range]
=> CollectionsMarshal.AsSpan(_items)[range];
}После этого можно использовать нашу крутую коллекцию вместе со slice patterns:
var result = myCoolCollection is [_, .. var middle, 10];
Итоги
Slice patterns позволяют:
сопоставлять произвольные участки коллекций;
извлекать хвосты, начала и промежуточные части с помощью
..;описывать сложные структуры данных компактно через
[start, ..middle, end].
Благодаря тому же duck typing любой пользовательский тип можно адаптировать под slice patterns, достаточно реализовать Length/Count, индексатор по int и индексатор по Range.
Удобная конструкция, но со своими нюансами.
Мне не очень нравится, что при использовании slice patterns зачастую происходит аллокация нового массива, особенно в случаях, когда мне бы хватило просто ссылки на срез, которую можно получить черезAsSpan. В таких ситуациях я предпочту написать явно:
var left = array.AsSpan(0, mid);
var right = array.AsSpan(mid, array.Length - mid);Вместо:
if (array is [.. var left, var mid, .. var right]) { }Но если для вас не критична эта дополнительная аллокация, то slice patterns выглядят стильно, современно и по-питонячьи.
