Технологии

.NET 10 и C# 14: что поменяется в вашем коде

11 ноября 2025 вышел .NET 10 - очередной LTS-релиз, который будет жить до ноября 2028 года (см. таблицу поддержки на сайте .NET 1). За это время многие проекты успеют мигрировать с .NET 6/8/9, а значит, нас ждут не только новые плюшки, но и немного боли от breaking changes. В этой статье постарался собрать всё самое важное чтобы за раз всё поднять: фичи C# 14, которые реально пригодятся в повседневном коде; полезные новшества в SDK/CLI; breaking changes, которые вы почти гарантированно поймаете при миграции с .NET 6/8/9. TL;DR Если совсем коротко: C# 14: extension-блоки, field -свойства, более дружелюбныйSpan , null-conditional assignment слева от= ,nameof(List<>) для открытых дженериков, модификаторы у параметров лямбд, partial-конструкторы/события, user-defined+= /++ .SDK: file-based apps с Native AOT по умолчанию, dotnet tool exec , платформенные tools сany RID,--cli-schema , pruning framework-package-референсов,dotnet new sln теперь делает.slnx .Breaking changes: другое поведение cookie-аутентификации для API, депрекация WithOpenApi и старых OpenAPI-analyzers,IPNetwork в ASP.NET Core помечен obsolete, поменялись правила overload resolution соSpan , плюс новые нюансы в NuGet/CLI и переход на.slnx . Зачем вообще смотреть на .NET 10 Немного сухих фактов: .NET 10 - LTS, поддержка до 14 ноября 2028 1. .NET 8 - тоже LTS, но до ноября 2026. .NET 9 - STS, до ноября 2026. .NET 6 уже вышел из поддержки в ноябре 2024 1. Если вы всё ещё на 6-ке, миграция на 10-ку - это уже не "хочу", а "надо". Особо если речь про прод, где безопасность и обновления важнее чем "мне лень трогать рабочий код". C# 14: фичи, которые меняют стиль кода 1. Extension members: когда ваши helpers становятся почти частью BCL Одна из главных фич C# 14 - extension-блоки, которые позволяют объявлять: extension-методы; extension-свойства (инстансные); статические extension-члены; даже user-defined оператор + как расширение типа. По сути, это способ аккуратно "допилить" существующие типы, а не городить очередной SomeTypeNewExtensions . using System; using System.Collections.Generic; using System.Linq; public static class EnumerableExtensions { // Инстансные extension-члены extension(IEnumerable source) { public bool IsEmpty => !source.Any(); public IEnumerable WhereNotNull() => source.Where(x => x is not null); } // Статические extension-члены extension(IEnumerable) { public static IEnumerable Empty => Enumerable.Empty(); public static IEnumerable operator +( IEnumerable left, IEnumerable right) => left.Concat(right); } } class Demo { static void Main() { var list = new[] { 1, 2, 3 }; // как будто это члены самого IEnumerable if (!list.IsEmpty) { var combined = IEnumerable.Empty + list; Console.WriteLine(string.Join(", ", combined)); } } } Ощущение довольно приятное: читаешь код и видишь "псевдо-члены" типа, а не утилитарный класс где-то сбоку. Подробности - в разделе про extension members в доке C# 14 2. 2. field-backed свойства: минус один приватный backing-field field - контекстное ключевое слово, которое позволяет не объявлять руками приватное поле в аксессоре свойства. До: private string _name = string.Empty; public string Name { get => _name; set => _name = value ?? throw new ArgumentNullException(nameof(value)); } Теперь: public string Name { get; set => field = value ?? throw new ArgumentNullException(nameof(value)); } Компилятор сам создаёт скрытое поле и подставляет вместо field . Самое приятное - не нужно каждый раз придумывать очередное _name . Нюанс: если у вас уже есть идентификатор field (например, поле с таким именем), придётся разруливать: public string field; // старое поле public string Name { get => field; set => this.field = value; // this.field = старое поле } Ну или просто переименовать старое поле - будущий читатель вам правда спасибо скажет. 3. Null-conditional assignment: ?. наконец можно ставить слева от = Теперь можно писать так: customer?.Order = GetCurrentOrder(); GetCurrentOrder() вызовется только если customer не null . Если customer == null , правая часть даже не будет вычислена. Работает и с compound-операторами: metrics?.RequestsPerMinute += 1; cart?.Items[index] ??= CreateDefaultItem(); То есть можно безопасно мутировать объект, если он есть, и вообще ничего не делать, если его нет. Логика ожидаемая, но раньше такой синтаксис был просто запрещён. Чего нельзя: customer?.Age++ и -- - инкремент/декремент с ?. по-прежнему не разрешён. 4. First-class Span: меньше .AsSpan() в generic-коде C# 14 подтягивает поддержку Span /ReadOnlySpan : появились дополнительные неявные конверсии и улучшения в generic-инференсе и overload resolution 2. В результате реже приходится явно писать .AsSpan() . static int IndexOfUpper(ReadOnlySpan span) { for (var i = 0; i < span.Length; i++) if (char.IsUpper(span[i])) return i; return -1; } void Demo() { string s = "helloWorld"; char[] array = "fooBar".ToCharArray(); Span buffer = stackalloc char[] { 'a', 'B', 'c' }; _ = IndexOfUpper(s); _ = IndexOfUpper(array); _ = IndexOfUpper(buffer); } Главный практический эффект: перегрузки со Span выбираются предсказуемее (и это фигурирует как отдельный breaking change в .NET 10). Если вы активно завозили span-перегрузки, при обновлении компилятора возможны "немые" изменения поведения - тесты здесь реально решают. 5. Модификаторы у параметров лямбд без явного типа Теперь можно добавлять модификаторы (ref , in , out , scoped и т.п.) к параметрам простых лямбд, не выписывая типы вручную. delegate bool TryParse(string text, out T value); TryParse parse = (text, out result) => int.TryParse(text, out result); Раньше нужно было писать: TryParse parse = (string text, out int result) => int.TryParse(text, out result); Мелочь, но все эти (string text, out int value) в TryParse-паттернах начинают исчезать из кода, и читается он чуть легче. 6. Partial-конструкторы и partial-события Теперь можно делить конструкторы и события между partial-частями типа 2. Это, по сути, прямой подарок для source generators. // Модель, сгенерированная source-generator’ом public sealed partial class User { public string Name { get; } public int Age { get; } // Определение конструктора public partial User(string name, int age); } // Вручную написанный кусок public sealed partial class User { // Реализация конструктора public partial User(string name, int age) { Name = name ?? throw new ArgumentNullException(nameof(name)); Age = age; } } Раньше приходилось либо генерировать фабрики, либо лезть в уже сгенерированный код. 7. nameof и открытые дженерики Теперь nameof умеет работать с unbound generic types: var typeName = nameof(List<>); // "List" Без необходимости указывать конкретный тип (List и т.п.). Вероятно будет полезно для логирования, генерации кода и диагностики. 8. User-defined compound assignment и ++/-- C# 14 позволяет перегружать compound-операторы (+= , -= , *= , …) и инкремент/декремент через новые синтаксические формы 6. Главное - такие операторы могут обновлять состояние объекта in-place, без лишних аллокаций. public struct Counter { public int Value { get; private set; } // Инстанс-оператор compound assignment public void operator +=(int delta) { Value += delta; } // Инстанс-оператор инкремента public void operator ++() { Value++; } public override string ToString() => Value.ToString(); } class Demo { static void Main() { var c = new Counter(); c += 10; // вызывает operator += ++c; // вызывает operator ++ Console.WriteLine(c); // 11 } } Для тяжёлых структур и high-perf типов это прям очень приятно: можно избавиться от лишних копий/аллокаций, при этом сохранив привычный синтаксис c += 10; и ++c . SDK и CLI: изменения в рабочем процессе File-based apps: "скрипты на C#", но с AOT и publish File-based apps в .NET 10 стали заметно взрослее 3: dotnet publish app.cs - публикует одиночный.cs как нативный exe (Native AOT по умолчанию для file-based apps).Поддерживаются директивы #:project ,#:property , shebang; путь к файлу и директории доступен черезAppContext . Пример "однофайлового" утилитарного скрипта: #!/usr/bin/env dotnet #:property PublishAot=true #:project ../Tools.Common/Tools.Common.csproj using Tools.Common; Console.WriteLine("Hello from file-based app!"); Console.WriteLine($"Args: {string.Join(", ", args)}"); var configPath = AppContext.GetData("appContext:appPath"); Console.WriteLine($"App located at: {configPath}"); Типовые сценарии: внутренние CLI-утилиты внутри репозитория; миграционные скрипты; "быстрый прототип" без полноценного .csproj . То, что раньше часто делали на bash + dotnet run , теперь можно сделать одним C#-файлом. .NET tools: dotnet tool exec, dnx и any RID Вокруг tools появились несколько приятных штук 3: dotnet tool exec - запускает инструмент без предварительной установки:dotnet tool exec --source ./artifacts/package/ dotnetsay "Hello" Удобно в CI и для внутренних тулов: не нужно засорять глобальные установки. Платформенные tools + any RID - можно паковать разные бинарники для разных платформ в один пакет и добавитьany : linux-x64; linux-arm64; win-x64; win-arm64; any any даёт fallback на обычный framework-dependent DLL, который запустится на любой поддерживаемой платформе с .NET 10.dnx - маленький скрипт-обёртка:dnx dotnetsay "Hello" просто прокидывает всё вdotnet . Сначала кажется игрушкой, но в повседневных командах экономит немного клавиатуры. --cli-schema: introspection CLI-дерева Любой dotnet -командой можно получить JSON-описание её схемы 3: dotnet clean --cli-schema На выходе - дерево аргументов/опций, которое удобно: использовать для генерации shell-completion; писать свои фронтенды над CLI; кормить тулзам, которые хотят понимать, какие флаги вообще бывают. Если вы пишете обёртки над dotnet (например, в CI), вещь может пригодиться. Pruning framework-package references и NuGet-audit В .NET 10 включили фичу, которая обрезает неиспользуемые package-референсы, уже поставляемые вместе с фреймворком 3: меньше мусора в deps.json ;меньше ложных срабатываний в NuGet Audit; возможны предупреждения вида NU1510 , если пакет был "обрезан" как лишний 4. Отключается это так: false Если у вас вся инфраструктура построена вокруг сканирования deps.json и точного списка пакетов - это то самое место, где нужно быть внимательным при миграции. Breaking changes при миграции с .NET 6/8/9 Теперь к неприятному, но нужному. Даже если вы перескакиваете прямо с 6/8 сразу на 10, вы упрётесь в breaking changes из 10-ки (и по ASP.NET, и по core-библиотекам, и по SDK). ASP.NET Core 1. Cookie-аутентификация и API: больше никаких редиректов на /Account/Login Для известных API-эндпоинтов (контроллеры с [ApiController] , минимальные API с JSON-телом, SignalR и т.п.) теперь по умолчанию 5: было: при неавторизованном запросе cookie-хэндлер делал 302 Redirect на login / access-denied (кроме XHR); стало: для API - честные 401/403. Если у вас SPA, которая почему-то рассчитывает именно на редирект на страницу логина, - поведение поменяется. В Postman / фронтовых клиентах это, наоборот, выглядит логичнее: никаких HTML-редиректов там и не ждёшь. Вернуть старый стиль можно так: builder.Services.AddAuthentication() .AddCookie(options => { options.Events.OnRedirectToLogin = context => { context.Response.Redirect(context.RedirectUri); return Task.CompletedTask; }; options.Events.OnRedirectToAccessDenied = context => { context.Response.Redirect(context.RedirectUri); return Task.CompletedTask; }; }); 2. IPNetwork и KnownNetworks в forwarded headers - obsolete Microsoft.AspNetCore.HttpOverrides.IPNetwork и ForwardedHeadersOptions.KnownNetworks помечены устаревшими. Вместо них - System.Net.IPNetwork и KnownIPNetworks 7. До: app.UseForwardedHeaders(new ForwardedHeadersOptions { KnownNetworks = { new IPNetwork(IPAddress.Loopback, 8) } }); Теперь: using System.Net; app.UseForwardedHeaders(new ForwardedHeadersOptions { KnownIPNetworks = { new IPNetwork(IPAddress.Loopback, 8) } }); Если у вас кастомная конфигурация reverse proxy (особенно в Kubernetes / behind nginx), миграция без этого изменения не соберётся без предупреждений. 3. OpenAPI: WithOpenApi, analyzers и компания В .NET 10 помечены deprecated 3: WithOpenApi для минимальных API;IncludeOpenAPIAnalyzers ;пакет Microsoft.Extensions.ApiDescription.Client и т.п. Смысл в том, что экосистема уезжает в сторону новых OpenAPI-пакетов и генераторов. При миграции есть смысл: поискать по коду WithOpenApi() и посмотреть, чем вы сейчас пользуетесь;проверить, не завязан ли билд на старые analyzers. C# / Core-библиотеки 1. Overload resolution со Span В статье по breaking changes для .NET 10 отдельно выделен пункт C# 14 overload resolution with span parameters 4. Суть: если у вас есть перегрузки T[] vsSpan /ReadOnlySpan ;и вы вызываете их из generic-кода или с "интересными" аргументами - компилятор может выбрать другую перегрузку, чем раньше. Компилироваться всё будет, но поведение способно тихо поменяться. Рецепт: прогоняем тесты и внимательно смотрим на hot-path-методы, где вы явно добавляли span-перегрузки "для производительности". 2. AsyncEnumerable в core-библиотеках System.Linq.AsyncEnumerable перекочевал в стандартные библиотеки .NET 10 4. Это может конфликтовать с вашими собственными типами/extension-методами с тем же именем (если вы их когда-то заводили). Сценарий редкий, но если "прилетело" неожиданное конфликтующее имя - искать стоит именно тут. SDK и CLI 1. dotnet new sln → .slnx по умолчанию Теперь dotnet new sln создаёт SLNX-формат решения, а не классический .sln 8: новый формат проще читать и диффить; поддерживается VS, Rider и остальными основными IDE 8. Если нужно старое поведение: dotnet new sln --format sln 2. NuGet/CLI: более строгие ошибки и аудиты Из полезного (и иногда неприятного) 4: dotnet package list теперь делаетrestore и может падать на проблемных фидах;HTTP-предупреждения чаще превращаются в ошибки; dotnet restore запускает аудит транзитивных пакетов;project.json окончательно выкинули;local-tools ( dotnet tool install --local ) по умолчанию создают manifest. Если у вас вокруг этих команд накручены скрипты - просто прогоните их на новом SDK и посмотрите, не посыпалось ли что-нибудь неожиданное. Как начать использовать всё это в живом проекте Для нового проекта минимально достаточно: net10.0 latest Для существующего проекта обычно делаю так: Обновляю SDK до .NET 10. Меняю TargetFramework (net6.0 /net8.0 /net9.0 →net10.0 ).Гоняю тесты и по чек-листу прохожусь по основным breaking changes, описанным выше (и смотрю полный список в документации 4). Если коротко: .NET 10 и C# 14 - это не революция, но довольно заметный эволюционный шаг. Некоторые вещи действительно упрощают ежедневный код, а миграционные подводные камни лучше поймать в тестах, чем в проде.

Фильтры и сортировка