Вышел .NET 11: разбираем новинки C# 15
10 ноября 2026 года вышел .NET 11. Это релиз со стандартным сроком поддержки (STS): Microsoft обещает обновления до 14 ноября 2028 года, то есть ровно до того же дня, что и у LTS-версии .NET 10. Вместе с платформой вышел C# 15 — для проектов с net11.0 он включён по умолчанию, ничего в .csproj дописывать не нужно.
В статье разобраны все новинки языка: union-типы, закрытые иерархии классов, аргументы в выражениях коллекций, индексаторы-расширения, break и continue с меткой, статические члены интерфейсов на старых средах выполнения и предварительная версия новых правил безопасности памяти. Все примеры собраны и запущены на SDK 11.0.100, вывод — настоящий, с русской локалью (поэтому дробная часть отделяется запятой). Ошибки компилятора приведены с кодами, как их выдаёт dotnet build.
Подготовка
Ставим SDK с dotnet.microsoft.com и проверяем:
dotnet --list-sdks
10.0.403 [C:\Program Files\dotnet\sdk]
11.0.100 [C:\Program Files\dotnet\sdk]
Создаём консольный проект, в котором будем пробовать примеры:
dotnet new console -n Demo -f net11.0
cd Demo
Весь код ниже кладём в Program.cs (операторы верхнего уровня сверху, объявления типов — после них) и запускаем dotnet run.
Union-типы
Главная новинка C# 15. Union-тип — это значение, которое в каждый момент хранит ровно один из заранее перечисленных типов. В F#, Rust, TypeScript и Swift такое есть давно, в C# до сих пор приходилось строить иерархию классов, заводить object или писать свой OneOf<T1, T2>.
Объявление
Объявляется ключевым словом union, в скобках — список типов-вариантов (case types):
public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
public union Pet(Cat, Dog, Bird);
Варианты — обычные типы, объявленные отдельно. Они не обязаны быть record-ами и не обязаны быть связаны наследованием: в union можно сложить и int со string:
public union IntOrString(int, string);
Из каждого варианта в union есть неявное преобразование, поэтому присваивание выглядит естественно:
Pet pet = new Dog("Rex");
IntOrString a = 42;
IntOrString b = "сорок два";
Сопоставление с образцом
Разбирают union через switch. Шаблон типа (Dog d) проверяет не сам union, а то, что в нём лежит:
static string Describe(Pet? pet) => pet switch
{
Dog d => $"собака {d.Name}",
Cat c => $"кошка {c.Name}",
Bird b => $"птица {b.Name}",
null => "питомца нет",
};
Console.WriteLine(Describe(pet));
Console.WriteLine(Describe(null));
Console.WriteLine(Describe(default(Pet)));
собака Rex
питомца нет
питомца нет
Заметьте: ветки _ нет, и компилятор не жалуется. Он знает полный список вариантов и считает switch исчерпывающим, если обработан каждый. Уберём ветку Bird:
warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bird' is not covered.
Это главное, ради чего union-типы стоит использовать: добавили в union новый вариант — компилятор покажет каждый switch, который его не обрабатывает. С веткой _ эта проверка пропадает, поэтому её лучше не писать.
Ветка null нужна, потому что union — структура, и её значение по умолчанию (default(Pet)) ничего не содержит. Шаблон null ловит и null у Pet?, и пустой union. Без этой ветки switch по пустому union бросит SwitchExpressionException.
Шаблон типа, который не может совпасть ни с одним вариантом, — ошибка:
static bool WrongType(Pet pet) => pet is string;
error CS8121: An expression of type 'Pet' cannot be handled by a pattern of type 'string'.
Работают и шаблоны свойств — если в шаблоне указан тип, он тоже применяется к содержимому:
Console.WriteLine(pet is Dog { Name: "Rex" });
True
Как union устроен внутри
Объявление union компилятор превращает в обычную структуру с атрибутом [Union], реализующую интерфейс IUnion из System.Runtime.CompilerServices:
public interface IUnion
{
object? Value { get; }
}
На каждый вариант — свой открытый конструктор, а содержимое хранится в одном поле типа object?. Отсюда два следствия.
Первое: варианты-структуры упаковываются (boxing). Проверим:
IntOrString v = 5;
Console.WriteLine(typeof(IntOrString).IsValueType);
Console.WriteLine(v.Value?.GetType());
True
System.Int32
Сам union — значимый тип, но int внутри лежит в куче. Для горячего кода с примитивами это стоит учитывать; ниже будет показано, как написать свой union без упаковки.
Второе: union — не record. Он не получает ни сравнения по значению, ни with, ни деконструкции.
И одна ловушка. Раз шаблоны типов проверяют содержимое, проверка на сам интерфейс IUnion тоже уходит внутрь:
Console.WriteLine(new Pet(new Cat("Tom")) is IUnion u ? u.Value : "no");
no
Cat не реализует IUnion, поэтому ответ — no, хотя Pet его реализует.
Члены и обобщённые union-ы
В теле union можно объявлять методы и свойства. Внутри члена this — сам union, и по нему так же работает switch:
public record class Meters(double Value);
public record class Feet(double Value);
public union Length(Meters, Feet)
{
public double TotalMeters => this switch
{
Meters m => m.Value,
Feet f => f.Value * 0.3048,
_ => throw new InvalidOperationException("The Length has no value."),
};
public Length Add(Length other) => new Meters(TotalMeters + other.TotalMeters);
}
Length len = new Meters(1);
len = len.Add(new Feet(10));
Console.WriteLine(len.TotalMeters);
Console.WriteLine(len.Value);
4,048
Meters { Value = 4,048 }
Полей экземпляра и автосвойств в union объявлять нельзя: всё состояние — это Value.
Union может быть обобщённым. Классический пример — результат операции, который содержит либо значение, либо ошибку:
public record class ParseError(string Message);
public union Result<T>(T, ParseError);
static Result<int> Parse(string s) =>
int.TryParse(s, out var n) ? n : new ParseError($"'{s}' не число");
Тернарный оператор здесь работает благодаря неявным преобразованиям: и int, и ParseError приводятся к Result<int>.
Console.WriteLine(Parse("12") switch
{
int n when n > 10 => "больше десяти",
int n => $"число {n}",
ParseError e => $"ошибка: {e.Message}",
null => "пусто",
});
Console.WriteLine(Parse("abc").Value);
больше десяти
ParseError { Message = 'abc' не число }
Ветку null здесь требует сам компилятор: T может оказаться ссылочным типом, допускающим null, а значит, Value «может быть null». Без неё:
warning CS8655: The switch expression does not handle some null inputs (it is not exhaustive). For example, the pattern 'null' is not covered.
Свой union-тип
Ключевое слово union — короткая запись. Компилятор считает union-ом любой тип с атрибутом [Union], у которого есть открытые конструкторы с одним параметром (каждый задаёт вариант) и свойство Value типа object?:
using System.Runtime.CompilerServices;
[Union]
public struct Shape : IUnion
{
private readonly object? _value;
public Shape(Circle value) { _value = value; }
public Shape(Rectangle value) { _value = value; }
public object? Value => _value;
}
public record class Circle(double Radius);
public record class Rectangle(double Width, double Height);
Shape shape = new Shape(new Circle(5.0));
Console.WriteLine(shape switch
{
Circle c => Math.Round(Math.PI * c.Radius * c.Radius, 2),
Rectangle r => r.Width * r.Height,
});
78,54
Проверка полноты и шаблоны работают так же, как у объявленного через union. Свой тип нужен, когда хочется хранить содержимое без упаковки: для этого добавляют методы HasValue и TryGetValue(out T) для каждого варианта, и компилятор при сопоставлении будет звать их вместо Value. Можно сделать union и классом, а не структурой.
Union-ы и JSON
В .NET 11 System.Text.Json умеет сериализовать union-ы: в JSON попадает содержимое, без обёртки. У питомцев здесь появляются разные свойства, поэтому пример лучше собирать в отдельном проекте.
using System.Text.Json;
public record Cat(string Name, int Lives);
public record Dog(string Name, string Breed);
public union Pet(Cat, Dog);
public union IntOrString(int, string);
Console.WriteLine(JsonSerializer.Serialize<Pet>(new Dog("Rex", "beagle")));
Console.WriteLine(JsonSerializer.Deserialize<IntOrString>("5").Value);
Console.WriteLine(JsonSerializer.Deserialize<IntOrString>("\"five\"").Value);
{"Name":"Rex","Breed":"beagle"}
5
five
У IntOrString варианты различаются видом JSON-значения (число и строка), и десериализатор выбирает вариант сам. А Cat и Dog оба — объекты, и десериализация Pet падает:
JSON value type 'Object' is ambiguous for union type 'Pet' because multiple case types can use this value type. Specify a custom type classifier to support deserialization.
Выбор варианта по набору свойств включается атрибутом:
using System.Text.Json.Serialization;
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union Pet(Cat, Dog);
Console.WriteLine(JsonSerializer.Deserialize<Pet>("""{"Name":"Tom","Lives":9}""").Value);
Console.WriteLine(JsonSerializer.Deserialize<Pet>("""{"Name":"Rex","Breed":"beagle"}""").Value);
Cat { Name = Tom, Lives = 9 }
Dog { Name = Rex, Breed = beagle }
Классификатор проверяет варианты заранее. Если бы у Dog были все свойства Cat и ещё какие-то (скажем, Cat(string Name) и Dog(string Name, int Age)), он отказался бы работать уже при первом обращении: Cat никогда нельзя было бы выбрать однозначно. Тот же механизм в ASP.NET Core 11 позволяет принимать union в теле запроса Minimal API и MVC, а в OpenAPI такой параметр описывается через anyOf.
Закрытые иерархии: модификатор closed
Union подходит, когда варианты — разные, никак не связанные типы. Если же варианты — наследники общего базового класса, в C# 15 есть другой инструмент: модификатор closed.
public closed record class GateState;
public record class Closed : GateState;
public record class Open(float Percent) : GateState;
Закрытый класс неявно абстрактный, а наследовать от него напрямую можно только в той же сборке. Значит, компилятор знает полный список прямых наследников и проверяет полноту switch:
static string DescribeGate(GateState state) => state switch
{
Closed => "закрыты",
Open(var percent) => $"открыты на {percent}%",
};
Console.WriteLine(DescribeGate(new Open(40)));
Console.WriteLine(DescribeGate(new Closed()));
открыты на 40%
закрыты
Ни _, ни throw в конце не нужно. Забытый вариант — то же предупреждение, что у union:
warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Open' is not covered.
abstract писать нельзя (он и так подразумевается), а sealed и static противоречат смыслу:
error CS9384: 'Bad2': a closed type cannot be marked abstract because it is always implicitly abstract.
error CS9381: 'Bad': a closed type cannot be sealed or static
Через границу сборки
Проверим ограничение на наследование. Создаём библиотеку:
dotnet new classlib -n Lib -f net11.0
dotnet new console -n Ext -f net11.0
dotnet add Ext reference Lib
В Lib — иерархия с закрытым промежуточным уровнем:
namespace Lib;
public closed record class Shape;
public record class Circle(double R) : Shape;
public closed record class Polygon : Shape;
public record class Square(double A) : Polygon;
В Ext пытаемся добавить своих наследников:
using Lib;
record Triangle(double A) : Shape;
record Pentagon : Polygon;
error CS9382: 'Triangle': cannot use a closed type 'Shape' from another assembly as a base type.
error CS9382: 'Pentagon': cannot use a closed type 'Polygon' from another assembly as a base type.
Закрытость не наследуется: она касается только прямых наследников. Не будь Polygon закрытым, от него можно было бы наследовать в любой сборке, а switch по Shape с ветками Circle и Polygon всё равно оставался бы полным. Но switch с веткой Square вместо Polygon тогда получил бы CS8509: компилятор не знает всех наследников Polygon. Здесь Polygon закрыт, поэтому полным считается и такой switch, где он разобран до Square:
static double Area(Shape s) => s switch
{
Circle c => Math.PI * c.R * c.R,
Square q => q.A * q.A,
};
Сборка проходит без предупреждений. Чем closed отличается от union: варианты обязаны быть наследниками одного класса и лежать в одной сборке, зато нет упаковки, обёртки и Value, а общие члены можно вынести в базовый класс.
Аргументы в выражениях коллекций: with(...)
Выражения коллекций ([1, 2, 3]) появились в C# 12, но передать конструктору коллекции ёмкость или компаратор было нельзя — приходилось возвращаться к new. В C# 15 первым элементом можно написать with(...), и его аргументы уйдут в конструктор:
string[] values = ["one", "two", "three"];
List<string> names = [with(capacity: values.Length * 2), .. values];
Console.WriteLine($"{names.Count} / {names.Capacity}");
HashSet<string> set = [with(StringComparer.OrdinalIgnoreCase), "Hello", "HELLO", "hello"];
Console.WriteLine(set.Count);
SortedSet<int> sorted = [with(Comparer<int>.Create((x, y) => y.CompareTo(x))), 3, 1, 2];
Console.WriteLine(string.Join(",", sorted));
3 / 6
1
3,2,1
Конструктор выбирается обычным разрешением перегрузок, работают именованные аргументы. Для типов с [CollectionBuilder] аргументы передаются в метод-фабрику перед ReadOnlySpan<T>. Если целевой тип — интерфейс, компилятор выбирает реализацию сам; для IList<T> и ICollection<T> это List<T>, и ёмкость доходит до него:
IList<int> list = [with(capacity: 100), 1, 2];
Console.WriteLine($"{list.GetType().Name} {((List<int>)list).Capacity}");
List`1 100
Ограничения. with — только первым элементом:
List<int> l = [1, with(capacity: 10)];
error CS9354: 'with(...)' element must be the first element
У массивов и спанов конструктора нет, значит, и аргументов тоже:
int[] arr = [with(10), 1];
error CS9355: 'with(...)' elements are not supported for type 'int[]'
А словари выражениями коллекций по-прежнему не создаются вовсе, with тут не помогает:
IDictionary<string, int> dict = [with(StringComparer.OrdinalIgnoreCase)];
error CS9174: Cannot initialize type 'IDictionary<string, int>' with a collection expression because the type is not constructible.
Синтаксис ["key": value] для словарей — отдельное предложение, в C# 15 он не вошёл.
Индексаторы-расширения
В C# 14 появились блоки extension, где можно объявлять свойства-расширения и статические члены-расширения. В C# 15 к ним добавились индексаторы:
public static class SequenceIndexer
{
extension(IEnumerable<int> sequence)
{
public int this[int index] => sequence.ElementAt(index);
}
}
IEnumerable<int> numbers = Enumerable.Range(1, 10);
Console.WriteLine(numbers[2]);
3
Интереснее, что индексатор-расширение участвует в индексах с конца (^1), диапазонах (1..3) и шаблонах списков. Для этого типу нужны Count (или Length), индексатор по int и Slice — и все три могут быть расширениями:
public class Readings(int[] values)
{
public int[] Values { get; } = values;
}
public static class ReadingsExtensions
{
extension(Readings r)
{
public int Count => r.Values.Length;
public int this[int index] => r.Values[index];
public Readings Slice(int start, int length) => new(r.Values.AsSpan(start, length).ToArray());
}
}
Readings readings = new([10, 20, 30, 40]);
Console.WriteLine(readings[^1]);
Console.WriteLine(readings[1..3][0]);
Console.WriteLine(readings is [10, .., 40]);
40
20
True
У самого класса Readings нет ни индексатора, ни Count, ни Slice — всё пришло из расширений. Собственный индексатор типа всегда главнее расширения, а для массивов и string индексаторы-расширения не применяются вовсе.
break и continue с меткой
Выйти из вложенного цикла раньше можно было тремя способами, и все плохие: флаг, goto или вынос циклов в отдельный метод ради return. В C# 15 у break и continue появилась метка — имя цикла, к которому они относятся:
int[][] grid = [[1, 2, -1, 4], [5, 6, 7, 8], [9, 0, 3, 3]];
outer: for (int row = 0; row < grid.Length; row++)
{
for (int col = 0; col < grid[row].Length; col++)
{
if (grid[row][col] < 0)
{
Console.WriteLine($"строка {row}: пропуск");
continue outer;
}
if (grid[row][col] == 0)
{
Console.WriteLine($"ноль в [{row},{col}]");
break outer;
}
}
Console.WriteLine($"строка {row}: проверена");
}
строка 0: пропуск
строка 1: проверена
ноль в [2,1]
continue outer переходит к следующей итерации внешнего цикла, минуя строку «проверена», а break outer завершает оба цикла сразу.
Особенно полезно внутри switch, где обычный break закрывает только case:
lines: foreach (var line in new[] { "a", "stop", "b" })
{
switch (line)
{
case "stop":
break lines;
default:
Console.WriteLine(line);
break;
}
}
a
Метка — тот же синтаксис, что для goto, но break и continue принимают только метку цикла или switch, внутри которого стоят:
x: Console.WriteLine();
for (;;) { break x; }
error CS9393: No enclosing loop or switch statement with the label 'x' out of which to break
В Visual Studio правило стиля IDE0410 находит старые конструкции с флагами и goto и предлагает заменить их меткой.
Статические члены интерфейсов на старых средах выполнения
Небольшое, но полезное для авторов библиотек изменение. Невиртуальные статические члены интерфейса с телом:
Console.WriteLine(IMeasurement.ToMeters(250));
public interface IMeasurement
{
public static double ToMeters(double centimeters) => centimeters / 100;
}
2,5
раньше требовали среды выполнения с поддержкой реализаций по умолчанию в интерфейсах — то есть не собирались под .NET Framework и netstandard2.0. Для такого члена среда выполнения ничего особенного не делает, это обычный статический метод, и C# 15 снял ограничение. Проверим на библиотеке под netstandard2.0, где по умолчанию стоит C# 7.3, поэтому версию языка задаём явно:
dotnet new classlib -n Old -f netstandard2.0
public interface IMeasurement
{
public static double ToMeters(double centimeters) => centimeters / 100;
}
public interface IDefault
{
void M() { }
}
dotnet build Old -p:LangVersion=15
error CS8701: Target runtime doesn't support default interface implementation.
Ошибка одна — на IDefault.M: реализация по умолчанию у члена экземпляра, как и static abstract и static virtual, по-прежнему требует современной среды выполнения. Статический ToMeters собирается.
Безопасность памяти (preview)
Последняя новинка — предварительная. Это первый шаг большой переделки unsafe, рассчитанной на несколько релизов; Microsoft предупреждает, что синтаксис и правила ещё могут измениться к C# 16. Включается в .csproj тремя свойствами:
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<LangVersion>preview</LangVersion>
<Features>$(Features);updated-memory-safety-rules</Features>
</PropertyGroup>
Идея: опасна не сама работа с указателем, а чтение и запись памяти по нему. Поэтому объявить указатель, взять адрес &, написать fixed или sizeof теперь можно без unsafe, а разыменование *p, p->x и p[i] — по-прежнему только в unsafe-блоке. Кроме того, unsafe на методе меняет смысл: раньше он разрешал небезопасный код внутри, теперь же говорит, что вызывать метод небезопасно. Вызов разрешён только из unsafe-контекста, а внутри метода нужен свой unsafe-блок. Для мест, где блок не поставить (инициализаторы полей), есть unsafe-выражение unsafe(...).
int number = 42;
int* pointer = &number; // без unsafe: объявить указатель и взять адрес
Console.WriteLine(sizeof(Guid)); // без unsafe: sizeof неуправляемого типа
unsafe { Console.WriteLine(*pointer); } // разыменование — только в unsafe-блоке
Console.WriteLine(unsafe(Read(pointer))); // вызов unsafe-метода — unsafe-выражение
Console.WriteLine(Header.Signature);
static unsafe int Read(int* p)
{
unsafe { return *p; } // тело unsafe-метода больше не unsafe-контекст
}
class Header
{
public static readonly int Signature = unsafe(ReadSignature());
static unsafe int ReadSignature()
{
unsafe
{
int raw = 0x1234;
int* p = &raw;
return *p;
}
}
}
16
42
42
4660
Что говорит компилятор, если нарушить правила. Разыменование вне блока, в том числе в теле unsafe-метода:
error CS9360: This operation may only be used in an unsafe context
Вызов unsafe-метода из обычного кода:
error CS9362: 'Read(int*)' must be used in an unsafe context because it is marked as 'unsafe'
Если включить только LangVersion=preview без флага updated-memory-safety-rules, работают послабления для указателей, а unsafe на методе сохраняет старый смысл. Но метод с указателем в сигнатуре всё равно считается небезопасным для вызова:
error CS9363: 'Read(int*)' must be used in an unsafe context because it has pointers in its signature
А без preview тот же код упирается в привычные CS0214 («Pointers … may only be used in an unsafe context») и CS8652 на unsafe(...): эта функция пока доступна только в preview.
Смысл переделки — чтобы небезопасность была видна по цепочке вызовов: кто вызывает unsafe-метод, тот сам явно пишет unsafe, и по коду можно найти все места, отвечающие за безопасность памяти.
Итоги
| Новинка | Что даёт | Статус |
|---|---|---|
union |
значение одного из нескольких типов, полная проверка switch |
готово |
closed |
закрытая иерархия классов, полная проверка switch |
готово |
with(...) в [...] |
ёмкость, компаратор и другие аргументы конструктора коллекции | готово |
индексаторы в extension |
x[i], x[^1], x[a..b], шаблоны списков через расширения |
готово |
break label;, continue label; |
выход из вложенных циклов без флагов и goto |
готово |
| статические члены интерфейсов | работают под .NET Framework и netstandard2.0 |
готово |
новые правила unsafe |
указатели без unsafe, unsafe на методе — для вызывающего |
preview |
Union-типы и closed закрывают давнюю дыру языка: до сих пор switch по иерархии классов или по object без ветки _ получал предупреждение, даже если все варианты были разобраны, — компилятор не мог знать, что других наследников нет. Теперь «один из» можно выразить типом, и при добавлении нового варианта компилятор сам найдёт все места, которые нужно поправить.
Источники
- What's new in C# 15 — Microsoft Learn
- Union types — справочник языка C#
- closed — справочник языка C#
- Extension members — справочник языка C#
- Collection expression arguments — спецификация
- Labeled break and continue — спецификация
- Unsafe evolution — спецификация новых правил безопасности памяти
- Explore new features available in C# 15 preview — блог .NET
- Announcing .NET 11 — блог .NET
- .NET and .NET Core Support Policy — сроки поддержки
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.