Грядёт новая версия C#, а это значит, что мы вновь выпускаем наш ежегодный обзор нововведений. Этот год принёс нам не так много изменений, как прошлый. Возможно, некоторым они покажутся совсем незначительными, но так ли это на самом деле? Давайте же взглянем на них.

Ключевое слово field

Да, теперь действительно можно не писать поле для свойства. Кто-то скажет, что и раньше можно было написать свойство такого вида:

public string Name { get; set; }

Это автоматически реализуемое свойство, которое создаёт внутри себя скрытое поле. Но проблема в том, что применять его можно только в случаях, когда не требуется дополнительная логика. Если же это необходимо, то нам приходится создавать поле, которое будет просто хранить данные, чтобы мы могли обработать их в сеттере свойства.

Например, когда необходимо убрать лишние пробелы по краям в электронной почте при её записи, обычно пишут что-то вроде этого:

public class User
{
  private string _oldEmail;
  public string OldEmail 
  { 
    get => _oldEmail; 
    set => _oldEmail = value.Trim(); 
  }
}

И мы привыкли такое видеть. Но теперь можно обойтись без явного объявления поля:

public class User
{
  public string Email 
  { 
    get; // Здесь также происходит использование field
    set => field = value.Trim(); 
  }
}

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

Лямбда-параметры с модификаторами без указания типа

Помните, как раньше при использовании внутри лямбды модификаторов scoped, ref, in, out, ref readonly нужно было обязательно указывать тип значения? Для примера рассмотрим такой код:

delegate bool ValidatorHandler(object value, out string errorMessage);
public void Validate(object objectValue)
{
  ValidatorHandler Validator = (object value, out string error) =>
  {
    // ....
  };
  // ....
}

Теперь разрешается использовать модификаторы и без указания типа параметра:

ValidatorHandler Validator = (value, out error) =>
{
  // ....
};

Это нововведение позволит нам сосредоточиться на написании кода, а не описании шаблонных моментов. Кажется, чаще всего с этим будут сталкиваться именно в контексте out-параметров.

Перегрузка операторов составного присваивания

Оптимизационная фича, которая позволит перегружать не только унарные или бинарные операторы, но и составные по типу +=, *= и т. п. Так как эти операторы по своей реализации очень схожи, далее по тексту будет рассматриваться оператор +=, но все выводы будут также применимы и к схожим операторам.

Стоит сразу отметить, что при использовании оператора += в C# 13 сначала вызывается перегруженный оператор +, а лишь потом применяется присваивание.

При работе со значимыми типами перегрузка оператора + лишний раз копирует оба операнда и создаёт новый экземпляр в качестве результата. Поведение ожидаемое, но может привести к неоправданным накладным расходам на копирование и обработку значений, тем более, если работать с крупными типами (математические векторы, тензоры и т.д.).

Можно написать метод, работающий аналогично оператору +=, который бы сводил копирование к минимуму и позволил модифицировать первый операнд:

public struct Vector3
{
  public double X, Y, Z;

  public Vector3(double x, double y, double z)
  {
    X = x;
    Y = y;
    Z = z;
  }

  public void Add(Vector3 vector)
  {
    X += vector.X;
    Y += vector.Y;
    Z += vector.Z;
  }
}

Хотя метод Add избавляет от большинства минусов, он выглядит менее нативно, чем оператор +=.

Перегрузка оператора += будет выглядеть так:

public void operator +=(Vector3 right)
{
  X += right.X;
  Y += right.Y;
  Z += right.Z;
}

Результат получается одинаковым, но улучшается читабельность кода. К тому же теперь не будет ситуаций, когда разработчик, оперирующий крупными типами, обнаружит, что производительность его кода резко упала.

Пара слов про ссылочные типы. Если не перегрузить оператор += для ссылочного типа, то при его использовании так же будет отрабатывать оператор +, после чего произойдёт присваивание. Проблема копирования операндов при этом не возникает. Однако в реализации + может создаваться новый объект, что может быть нежелательными в контексте выполнения +=.

Больше partial-элементов

Ранее C# 13 уже разрешил применение partial для свойств и индексаторов. В новой же версии partial можно использовать для разделения конструкторов и ивентов.

Как и всегда с partial, наиболее полезной эта возможность станет для разработчиков библиотек и генераторов кода. Так, partial события полезны для библиотек для слабых событий, а конструкторы для платформ с генерацией совместимого кода, например, Xamarin.

Единственное отличие в том, частичные конструкторы и ивенты обязательно должны иметь реализацию, в то время как методы могут оставаться лишь объявленными.

Null-условное присваивание

Продолжается традиция улучшения читабельности. На этот раз добавлена возможность проверять переменную на null не только в правой стороне выражения, но и в левой.

Важно! Стоит учитывать, что инкремент и декремент в этом случае нельзя использовать.

Другими словами, при присваивании с возможным null-разыменованием ранее была необходима классическая проверка на null в виде if.

public class User
{
  public DateTime LastActive { get; set; }
  public bool LoggedIn { get; set; }

  // ....

  public void UpdateLastActivity(User user)
  {
    if (user is not null)
      user.LastActive = DateTime.UtcNow;
    // ....
  }
}

В C# 14 эту запись можно сократить всего до одной строки:

public void UpdateLastActivity(User user)
{
  user?.LastActive = DateTime.UtcNow;
  // ....
}

Хочется уточнить, что ранее null-условие можно было применять при проверках, однако присваивание с помощью него было запрещено:

if (user?.LoggedIn == true) // OK
{
  user?.LoggedIn = false; // Error
}

Каков итог нововведения? Поведение то же: правая часть выражения не будет вычисляться, если проверка не пройдёт, но выглядит гораздо компактнее.

Элементы расширения

Теперь можно писать не только методы расширения, но и свойства. Для выделения блоков расширения создали новое ключевое слово — extension.

Как можно было имитировать это раньше? Неудобно, массивно и с костылями. Но код скажет лучше слов. Вот пример реализации:

public static class ExtensionMembers
{
  public static EnumerableWrapper<TSource> AsExtended<TSource>
  (this IEnumerable<TSource> source) =>
    new(source);
}

public class EnumerableWrapper<T> : IEnumerable<T>
{
  private readonly IEnumerable<T> _source;

  public EnumerableWrapper(IEnumerable<T> source)
  {
    _source = source;
  }

  public IEnumerator<T> GetEnumerator() => _source.GetEnumerator();
  IEnumerator IEnumerable.GetEnumerator() => _source.GetEnumerator();

  public bool IsEmpty => !_source.Any();
}

public class TestClass
{
  public void OldExtensions()
  {
    var enumerable = new List<int>();

    bool isEmpty = enumerable.AsExtended().IsEmpty;
  }
}

То есть, чтобы IEnumerable смог получить, например, свойство IsEmpty, ему приходилось стать обёрткой. Всё это очень громоздко, согласны? А если вам всё ещё кажется иначе, то просто посмотрите, как подобное можно реализовать теперь:

public static class ExtensionMembers
{
  extension<TSource>(IEnumerable<TSource> source)
  {
    public bool IsEmpty => !source.Any();
  }
}

public class TestClass
{
  public void NewExtensions()
  {
    var enumerable = new List<int>();

    bool isEmpty = enumerable.IsEmpty;
  }
}

В результате код стало проще и приятнее читать, а подобные расширения выглядят куда естественнее прежних. Уточню, что в этом случае IsEmpty вызывается как член экземпляра IEnumerable<TSource>, но мы можем создать и его статические элементы, если объявим блок расширений вот так:

extension<TSource>(IEnumerable<TSource>)
{
  // ....
}

Однако можно использовать новые расширения и более интересно. Например, чтобы упростить извечную проверку пустоты строки или сделать геттер для получения строки, написанной с заглавной буквы:

public static class StringExtensions
{
  extension(string str)
  {
    public bool IsNullOrEmpty => string.IsNullOrEmpty(str);
    public bool IsNullOrWhiteSpace => string.IsNullOrWhiteSpace(str);

    public string ToTitleCase => 
      System.Globalization.CultureInfo
      .CurrentCulture.TextInfo
      .ToTitleCase(str.ToLower());  
  }
}

В общем, нововведение позволяет более естественно расширять нужные типы не только методами, но и свойствами без создания классов-обёрток.

Неявные преобразования Span

Изменение поможет сделать код более оптимизированным с меньшими усилиями. C# научился проводить больше неявных преобразований, чтобы нам было приятнее использовать Span в работе. Для начала предложу разобраться, почему вообще стоит рассмотреть Span как один из способов сделать свой код чуть лучше.

Примечание: Использование Span критично в случаях, когда мы работаем в высокопроизводительных сценариях, где важна каждая миллисекунда.

Span — это тип, который позволяет эффективно управлять памятью. Мы как бы создаём "окно", через которое взаимодействуем с памятью. Например, если мы хотим провести операцию над строкой, то можем просто передать её в метод. Но если там нам необходима будет лишь часть строки? Методу Substring придётся создавать новую строку, что повлечёт за собой лишние расходы.

Избежать этого можно с помощью использования Span. Например, чтобы получить подстроку без помощи Span, нужно писать так:

string str = "test string";
string testString = str.Substring(startIndex: 0, length: 4);

И таким способом выделяется память под новую подстроку, что довольно неэффективно, так как требует времени и ресурсов. Но через Span это делается так:

ReadOnlySpan<char> testSpan = str.AsSpan().Slice(start: 0, length: 4);

Здесь, соответственно, не выделяется память для подстроки, потому что мы обращаемся напрямую к памяти, содержащей переданную строку, что быстрее и экономичнее.

В целом, можно представить Span как поведение любого человека, которому дали задачу найти, скажем, главу в книге. Ведь никто из нас не станет переписывать её без необходимости. Мы просто узнаем, где начало главы, а где конец.

К тому же добавлю, что Span, как и массивы, не допустит выхода за границы выделенного "окна" памяти, как это могло бы произойти при небезопасном обращении к ней. Он сам проверит и выдаст ошибку, если индекс выходит за границы.

Теперь, имея общее понимание того, зачем нам нужна эта структура, можем рассмотреть и само изменение. Напомню, что неявные преобразования к Span могли быть и раньше, но сейчас этот список расширили.

Предположим, что у нас есть некоторый метод для обработки ReadOnlySpan:

public static void ProcessSpan<T>(ReadOnlySpan<T> span)
{
  // ....
}

И мы пытаемся вызвать его, передавая внутрь разные данные:

var intArray = new int[10];
string str = "test";
Span<char> charSpan = new();
ReadOnlySpan<char> charReadOnlySpan = new();

ProcessSpan(str);              // Error
ProcessSpan(intArray);         // Error
ProcessSpan(charSpan);         // Error
ProcessSpan(charReadOnlySpan); // OK

В этом случае никаких неявных преобразований не происходит. А чтобы всё сработало, нам необходимо сделать примерно следующее:

ProcessSpan(str.AsSpan());
ProcessSpan<int>(intArray.AsSpan());
ProcessSpan<char>(charSpan);
ProcessSpan(charReadOnlySpan);

Но с новыми неявными преобразованиями всё становится гораздо проще. Остаётся лишь единственный момент: для строк всё ещё нужно указать generic-параметр char.

ProcessSpan<char>(str);
ProcessSpan(intArray);
ProcessSpan(charSpan);
ProcessSpan(charReadOnlySpan);

Теперь можно гораздо проще писать более производительный код и тратить на рефакторинг меньше времени.

Несвязанные обобщённые типы и nameof

Раньше получить имя типа можно было только при вызове с указанием параметра:

Console.WriteLine(nameof(List<int>));

Сейчас же разрешены обобщённые реализации:

Console.WriteLine(nameof(List<>));

Да, результат вывода у обоих способов будет одинаковым, но суть в том, что так мы не вводим никого в заблуждение, а чётко выражаем намерение получить имя открытого типа. Поэтому рефакторить и понимать код, когда не придётся разбираться, почему вдруг в параметры попал int, будет проще.

Заключение

Нововведения C# 14 принесли ещё больше удобств и расширили возможности разработчика, хотя и без изменений жанра "синтаксический сахар" тоже не обошлось. Заглядывая в будущее, можно предположить, что наибольшим спросом будут пользоваться новые расширения, ключевое слово field и null-условное присваивание. А как вам эти изменения? Напишите в комментариях.

Можете ознакомиться с документацией по C# 14 самостоятельно по ссылке. Если хотите изучить наши разборы предыдущих версий, вот их список:

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Dmitrii Kharitonov. What's new in C# 14: overview.

Комментарии (16)


  1. KoIIIeY
    22.10.2025 16:54

    Если массивы и так передаются по ссылке, какой практический смысл в спане для массива?


    1. Meloman19
      22.10.2025 16:54

      Вся соль в слайсах. Так можно передавать часть массива и работать в функции с ним, как цельным. Т.е. функции типа Func(byte[] buffer, int offset, int length) можно просто превратить в Func(Span<byte> buffer).

      Ну и главное, что Span - это не только управляемый массив, за ним может быть и неуправляемая память. Соответственно тебе достаточно будет написать одну функцию с реализацией для Span, вместо отдельных реализаций для byte[] и byte*. Хотя реализация в таком случае обычно сводятся к unsafe с fixed массива, но со Span можно писать safe реализацию, что тоже плюсом.


      1. mvv-rus
        22.10.2025 16:54

        Ну, можно сделать свою собственную структуру с этими полями, и передавать ее. И когда мне приходилось ограничиваться возможностями .NET Framework 4.5.2 (потому что в Windows Server 2012 R2 стоял именно он), в котором никаких Span не было, я именно так и делал для всяческих разборок (AKA parsing).

        Но в наше время у Span есть преимущество: его библиотека времени выполнения вполне прилично поддерживает.


        1. Ydav359
          22.10.2025 16:54

          Еще Span это ref struct


    1. nronnie
      22.10.2025 16:54

      В дополнение к тому что выше - Span<T> позволяет работать со staсkalloc-массивами без использования unsafe кода.


      1. KoIIIeY
        22.10.2025 16:54

        А можно какой то банальный пример?) видимо я не настоящий сварщик, а маску сишарпа в офисе нашёл :)


        1. mynameco
          22.10.2025 16:54

          Span stackAllocatedSpan = stackalloc int[10];

          вот. использую в проекте повсюду. в основном для парсеров.


        1. Spearton
          22.10.2025 16:54

          Например можно вместо каких-то маленьких аллокаций массивов или arraypool.rent тупо на стеке размещать и работать с этим, меньше нагрузка на GC => меньше потребление памяти, задержки


          1. Siemargl
            22.10.2025 16:54

            как в С, да?


  1. DmitryITWorksMakarov
    22.10.2025 16:54

    а discriminated unions когда-то появятся?


    1. TraX325
      22.10.2025 16:54

      Есть уже несколько официальных предложений/обсуждений, выглядят они пока довольно неловко (можно тут порыться https://github.com/dotnet/csharplang/tree/main/proposals , некоторые уже в discarded, некоторые просто висят)


  1. Dhwtj
    22.10.2025 16:54

    Когда коту делать нечего он яйца лижет.

    • Exhaustive switch,

    • Pattern matching,

    • Discriminated unions

    • Higher-kinded types (хотя бы через type providers и quotations)

    Где вот это вот всё?


    1. mvv-rus
      22.10.2025 16:54

      В другом языке. В этом - пали жертвой принципа YAGNI (хотя, какой-то pattern matching в C# все же притащили). B это правильно: не надо перегружать язык всем хорошим (и не очень), что только взбредет в голову или удастся подсмотреть у конкурентов. Язык IMHO должен оставаться концептуально целостным, чтобы быть обозримым. В частности, не надо тащить FP в C#, по крайней мере, пока компилятор не научится контролировать типы параметров-делегатов на отсутствие побочных эффектов.

      История с PL/1 должна была этому научить, по крайней мере - тех кто ее застал.


    1. MaNaXname
      22.10.2025 16:54

      в F#. Юзайте его)


  1. mvv-rus
    22.10.2025 16:54

    Нововведения C# 14 принесли ещё больше удобств и расширили возможности разработчика, хотя и без изменений жанра "синтаксический сахар" тоже не обошлось.

    По-моему, все перечисленное в статье - оно именно в этом жанре. Всё это можно было сделать и раньше, только больше текста набирать пришлось бы.

    И да, появление нвого синтаксического сахара читабельность, скорее, понижает - как минимум, до тех пор, пока он не станет привычным. А до того он будет постоянно вызывать вопросы, что это за хрень. А если как это сейчас принято, новую версию языка выпускать каждый год, то читабельность будет страдать регулярно.


    1. Wolfdp
      22.10.2025 16:54

      ИМХО, большая часть читаема, даже если не знаешь о таких фичах. Тотже field или проверка на null сложно интрепитировать как-то иначе.