
В следующем месяце выходит очередная версия нашего любимого языка программирования. Чем не повод присесть на кухне с рюмкой чая и обсудить, что не так с современными версиями C#?
В студенческие годы была у меня книга небезызвестного датчанина о небезызвестном языке программирования. И хотя я испытывал неподдельные тёплые чувства к C++, книгу ту я не осилил даже наполовину (настолько унылым было чтиво). Тем не менее, это не помешало мне продолжать верить в предначертанную нам с плюсами судьбу и летом 2011-го пойти устраиваться программистом на этом языке.
Итог собеседования был следующий: C++ я не знаю. Но мне было предложено попробовать себя в C#. Чувства были задеты, но я согласился: хотелось уже начать работать.
Тестовое задание состояло в написании игры “Змейка”. Для усложнения поле представляло собой карту России, передвигаться можно было только в её пределах, а расти предлагалось за счёт поедания крупных городов. Игра должна была запускаться в браузере с использованием ныне покойного Silverlight.
Приложение было написано и одобрено, я был принят на своё первое место работы, а С++ остался в прошлом. Так волею судеб я стал разработчиком на C#, полюбил этот язык, и по сей день неразлучен с ним. К сожалению, иногда у меня возникает чувство, что с ним не всё в порядке.
Оглавление
А есть ли проблема?
Рано или поздно любой программист, увлекающийся C++, приходит к выводу: “Никто не знает этот язык полностью”. Но, хотя наши с плюсами дороги разошлись много лет назад, в последние годы мысль эта стала всплывать у меня снова. Правда, в слегка изменённом виде: вместо ++ я вижу там #. Конечно, шарпу ещё очень далеко до сложности творения Бьёрна Страуструпа. Однако многие новшества начинают вызывать обеспокоенность за былое изящество C#.
Но прежде, чем мы начнём аккуратно раскладывать на лабораторном стекле синтаксические конструкции, отвечу на абсолютно справедливое замечание: “Никто ведь не заставляет пользоваться новыми фичами”. Т.е. проблемы по факту нет, и зачем тогда весь этот разговор? Тут интерес скорее личный.
Я, как разработчик .NET библиотеки, иногда сталкиваюсь с запросами пользователей на расширение API. Бывает, что я и сам вижу в чужом коде почву для новых функций или переработки старых. Но целесообразны ли изменения? Насколько увеличится сложность API? Так ли неудобно решать определённые задачи при нынешнем устройстве библиотеки?
Лаконичность контрактов и простота решения реальных пользовательских проблем — параметры, между которыми постоянно приходится балансировать. И мне очень интересно, как с такой эквилибристикой справляются в Microsoft при разработке инструмента, которым я пользуюсь ежедневно.
Безусловно, любой язык программирования должен развиваться. Да чего уж скрывать, я и сам использую многие новинки. Но так ли без них было сложно и неудобно? И хотя мои претензии могут звучать ультраконсервативно в духе “Раньше байты были восьмибитнее”, хочется поделиться своими мыслями и обсудить их вместе с вами.
Точка отсчёта
Согласно официальной летописи, C# к моменту написания статьи насчитывает 17 ревизий. Любопытно, что первые семь версий (до шестой включительно) охватывают период в 13 лет, тогда как остальные десять были выпущены всего за 8:

Стоит заметить, что в отличие от кажущегося хаотическим раннего графика релизов, в наши дни новые версии языка стоит ожидать ежегодно в ноябре, ибо они привязаны к выпускам .NET:

Бурный рост количества фич в C# начался примерно между седьмой и восьмой версиями. Вот, например, график роста количества ключевых слов (keywords):
Как собраны данные
Исходники страницы C# Keywords официальной документации лежат в файле docs/docs/csharp/language-reference/keywords/index.md. Поэтому взяты изменения этого файла и собраны списки ключевых слов.
Но коммиты есть только до ноября 2016-го. Данные о перечне ключевых слов до этой даты собраны вручную из архивов MSDN.
В какие-то моменты времени одни и те же ключевые слова считались разными в зависимости от контекста (например,
partialпри объявлении классов и методов). При сборе данных такого разделения не делалось.

Можно также взглянуть на содержимое файла Syntax.xml в репозитории dotnet/roslyn. Он используется для генерации компилятора Roslyn и содержит все синтаксические правила C#. Отсчёт начнём с релиза Visual Studio 2015 RTM, т.е. с июля 2015-го, когда Roslyn стал компилятором по умолчанию в VS (стало быть, начал поддерживать все фичи языка). Вот как с годами менялось количество узлов в указанном файле:
Как собраны данные
Взяты коммиты файла Syntax.xml. Файл два раза в истории перемещался/переименовывался, изменение отслежены вплоть до первой версии исходного документа.
Для каждого коммита собрано количество дочерних узлов элемента
Tree.

Помимо того, что Microsoft и сама начала активно развивать язык, компания предоставила всем нам возможность предлагать фичи и голосовать за них в соответствующем репозитории на GitHub. Поэтому многие кинулись создавать запросы, в которых описывали свою профессиональную боль и способы её решения через новые языковые конструкции. Каюсь, за мной тоже числятся такие предложения (раз и два). И я очень рад, что они были отклонены.
Если зайти в папку proposals, то стартовать подпапки будут с шестой версии. Но в соответствующей директории всего три небольших файла. Вероятно, это ещё были внутренние хотелки команды разработчиков языка. А вот в папке с материалами по седьмой версии будут уже документы со ссылками на дискуссии. Здесь мы и приходим к точке отсчёта, когда стали проскакивать сомнительные (с моей абсолютно субъективной и неавторитетной точки зрения) новые функции C#.
Также стоит отметить любопытный момент. Существует стандарт ECMA-334, описывающий полную спецификацию C#. На момент написания статьи последняя опубликованная версия седьмая. И в ней нет, например, ключевого слова and. А слово это появилось аж в C# 9, т.е. четыре версии назад. Более того, в официальном репозитории, где ведётся разработка стандарта, есть две ветки: draft-v8 и draft-v9. И даже в черновике девятой версии в файле с описанием грамматики языка отсутствует тот самый and. То есть, официальная спецификация и даже черновики её новых редакций не поспевают за фактическим развитием C#.
Когда остановиться невозможно
Посмотрим, например, на локальные функции, добавленные в C# 7.0. Выглядят, как обычные методы, разве что без модификаторов доступа. Значит, наверное, можно и статическими их делать? С восьмой версии можно — говорит Microsoft. А ещё в сигнатурах методов есть возможность использовать атрибуты, на параметрах или возвращаемом значении. Как там дела с локальными функциями? Вот вам C# 9, пожелание реализовано — отвечает Microsoft.
Но разработчики языка пошли дальше. По их мнению, пространства имён и метод Main так сильно пугают новичков, что те бегут изучать Python (информация непроверенная), а потому в той же девятой версии стало возможным отбросить эти ужасы. Top-level statements позволили писать код так, будто мы находимся внутри Main, существующего где-то там. И это правда, ибо фича не более чем синтаксический сахар. Объявлять методы при таком подходе помогают именно введённые ранее локальные функции.
Кстати, лямбда-функции ведь очень похожи на локальные. Обладают некоторыми неудобствами по сравнению с последними, но всё же. В локальных функциях можно объявлять опциональные параметры и использовать атрибуты, а в лямбда-функциях почему нет? C# версий 10 и 12 исправляет эти упущения.
Или рассмотрим ключевое слово ref, позволяющее передать в метод объект по ссылке. И снова C# 7.0 расширяет горизонты дозволенного. Теперь можно объявлять переменные через ref (создавая псевдоним для другой переменной) и даже возвращать значение по ссылке. Дальше — больше: ref readonly, ref struct, ref-поля, scoped ref. При этом нужно ещё научить компилятор не ругаться на использование тех же ref-структур в дженериках или попытки реализовать такими структурами интерфейсы.
Обширный пласт функциональности связан с записями (records). Изначально ключевое слово record позволяло быстро объявить класс с набором свойств, причём всякую обвязку вроде Equals и ToString компилятор генерировал сам. А что если хочется сделать такой тип значимым (value type), а не ссылочным? Встречайте — record struct. Связка этих ключевых слов (а заодно и record class в качестве псевдонима для record) появилась в C# 10 и повлекла за собой сопряжение с модификатором readonly, применимым с C# 7.2 к обычным структурам. В итоге сейчас можно объявлять записи такими способами:
record A();
record class B();
record struct C();
readonly record struct D();
На dev.to, к слову, есть статья, в которой приведён такой код:
public ref readonly record struct Point(double X, double Y);
Думается, что автором является его величество ИИ, ибо скомпилировать такую конструкцию не удастся. Хотя её наличие отлично бы вписалось в тенденцию, показанную выше: readonly ref struct ведь существует.
При этом в записи можно добавлять методы и свойства, переопределять их и т.д. Иными словами, очень похоже на обычные классы и структуры. Вот только последние не поддерживают тот замечательный синтаксис, в котором имя типа сочетается с конструктором. Здесь история C# приводит нас к концепции первичных конструкторов (primary constructors), появившихся в 12-ой версии языка. К слову, данная фича находится в лидерах по количеству отрицательных реакций — 186 голосов “за” и 96 “против”.
Как подсчитаны голоса
Взяты документы с предложениями новых фич из папки proposals в репозитории dotnet/csharplang (файлы в подпапках csharp-<version>).
Внутри файлов найдены ссылки на champion issues, в рамках которых велись обсуждения нововведений.
В каждом предложении-чемпионе подсчитаны реакции пользователей (+1, heart и hooray — голоса “за”, -1 — голоса “против”). Разумеется, когда один человек поставил, например, и +1 и heart, прибавится только один голос “за”.
На основе реакций для каждого issue вычислены два параметра: like ratio (отношение числа положительных голосов к общему числу голосов) и dislike ratio (отношение числа отрицательных голосов к общему числу голосов). Отсортировав предложения по убыванию значения каждого параметра получены два списка.
Также параллельно созданы два набора, где предложения упорядочены по убыванию абсолютного числа голосов “за” и “против” соответственно.
Сопоставив соответствующие списки друг с другом получены фичи-лидеры и фичи-аутсайдеры.
Метод не суперточный, но такового я и не придумал. Тем более, неизвестно, какие ещё эмодзи могут использоваться людьми и какой смысл они в них вкладывают. Но это мелочи, не сильно меняющие позиции в рейтингах.
Тем не менее, всё это примеры полезных нововведений. Локальные функции и записи я и сам время от времени использую. Разве что top-level statements полюбить не смог, и, кажется, я не один такой, учитывая, что Microsoft даже добавила галочку Do not use top-level statements при создании нового проекта в Visual Studio.
Когда нужно больше ключевых слов
Интересная история приключилась с native sized integers. Введённые в C# 9 ключевые слова nint и nuint представляли собой “улучшенные” версии IntPtr и UIntPtr соответственно. Улучшение заключалось в поддержке арифметики, и неявных приведениях между числовыми типами. Иными словами, вот такой код не скомпилировался бы:
IntPtr a = 2;
IntPtr b = 3;
var c = a + b;
И такой тоже:
IntPtr a = (IntPtr)2;
IntPtr b = (IntPtr)3;
var c = a + b;
А с nint пожалуйста:
nint a = 2;
nint b = 3;
var c = a + b;
Но в C# 11 в Microsoft решили добавить все эти возможности и в давно живущие среди нас IntPtr и UIntPtr. А если нет разницы, то пусть nint и nuint будут в конце концов просто псевдонимами для старых типов. И вот мы имеем два ключевых слова, которые не добавляют никакой пользы.
Забавный факт: выглядят они как обычные псевдонимы простых типов (вроде int или byte), но объявить переменную с именем int вы не сможете, а с nint запросто:
nint nint = 4;
Так получилось потому, что nint и nuint это контекстные ключевые слова. Кроме того, если не знать их историю, можно подумать, а чего именно IntPtr и UIntPtr удостоились чести иметь псевдонимы? Почему, например, не TimeSpan? Думается, в жизни большинства разработчиков он встречается даже чаще.
Есть ещё один пример внезапного пополнения списка ключевых слов. И даже не двумя, а сразу тремя. Речь про сопоставление с образцом (pattern matching, aka сопоставление шаблонов). Фича безусловно полезная, значительно упрощающая жизнь. Будучи добавленным в C# 7.0, паттерн матчинг пережил множество расширений и сейчас представляет собой очень мощный инструмент.
Эволюция сего функционала привела к появлению в C# 9 логических паттернов (logical patterns), а вместе с ними и новых ключевых слов: not, and и or (на всякий случай, оставлю ссылку на сообщение, в котором рассказывается, почему нельзя обойтись привычными !, && и ||). Кроме того, сопоставление с образцом доступно не только в конструкциях switch, но и в if посредством использования оператора is.
И хотя логика добавления новых операторов понятна, есть ощущение, что они лишние. При этом вариант лучше придумать не могу. Критикую и не предлагаю.
Когда догадаться сложно
Временами Microsoft благоразумно старается избежать добавления новых ключевых слов и переиспользует существующие. Не всегда результат такой заботы радует глаз.
Начало было положено задолго до обозначенной нами точки отсчёта. Думаю, мало кто из программистов, кому довелось впервые увидеть модификатор доступа protected internal, догадывались, что союз между этими двумя словами вовсе не “и”, а “или”. Кажется вполне разумным читать это как “можно обращаться только из производного класса в рамках текущей сборки (assembly)”. Но реальность жестока. Правильная интерпретация: “можно обращаться из текущей сборки (из любого класса) или из производного класса (из любой сборки)”.
Когнитивный диссонанс и потребность в модификаторе доступа, соответствующем логичной мысли выше, привели к появлению не менее странного private protected в C# 7.2. Кто знает, возможно, в будущем мы увидим появление, например, private protected internal (пусть интерпретация будет такая: “можно обращаться только из производного класса в рамках текущего пространства имён”).
Ещё одним интересным объектом для рассмотрения является default. Давным-давно введённый оператор позволяет получать значение по умолчанию для любого типа. Особенно удобно это для, например, дженериков:
public T Foo<T>()
{
// …
return default(T);
}
C# 7.1 упрощает оператор до литерала:
public T Foo<T>()
{
// …
return default;
}
При этом издревле конструктор без параметров назывался конструктором по умолчанию (default constructor). И там и там слово default, выходит, и default и new T() должны давать нечто одинаковое. Или нет?
Объявим структуру, представляющую число, которое не может быть меньше 10 (такие у нас специфические желания):
private struct A
{
private int _x = 10;
public A(int x)
{
if (x < 10)
throw new ArgumentException("X is too small.", nameof(x));
_x = x;
}
public override string ToString() => _x.ToString();
}
Далее создадим дефолтный экземпляр этого типа разными способами:
A a1 = default(A);
Console.WriteLine($"A1 = {a1}");
A a2 = default;
Console.WriteLine($"A2 = {a2}");
A a3 = new();
Console.WriteLine($"A3 = {a3}");
A a4 = Activator.CreateInstance<A>();
Console.WriteLine($"A4 = {a4}");
В консоли будут напечатаны такие строки:
A1 = 0
A2 = 0
A3 = 0
A4 = 0
Здесь перед нами предстаёт интересная особенность оператора и литерала default: он игнорирует инициализацию полей (на что мне обратили внимание в комментариях к другой моей статье). И хотя, как и в примере с protected internal, мы можем (и должны) обратиться к документации, с первого взгляда поведение кажется неочевидным.
Начиная с C# 10 можно добавить в структуру конструктор без параметров, и вывод консоли поменяется:
A1 = 0
A2 = 0
A3 = 10
A4 = 10
Такое поведение вызвало споры, а фича попала на верхние строчки анти-рейтинга нововведений — 40 положительных голосов и 29 отрицательных.
Выходит, что слово default не совсем применимо одновременно и к соответствующему оператору/литералу, и к new T(). В Microsoft тоже это поняли, так что в статье Use constructors начиная с 20 апреля 2019 компания не использует термин конструктор по умолчанию — теперь это конструктор без параметров (parameterless constructor). Последняя версия текста, где фигурирует словосочетание default constructor, датируется 28 февраля 2019.
Заключение
Конечно, есть ещё, на что побрюзжать. Например, на возможность реализовывать методы прямо в интерфейсе (default interface methods), появившуюся в C# 8 и вызвавшая возмущение — 89 дизлайков против 142 голосов “за”. Но, перефразируя Маяковского: если фичи добавляют — значит — это кому-нибудь нужно? Без сомнений.
Язык расширяется, а разработчики получают возможность решать свои задачи чуточку (а иногда и ого-го как) быстрее и проще. Но всё-таки иногда возникает ощущение утраченной стройности, чего-то, что делало язык одновременно простым и мощным.
Комментарии (12)

danilenko-a
24.10.2025 15:01Конечно, есть ещё, на что побрюзжать.
Да, есть: В Вашей .Net-библиотеке немалая часть функциональности в статических классах.

holgw
24.10.2025 15:01И какие проблемы это создает?
Если очень нужно отвязаться от реализации, то никто не мешает вызов статических методов вынести в отдельный класс и закрыть его интерфейсом.

ArtemKonkin
24.10.2025 15:01Как же понимаю автора)
Я в универе в восторге был от С++. В будущем это очень повлияло на выбор постоянного языка программирования - C# зашёл как родненький (пробовал Python, JS, Java - всё не то).
Ничего лучше C# так и не встретил. ❤️

MVMmaksM
24.10.2025 15:01Хорошо, что язык развивается, плохо, что он превращается в c++. Это увеличивает порог входа для новичков и приведет к тому, что язык будет терять популярность, и к тому, что один разработчик не сможет понять код, написанный другим разработчиком.

jakobz
24.10.2025 15:01Мне кажется, пора кому-то написать C#: The Good Parts :) Или сделать какой-нибудь C-бемоль.
Они идут очень долгой дорожкой от деревянности и многословности джавы к минимализму. Но там так много всего мешается, что не получается ни просто, ни реально минималистично.
Но у C# есть прям крутая фишка - там можно делать зубодробильные мета-штуки, и прям рядом писать на них тупой понятный код. Можно делать большие и сложные аппы, с серьезными абстракциями, в 10 средних бойцов, и одного крутого лида. Все другое такое не тянет.

Ydav359
24.10.2025 15:01Среди таких чисто "сахарных" нововведений много именно улучшений производительности, так что грех жаловаться. Вспомните, когда вообще последний раз приходилось лезть в unsafe? Из последних нововведений особенно приятна аллокация небольших массивов на стеке.

lightln2
24.10.2025 15:01C# всегда был одним из моих основных языков программирования, и, пожалуй, самым любимым, еще до его официального релиза в 2002 году, когда к нам на мат-мех СПБГУ приходили ребята из Микрософт показать новый крутой язык. Я хорошо помню, как они на наших глазах открыли Visual Studio, написали "Console.", вызвали контекстную подсказку, и ноутбук завис намертво! Его перезагрузили, и со второго раза все получилось!
Потом я много лет на нем работал (вместе с Java и C++), но где-то после шестой версии он начал терять популярность и уступать яве и Go (хотя вы можете возразить, что Ява всегда была популярнее?). И такое впечатление, на самом высоком уровне в Микрософт было принято решение экстенсивного развития языка. Я помню, я читал тикет про nullable types (известное изменение в семантике языка, делающее все классы по умоллчанию non-nullable), еще до его официального принятия. Там один человек спросил: "Теперь выражение default(T) больше не имеет тип T - вам не кажется, что это не консистентно?" На что представилель Микрософта прямо ответил: "на данном этапе развития языка создание сообщества вокруг него для нас важнее его внутренней непротиворечивости."
Тем не менее, я признаю, что это было, наверно, правильное решение. Сейчас c# мультипарадигменный, я на нем писал и суровый энтерпрайз с абстрактными фабриками, и супербыстрые числодробилки с System.Runtime.Intrinsics и вычислениями на видеокарте с ILGPU.net, и ad-hoc скрипты как в питоне. Если кто увлекается программистскими задачками, и решает Advent of Code каждый год, знает, что подавляющее большинство их делают на питоне. Я решал их на c# два последних года, и даже пару раз попал на глобальный leaderboard (что лично для меня огромное достижение, так как приходится соревноваться и с competitive programmers, соревнующихся с детства, так и с LLMs, решающих простые задачи за секунды)! Я описал свой опыт использования c# вместо питона на реддите в этой статье.
Лично мое мнение - современный C# очень недооценен: он по удобству тулинга сопоставим с Ява, по скорости разработки - с Питоном, по скорости компиляции - с Go, по скорости работы - с C++. То ли Микрософт не вполне справились с продвижением языка, то ли это не было для них приоритетом...
Arragh
Меня уже давно напрягает перегруженность C# всякими фичами, некоторые из которых выглядят весьма сомнительно. Взять те же “field” в геттерах/сеттерах - зачем они это сделали? Я не смог придумать ни одного сценария, где бы мне понадобилось к ним прибегнуть. И таких примеров вагон и маленькая тележка.
Лучше бы вносили побольше удобства в функциональность языка. Я помню, как я радовался, когда наконец-то добавил возможность использовать лямбда-выражения внутри лямбда-выражений при обращении к БД - вот это было просто супер. По-моему это фишка появилась в .NET 5 или 6, точно не помню.
А в .NET7 наконец-то добавили метод PatchAsJsonAsync, который избавлял от головной боли с конвертацией модели, что тоже очень обрадовало.
Вот такого бы функционала побольше добавляли, а не чепуху всякую.
Gromilo
А мне нравится. Просто как хочешь логику написать в {get; set;} приходится поле заводить. А так нам просто дали доступ до автогенерённого. Выглядит как шлифовка синтаксиса, а не как новая фича.
Spearton
Field не в пример вообще как-будто))
holgw
А какое отношение эти две фичи имеют к функциональности языка?
Первое -- это про EF, а не про сам язык, второе -- вообще просто добавление экстеншен-метода в библиотеке System.Net.Http.Json.
Ydav359
field прекрасно упрощает написания свойств в WPF\Avalonia и защищает от возможной ошибки при копипасте полей для реактивных свойств.