Совсем скоро нам станет полноценно доступна новая версия C#, и мы продолжаем наш ежегодный цикл обзора нововведений. В этот раз команда Microsoft приготовила нам ряд очень интересных и ожидаемых изменений. Предлагаю рассмотреть их подробно.

Union-типы
Для начала рассмотрим наиболее понятное нововведение — union-типы. Данная фича позволит разработчикам объединять типы, не прибегая к наследованию и интерфейсам, где это не нужно или невозможно. Union — это структура, которая содержит в себе полный набор поддерживаемых типов.
public union TestUnion(int, Version, IEnumerable<string>);
В коде выше union — ключевое слово, а int, Version, IEnumerable<string> — набор поддерживаемых типов.
Примером использования новой структуры может служить ситуация, где традиционное наследование не всегда может подойти, так как оно обязывает объекты иметь что-то общее. Но могут быть случаи, когда наследование невозможно или бессмысленно с точки зрения архитектуры. Допустим, нам нужно обработать определённый ряд типов, включая примитивы. В обычном случае нам пришлось бы указать на входе переменную типа object, которую внутри необходимо было кастить к нужным типам, включая описание логики для неподходящих вариантов. Это звучит ненадёжно. Использование union в данном случае исправило бы эти проблемы, делая код надёжнее и читабельнее.
Исходя из сущности новой структуры, при использовании union компилятору изначально известен полный список всех типов, которые находятся внутри него. Это убирает необходимость в ветке default, что защищает программиста от ситуации, когда в switch были обработаны не все типы, а новые наследники постоянно попадают в default-секцию, что может вызывать непредсказуемое поведение программы.
Предлагаю рассмотреть более конкретно. Например, раньше, чтобы описать метод для обработки ответов от API, нужно было делать что-то подобное:
public abstract record ApiResult; public record ErrorData(string Message); public record Success(string Data) : ApiResult; public record ValidationError(ErrorData Data) : ApiResult; public string Process(ApiResult result) { return result switch { Success s => s.Data, ValidationError s => s.Data.Message, _ => "Unknown result" }; }
Объявляется абстрактный класс ApiResult, от которого наследуются конкретные ответы. Потому, если появится новый наследник, а разработчик не предусмотрит его обработку, то выполнение кода приведёт в ветку default. Union помогает реализовать это иначе:
public record ErrorData(string Message); public record Success(string Data); public record ValidationError(ErrorData Data); public union ApiResult(Success, ValidationError) { public string GetData() { return this switch { Success s => s.Data, ValidationError s => s.Data.Message }; } }
Как видно, здесь не нужно создавать родительский класс, чтобы объединить их. Также мы получаем более стабильное поведение за счёт того, что ещё во время компиляции известно, какие именно типы может содержать данный union. Это значит, что при попытке реализовать switch без обработки всех возможных типов объединения, компилятор выдаст предупреждение об этом.
Кроме того, появление union открывает разработчикам возможность, например, проще описывать такой паттерн, как монада. А также есть возможность реализации внутри union своих методов и свойств, что позволяет удобно работать со всеми типами и при этом не использовать снаружи switch.
Закрытые иерархии
Данное изменение в некоторой степени связано с union в том плане, что тоже позволяет компилятору знать, с каким числом типов он в итоге будет работать. В данном случае это достигается с помощью того, что закрывается возможность наследования от класса за пределами сборки, где он был объявлен. Это поведение напоминает некоторую смесь internal и sealed, где от internal берётся область действия, а от sealed — запрет наследования. Это может быть полезно, например, в библиотеках, где мы хотим ограничить наследование от определённых классов, не мешая своей же сборке. Предлагаю сразу рассмотреть пример объявления:
public closed record ClosedRecordClass;
Здесь ключевое слово closed обозначает, что класс закрыт для наследования. Соответственно, попытка наследоваться от такого класса из другой сборки вызовет ошибку компиляции. И стоит отметить, что закрытая иерархия так же, как и union, позволяет использовать так называемый exhaustive switch, который разрешает не описывать default-ветку.
Также есть важные уточнения. Во-первых, класс, помеченный как closed, всегда будет являться абстрактным. Это в том числе позволяет обеспечить exhaustive switch. Во-вторых, наследники не становятся закрытыми автоматически. Если потребуется сокрыть их от других сборок, то им можно указать модификатор доступа internal. И в-третьих, закрыть для наследования из другой сборки можно только классы (class и record class), так как для структур в целом нет наследования, а интерфейсы и нужны для того, чтобы от них наследоваться. Таким образом, при попытке добавить ключевое слово к структуре или интерфейсу будет выдана ошибка.
Именованные break и continue
Небольшое, но тоже довольно важное нововведение. У каждого иногда возникали ситуации, когда нужно было прервать выполнение внешней конструкции, либо перейти на следующую её итерацию. С классическим подходом пришлось бы реализовать нечто такое:
public enum WorkItemType { Process, StopAll } public class WorkItem { // .... public WorkItemType Type { get; set; } } public class OldLabeledBreak { public void PerformAction(IEnumerable<WorkItem> workList) { bool stopProcessing = false; foreach (var item in workList) // #1 { switch (item.Type) // #2 { case WorkItemType.Process: // .... break; // break #2 case WorkItemType.StopAll: stopProcessing = true; break; // break #2 } if (stopProcessing) break; // break #1 } // .... } }
То есть необходимо использовать дополнительный флаг, чтобы внешний цикл мог выйти после завершения выполнения внутреннего switch. Конечно, есть вариант с использованием goto, но он может внести в код некоторую путаницу, так как маркер, к которому перейдёт выполнение кода, может располагаться в любом месте. Именованные break и continue позволяют упростить код. Давайте посмотрим, как бы выглядел данный метод с применением новых возможностей.
public void PerformAction(IEnumerable<WorkItem> workList) { outer: foreach (var item in workList) // #1 { switch (item.Type) // #2 { case WorkItemType.Process: // .... break; // break #2 case WorkItemType.StopAll: break outer; // break #1 } } // .... }
Как видно, синтаксис походит на goto, но при этом код выглядит более читабельным. То есть, таким образом можно избежать создания лишних флагов и проверок, что позволит немного сэкономить память и сделать код более выразительным. Стоит уточнить, что данное нововведение можно применить для следующих конструкций: for, foreach, while, do и switch.
Индексаторы расширений
Это изменение позволяет разработчикам описать способы доступа к элементам коллекции по индексам с помощью индексаторов расширения. Например, можно реализовать получение элементов через Range в IReadOnlyList. До грядущей версии это можно было реализовать подобным образом:
public class OldExtensionIndexers { public void Process() { IReadOnlyList<string> readOnlyList = ["val1", "val2", "val3"]; var elem = readOnlyList.Slice(..2); } } public static class OldReadOnlyListExtensions { public static IReadOnlyList<T> Slice<T>(this IReadOnlyList<T> list, Range range) { var (offset, length) = range.GetOffsetAndLength(list.Count); var result = new List<T>(length); for (int i = 0; i < length; i++) result.Add(list[offset + i]); return result; } }
То есть проблема решается созданием обычного метода расширения, но C# 15 приносит нам возможность создать расширения для индексаторов. Следовательно, можно будет получать элементы через привычные квадратные скобки. Предлагаю рассмотреть, как бы это выглядело в нашем примере:
public class NewExtensionIndexers { public void Process() { IReadOnlyList<string> readOnlyList = ["val1", "val2", "val3"]; var elem = readOnlyList[..2]; } } public static class NewReadOnlyListExtensions { extension<T>(IReadOnlyList<T> list) { public IReadOnlyList<T> this[Range range] { get { var (offset, length) = range.GetOffsetAndLength(list.Count); return list.Skip(offset).Take(length).ToList(); } } } }
Стоит сразу учесть, что если у типа есть свой индексатор, то он будет приоритетнее, чем тот, который разработчик объявит в качестве расширения. Официальный порядок таков: explicit индексаторы типа, implicit индексаторы типа, explicit индексаторы расширений, implicit индексаторы расширений.
Аргументы выражения коллекции
Следующая фича позволит сократить количество строк кода и удобнее инициализировать коллекции, передавая напрямую в конструктор необходимые аргументы. Таким образом можно передать, например, capacity и comparer. Соответственно, в версии языка C# 14 и ранее нужно писать подобные конструкции:
public void ProcessList() { List<string> loginList = ["login1", "login2"]; List<string> newLoginList = new List<string>(loginList.Count); newLoginList.AddRange(loginList); } public void ProcessHashSet() { HashSet<string> hashSet = new HashSet<string> (StringComparer.OrdinalIgnoreCase); hashSet.Add("value 1"); hashSet.Add("value 2"); }
Инициализатор предусматривает что-то одно, так как тот же List содержит всего лишь 3 конструктора: пустой, с указанием capacity, либо просто с коллекцией. Предлагаю посмотреть новую возможную реализацию:
public void ProcessList() { List<string> loginList = ["login1", "login2"]; List<string> newLoginList = [with(loginList.Count), ..loginList]; // newLoginList.AddRange(loginList); } public void ProcessHashSet() { HashSet<string> hashSet = [with(StringComparer.OrdinalIgnoreCase), "value 1", "value 2"]; // hashSet.Add("value 1"); // hashSet.Add("value 2"); }
Выглядит чище, не правда ли? Вероятно, IDE даже сама предложит вам такой вариант написания и свернёт всё в одну строчку. Но обратите внимание, что with всегда должен идти первым элементом, иначе компилятор выдаст ошибку.
Безопасность памяти
Последнее изменение касается пересмотра того, что язык будет считать небезопасным. Разработчики языка поставили цель называть небезопасным только тот код, который действительно получает доступ к неуправляемой памяти. То есть теперь использование указателей не будет автоматически делать код небезопасным. Напомню, как сейчас работает обозначение небезопасного кода:
public unsafe class OldMemorySafety { public int ReadMemory(byte* ptr) { return *ptr; } public int Process() { byte b = 42; byte* newPtr = &b; return ReadMemory(newPtr); } }
Любое использование указателей влекло за собой необходимость ставить ключевое слово unsafe. Для более ясного представления изменения сразу посмотрим на то, как будет выглядеть этот же код в новой версии:
public class NewMemorySafety { public int ReadMemory(byte* ptr) { unsafe { return *ptr; } } public int Process() { byte b = 42; byte* newPtr = &b; return ReadMemory(newPtr); } }
Видно, что теперь класс не помечается как unsafe. Вместо этого ключевое слово ставится более локально — непосредственно в месте применения. В данном случае небезопасным является именно разыменование указателя, потому его необходимо обозначить небезопасным.
Кроме того, добавлено и ключевое слово safe, которое следует использовать в случаях, когда внутри содержатся небезопасные операции, но они безопасны для вызывающего контекста и не потребуют объявления unsafe-блока. Таким образом, safe нужно использовать, когда компилятор не может сам проверить безопасность. Например, для extern-методов либо в полях структур с явным размещением памяти ([StructLayout(LayoutKind.Explicit)]).
Отдельно можно сказать про stackalloc. Теперь его применение считается небезопасным, если выполняются все следующие условия одновременно: конвертация в Span<T> или ReadOnlySpan<T>, отсутствие инициализатора, применение там, где есть SkipLocalsInitAttribute.
Добавлю, что новые правила этого изменения включаются отдельно. Для заинтересовавшихся есть официальная инструкция по данной ссылке.
Заключение
В этом году новая версия языка принесла разработчикам много довольно важных возможностей. Например, были реализованы полезные фичи в виде union-типов и закрытых иерархий; ряд изменений упростил написание типичных конструкций, что в очередной раз сделает работу приятнее. Пусть некоторые, вероятно, будут не настолько популярны, но это не отменяет их полезности определённым группам разработчиков. А какое из этих нововведений показалось наиболее полезным для вас? Напишите в комментариях!
Можете ознакомиться с документацией по C# 15 самостоятельно по ссылке. Если хотите изучить наши разборы предыдущих версий, вот их список:
Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Dmitrii Kharitonov. What’s new in C# 15: Overview.
Комментарии (15)

impwx
21.10.2026 07:08Насчет аргументов для collection expression - в моем понимании наоброт, выглядит хуже. Сравним, по-честному:
HashSet<string> foo = [with(StringComparer.OrdinalIgnoreCase), "value 1", "value 2"]; var foo = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "value 1", "value 2" };Разве экономия в пару символов стоила того, чтобы вводить в язык новую концепцию и расширять его грамматику, и без того одну из самых объемных среди всех используемых языков?

domix32
21.10.2026 07:08Union-типы
А оптимизацию по выравниванию памяти для них тоже завезли или пока всё жирным куском лежит?

navferty
21.10.2026 07:08При чём тут выравнивание? Речь о том, что переменная может хранить значение одного из указанных типов:
public union SupportedOS(Windows, Linux, MacOS); SupportedOS os = new MacOS("Tahoe", 25); string GetDescription(SupportedOS os) => os switch { Windows windows => $"Windows {windows.Version}", Linux linux => $"{linux.Distro} {linux.Version}", MacOS macOS => $"MacOS {macOS.Name} ({macOS.Version})", }; // note: no discard _ requiredНа эту фичу, которая есть например в TypeScript, многие C#-разработчики облизывались уже годами.
Вот тут подробнее о том как они реализованы: https://andrewlock.net/exploring-the-dotnet-11-preview-2-dotnet-gets-union-types/#how-are-union-types-implemented

domix32
21.10.2026 07:08Я понимаю для чего используется tagged union. Проблема возникает когда варианты сохраняемых элементов разные по размеру.
public union Foo(bool, int, long);Накидайте их в линейный массив вперемешку и получите либо очень много потраченной памяти - на выравнивание по ширине максимального элемента, либо неконстанстный доступ к элементу - потому что память не выровнена. Rust и C++ их явным образом тратят память, в Zig добавили возможность иметь SoA-контейнеры и получать профиты по утилизации памяти. Собственно вопрос в этом и состоит - завезли ли подобную структуру данных в С# дабы оптимизировать дырки от выравнивания?

for7raid
21.10.2026 07:08Boxing и unboxing вам о чем-нибудь говорит? Вопрос был про то, как это устроено внутри, на сколько это будет экономно и быстро. Вот сможете ли вы ответить на вопрос, данные будут ли храниться только в стеке при использовании такого типа?

Lewigh
21.10.2026 07:08По умолчанию все бокситься но есть возможность вручную определить свой union и расположить все по памяти красиво. Например
[System.Runtime.CompilerServices.Union] public struct IntOrBool : System.Runtime.CompilerServices.IUnion { private readonly int _intValue; private readonly bool _boolValue; private readonly byte _tag; // 0 = none, 1 = int, 2 = bool public IntOrBool(int? value) { if (value.HasValue) { _intValue = value.Value; _tag = 1; } } public IntOrBool(bool? value) { if (value.HasValue) { _boolValue = value.Value; _tag = 2; } } public object? Value => _tag switch { 1 => _intValue, 2 => _boolValue, _ => null }; public bool HasValue => _tag != 0; public bool TryGetValue(out int value) { value = _intValue; return _tag == 1; } public bool TryGetValue(out bool value) { value = _boolValue; return _tag == 2; } }
for7raid
21.10.2026 07:08Ну кайф. Сарказм, если что. Вопрос, с которого начиналось собеседование на .net разработчика с самого момента его появления был помножен на ноль самой же платформой, которая била по рукам за такой инфантилизм использования памяти и процессорного времени. Вот поколение ЕГЭ и доросло до архитектура языка.

Meloman19
21.10.2026 07:08unionвыглядит интересно, но я так понял типы нужно всё равно заранее объявлять. Автовыводаunionтипа, как в TS, я так понимаю нет? Можно ли в функцию сunion TestUnion(int, Version, IEnumerable)передатьunion TestUnion2(int, Version)?Вот вроде
closedполезна, но чаще всего публичные либы разбивают на множество сборок типа Abstracts, Core и т.д. И как я понял аналога InternalVisibleTo для closed нет и не предвидится, иначе сломается switch.Остальное - просто норм. Логическое развитие уже ранее существующих фич. Хотя именованные break с continue могли бы и пораньше добавить, всё-таки goto всегда существовал.
Gromilo
Union-типы - я джва года ждал эту фичу
Закрытые иерархии - для самопальных юнион типов
Именованные break и continue - пару раз бы пригодилось, хорошо что будут
Аргументы выражения коллекции - штука полезная, но синтаксис несколько инопланетный
a-tk
И не к такому привыкали!