Я преподаю C# и делаю платформу обучения IRON PROGRAMMER во Владикавказе. Когда ученики присылают решения, я часто вижу длинные цепочки проверок. Объект существует, вложенное свойство не null, город совпадает, прогресс дошел до нужного значения. Например, нужно найти ученика из Владикавказа, который прошел не меньше половины курса:
if (enrollment != null && enrollment.Student != null && enrollment.Student.City != null && enrollment.Student.City.Name == "Владикавказ" && enrollment.Progress >= 50) { Console.WriteLine("ученик из Владикавказа прошел половину курса"); }
А вот то же самое на современном C#:
if (enrollment is { Student.City.Name: "Владикавказ", Progress: >= 50 }) { Console.WriteLine("ученик из Владикавказа прошел половину курса"); }
Второй вариант использует сопоставление с образцом (pattern matching). Мы описываем, каким условиям должны соответствовать данные, а компилятор сам проверяет каждый уровень и заодно может вытащить нужные части в переменные. Если enrollment, Student или City равны null, образец просто не совпадет, исключения не будет.
Такой синтаксис появился не сразу. В C# 7 были первые образцы, объявления, константы и var, а к одиннадцатой версии язык научился разбирать вложенные объекты, диапазоны чисел и целые списки.
Коротко. C# 7 дал образцы объявления и when, C# 8 выражение switch и образцы свойств, C# 9 сравнения и and/or/not, C# 10 вложенные свойства через точку, C# 11 образцы списков. В C# 12, 13 и 14 новых образцов не появилось, а что будет в C# 15, расскажу в конце.
Статья для тех, кто уже знаком с условиями, классами и коллекциями в C#. Разберем, какие задачи решают образцы и в какой версии появился каждый прием.
Как читать примеры. Они взяты из учебной платформы, данные в них выдуманные, а ситуации настоящие. В каждом разделе используется только тот синтаксис, который был доступен в этой версии языка, я проверил это отдельными проектами с LangVersion 7.3, 8, 9, 10 и 11 на .NET SDK 10. В разделах про C# 7 и C# 8 методы со словом static это члены класса, а отдельные инструкции это тело Main, инструкции верхнего уровня появились только в C# 9. Для краткости в некоторых фрагментах опущены объявления переменных и моделей, которые встречались выше. Если собираете примеры одним файлом с инструкциями верхнего уровня, объявления record и enum ставьте в конец файла, иначе компилятор выдаст CS8803.
C# 7. Образцы появляются в языке
В седьмой версии у оператора is и у switch появилось три вида образцов:
Образец объявления (declaration pattern). Проверка типа и объявление переменной в одном действии.
Константный образец (constant pattern). Сравнение с числом, строкой или
null.Образец
var. Совпадает с чем угодно и просто сохраняет значение в переменную.
Допустим, система получила ответ ученика, и мы пока не знаем, что это:
object answer = 42; if (answer is int score) { Console.WriteLine(score + 1); // 43 }
Раньше для этого писали проверку через is, а потом отдельное явное приведение. Теперь при совпадении значение сразу лежит в score. Сама переменная видна и после if, но компилятор следит, чтобы вы не прочитали ее там, где образец мог не совпасть.
Образец var полезен в switch, когда нужно поймать значение любого типа и дать ему имя. В отличие от образца объявления он совпадает даже с null, поэтому ветка case var value перехватывает вообще все:
object answer = 42; switch (answer) { case var value: Console.WriteLine($"получили {value}"); break; }
В switch можно добавить условие через when:
static string DescribeAnswer(object answer) { switch (answer) { case null: return "ученик еще не ответил"; case int n when n < 0: return "баллы не бывают отрицательными"; case int _: return "ответ числом"; case string s: return $"ответ текстом: {s}"; default: return "что-то странное"; } }
Обратите внимание на case int _. В C# 7 у образца типа обязательно должна быть переменная, и если она не нужна, вместо имени пишут _. Записать просто case int: можно будет только в C# 9.
Выбирается первая ветка, образец и условие when которой подходят. Если компилятор может доказать, что предыдущие образцы полностью перекрыли следующую ветку, он сообщает об ошибке. Условия when он при этом не анализирует, недостижимость из-за них остается на вашей совести.
C# 8. Выражение switch, свойства и кортежи
Восьмая версия сделала сопоставление с образцом заметно выразительнее. Появились три вещи:
Выражение
switch(switch expression). Оно возвращает значение, поэтому не нужныcase,breakиreturnв каждой ветке.Образец свойств (property pattern). Проверяет значения свойств объекта прямо в фигурных скобках.
Образцы кортежей и позиционные образцы (positional pattern). Разбирают сразу несколько значений или деконструируемый объект.
Перепишем разбор ответа ученика через выражение switch и добавим ветку для пустого ответа. Получается шесть ветвей одного выражения:
static string DescribeAnswer(object answer) => answer switch { null => "ученик еще не ответил", int n when n < 0 => "баллы не бывают отрицательными", int _ => "ответ числом", string { Length: 0 } => "пустой ответ", string s => $"ответ текстом: {s}", _ => "что-то странное" };
Ветка string { Length: 0 } и есть образец свойств. Она читается почти как обычная фраза. Строка, у которой длина равна нулю. Ветка _ в конце ловит все остальное, это аналог default.
Здесь стоит запомнить правило, которое дальше будет работать везде. Образцы объявления, свойств, позиционные образцы и образцы списков с null не совпадают. Образцы var и _ совпадают с любым значением, включая null, константный образец null только с ним, а из логических с null совпадет, например, not string. Поэтому string { Length: 0 } на null не упадет. Отдельная ветка null в начале нужна, чтобы отличить отсутствие ответа от остальных случаев, без нее null дойдет до _ и вернется «что-то странное». А в примерах с Deconstruct и списками ниже ни одна ветка с null не совпадает, поэтому при таком аргументе выражение switch бросит SwitchExpressionException.
Образец кортежа пригодится, когда решение зависит сразу от двух значений. Например, от того, посмотрел ученик урок и сдал ли он задачу:
static string Status(bool lessonDone, bool taskDone) => (lessonDone, taskDone) switch { (true, true) => "шаг пройден", (true, false) => "осталось сдать задачу", (false, _) => "урок еще не открыт" };
Это можно записать и обычными условиями, кортеж позволяет собрать все комбинации в одном месте и увидеть, какие из них забыты.
Позиционный образец делает то же самое с типом, у которого есть подходящий доступный метод Deconstruct. Пусть попытка решения хранит номер и баллы:
class Attempt { public int Number { get; } public int Score { get; } public Attempt(int number, int score) { Number = number; Score = score; } public void Deconstruct(out int number, out int score) { number = Number; score = Score; } } static string Describe(Attempt attempt) => attempt switch { (1, 100) => "решил с первого раза", (_, 100) => "дожал до ста", (var number, var score) => $"попытка {number}, {score} баллов" };
Компилятор сам вызывает Deconstruct и раскладывает объект на части, а вы сравниваете их позиционно, по порядку, как элементы кортежа. Последняя ветка ловит любую попытку, но не null, и Describe(null) бросит исключение. В C# 9 класс Attempt заменит одна строка record Attempt(int Number, int Score), Deconstruct у record генерируется автоматически.
C# 9. Сравнения и логика прямо в образце
Девятая версия добавила реляционные и логические образцы. Теперь диапазоны значений записываются так же, как говорит человек:
Реляционные образцы (relational pattern).
<,<=,>,>=.Логические образцы (logical pattern).
and,or,not.Скобки для группировки.
Образец типа без переменной (type pattern). Достаточно написать имя типа.
Пусть по итогам курса ученик набрал баллы, и нужно понять, что ему выдать:
static string Certificate(int points) => points switch { >= 90 => "сертификат с отличием", >= 60 and < 90 => "сертификат", _ => "нужно добрать баллы" };
Здесь and стоит ради демонстрации. На практике проверку < 90 можно убрать, значения от 90 уже перехватила первая ветка.
Правая часть реляционного образца должна быть константой. Если порог вычисляется во время работы программы, используйте обычное сравнение или условие when:
int minimum = GetMinimum(); if (points >= minimum) { } // так можно // if (points is >= minimum) { } // ошибка CS9135, нужна константа string verdict = points switch { var p when p >= minimum => "зачет", _ => "незачет" };
Скобки нужны, когда условия смешиваются:
int age = 15; if (age is >= 10 and <= 17) { Console.WriteLine("школьник"); } var day = DayOfWeek.Sunday; if (day is DayOfWeek.Saturday or DayOfWeek.Sunday) { Console.WriteLine("выходной"); } int points = 95; if (points is (>= 0 and < 10) or (> 90 and <= 100)) { Console.WriteLine("крайний результат, посмотреть руками"); }
Самое частое применение not это проверка на null:
string name = "Залина"; if (name is not null) { Console.WriteLine(name); }
Такая запись надежнее, чем name != null. Если у типа перегружен оператор !=, он может вести себя неожиданно, а образец is not null перегрузку не вызывает.
Теперь у образца типа переменная необязательна, и ветки из раздела C# 7 можно писать короче:
static string DescribeAnswer(object answer) => answer switch { null => "ученик еще не ответил", int n when n < 0 => "баллы не бывают отрицательными", int => "ответ числом", string { Length: 0 } => "пустой ответ", string s => $"ответ текстом: {s}", _ => "что-то странное" };
Кортежи теперь можно сочетать с реляционными образцами. Посмотрим на активность ученика, где первое число это просмотренные уроки, а второе решенные задачи:
static string Activity((int Lessons, int Tasks) p) => p switch { (0, 0) => "еще не начал", (> 0, 0) => "смотрит уроки", (0, > 0) => "решает задачи", (> 0, > 0) => "смотрит уроки и решает задачи", _ => "счетчики отрицательные, так не бывает" };
C# 10. Вложенные свойства через точку
В десятой версии поправили неудобство образца свойств. Раньше, чтобы заглянуть глубже, приходилось писать фигурные скобки внутри фигурных. Возьмем модель записи на курс:
var enrollment = new Enrollment( new Student("Залина", new City("Владикавказ")), "PRO C#. Эволюция языка", 60); // C# 9 if (enrollment is { Student: { City: { Name: "Владикавказ" } } }) { } // C# 10 if (enrollment is { Student.City.Name: "Владикавказ" }) { } record City(string Name); record Student(string Name, City City); record Enrollment(Student Student, string Course, int Progress);
Условий в одних скобках может быть несколько, и их можно смешивать с реляционными образцами:
if (enrollment is { Student.City.Name: "Владикавказ", Progress: >= 50 }) { Console.WriteLine("ученик из Владикавказа прошел половину курса"); }
Синтаксическое изменение небольшое, но именно с ним образцы свойств перестали выглядеть громоздко на реальных моделях, где объекты вложены на два-три уровня. И помните правило из раздела C# 8. Если enrollment или любой промежуточный объект равен null, образец не совпадет, а исключения не будет, ровно это и показывал пример из начала статьи.
C# 11. Образцы списков
Одиннадцатая версия научила сопоставление с образцом работать с последовательностями. Образец списка (list pattern) проверяет длину и элементы:
[]совпадает с пустым списком.[var x]совпадает со списком из одного элемента...обозначает любое количество элементов, в том числе ни одного... var restто же самое, но срез сохраняется в переменную.
Пусть в массиве лежат баллы за все попытки ученика решить одну задачу:
static string Attempts(int[] scores) => scores switch { [] => "попыток еще не было", [var only] => $"одна попытка, {only} баллов", [.., var last] => $"последняя попытка, {last} баллов" }; int[] attempts = { 0, 40, 100 }; if (attempts is [0, .., 100]) { Console.WriteLine("начал с нуля, дошел до ста"); }
Проверка понимания. Совпадет ли [0, .., 100] с массивом из двух элементов { 0, 100 }? А с массивом из одного { 100 }? Да и нет. Два крайних элемента обязательны, а срез между ними может быть пустым. И по правилу про null метод Attempts(null) бросит исключение, ни одна из трех веток с null не совпадает.
Образец списка работает не с любой коллекцией. Компилятору нужно, чтобы у статического типа выражения было свойство Length или Count и индексатор по int или по System.Index. Массивы, string, List<T>, Span<T> подходят. А вот IEnumerable<T> не подходит, даже если внутри лежит массив, компилятор ответит ошибкой CS8985:
IEnumerable<int> numbers = new[] { 1, 2, 3 }; // if (numbers is [1, ..]) { } // CS8985, у IEnumerable<int> нет Length или Count
Для среза .. var rest нужно еще больше, метод Slice или индексатор по Range. И у среза есть цена. Для массива это новый массив с копией элементов. У List<T> начиная с .NET 8 есть Slice, и срез тоже копия, новый список:
int[] numbers = { 10, 20, 30, 40 }; if (numbers is [var first, .. var rest]) { Console.WriteLine(first); // 10 Console.WriteLine(rest.Length); // 3 Console.WriteLine(ReferenceEquals(rest, numbers)); // False, это копия }
Если копировать нельзя, берите срез от Span<T> или ReadOnlySpan<T>, там само создание среза управляемую память не выделяет.
В этой же версии строковые константы стало можно сопоставлять с ReadOnlySpan<char>. Это пригодится при разборе текста без лишних выделений памяти, например команд бота. Берем кусок сообщения как span и сравниваем с константами, Substring и новые строки не нужны:
ReadOnlySpan<char> message = "/start 12345"; int space = message.IndexOf(' '); ReadOnlySpan<char> command = space < 0 ? message : message[..space]; string reply = command switch { "/start" => "приветствие", "/help" => "справка", _ => "неизвестная команда" };
Образцы в реальном коде
Образцы нужны не только для учебных примеров. Вот два места из кода нашей платформы, которые встречаются чаще всего.
Первое. Раскладка чатов по папкам. Чат потока и кураторский чат попадают в папку своего потока, а чаты интенсивов и курсов в общие папки. Я немного упростил настоящий код:
enum ChatRoomType { Stream, Curator, Intensive, Course, Mentor } static string? AutoFolderOf(ChatRoomType type, Guid? streamId) => type switch { ChatRoomType.Stream or ChatRoomType.Curator when streamId is { } sid => $"stream:{sid}", ChatRoomType.Intensive => "intensives", ChatRoomType.Course => "courses", _ => null }; Console.WriteLine(AutoFolderOf(ChatRoomType.Stream, Guid.NewGuid())); // stream:... Console.WriteLine(AutoFolderOf(ChatRoomType.Stream, null)); // пусто, чат наверху Console.WriteLine(AutoFolderOf(ChatRoomType.Course, null)); // courses
В одной ветке сразу три приема. Образец or объединяет два типа чата. Условие when относится ко всему образцу слева, то есть и к Stream, и к Curator, а не только к последнему. А запись streamId is { } sid достает значение из Guid?, если оно есть. Если у чата потока нет streamId, when не выполняется, ветка пропускается, Intensive и Course тоже не подходят, и чат попадает в _ и остается наверху списка.
Второе. Еще проще и встречается в коде постоянно. Приглашение в класс истекло или нет:
bool IsExpired(DateTimeOffset? inviteExpiresAt, DateTimeOffset now) => inviteExpiresAt is { } expires && expires <= now;
Запись is { } означает «значение есть». Она заменяет HasValue и .Value и сразу дает имя переменной. Обратите внимание на бизнес-правило, которое спрятано в этой строке. Если срока нет, приглашение бессрочное и не истекает никогда. У нас так и задумано, но при копировании в свой проект стоит проверить, что у вас то же самое.
Ошибки и ловушки
Вот что чаще всего идет не так, когда ученики начинают пользоваться образцами.
Порядок веток
Выбирается первая подходящая ветка. Если общая ветка стоит раньше частной, частная никогда не сработает, и компилятор, когда может это доказать, не даст собрать проект:
static string Certificate(int points) => points switch { >= 60 => "сертификат", >= 90 => "сертификат с отличием", // ошибка CS8510, образец уже обработан выше _ => "нужно добрать баллы" };
Правило простое. Сначала самые узкие условия, в конце общие.
Not и or без скобок
Это самая коварная ошибка, потому что код собирается и выглядит разумно:
// хотели «статус не done и не closed» if (status is not "done" or "closed") { }
Приоритет у not выше, чем у or, поэтому на деле это «не done» или «closed». Вот что получается для разных статусов:
status | Что выдает условие | Чего хотели |
|---|---|---|
done | false | false |
closed | true | false |
new | true | true |
Правильно так:
if (status is not ("done" or "closed")) { }
Это то же самое, что привычное status != "done" && status != "closed". Хорошая новость в том, что начиная с C# 14 (.NET SDK 10) компилятор предупреждает о таком случае сообщением «избыточный шаблон» (CS9336), потому что closed и так попадает под not "done". На LangVersion 13 и ниже предупреждения нет, я проверил. Не отключайте предупреждения.
Switch без ветки по умолчанию
Если выражение switch не покрывает все значения, компилятор выдаст предупреждение CS8509. В примере ниже перечислены все именованные значения enum, но остаются неименованные, поэтому код у предупреждения другой, CS8524. А во время работы программа упадет на неожиданном значении:
enum LessonStatus { New, InProgress, Done } static string Name(LessonStatus s) => s switch { LessonStatus.New => "новый", LessonStatus.InProgress => "в процессе", LessonStatus.Done => "пройден" }; Name((LessonStatus)7); // SwitchExpressionException
С перечислениями это легко упустить. В enum можно записать число, которого нет в списке, и через год кто-то добавит в перечисление новое значение. Ветка _ нужна, но что в нее писать, зависит от задачи. Если неизвестное значение это ошибка в программе, бросайте понятное исключение, например ArgumentOutOfRangeException с именем параметра и значением, его проще найти в логах, чем SwitchExpressionException. Если неизвестное значение допустимо, возвращайте значение по умолчанию, но помните, что так можно спрятать настоящую ошибку.
Когда образцы не нужны
Не стоит заменять каждое условие на образец. Вот два варианта одной проверки:
if (age is >= 18) { } if (age >= 18) { }
Второй ничем не хуже, а большинству читателей он привычнее. Образцы выигрывают, когда нужно одновременно проверить тип, несколько свойств, форму коллекции или вытащить значения в переменные. Для одного простого сравнения обычный оператор читается лучше.
Быстро ли это работает
Частый вопрос, не станет ли код медленнее из-за образцов. Сам синтаксис лишней памяти не требует, компилятор разворачивает образец в проверки типов, сравнения и чтения свойств, то есть в то, что вы написали бы руками. Но образец делает ровно то, что вы в нем описали. Образец свойств вызывает геттеры, а внутри геттера может быть что угодно. Позиционный образец вызывает Deconstruct. Образец списка читает Length или Count и индексатор, а срез .. var rest на массиве копирует элементы, об этом было выше. Поэтому цена образца это цена тех членов, к которым он обращается. Для простых проверок типов и значений разница с обычными условиями обычно незаметна, но на критичных участках ее стоит измерять, а не угадывать.
Я замерил три пары из этой статьи через BenchmarkDotNet 0.15.8 на .NET 10.0.8, Windows 10, AMD Ryzen 5 2600X. Каждый метод прогоняет 10 000 записей со случайными данными с фиксированным seed. Код бенчмарка под спойлером ниже, а результаты такие:
Сценарий | Метод | Mean | Error | StdDev | Ratio | Allocated |
|---|---|---|---|---|---|---|
Баллы | цепочка if | 24,82 мкс | 0,22 мкс | 0,19 мкс | 1,00 | 0 |
Баллы | switch с образцами | 32,18 мкс | 0,41 мкс | 0,36 мкс | 1,30 | 0 |
Свойства | цепочка != null | 46,42 мкс | 0,83 мкс | 2,01 мкс | 1,00 | 0 |
Свойства | образец свойств | 45,97 мкс | 0,89 мкс | 0,84 мкс | 0,99 | 0 |
Срез | массив, .. var rest | 180,92 мкс | 3,56 мкс | 4,37 мкс | 1,00 | 367 208 B |
Срез | ReadOnlySpan, .. var rest | 74,48 мкс | 1,46 мкс | 1,44 мкс | 0,41 | 0 |
Ratio считается внутри своей пары, первый метод пары это база. Error и StdDev описывают разброс внутри одного прогона. Между прогонами разброс больше, и таблица его не показывает. Между запусками я изменил группировку в конфигурации BenchmarkDotNet и переработал сценарий среза, а реализации первых двух пар и генерация их входных данных остались прежними. Вот средние двух прогонов для первых двух пар:
Сценарий | Метод | Прогон 1 | Прогон 2 |
|---|---|---|---|
Баллы | цепочка if | 31,48 мкс | 24,82 мкс |
Баллы | switch с образцами | 24,74 мкс | 32,18 мкс |
Свойства | цепочка != null | 48,64 мкс | 46,42 мкс |
Свойства | образец свойств | 45,12 мкс | 45,97 мкс |
Что из этого видно. В первом прогоне среднее время образца свойств было примерно на 7 процентов меньше ручной цепочки, во втором примерно на 1 процент. Устойчивого преимущества одного варианта два прогона не показывают, и обещать такой выигрыш в другом приложении нельзя. С баллами интереснее. В первом прогоне switch был на 20 процентов быстрее if, во втором на 30 процентов медленнее, при том же коде и тех же данных. По таким замерам причину не установить, не глядя на сгенерированный код, и вывода про скорость я из них не делаю ни в какую сторону. Твердо здесь только одно. Памяти ни один из четырех методов не выделяет.
Зато третья пара показывает настоящую разницу. Каждый метод в ней берет первую попытку и суммирует остальные, чтобы JIT не выбросил срез как неиспользуемый, в первой версии бенчмарка я на этом обжегся и получил красивые десять раз на пустом цикле. В этом прогоне вариант с массивом выделял память, в среднем 37 байт на одну историю попыток, а вариант со span не выделял ничего. Это согласуется с тем, что срез массива копирует элементы, а срез span это участок той же памяти. По времени массив отстал в 2,4 раза. Часть этой разницы может быть и в том, как JIT скомпилировал два разных метода, поэтому точную долю копирования я не называю. Вот где стоит смотреть не на синтаксис, а на тип данных.
Так что в обычном коде выбирайте по читаемости. На горячем участке с миллионами вызовов измерьте на своих данных и смотрите не только на время, но и на колонку Allocated.
Код бенчмарка целиком, чтобы можно было повторить
Проект обычный консольный на net10.0 с пакетом BenchmarkDotNet 0.15.8, запуск dotnet run -c Release:
using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Configs; using BenchmarkDotNet.Running; BenchmarkRunner.Run<PatternBench>(); record City(string Name); record Student(string Name, City? City); record Enrollment(Student? Student, string Course, int Progress); [MemoryDiagnoser] [GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] [CategoriesColumn] public class PatternBench { private const int N = 10_000; private int[] _points = null!; private Enrollment?[] _enrollments = null!; private int[][] _scores = null!; [GlobalSetup] public void Setup() { var rnd = new Random(42); _points = Enumerable.Range(0, N).Select(_ => rnd.Next(0, 101)).ToArray(); _enrollments = Enumerable.Range(0, N).Select(i => { int k = rnd.Next(0, 10); if (k == 0) return null; City? city = k == 1 ? null : new City(k % 2 == 0 ? "Владикавказ" : "Москва"); Student? student = k == 2 ? null : new Student("Залина", city); return new Enrollment(student, "PRO C#. Эволюция языка", rnd.Next(0, 101)); }).ToArray(); // У каждого ученика своя история попыток от 1 до 8 штук. _scores = Enumerable.Range(0, N) .Select(_ => Enumerable.Range(0, rnd.Next(1, 9)).Select(_ => rnd.Next(0, 101)).ToArray()) .ToArray(); } // 1. Баллы. Цепочка if против switch с реляционными образцами. static string CertificateIf(int points) { if (points >= 90) return "сертификат с отличием"; if (points >= 60) return "сертификат"; return "нужно добрать баллы"; } static string CertificateSwitch(int points) => points switch { >= 90 => "сертификат с отличием", >= 60 => "сертификат", _ => "нужно добрать баллы" }; [BenchmarkCategory("Баллы"), Benchmark(Baseline = true)] public int Points_If() { int n = 0; foreach (var p in _points) if (CertificateIf(p).Length > 10) n++; return n; } [BenchmarkCategory("Баллы"), Benchmark] public int Points_Switch() { int n = 0; foreach (var p in _points) if (CertificateSwitch(p).Length > 10) n++; return n; } // 2. Вложенные свойства. Ручная цепочка != null против образца свойств. static bool ManualCheck(Enrollment? e) => e != null && e.Student != null && e.Student.City != null && e.Student.City.Name == "Владикавказ" && e.Progress >= 50; static bool PatternCheck(Enrollment? e) => e is { Student.City.Name: "Владикавказ", Progress: >= 50 }; [BenchmarkCategory("Свойства"), Benchmark(Baseline = true)] public int Nested_Manual() { int n = 0; foreach (var e in _enrollments) if (ManualCheck(e)) n++; return n; } [BenchmarkCategory("Свойства"), Benchmark] public int Nested_Pattern() { int n = 0; foreach (var e in _enrollments) if (PatternCheck(e)) n++; return n; } // 3. Срез. Первая попытка плюс сумма остальных. Массив копируется, Span нет. static int FirstPlusRest(int[] scores) { if (scores is [var first, .. var rest]) { int sum = first; foreach (var s in rest) sum += s; return sum; } return 0; } static int FirstPlusRest(ReadOnlySpan<int> scores) { if (scores is [var first, .. var rest]) { int sum = first; foreach (var s in rest) sum += s; return sum; } return 0; } [BenchmarkCategory("Срез"), Benchmark(Baseline = true)] public int Slice_Array() { int n = 0; foreach (var scores in _scores) n += FirstPlusRest(scores); return n; } [BenchmarkCategory("Срез"), Benchmark] public int Slice_Span() { int n = 0; foreach (var scores in _scores) n += FirstPlusRest((ReadOnlySpan<int>)scores); return n; } }
Итоговая картина
Версия | Что добавилось |
|---|---|
C# 7 | Образцы объявления, константы и |
C# 8 | Выражение |
C# 9 | Операторы сравнения, |
C# 10 | Вложенные свойства через точку |
C# 11 | Образцы списков и срезы, строки для |
Идея у всех версий одна. Вместо цепочки if с приведениями типов и ручными проверками вы описываете форму данных, которые ждете, а компилятор сам проверяет условия и заполняет переменные. Код становится короче, и по нему видно, что именно вы ожидаете от данных. При этом ценность образцов не в сэкономленных строках, а в том, насколько очевиднее становится проверяемое условие.
Что дальше
В C# 12, 13 и 14 новых образцов не появилось. Язык развивался в других местах, например в выражениях коллекций, первичных конструкторах и членах-расширениях.
А вот в C# 15, который ожидается вместе с .NET 11 в ноябре 2026, развивается не синтаксис образцов, а проверка полноты switch. Появляются union-типы и закрытые иерархии классов с модификатором closed. В обоих случаях компилятор знает полный набор вариантов, у closed прямые наследники могут быть только в той же сборке, и проверяет выражение switch на полноту без ветки _. А если набор вариантов потом расширится, компилятор найдет выражения switch, которые перестали покрывать все случаи. Пока это превью, и синтаксис до релиза еще может поменяться, поэтому в эту статью я их не включал. Разберу отдельно, когда выйдет.
Вместо послесловия
Полный справочник по всем видам образцов с формальными правилами лежит в документации Microsoft, там же есть ссылки на спецификацию каждой версии.
Если хочется разобрать, как менялся язык в целом, с задачами и автопроверкой, у нас есть курс PRO C#. Эволюция языка.
А какие образцы вы чаще всего используете в своих проектах? И были ли случаи, когда новый синтаксис, наоборот, сделал код хуже? Пишите в комментариях, разберем.


