- PVSM.RU - https://www.pvsm.ru -
11 ноября 2025 вышел .NET 10 - очередной LTS-релиз, который будет жить до ноября 2028 года (см. таблицу поддержки на сайте .NET 1 [1]).
За это время многие проекты успеют мигрировать с .NET 6/8/9, а значит, нас ждут не только новые плюшки, но и немного боли от breaking changes.
В этой статье постарался собрать всё самое важное чтобы за раз всё поднять:
фичи C# 14, которые реально пригодятся в повседневном коде;
полезные новшества в SDK/CLI;
breaking changes, которые вы почти гарантированно поймаете при миграции с .NET 6/8/9.
Если совсем коротко:
C# 14: extension-блоки, field-свойства, более дружелюбный Span<T>, 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<T>, плюс новые нюансы в NuGet/CLI и переход на .slnx.
Немного сухих фактов:
.NET 10 - LTS, поддержка до 14 ноября 2028 1 [1].
.NET 8 - тоже LTS, но до ноября 2026.
.NET 9 - STS, до ноября 2026.
.NET 6 уже вышел из поддержки в ноябре 2024 1 [1].
Если вы всё ещё на 6-ке, миграция на 10-ку - это уже не "хочу", а "надо". Особо если речь про прод, где безопасность и обновления важнее чем "мне лень трогать рабочий код".
Одна из главных фич C# 14 - extension-блоки, которые позволяют объявлять:
extension-методы;
extension-свойства (инстансные);
статические extension-члены;
даже user-defined оператор + как расширение типа.
По сути, это способ аккуратно "допилить" существующие типы, а не городить очередной SomeTypeNewExtensions.
using System;
using System.Collections.Generic;
using System.Linq;
public static class EnumerableExtensions
{
// Инстансные extension-члены
extension<T>(IEnumerable<T> source)
{
public bool IsEmpty => !source.Any();
public IEnumerable<T> WhereNotNull() =>
source.Where(x => x is not null);
}
// Статические extension-члены
extension<T>(IEnumerable<T>)
{
public static IEnumerable<T> Empty => Enumerable.Empty<T>();
public static IEnumerable<T> operator +(
IEnumerable<T> left,
IEnumerable<T> right) =>
left.Concat(right);
}
}
class Demo
{
static void Main()
{
var list = new[] { 1, 2, 3 };
// как будто это члены самого IEnumerable<T>
if (!list.IsEmpty)
{
var combined = IEnumerable<int>.Empty + list;
Console.WriteLine(string.Join(", ", combined));
}
}
}
Ощущение довольно приятное: читаешь код и видишь "псевдо-члены" типа, а не утилитарный класс где-то сбоку. Подробности - в разделе про extension members в доке C# 14 2 [2].
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 = старое поле
}
Ну или просто переименовать старое поле - будущий читатель вам правда спасибо скажет.
Теперь можно писать так:
customer?.Order = GetCurrentOrder();
GetCurrentOrder() вызовется только если customer не null.
Если customer == null, правая часть даже не будет вычислена.
Работает и с compound-операторами:
metrics?.RequestsPerMinute += 1;
cart?.Items[index] ??= CreateDefaultItem();
То есть можно безопасно мутировать объект, если он есть, и вообще ничего не делать, если его нет.
Логика ожидаемая, но раньше такой синтаксис был просто запрещён.
Чего нельзя: customer?.Age++ и -- - инкремент/декремент с ?. по-прежнему не разрешён.
C# 14 подтягивает поддержку Span<T>/ReadOnlySpan<T>: появились дополнительные неявные конверсии и улучшения в generic-инференсе и overload resolution 2 [2]. В результате реже приходится явно писать .AsSpan().
static int IndexOfUpper(ReadOnlySpan<char> 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<char> buffer = stackalloc char[] { 'a', 'B', 'c' };
_ = IndexOfUpper(s);
_ = IndexOfUpper(array);
_ = IndexOfUpper(buffer);
}
Главный практический эффект: перегрузки со Span<T> выбираются предсказуемее (и это фигурирует как отдельный breaking change в .NET 10). Если вы активно завозили span-перегрузки, при обновлении компилятора возможны "немые" изменения поведения - тесты здесь реально решают.
Теперь можно добавлять модификаторы (ref, in, out, scoped и т.п.) к параметрам простых лямбд, не выписывая типы вручную.
delegate bool TryParse<T>(string text, out T value);
TryParse<int> parse = (text, out result) =>
int.TryParse(text, out result);
Раньше нужно было писать:
TryParse<int> parse = (string text, out int result) =>
int.TryParse(text, out result);
Мелочь, но все эти (string text, out int value) в TryParse-паттернах начинают исчезать из кода, и читается он чуть легче.
Теперь можно делить конструкторы и события между partial-частями типа 2 [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;
}
}
Раньше приходилось либо генерировать фабрики, либо лезть в уже сгенерированный код.
Теперь nameof умеет работать с unbound generic types:
var typeName = nameof(List<>); // "List"
Без необходимости указывать конкретный тип (List<int> и т.п.).
Вероятно будет полезно для логирования, генерации кода и диагностики.
C# 14 позволяет перегружать compound-операторы (+=, -=, *=, …) и инкремент/декремент через новые синтаксические формы 6 [3]. Главное - такие операторы могут обновлять состояние объекта 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.
File-based apps в .NET 10 стали заметно взрослее 3 [4]:
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#-файлом.
Вокруг tools появились несколько приятных штук 3 [4]:
dotnet tool exec - запускает инструмент без предварительной установки:
dotnet tool exec --source ./artifacts/package/ dotnetsay "Hello"
Удобно в CI и для внутренних тулов: не нужно засорять глобальные установки.
Платформенные tools + any RID - можно паковать разные бинарники для разных платформ в один пакет и добавить any:
<PropertyGroup>
<RuntimeIdentifiers>
linux-x64;
linux-arm64;
win-x64;
win-arm64;
any
</RuntimeIdentifiers>
</PropertyGroup>
any даёт fallback на обычный framework-dependent DLL, который запустится на любой поддерживаемой платформе с .NET 10.
dnx - маленький скрипт-обёртка: dnx dotnetsay "Hello" просто прокидывает всё в dotnet. Сначала кажется игрушкой, но в повседневных командах экономит немного клавиатуры.
Любой dotnet-командой можно получить JSON-описание её схемы 3 [4]:
dotnet clean --cli-schema
На выходе - дерево аргументов/опций, которое удобно:
использовать для генерации shell-completion;
писать свои фронтенды над CLI;
кормить тулзам, которые хотят понимать, какие флаги вообще бывают.
Если вы пишете обёртки над dotnet (например, в CI), вещь может пригодиться.
В .NET 10 включили фичу, которая обрезает неиспользуемые package-референсы, уже поставляемые вместе с фреймворком 3 [4]:
меньше мусора в deps.json;
меньше ложных срабатываний в NuGet Audit;
возможны предупреждения вида NU1510, если пакет был "обрезан" как лишний 4 [5].
Отключается это так:
<PropertyGroup>
<RestoreEnablePackagePruning>false</RestoreEnablePackagePruning>
</PropertyGroup>
Если у вас вся инфраструктура построена вокруг сканирования deps.json и точного списка пакетов - это то самое место, где нужно быть внимательным при миграции.
Теперь к неприятному, но нужному. Даже если вы перескакиваете прямо с 6/8 сразу на 10, вы упрётесь в breaking changes из 10-ки (и по ASP.NET, и по core-библиотекам, и по SDK).
Для известных API-эндпоинтов (контроллеры с [ApiController], минимальные API с JSON-телом, SignalR и т.п.) теперь по умолчанию 5 [6]:
было: при неавторизованном запросе 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;
};
});
Microsoft.AspNetCore.HttpOverrides.IPNetwork и ForwardedHeadersOptions.KnownNetworks помечены устаревшими. Вместо них - System.Net.IPNetwork и KnownIPNetworks 7 [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), миграция без этого изменения не соберётся без предупреждений.
В .NET 10 помечены deprecated 3 [5]:
WithOpenApi для минимальных API;
IncludeOpenAPIAnalyzers;
пакет Microsoft.Extensions.ApiDescription.Client и т.п.
Смысл в том, что экосистема уезжает в сторону новых OpenAPI-пакетов и генераторов. При миграции есть смысл:
поискать по коду WithOpenApi() и посмотреть, чем вы сейчас пользуетесь;
проверить, не завязан ли билд на старые analyzers.
В статье по breaking changes для .NET 10 отдельно выделен пункт C# 14 overload resolution with span parameters 4 [5]. Суть:
если у вас есть перегрузки T[] vs Span<T>/ReadOnlySpan<T>;
и вы вызываете их из generic-кода или с "интересными" аргументами -
компилятор может выбрать другую перегрузку, чем раньше. Компилироваться всё будет, но поведение способно тихо поменяться.
Рецепт: прогоняем тесты и внимательно смотрим на hot-path-методы, где вы явно добавляли span-перегрузки "для производительности".
System.Linq.AsyncEnumerable перекочевал в стандартные библиотеки .NET 10 4 [5]. Это может конфликтовать с вашими собственными типами/extension-методами с тем же именем (если вы их когда-то заводили).
Сценарий редкий, но если "прилетело" неожиданное конфликтующее имя - искать стоит именно тут.
Теперь dotnet new sln создаёт SLNX-формат решения, а не классический .sln 8 [8]:
новый формат проще читать и диффить;
поддерживается VS, Rider и остальными основными IDE 8 [8].
Если нужно старое поведение:
dotnet new sln --format sln
Из полезного (и иногда неприятного) 4 [5]:
dotnet package list теперь делает restore и может падать на проблемных фидах;
HTTP-предупреждения чаще превращаются в ошибки;
dotnet restore запускает аудит транзитивных пакетов;
project.json окончательно выкинули;
local-tools (dotnet tool install --local) по умолчанию создают manifest.
Если у вас вокруг этих команд накручены скрипты - просто прогоните их на новом SDK и посмотрите, не посыпалось ли что-нибудь неожиданное.
Для нового проекта минимально достаточно:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>
Для существующего проекта обычно делаю так:
Обновляю SDK до .NET 10.
Меняю TargetFramework (net6.0 / net8.0 / net9.0 → net10.0).
Гоняю тесты и по чек-листу прохожусь по основным breaking changes, описанным выше (и смотрю полный список в документации 4 [5]).
Если коротко: .NET 10 и C# 14 - это не революция, но довольно заметный эволюционный шаг. Некоторые вещи действительно упрощают ежедневный код, а миграционные подводные камни лучше поймать в тестах, чем в проде.
Автор: makushevski
Источник [9]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-2/437197
Ссылки в тексте:
[1] 1: https://dotnet.microsoft.com/platform/support/policy/dotnet-core
[2] 2: https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-14
[3] 6: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-14.0/user-defined-compound-assignment
[4] 3: https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10/sdk
[5] 4: https://learn.microsoft.com/en-us/dotnet/core/compatibility/10.0
[6] 5: https://learn.microsoft.com/en-us/dotnet/core/compatibility/aspnet-core/10/cookie-authentication-api-endpoints
[7] 7: https://learn.microsoft.com/en-us/dotnet/core/compatibility/aspnet-core/10/ipnetwork-knownnetworks-obsolete
[8] 8: https://learn.microsoft.com/en-us/dotnet/core/compatibility/sdk/10.0/dotnet-new-sln-slnx-default
[9] Источник: https://habr.com/ru/articles/968538/?utm_source=habrahabr&utm_medium=rss&utm_campaign=968538
Нажмите здесь для печати.