Вышел .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 без ветки _ получал предупреждение, даже если все варианты были разобраны, — компилятор не мог знать, что других наследников нет. Теперь «один из» можно выразить типом, и при добавлении нового варианта компилятор сам найдёт все места, которые нужно поправить.

Источники

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.