Самым фатальным недостатком C++ является то, что он скоро умрёт. То, что C++ скоро умрёт — это истина, проверенная временем. 30 лет назад все говорили, что C++ скоро умрёт, потому что появилась Java. 20 лет назад ему пророчили скорую смерть, потому что к Джаве присоединился C#. 10 лет назад C++ должен был умереть, потому что появился Rust. 3 года назад его снова принялись хоронить, когда АНБ выпустило спорную рекомендацию «переходить на memory-safe языки» везде, где это возможно, и я думаю, что ещё через 30 лет тоже все будут говорить, что C++ скоро умрёт.
Откуда столько пессимизма? Считается, что C++ неудобен в использовании, и из-за неочевидных правил языка на нём слишком легко написать опасный и забагованный код. В этой статье я сравню C++ и C# в тех аспектах, которые определяют простоту и удобство использования — насколько легко и приятно писать на этих языках, и какой из них вызывает наименьшее количество боли.
Я не собираюсь ничего набрасывать на вентилятор или разводить холивар, а просто хочу объективно и беспристрастно ответить, наконец, на вечный вопрос: что же всё-таки лучше — C++ или C#?

1. Хардкор
C# — это детище Microsoft, за которым стоит поддержка IT-гиганта с огромным репозиторием пакетов и регулярными обновлениями компилятора и рантайма. За развитие C++ отвечает Международная Организация по Стандартизации (ISO), а точнее — её подразделение под названием «ISO/IEC JTC 1/SC 22/WG 21», в простонародье именуемое «Комитетом по стандартизации C++», или просто WG21. В его обязанности входит периодический выпуск новых версий Стандарта C++. Стандарт — это талмуд на 2000 страниц, который невозможно читать неподготовленному человеку и который определяет синтаксис и правила языка, границы допустимых оптимизаций для компиляторов и спецификацию классов стандартной библиотеки. В обязанности комитета не входит стандартизация систем сборки, разработка компиляторов, стандартизация ABI и, тем более, содержание сетевой инфраструктуры, такой как репозитории пакетов. Любая частная компания свободна выпускать свои менеджеры пакетов, компиляторы и системы сборки, в результате чего в мире C++ царит анархия и фрагментация. Стандартным выбором для большинства проектов являются компиляторы MSVC, GCC и Clang. Кроме них существуют компиляторы от Intel, Nvidia, Keil, MPLAB и других компаний, которые входят в тулчейны для embedded-разработки и быстрых вычислений с помощью GPU и AI-ускорителей. Реализация новых версий Стандарта в разных компиляторах происходит с разной скоростью, так что в определённой мере от компилятора зависит, с какой версией C++ вы будете работать.
В C++ кроссплатформенный проект под каждую платформу собирается отдельно, а бинарная совместимость даже в рамках одной ОС не гарантируется по умолчанию. В C# ситуация проще, поскольку код C# во всех операционных системах компилируется в один и тот же бинарный формат. Это стирает границы между платформами и позволяет переносить компоненты приложений с одной ОС на другую, подключать их к разным проектам и не беспокоиться об их совместимости — кроме случаев с разными версиями рантайма, нативными библиотеками и другими ограничениями, на которых я не буду сейчас останавливаться.
Какой бы язык вы ни выбрали, большинство приложений вы будете писать с использованием готовых компонентов (библиотек). Чтобы разные компоненты, написанные на C++, могли работать вместе, они должны быть совместимы по ABI. Другими словами, их бинарные файлы должны быть скомпилированы под один и тот же ABI стандарт. Без этого они не смогут друг с другом взаимодействовать — иначе как через C API или ещё какую-нибудь прослойку.
Строго говоря, основные компиляторы C++ не имеют единого стандарта ABI — компилятор MSVC, доминирующий в нативной Windows-разработке, использует собственный ABI, не совместимый со спецификацией Itanium, которой придерживаются компиляторы GCC и Clang для Unix-подобных систем. Но бинарная совместимость между этими двумя мирами всё равно недостижима, и на практике достаточно того, что единый ABI существует отдельно в каждом из них. За небольшим исключением в виде компилятора MinGW, который является адаптацией GCC под Windows и, как и весь остальной GCC, использует Itanium ABI. C++ библиотеку, собранную MinGW, нельзя подключить к проекту на MSVC — если её интерфейс не объявлен через extern "C" (со всеми сопутствующими ограничениями).
В C++ ABI-совместимость может сломаться из-за разных настроек сборки, из-за разных реализаций стандартной библиотеки, или даже из-за разных версий одного и того же компилятора. ABI-совместимость в C# доставляет гораздо меньше проблем — по крайней мере, для тех библиотек, которые не имеют нативных зависимостей.
Система сборки — это инструмент, позволяющий составить сценарий: какие файлы компилировать, какие библиотеки подключать, откуда эти библиотеки скачивать, как вести себя в разных операционных системах, куда класть исполняемые файлы и другие бинарные артефакты, куда копировать файлы с данными и конфигурационные файлы, какие флаги компиляции использовать, и так далее. Можно даже настроить запуск каких-нибудь питоновских скриптов перед компиляцией, чтобы генерировать C++ код из конфигов. Среди систем сборки для C++ можно выделить Visual Studio Projects, CMake, Premake, Meson и Bazel. Работая над разными проектами, вы будете вынуждены осваивать разные системы сборки. Если вы захотите воспользоваться санитайзерами или покрыть debug-символами весь код проекта, то вам придётся собирать из исходников сторонние библиотеки и использовать несколько систем сборки одновременно.
В C#, чтобы подключить библиотеку, вам достаточно выполнить команду «dotnet add package <PackageName>». Менеджер пакетов NuGet зайдёт на сервер Microsoft, скачает оттуда файлы библиотеки вместе со всеми зависимостями, положит их в локальный кэш и подключит к проекту. От пользователя требуется одно простое действие, чтобы начать использовать библиотеку в своём коде. Именно для этого и нужен менеджер пакетов.
NuGet — это официальный менеджер .NET пакетов от Microsoft. Его основной репозиторий содержит более пятисот тысяч пакетов, и если вы создадите какую-нибудь библиотеку для C#, то распространять её вы будете именно через этот репозиторий. При необходимости, можно настроить доступ к пакетам из любого другого источника — например, из вашего корпоративного feed-а, если это требуется для безопасности или для доступа к внутренним зависимостям, которые вы не собираетесь открыто публиковать.
Для C++ существуют несколько менеджеров пакетов, из которых в основном используются только Vcpkg и Conan, причём доля обоих, согласно опросам, не превышает 20%. Согласно тем же опросам, самый популярный способ подключения зависимостей в C++ — это скачать библиотеку с сайта разработчика и следовать плохо написанным инструкциям о том, как её готовить. И разбираться, почему именно в вашей системе эти инструкции не работают.

Для C++ программиста слишком велик соблазн просто использовать системный пакетный менеджер, такой как apt, pacman или brew. С его помощью можно установить на компьютер -dev версии библиотек, чтобы система сборки могла их найти в стандартных директориях. Это рабочий вариант, если вы создаёте ПО под конкретную версию конкретного Линукса, но для изолированной сборки, не зависящей от текущего окружения с его версиями библиотек, нужен пакетный менеджер зависимостей, такой как Vcpkg или Conan.
Основной репозиторий Vcpkg насчитывает 2800 библиотек, а основной репозиторий Conan — 1900 библиотек. Это несколько меньше, чем полмиллиона Nuget-пакетов для C#, и к тому же часть из этих библиотек поддерживается не для всех платформ. Рано или поздно вы столкнётесь с отсутствием нужной библиотеки, и вам придётся настраивать пути к приватным репозиториям. Либо поднимать свой собственный репозиторий и писать рецепты для сборки пакетов — а для этого уже требуется отдельная экспертиза.
Бинарные артефакты C++ (в отличие от C#) собираются под конкретную комбинацию процессора, компилятора и ОС. Если репозиторий пакетного менеджера предоставляет библиотеку в виде бинарных файлов, значит он должен одновременно сопровождать её для разных архитектур, операционных систем, параметров сборки, версий компиляторов и так далее. Из-за этого популярные репозитории предпочитают хранить не бинарные файлы, а рецепты их сборки из исходного кода, чтобы каждый пользователь собирал их своим компилятором со своими флагами и настройками.
В C++ заложена идея предоставить компиляторам максимум возможностей для оптимизации, а программистам — максимум гибкости и контроля над управлением памятью. Из-за этого в C++ слишком легко допустить маленькую ошибку, которая приведёт к большим последствиям — программа начнёт крашиться, выдавать неверные результаты или демонстрировать разное поведение в зависимости от платформы, компилятора, фазы луны и других неустановленных факторов. На жаргоне программистов это называется «выстрелить себе в ногу», причём отстреливать себе ноги считается исключительной прерогативой программистов C и C++.
Для примера, посмотрите на этот с виду безобидный код и попробуйте найти ошибку (подумайте, прежде чем открывать спойлер с ответом):
bool ComposerClient::CommandReader::parseSetLayerCursorPosition( uint16_t length) { if (length != CommandWriterBase::kSetLayerCursorPositionLength) { return false; } auto err = mHal.setLayerCursorPosition(mDisplay, mLayer, readSigned(), readSigned()); if (err != Error::NONE) { mWriter.setError(getCommandLoc(), err); } return true; }
В чём тут проблема?
В том, что автор кода не использует статический анализатор
Отметим, что в C# эта проблема в принципе невозможна, потому что там чётко определён порядок вычисления аргументов функции слева направо. В C++ этот порядок оставлен на усмотрение компилятора — не иначе как затем, чтобы он мог сэкономить пару циклов процессора на каких-нибудь оптимизациях. Хорошо, что хотя бы в 17-м Стандарте приняли чёткий порядок для оператора присваивания, выражения индексации и ещё нескольких языковых конструкций.
Следующий пример. Чтобы найти в нём ошибку, от разработчика требуется хорошо знать процесс инициализации объектов в C++:
class install_dependencies : public modal_dialog { public: explicit install_dependencies(const addons_list& addons) : modal_dialog(window_id()), addons_(addons) {} .... private: virtual const std::string& window_id() const override; .... }
В чём же ошибка?
В том, что автор кода не использует статический анализатор
Если заменить вызов конструктора базового класса на инициализацию члена, то можно будет сказать, что подобная ошибка тоже невозможна в C#.
Оба примера взяты из блога PVS Studio.
Понятно, что сложность C++ отчасти снимается статическими анализаторами, но проблема в том, что они находят не все ошибки, и к тому же генерируют много ложных срабатываний, которые создают информационный шум и ослабляют бдительность.
Рассмотрим вот такой синтетический пример:
void remove(std::vector<int>& v, const std::function<bool(int)>& pred) { auto it = std::find_if(v.begin(), v.end(), pred); if (it != v.end()) for (int i = 0; i != 2; ++i) v.erase(it); }
Хотите верьте, хотите нет, а статический анализатор в этом коде проблемы не видит. Вы можете возразить, что в C# подобная ошибка тоже возможна, а я отвечу, что в C# при доступе по невалидному индексу у вас гарантированно выбрасывается исключение, в то время как в C++ вы получаете неопределённое поведение — то есть ваша программа начинает выдавать непонятные глюки, источник которых придётся долго искать.
В новых версиях C++ есть средство устранить эту проблему. Если ваш компилятор уже поддерживает hardening стандартной библиотеки из C++26, и вы включили его при сборке, то нарушения контрактов будут проверяться в рантайме. Понятно, что рантайм проверки не берутся из воздуха и занимают процессорное время, так что ценой этому будет ухудшение производительности на какие-нибудь доли процента. Как правило, это приемлемый компромисс.
Мне будет сложно объяснить, зачем я написал целый раздел про сложность языков, и назвал его «Хардкор». Чтобы яснее выразить мысль, я немного отойду от темы программирования и расскажу, что понимают под хардкором в мире видеоигр.
Существует игра под названием «Detroit: Become Human», которая почти не заставляет игрока потеть и напрягаться, не бросает ему никакого вызова и не требует тренировки навыков, а её сюжет не стоит бумаги, на которой он написан. Тем не менее, у неё есть своя база поклонников, и я абсолютно толерантно отношусь к этим людям, и ни в коем случае не осуждаю их выбор.
С другой стороны, серия игр под названием Dark Souls предлагает совершенно иной игровой опыт. Путешествуя по миру Dark Souls, вы постоянно испытываете желание разбить геймпад об стену, либо выкинуть монитор в окно, но больше всего вам хочется найти Хидэтаку Миядзаки и разбить геймпад об его лицо, да так, чтобы и геймпад, и лицо разлетелись на тысячу маленьких осколков. Чтобы почувствовать атмосферу Dark Souls, можете посмотреть нарезку кадров из этой игры под песню мёртвого казахского рэпера:
«Detroit: Become Human» — это развлечение для работяг, которые пришли домой, хлебнули пивка и решили немного позалипать в монитор. «Dark Souls» — это испытание для настоящих хардкорных геймеров, которые не боятся принять вызов. Которым нравится, когда игра их всячески сношает и заставляет преодолевать трудности, и которые ценят игры как источник сильных и незабываемых эмоций, потому что эмоции — это то, что заставляет нас чувствовать себя живыми.
Думаю, что примерно в той же плоскости лежит различие между настоящими программистами и пассажирами, которые пришли в ИТ за длинным рублём. По уровню хардкора C++ опережает C#, так что по итогам 1-го пункта моей статьи, счёт становится 1:0 в пользу C++.
2. Ссылки и значения
Первое знакомство с концепцией ссылочных и значимых типов в C# вызывает лёгкое недоумение. В C++ все типы по умолчанию значимые, но любой из них легко превращается в ссылку, если добавить к нему ссылочный квалификатор &. Это лёгкий для понимания, прямолинейный и очевидный подход, при котором понятие ссылки существует само по себе, независимо от типов.
В C# значимыми являются встроенные типы, структуры, объединения и энумы. Всё остальное доступно только в виде ссылок, и C#-программистам предлагается это просто запомнить. То есть вы всё время должны держать в голове, что переменная типа double — это значение, а переменная типа string — это ссылка.

Такое разделение ограничивает способность программистов «стрелять себе в ногу». Если разрешить им передавать полиморфные объекты по значению, то рано или поздно кто-то из них столкнётся с проблемой слайсинга — когда переменная дочернего класса копируется в переменную базового класса, и от неё отрезаются все данные, не относящиеся к базовому классу. В C++ такую ошибку можно допустить, в C# — нельзя. При этом в C# у вас всегда есть куча ссылочных типов, от которых никто и никогда не будет наследовать, но вы всё равно не можете передавать их по значению.
Отсутствие семантики значений для ссылочных типов гарантирует невозможность слайсинга, точно так же как отсутствие ноги исключает вероятность ранения в ногу. Создатели сишарпа просто ампутировали вам ноги, чтобы спасти вас от возможных ранений. И всё ради решения проблемы, которую статические анализаторы обнаруживают на раз-два.
Если в C# вам нужно передать значимый тип по ссылке, то вы делаете это с помощью ref. Но если вам нужно передать ссылочный тип по значению, то начинаются проблемы. В C# нет стандартного способа сделать глубокую копию объекта, и для каждого класса приходится руками писать какую-нибудь функцию с названием вроде Clone или DeepCopy. В C++ компилятор генерирует копирующий конструктор всегда, если только у него нет объективных причин этого не делать — например, если программист сам написал реализацию копирования, то компилятору её создавать не нужно.
Примерно так же дела обстоят с операторами сравнения ==, != <=, >=, <, >. В C++ оператор <=>, как и оператор ==, предназначен для сравнения данных, а не адресов в памяти. В версии C++20, оба этих оператора будут сгенерированы компилятором, если объявить их через = default. По умолчанию, для ссылочных типов в C# (кроме record) сравнение двух переменных означает проверку, что две ссылки указывают на один и тот же объект, а если вы хотите сравнивать данные, то всю логику приходится писать руками. Причём из-за отсутствия оператора трёхстороннего сравнения, который неявно заменял бы все остальные операторы, в C# для этого реально приходится писать 6 функций.
Деление типов на ссылочные и значимые создаёт лишнюю когнитивную нагрузку, заставляет писать тонну бойлерплейт-кода и в конечном итоге приводит к тому, что счёт становится 2:0 в пользу C++.
3. Nullable
Допустим, у вас есть функция поиска строки в таблице по уникальному индексу. В C++ её сигнатура может выглядеть так:
string find(int id);
Каким должен быть её результат, если в таблице нет ячейки с нужным индексом? Пустая строка не подходит, потому что пустую строку мы возвращаем, когда запрошенная ячейка существует, и в ней лежит пустая строка. Как сообщить вызывающему коду, что нужной ячейки вообще нет? Бросать исключение — это медленно, неудобно и неправильно с точки зрения интерфейса, потому что отсутствие ячейки — это не ошибка, а штатная ситуация. Для таких случаев в C++ существует класс optional:
optional<string> find(int id);
Объект класса optional<T> может либо содержать проинициализированный объект класса T, либо не содержать ничего. Вот как это работает:
auto opt = find(42); if (opt) { const string& str = *opt; println("Результат: {}", str); } else println("Результат не найден.");
В C++ это совершенно элементарная концепция; мне даже слегка неудобно, что я потратил столько времени на её объяснение. Но давайте попробуем найти её аналог в C#.
Долго искать не придётся — аналогом std::optional в C# является структура System.Nullable<T>. Чтобы не писать это длинное название, можно просто использовать запись с вопросиком в конце, например вот так:
int? i = null;
Правда, есть небольшая проблема — в Nullable можно обернуть только значимый тип, который при этом сам не является Nullable. То есть да, Nullable в C# тоже работает по-разному для значимых и ссылочных типов. В C++, optional принимает любой Destructible тип, в нём нет неочевидных ограничений, которые надо запоминать. Можете хоть 10 optional-ов запихать друг в друга, никто вам слова поперёк не скажет.
Nullable не используется для ссылочных типов, потому что ссылка в C# по своему изначальному дизайну и так умеет принимать значение null — т. е. такое значение, в котором она точно не указывает ни на какой объект. Если проводить аналогии с C++, то ссылка в C# не является аналогом std::optional — она скорее является аналогом указателя, который может принимать нулевое значение (nullptr). Когда вы работаете с переменной ссылочного типа, вы практически перед каждым использованием должны проверять её на null. У вас нет способа достать из этой ссылки живой объект, который чисто семантически не может быть null. Посмотрите ещё раз на эту строчку из приведённого выше примера на C++:
const string& str = *opt;
С момента, когда вы достали строку из объекта opt и положили её в переменную str, вы можете смело обращаться к её данным, не опасаясь нарваться на неопределённое поведение. Объект типа string в принципе не может иметь нулевое значение — потому что это string, а не optional.
В C# 8.0 была попытка решить эту проблему через добавление nullable контекста, который можно включать либо в настройках проекта, либо на уровне кода с помощью прагм. Давайте попробуем с его помощью получить то, ради чего всё и затевалось, а именно объект ссылочного типа, который гарантированно не является null. На уровне проекта контекст включается следующим образом:
<Nullable>enable</Nullable>
После этого компилятор начинает различать nullable и не-nullable ссылочные типы по наличию вопросика. Например:
string? s1 = null; // OK string s2 = null; // warning CS8600 s2 = s1; // warning CS8600 s1 = s2; // OK if (s1 is not null) s2 = s1; // OK
В этом коде мы получаем предупреждение компилятора при попытке присвоить переменной s2 значение null, либо значение, которое потенциально может быть null.
Nullable-контекст не добавляет в рантайм новые типы ссылок; string и string? — это один и тот же тип. Включённый контекст просто используется для статического анализа, то есть компилятор смотрит на код и генерирует предупреждение, когда видит потенциальное нарушение правил контекста.
Если мы хотим, чтобы такие ситуации были вообще невозможны, то предупреждения компилятора нас не устраивают. Нам придётся использовать ещё одну настройку проекта, чтобы заменить предупреждения на ошибки:
<WarningsAsErrors>nullable</WarningsAsErrors>
Теперь этот код просто не скомпилируется, что нам и требовалось. Компилятор защищает нас от любых действий, которые могут привести к попаданию null в переменную не-nullable типа. Получается, что проблема решена?
Давайте посмотрим, можно ли обойти этот запрет с помощью рефлексии:
using static System.Exception; class Program { public class A { required public string str { get; set; } } static void Main() { A a = new(){ str = string.Empty }; typeof(A).GetProperty(nameof(A.str))!.SetValue(a, null); if (a.str is null) throw new Exception("???"); } }
Этот код компилируется без ошибок, и при запуске программы в нас летит исключение с тремя вопросительными знаками. Вам кажется этот пример надуманным? А я скажу, что этот код используется повсеместно, просто он спрятан в библиотеках, занимающихся десериализацией объектов из json-ов, yaml-ов и прочих форматов. Каждый раз, когда вы загружаете данные с диска, или когда какой-то микросервис получает запрос, происходит парсинг строки и создание объектов с помощью рефлексии.
Но ещё не всё потеряно. Давайте перепишем этот пример так, чтобы он был больше похож на реальный кейс:
using static System.Exception; using System; using System.Text.Json; class Program { public class Person { required public string Name { get; set; } } static void Main() { string json = """{"Name":null}"""; Person p = JsonSerializer.Deserialize<Person>(json)!; if (p.Name is null) throw new Exception("???"); } }
Начиная с .NET 9, в настройках JsonSerializer появился флаг RespectNullableAnnotations:
string json = """{"Name":null}"""; JsonSerializerOptions options = new() { RespectNullableAnnotations = true }; Person p = JsonSerializer.Deserialize<Person>(json, options)!;
Нам повезло, что разработчики JsonSerializer-а вообще озаботились этой проблемой. Теперь программа выбрасывает исключение JsonException во время десериализации с подробным описанием проблемы:
Unhandled exception. System.Text.Json.JsonException: The property or field 'Name' on type 'Program+Person' doesn't allow setting null values. Consider updating its nullability annotation.
Но вы только подумайте — для того, чтобы правильно пользоваться nullable контекстом, вам приходится явно задавать какие-то малоизвестные настройки для любых инструментов, которые можно заподозрить в использовании рефлексии. Да, и ещё один момент — настройка RespectNullableAnnotations имеет ограничения по типам, с которыми она может работать — например, она не различает List<string> и List<string?>.
Nullable-версии для ссылочных и значимых типов синтаксически выглядят одинаково, но чтобы получить значение string?, вы выполняете обычное присваивание, а значение int? вы получаете через свойство .Value класса Nullable<T>:
string? s_ = "asdf"; string s = s_; int? i_ = 1; int i = i_.Value;
Если эти сущности настолько разные, то почему их объявление выглядит одинаково? Это только вводит в заблуждение и сбивает с толку.
В общем, итог этого раздела понятен: 3:0 в пользу C++.
4. RAII
Предположим, у нас есть система логгирования на C++, которая позволяет менять уровень отступа в файле журнала с помощью методов push и pop:
void logSomeStuff() { auto& log = Log::getInstance(); log.print("1111"); log.push(); log.print("2222"); log.push(); log.print("3333"); log.print("4444"); log.pop(); log.pop(); log.print("5555"); }
Допустим, что при выполнении этого кода получается вот такой лог:
1111 2222 3333 4444 5555
Давайте с помощью этой системы залоггируем какую-нибудь полезную работу, чтобы по этим логам в будущем можно было восстанавливать историю событий, собирать статистику и расследовать инциденты.
void bar() { auto& log = Log::getInstance(); log.print("bar:"); log.push(); ... log.print("Делай раз"); ... log.print("Делай два"); ... log.print("Делай три"); log.pop(); } void foo() { auto& log = Log::getInstance(); log.print("foo:"); log.push(); ... log.print("Делай раз"); ... bar(); ... log.print("Делай два"); log.pop(); }
Выполнение этого кода даёт нам следующий лог:
foo: Делай раз bar: Делай раз Делай два Делай три Делай два
Благодаря отступам, в логе чётко видно, к какой функции относится каждое сообщение. Картина произошедших событий ясно встаёт перед глазами, и читать такие логи — одно удовольствие. Но чтобы увидеть такую красоту, при выходе из каждой функции никогда и ни при каких обстоятельствах нельзя забывать поставить pop() — иначе все остальные логи во всей программе будут вываливаться с одним лишним отступом. Часть кода в этом примере скрыта многоточиями, но давайте предположим, что в этих местах существуют промежуточные точки выхода в виде return-ов, так что вызов pop() надо поставить перед каждым из них. Не слишком ли много внимательности требует от нас эта система логгирования? Самое печальное, что даже если мы ничего не забудем и не пропустим ни один return, нас это всё равно не спасёт. Выход из функции может произойти в результате исключения, которое может быть выброшено вообще в любом месте. Нам нужно придумать механизм, который вызывает определённый код при выходе из области видимости, независимо от причины такого выхода.
В C++ этим механизмом является деструктор локальной переменной. Дело в том, что в отличие от C#, в C++ у нас есть полный контроль над временем жизни объектов. Для локальных переменных действует простое правило — при выходе из области видимости они уничтожаются в порядке, обратном порядку их объявления, а при уничтожении любого объекта вызывается его деструктор.
(В данном случае, имеется в виду локальная область видимости, то есть тело функции, либо блок кода внутри функции, ограниченный фигурными скобками)
struct LogIndentationGuard { LogIndentationGuard() { Log::getInstance().push(); } // конструктор ~LogIndentationGuard() { Log::getInstance().pop(); } // деструктор }; void bar() { // <- начало «области видимости» («scope») auto& log = Log::getInstance(); log.print("bar:"); LogIndentationGuard guard; ... log.print("Делай раз"); ... log.print("Делай два"); ... log.print("Делай три"); } // <- конец «области видимости» и момент уничтожения guard void foo() { auto& log = Log::getInstance(); log.print("foo:"); LogIndentationGuard guard; ... log.print("Делай раз"); ... bar(); ... log.print("Делай два"); }
Приём, который мы только что применили, называется RAII (Resource acquisition is initialization), и состоит он в том, что управление ресурсом привязывается к времени жизни объекта. Можно ли что-то подобное сделать в C#?
Начнём с того, что в C# вызов pop() можно поместить в finally-блок — тогда он действительно будет выполняться при любом выходе из блока try, происходит ли это через return, через исключение, через break, goto или любым другим способом. Но если вы сделаете систему логгирования, которая заставляет пользователей оборачивать весь свой код в try и finally блоки, спасибо они вам за это не скажут, поэтому давайте всё-таки попробуем реализовать что-то наподобие RAII идиомы из C++.
Если коротко, то в C# есть законный способ это сделать, но он требует гораздо больше писанины. В языках со сборщиком мусора время жизни объектов не определено, поэтому идиома RAII со сборкой мусора сочетается плохо. Невозможно предсказать, когда сборщик мусора решит заняться удалением неиспользуемых объектов.
Чтобы сделать класс похожим на RAII в C++, нужно унаследовать его от интерфейса IDisposable, добавить в него флаг состояния _isDisposed и определить в нём 3 метода: Dispose(), Dispose(bool) и финализатор. Финализатор является аналогом деструктора в C++, но вызывается он не на выходе из области видимости, а когда-нибудь потом, когда сборщик мусора начнёт удалять объект. На выходе из области видимости вызывается метод Dispose(), то есть метод Dispose() как бы тоже является аналогом деструктора RAII-объекта в C++. Эти два метода выполняют похожую логику освобождения ресурсов, и чтобы не было повторения кода, эта логика выносится в отдельную функцию Dispose(bool). Dispose(bool) — это самая сложная часть, которая освобождает ресурсы и выставляет флаг _isDisposed, либо не делает ничего, если _isDisposed уже выставлен. Dispose(true) освобождает managed и unmanaged ресурсы, Dispose(false) — только unmanaged. Managed ресурсы — это объекты других классов, унаследованных от IDisposable, которыми владеет наш класс. Их важно освобождать только при вызове Dispose(), но не при сборке мусора, потому что порядок удаления объектов при сборке мусора не определён, и к моменту вызова финализатора нашего класса, ресурсы managed объектов, которыми он владеет, уже могут быть освобождены. Кроме того, к managed относятся объекты обычных классов C#, память для которых отслеживает и освобождает сборщик мусора. Если класс содержит объекты, занимающие много памяти, то ссылки на них выгодно обнулить при вызове Dispose(), чтобы при следующей сборке мусора они с большей вероятностью были удалены. Unmanaged ресурсы — это другие ресурсы, которыми владеет наш класс и о которых сборщик мусора не имеет никакого представления — такие как дескрипторы файлов и окон, сетевые соединения или, например, тот же отступ логгера из предыдущего примера. Финализатор вашего класса должен вызывать Dispose(false), а функция Dispose() должна вызывать Dispose(true). Кроме того, она должна вызвать метод GC.SuppressFinalize(this), чтобы предотвратить финализацию нашего объекта при следующей сборке мусора. То есть да, чтобы правильно реализовать этот класс, вам нужно немножко знать, как работает сборщик мусора и обращаться к его интерфейсу. Функция Dispose() должна быть public, а функция Dispose(bool) должна быть protected virtual.
Всё это — только общие сведения. Для более сложных случаев в документации описано ещё несколько тонкостей, которые надо учитывать. И есть отдельная история про то, как реализовать класс с интерфейсом IAsyncDisposable для асинхронного контекста.
Объект Disposable-класса следует создавать с ключевым словом using, чтобы компилятор мог неявно завернуть текущий код в try-finally блок, и в блоке finally вызвать метод Dispose(). То есть вам недостаточно помнить, что бывают ссылочные и значимые типы — надо ещё помнить про Disposable типы, и переменные этих типов надо не забывать объявлять через using.
Интерфейс Disposable объектов — это пример исключительно поганой архитектуры. Сборщик мусора создавался для того, чтобы облегчить программистам управление ресурсами, но в этом примере мы видим, что как раз-таки управление ресурсами он до безобразия усложнил. Сборщик мусора упрощает управление памятью, но управляемым ресурсом далеко не всегда является память. И контекст логгирования — это только один из возможных примеров.
И вообще, насколько удобно иметь в своём проекте целую подсистему, которую вы не контролируете, и которая может в любой момент остановить все ваши потоки и запустить сборку мусора, без предупреждения и объявления войны? Говорят, что сборщик мусора стал невероятно эффективен и отрабатывает так быстро, что этим временем можно пренебречь, но давайте будем честны — сборщик мусора в C# работает плохо. Если бы он работал хорошо, то никакого C#-а бы не существовало — сборщик мусора его бы собрал.
В завершение этого раздела, я всё-таки представлю вам реализацию класса LogIndentationGuard на C# — интересно же, как она выглядит?
public sealed class LogIndentationGuard : IDisposable { public LogIndentationGuard() { Log.getInstance().push(); } public void Dispose() { if (!_isDisposed) { Log.getInstance().pop(); _isDisposed = true; } } private bool _isDisposed; }
Как видим, всё оказалось гораздо проще, чем я рассказывал. Но даже чтобы написать такую простую реализацию, надо держать в голове всю картину работы с IDisposable и понимать, почему конкретно в этом случае можно обойтись без финализатора, обращения к сборщику мусора и виртуальной функции Dispose(bool), почему класс должен быть sealed, и почему функция Dispose() должна быть идемпотентной.
Из-за того, что интерфейс Disposable идёт в комплекте с таким количеством сложностей и проблем, счёт становится 4:0 в пользу C++.
5. Зарплата
Давайте ненадолго отвлечёмся от технических вопросов, спустимся на пару этажей вниз по пирамиде Маслоу и поговорим о зарплате. В блоге Хабр Карьеры есть статистика по России за 2025 год, по которой медианная зарплата для разработчиков C++ составляет 240 000, а для C# — 250 000. Как видите, по этому показателю C++ снова оказывается впереди. Если у вас есть бизнес по разработке ПО, то вам выгоднее нанимать C++ разработчиков, потому что им можно меньше платить.
В этот момент мне становится даже как-то неловко за C#. Неужели не будет ни одной номинации, в которой он сумеет выйти вперёд? Скоро мы это узнаем, а пока что счёт становится 5:0 в пользу C++.
6. Темплейты и дженерики
Класс optional<T> в C++ — это простой пример template класса, а его аналог в C# — Nullable<T> — это простой пример дженерика. Идея у них одна и та же — они предоставляют контейнер, который либо содержит валидный, проинициализированный объект, либо является пустым. Если писать отдельную реализацию такого контейнера для каждого хранимого типа, то код у этих реализаций будет по большей части одинаковый. Вместо этого инструменты обобщённого программирования позволяют написать один шаблонный класс, который параметризуется типом объекта, чтобы пользователь мог инстанцировать нужную ему разновидность, подставив параметр типа в угловые скобки:
optional<int> — optional, в который можно положить целое число.
vector<string> — контейнер, который может хранить любое количество строк.
unique_ptr<vector<string>> — владеющий «умный» указатель, указывающий на вектор строк.
В C# это выглядит примерно так же: List<int>, Dictionary<string, int> и так далее.
Кроме классов, параметры типов могут быть и у функций, например:
make_unique<string>("Hello, World!") — возвращает указатель unique_ptr<string>, указывающий на строку "Hello, World!".
В большинстве случаев, вы не можете просто использовать какой-то неизвестный тип, переданный пользователем в списке параметров. Чтобы сделать с ним что-то полезное, вы должны иметь о нём хоть какую-то информацию, то есть вам нужно ограничить параметр типа какими-то условиями. Для примера, давайте напишем на C++ функцию max, которая находит наибольшее из двух значений:
template <typename T> const T& max(const T& a, const T& b) requires requires (const T& x, const T& y) { { x < y } -> std::convertible_to<bool>; } { return (a < b) ? b : a; }
Функция проверяет, что для типа T существует оператор <, который возвращает результат, конвертируемый в bool. Если вы попробуете использовать эту функцию с типом, не имеющим такого оператора, то вы получите ошибку компиляции:
struct A{}; auto f = max(A{}, A{}); // error: constraints not satisfied // the required expression ‘(x < y)’ is invalid
Теперь напишем дженерик функцию в C# с такими же ограничениями:
static T max<T>(T a, T b) where T : IComparisonOperators<T, T, bool> { return a < b ? b : a; }
Если решение на C++ требует только оператора <, то решение на C# требует наличия 6 операций сравнения: >, <, >=, <=, == и !=. Если на C++ мы можем потребовать, чтобы оператор < возвращал любой тип, конвертируемый в bool, то в C# операторы сравнения должны возвращать именно bool и ничто иное. Дженерики в C# не дают нам достаточной гибкости, чтобы написать менее ограничительную версию.
Но недостаток гибкости — это вообще ничто по сравнению с главной проблемой, которую мы видим в этом коде. Ограничение дженерика требует, чтобы тип T наследовал интерфейс IComparisonOperators. В C++ всю необходимую информацию об этом типе функция max получает сама. В C#, каждый тип, используемый в дженерике, должен сообщать о себе подобную информацию через реализацию интерфейсов. Это называется размыванием ответственности и является признаком плохой архитектуры.
Кроме наличия каких-то операторов, requires-выражение в C++ позволяет проверить наличие поля или статического члена в классе, а также существование метода класса или свободной функции с определённой сигнатурой:
struct X { int id; static constexpr int version = 1; void resize(size_t) {} }; string to_string(const X&) { return "X"; } template <typename T> concept C = requires(T x, size_t n) { { x.id } -> same_as<int&>; { T::version } -> convertible_to<int>; { x.resize(n) } -> same_as<void>; { to_string(x) } -> same_as<string>; }; int main() { static_assert(C<X>); return 0; }
В C# эти проверки также работают через интерфейсы, либо через рефлексию, которая нагружает рантайм и требует юнит-тестов для проверки, в то время как выражения requires в C++ работают во время компиляции и проверяются с помощью static_assert-ов.
Функция max, реализованная через дженерики, может работать со встроенными типами, такими как double и int, только потому что создатели C# озаботились реализовать для них интерфейс IComparisonOperators. Наряду с этим, тип int наследует ещё 26 интерфейсов:

И в этом весь C#. Им мало, что они поделили все типы на ссылочные и значимые, которые ведут себя по-разному, хотя для тех и других используется одинаковый синтаксис. Им мало, что Nullable контекст не даёт твёрдых гарантий для не-nullable объектов, а только создаёт чувство ложной безопасности, из-за которого можно упустить лишнюю проверку и получить null там где его не ждали. Им мало, что вместо простого RAII мы получили интерфейс, требующий соблюдения сложных и неочевидных правил. Вдобавок ко всему, они заставили бедный маленький int помнить о том, что его можно сравнивать, складывать, умножать, подвергать побитовым операциям, конвертировать в другие встроенные типы, инициализировать нулём, парсить из строки и так далее.
К этому моменту, я уже могу сформулировать простой принцип выбора языка для начинающих программистов. Если вас устраивает, когда у вас бардак в жизни, бардак в голове и бардак на проекте, то смело выбирайте C#. Если же вы любите, чтобы всё было чисто и аккуратно, тогда запасайтесь обезболивающими и готовьте вазелин, ибо перед вами лежит путь C++ разработчика.
Давайте вернёмся к темплейтам и рассмотрим ещё один пример на C++:
template <typename T, size_t N> requires (N > 0) struct Vector { ... private: T data[N]; };
Здесь описан контейнер, размер которого произволен, но известен на этапе компиляции, и при этом не равен нулю. Этот контейнер хорош тем, что в нём отсутствуют динамические аллокации памяти, он лучше оптимизируется компилятором, а его размер можно использовать в compile-time выражениях. В C# в качестве дженерик параметра можно использовать только тип, но не число, поэтому создать такой класс в C# невозможно. Кроме целых чисел, параметрами типа в C++ могут выступать энумы, вещественные числа, указатели и лямбды.
Вот ещё один пример:
template <class F, class... Ts> void for_each_arg(F&& f, Ts&&... xs) { (f(std::forward<Ts>(xs)), ...); } int main() { for_each_arg( [](const auto& x) { std::cout << x << '\n'; }, 42, 3.14, "hello" ); return 0; }
Это называется variadic template. В C# нельзя повторить подобный фокус, потому что дженерики не могут иметь переменное число параметров типа. Благодаря этому, вместо нормального аналога std::function в C# имеется 17 делегатов с числом параметров от 0 до 16:

В C++ темплейтах можно сделать несколько специализаций для одного шаблона. Например, класс unique_ptr из стандартной библиотеки имеет отдельную специализацию для массивов, поскольку им требуется освобождение памяти через специальный оператор delete[]. В C# у каждого дженерика может быть только одна специализация.
Итак, счёт становится 6:0 в пользу C++.
7.
В этой главе я собрал несколько второстепенных концепций, ни одна из которых не тянет на отдельный раздел.
Во-первых, это итераторы. В C++ они представляют элементы контейнеров — массивов, списков, словарей и так далее — таким образом, что с помощью итератора можно получить значение элемента, быстро удалить элемент, вставить перед ним новый элемент, либо использовать диапазон из двух итераторов, чтобы удалить сразу несколько расположенных рядом элементов. В C# вы можете получить значение, хранящееся в контейнере, но не объект, который представляет элемент контейнера, и который можно использовать для прицельных манипуляций с самим контейнером. Самое близкое по смыслу из того, что есть в C# — это энумератор, но его возможности настолько ограничены, что даже несерьёзно об этом говорить. Потратив время на поиск элемента в хэш-таблице, вы получите его ключ, и чтобы, к примеру, удалить этот элемент, вам придётся заново искать его по этому ключу:
var key = ComplicatedSearchAlgorithm(dict); dict.Remove(key); // <- тратим время на поиск того, // что мы уже нашли на предыдущем шаге
В C++ вы используете константный итератор, если хотите запретить изменение элементов контейнера, и неконстантный итератор, когда вы хотите свободно их изменять. В этих правилах невозможно запутаться — всё просто и понятно.
В C# всё сложно. Стандартные средства доступа к элементам могут позволять или запрещать изменяющие операции — это зависит как от самого контейнера, так и от того, хранит ли он значимые или ссылочные типы. Если речь о значимых типах, то выражение вида points[0].X = 1 скомпилируется для массива, но не скомпилируется для списка. Если в словаре хранятся объекты ссылочного типа, то запрет изменяющих операций распространяется только на саму ссылку — её нельзя переназначать на другой объект, но текущий объект можно подвергать изменениям. В списках и словарях со значимыми типами нельзя изменять внутреннее состояние элементов — их можно только заменять на новые, заново сконструированные объекты, что неудобно и вредно для производительности. Для продвинутых пользователей C# существуют обходные пути вроде цикла ref foreach или функции CollectionsMarshal.GetValueRefOrNullRef(dictionary, key), которые предоставляют ref ссылки прямо на элементы контейнера, чтобы их можно было модифицировать. Из стандартных контейнеров C#, цикл ref foreach поддерживает только Span. Списки и массивы можно превращать в Span, поэтому для них этот цикл доступен, но у словарей и HashSet-ов такой возможности нет.
Сколько всего приходится запоминать, и какими извилистыми путями приходится ходить программистам C# ради того, что на C++ получается легко и естественно!
В C++ от типа итератора зависят возможности обхода элементов контейнера. В лучшем случае вы можете двигаться в обе стороны и даже делать скачки с пропуском элементов, а в худшем вы можете двигаться только вперёд и только на один шаг. Чтобы выполнять какую-то логику над элементами контейнера, вам не нужен сам контейнер — вам не нужно даже знать, что это за контейнер. Вам нужен только диапазон, представленный двумя итераторами. Именно так работают алгоритмы STL в C++, такие как поиск по предикату, бинарный поиск, накопительное сложение, сортировка, копирование и пр. Если в C# каждый контейнер имеет свой набор методов класса для реализации некоторых алгоритмов, то в C++ алгоритмы существуют отдельно, и их реализация привязана не к типу контейнера, а к типу итератора. Если дать алгоритму сортировки итераторы двусвязного списка, то код не скомпилируется, потому что std::sort работает только с итераторами произвольного доступа. Вы можете передать ему пару итераторов, указывающих на диапазон внутри вектора, и тогда он отсортирует только часть вектора. Итераторы в C++ — это пример того, чем полезен принцип разделения ответственности.
Заканчивая тему контейнеров, упомяну, что в стандартной библиотеке C++ есть такие типы как multiset, unordered_multiset, multimap и unordered_multimap, аналогов которым в C# просто не существует.
Ещё одна удобная вещь в C++ — это квалификатор const. Переменную, объявленную как const, запрещено модифицировать, и если кто-то попытается применить к ней изменяющую операцию, то возникнет ошибка компиляции. Применив константный квалификатор к методу класса, вы запрещаете этому методу модифицировать свой объект (*this). Это помогает программисту выразить свои намерения в виде твёрдого контракта и спасает от багов, связанных с нарушением логики, изначально заложенной в код. Многие стандарты кодирования, рекомендации и лучшие практики предписывают по умолчанию применять const ко всем сущностям, а потом уже разбираться, откуда его нужно убрать.
В C# аналогом константного квалификатора является readonly. По неизвестным причинам, readonly нельзя применять к локальным переменным и методам обычного класса (который не struct). Его можно применить к ссылочной переменной, в результате чего получается неизменяемая ссылка, которую нельзя переназначить на другой объект, но зато можно изменять сам объект, на который она указывает. В C++ вы можете определить константный указатель на неконстантный объект, или неконстантный указатель на константный объект — короче, const там применяется отдельно к указателю и к объекту, на который он указывает, потому что это логично, и реализовать в языке эту идею по-другому, находясь в здравом уме, просто невозможно. Если, конечно, вы не входите в число создателей C#.
В C# есть квалификатор константы времени компиляции const (урезанный аналог constexpr из C++), который нельзя применять к чему-то кроме чисел, bool, строк и нулевых ссылок. Ещё его почему-то нельзя использовать с ключевым словом var. Нельзя объявлять классы внутри функций. Множественное наследование запрещено. Пространство имён может содержать только классы, но не переменные и не методы. Как программисту C++, привыкшему дышать воздухом свободы, мне непонятно, зачем C# накладывает на меня столько бессмысленных ограничений.
Счёт становится 7:0 в пользу C++.
8. Рефлексия
В переводе с английского, reflection означает «отражение». Рефлексия позволяет программе видеть свою структуру, как будто она смотрит на себя в зеркало. Через рефлексию ей становятся доступны такие приёмы, как получить список методов и полей класса, найти метод класса по его имени и вызвать его со списком параметров, получить сигнатуру функции и в цикле обойти её параметры, извлекая информацию об их именах и типах, получить метаданные класса по его имени и сконструировать объект этого класса, и так далее, и тому подобное. С первого взгляда непонятно, зачем всё это нужно, ведь гораздо проще создавать объекты через объявление переменных, а функции вызывать обычным способом, вместо того чтобы искать их по названию, формировать контейнер с аргументами и вызывать их через методы рефлексии.
Список возможностей, которые открывает рефлексия, начинается с сериализации и десериализации данных. Сериализация — это когда данные преобразуются в строку или в последовательность байтов, чтобы их можно было отправить по сети или сохранить на диск. Соответственно, десериализация — это восстановление данных из строки или массива.
Допустим, у нас есть JSON-файл с конфигурацией, в котором хранится настройка шрифта для текстового редактора в следующем виде:
{ "TextEditor": { "Fonts": { "CommonText": { "Family": "Times New Roman", "Size": 13, "Color": "FFFFFF" }, ... }, ... }, ... }
Чтобы применить эту настройку в программе, сперва вам нужно вытащить все данные из JSON-а и разложить их по своим местам. Проще всего будет поместить их в конфигурационный объект, повторяющий структуру этого JSON-файла:
public sealed class Config { public TextEditorConfig TextEditor { get; set; } = new(); ... } public sealed class TextEditorConfig { public FontCollection Fonts { get; set; } = new(); ... } public sealed class FontCollection { public FontSettings CommonText { get; set; } = new(); ... } public sealed class FontSettings { public string Family { get; set; } = ""; public int Size { get; set; } public string Color { get; set; } = ""; }
Чтобы десериализовать этот конфиг без рефлексии, вам нужно написать простыню тривиального повторяющегося кода, который будет применим только к этому конкретному конфигу. Рефлексия даёт вам возможность проанализировать структуру класса и сопоставить её со структурой JSON-а, поэтому с ней вы можете написать универсальный десериализатор, который работает вообще с любыми типами. Один такой десериализатор лежит прямо в стандартной библиотеке C#, и с его помощью преобразование JSON-а в конфигурационный объект занимает всего одну строчку:
Config config = JsonSerializer.Deserialize<Config>(json);
До утверждения 26-го Стандарта, в C++ рефлексии не было вообще, а сейчас, на июнь 2026 года, её поддержка ограничена всего одним компилятором (GCC 16). Как C++-разработчики обходятся без такого важного инструмента? В лучшем случае они используют кодогенерацию, а в худшем обращаются к неудобным макросным решениям, которые сложно диагностировать.
Сериализация и десериализация классов из текстового и бинарного формата — это трудоёмкая, но логически простая задача, хорошо поддающаяся автоматизации, поэтому код для неё можно генерировать с помощью скриптов и программ. Системы сборки в C++ позволяют автоматически запускать этот процесс каждый раз перед началом компиляции.
Если в проекте используется сразу несколько языков, то кодогенерацию имеет смысл делать централизованно. Разработчик создаёт контракт в виде JSON-схемы, а кодогенератор пишет реализацию соответствующих классов на всех языках программирования, используемых в проекте. В этом случае отсутствие рефлексии в C++ не является такой уж большой проблемой.
Рефлексия позволяет определять атрибуты для классов и их членов, методов и даже параметров функций. Например, снабдив свойство класса атрибутом [JsonIgnore], вы предпишете сериализатору его игнорировать:
public class WeatherForecastWithIgnoreAttribute { public DateTimeOffset Date { get; set; } public int TemperatureCelsius { get; set; } [JsonIgnore] public string? Summary { get; set; } }
С помощью рефлексии, сериализатор увидит этот атрибут и не станет записывать данные этого свойства в итоговый JSON-документ.
Посмотрите на этот код:
app.MapGet( "/users/{id:int}", ([FromServices] IAppLogger logger, [FromRoute] int id, [FromQuery] string? format) => { ... });
Здесь регистрируется обработчик GET-запроса для фреймворка ASP.NET. В качестве обработчика выступает лямбда-функция с 3 параметрами: logger, id и format. Данная лямбда специфична для конкретного приложения, но в целом ASP.NET-у без разницы, какую сигнатуру обработчика вы захотите использовать, потому что он не вызывает этот обработчик напрямую — он анализирует его с помощью рефлексии и на его основе строит делегат запроса. Исследуя сигнатуру лямбды, он видит, что параметр logger снабжён атрибутом [FromServices], поэтому, когда в приложение приходит GET-запрос, и ASP.NET вызывает этот делегат, логгер он берёт из контейнера внедрения зависимостей. Он видит, что id снабжён атрибутом [FromRoute], поэтому значение id он берёт из пути, указанного в URL полученного запроса. Параметр format снабжён атрибутом [FromQuery], поэтому его ASP.NET берёт из query-параметров в строке URL.
Рантайм рефлексия упрощает разработку, но замедляет приложение и увеличивает нагрузку на память. Стандартный способ убрать эти расходы — это перестать использовать рефлексию в рантайме и вместо этого во время компиляции автоматически генерировать всю необходимую логику в виде обычного кода на C#. Другими словами, использовать кодогенерацию. В C# этот способ повышения производительности обеспечен стандартными инструментами языка. Например, в новых версиях ASP.NET для AOT сборок по умолчанию делегат запроса строится на этапе компиляции. Сериализацию JSON-а можно перевести с рефлексии на кодогенерацию с помощью JsonSerializerContext. При этом атрибуты членов, такие как [JsonIgnore], всё ещё имеют силу, просто их уже использует не рантайм-рефлексия, а генератор кода.
То, что разработчики популярных библиотек начинают использовать кодогенерацию как замену рефлексии, говорит о том, что рефлексия времени выполнения изначально была не самым удачным решением.
Если в проекте есть какая-то часть логики, которая может быть выполнена до его сборки, то инструменты кодогенерации позволяют выполнить эту логику на раннем этапе компиляции и вернуть её результат в виде сгенерированного C#-кода, который становится частью проекта. Таким образом можно немного разгрузить рантайм и повысить его производительность. В C++ compile-time логика тоже есть, и она реализована намного удобнее — в виде обычных функций, снабжённых квалификаторами constexpr и consteval, которые можно вызывать напрямую. В то время как в C# compile-time логика должна на выходе создавать .cs-файл, содержащий полноценный код с пространствами имён, классами и методами, и уже эти классы и методы можно использовать в остальном коде. Зато кодогенератор в C# может проводить семантический анализ символов компиляции и исследовать построенные компилятором синтаксические деревья. Это даёт примерно те же возможности, что и рефлексия, только разобраться с этим инструментом гораздо сложнее. Проблема в том, что рефлексия работает во время выполнения, а кодогенератор запускается в начале сборки, и рефлексия в нём просто недоступна. При сборке C# проекта, компилятор сначала собирает информацию для кодогенераторов, которые с её помощью генерируют код, и уже после этого проект компилируется вместе со всем, что они нагенерировали. При необходимости перевести какую-то систему с рефлексии на кодогенерацию, семантический анализ компиляции приходится использовать, несмотря на всю его сложность — например, тот же сериализатор JSON-а должен видеть структуру сериализуемого класса, включая имена свойств и их атрибуты.
В C++ рефлексия сама по себе работает во время компиляции, и это естественно — зачем исследовать структуру классов и функций во время выполнения, если информация об этой структуре доступна ещё перед сборкой? Это значит, во-первых, что рефлексия в C++ не создаёт накладных расходов для рантайма, а во-вторых, что её можно использовать для генерации кода, не заставляя программиста работать со сложными компиляторными API.
С другой стороны, в Стандарте C++ она появилась буквально вчера, и вокруг неё ещё не выстроилась устойчивая экосистема с поддержкой во всех компиляторах, с библиотеками сериализации для разных форматов и с другими полезными инструментами. К тому же, большинству C++ проектов понадобится ещё много-много лет, чтобы перейти хотя бы на C++23, не говоря уже о C++26. Но не могу же я из-за этого маленького недостатка объявить, что C++ проигрывает C# по критерию рефлексии? Я обещал объективный и беспристрастный анализ, поэтому я должен быть строг, но справедлив. Формально рефлексия в C++ есть, и через несколько лет, когда её доделают, она будет лучше, чем в C#, так что это 8:0 в пользу C++.
9. Корутины
Объяснить, что такое корутины, в двух словах не получится. Давайте начнём с простого GUI-приложения на C#, которое по нажатию на кнопку Start запускает скачивание файла, читает его в память и производит над ним какие-то вычисления. Обработчик нажатия на кнопку Start будет выглядеть вот так:
private void OnStartBtnClicked(object? sender, EventArgs e) { Log($"Downloading \"{url}\""); string path = DownloadFile(url); Log($"Reading \"{Path.GetFileName(path)}\""); byte[] data = ReadFile(path); Log("Processing data"); string result = CalculateResult(data); Log($"Result: {result}"); }
Проблема, которая бросается в глаза — это то, что код выполняется синхронно. То есть, нажав на кнопку, пользователь какое-то время будет видеть замороженный интерфейс, пока программа не выполнит все эти шаги, после чего интерфейс снова оживёт. Если подумать, то потоку графического интерфейса незачем простаивать без дела, ожидая ответа от сети — тем более, что основная часть работы по сетевому взаимодействию вообще происходит за пределами нашего компьютера. Программе также незачем простаивать во время операций ввода-вывода, происходящих без участия потока GUI, а функцию CalculateResult можно запустить в отдельном потоке, чтобы графический интерфейс в это время мог свободно функционировать.
Давайте попробуем реализовать все эти идеи и для начала применим асинхронный подход на основе коллбэков (callbacks). Теперь обработчик нажатия на кнопку Start примет следующий вид:
private void OnStartBtnClicked(object? sender, EventArgs e) { Log($"Downloading \"{url}\""); DownloadFileAsync(url) .ContinueWith(downloadTask => // (1) { string path = downloadTask.Result; Log($"Reading \"{Path.GetFileName(path)}\""); ReadFileAsync(path) .ContinueWith(readTask => // (2) { byte[] data = readTask.Result; Log("Processing data"); CalculateResultAsync(data) .ContinueWith(calculateTask => // (3) { string result = calculateTask.Result; Log($"Result: {result}"); }, uiScheduler); }, uiScheduler); }, uiScheduler); }
Что заставило нас так изуродовать код? Всего лишь то, что это цена асинхронности. Чтобы понять, как теперь выполняется эта функция, сначала нужно понять такую концепцию, как «цикл обработки событий».
Приложения с графическим интерфейсом выполняются в бесконечном цикле, который выглядит примерно так:
void eventloop(): { while(true) { event = eventQueue.waitForEvent(); process(event); } }
Каждый раз, когда приходит сигнал от клавиатуры, либо когда пользователь двигает мышь, либо ещё как-то взаимодействует с приложением, либо срабатывает таймер — в общем, когда происходит любое событие, на которое наше приложение может среагировать, это событие заносится в очередь событий (eventQueue). Цикл eventloop постоянно достаёт события из очереди и обрабатывает их.
Когда пользователь кликает по кнопке Start, чтобы запустить загрузку файла, нажатие на левую кнопку мыши помещается в очередь событий. Через какое-то время GUI фреймворк достаёт это событие из очереди, начинает его обрабатывать, по координатам курсора выясняет, что произошло нажатие именно на кнопку Start в GUI-интерфейсе, и вызывает функцию OnStartBtnClicked. Затем поток выполнения доходит до вызова DownloadFileAsync. На этом этапе мы обращаемся к планировщику задач и говорим ему примерно следующее: «запусти фоновую задачу по скачиванию файла с этого URL, а когда она завершится, вызови вот этот коллбэк (лямбда (1), переданная в ContinueWith). Отдав такое распоряжение, функция OnStartBtnClicked не дожидается выполнения всей цепочки задач, а продолжает двигаться дальше по коду. Поскольку в коде после этой цепочки коллбэков ничего нет, она сразу завершается и возвращает управление циклу обработки событий. Цикл продолжает крутиться, и интерфейс приложения, вместо того чтобы замереть в ожидании, продолжает работать и реагировать на действия пользователя.
После скачивания файла, в очередь событий помещается сообщение, что скачивание завершено, и у нас есть результат операции в виде файла на диске. Теперь пора вызвать указанный коллбэк — то есть, в данном случае, лямбду (1), которая логгирует отчёт о чтении файла и вызывает функцию ReadFileAsync. Через какое-то время GUI фреймворк достаёт из очереди это сообщение, начинает выполнять лямбду, доходит до вызова ReadFileAsync, и история повторяется — управление возвращается в цикл обработки событий, а система ввода-вывода где-то в фоновом режиме начинает читать файл, чтобы потом в очереди событий появилась команда на вызов следующей лямбды(2).
В результате, код стал похож на ёлочку из коллбэков, когда один коллбэк вызывает другой коллбэк, а тот коллбэк ещё вызывает третий коллбэк. Немного усложните логику лямбд, добавьте обработку ошибок, добавьте в эту цепочку ещё больше коллбэков, и этот код станет вообще невозможно читать.
Чтобы сделать асинхронный код читаемым, нужно использовать корутины. С применением async/await, функция OnStartBtnClicked будет выглядеть так:
private async void OnStartBtnClicked(object? sender, EventArgs e) { Log($"Downloading \"{url}\""); string path = await DownloadFileAsync(url); Log($"Reading \"{Path.GetFileName(path)}\""); byte[] data = await ReadFileAsync(path); Log("Processing data"); string result = await CalculateResultAsync(data); Log($"Result: {result}"); }
Теперь это смотрится как обычный линейный код без вложенных коллбэков. Между тем, он делает то же самое, что и предыдущий вариант: когда выполнение доходит до await, запускается асинхронная задача («скачать файл», «прочитать файл», или «вычислить результат»), которая выполняется где-то вовне — например, в другом потоке, или вообще за пределами системы (в случае запроса к интернет-ресурсу). Не дожидаясь выполнения этой задачи, корутина приостанавливается, и управление возвращается в цикл обработки событий. Состояние корутины, то есть значения локальных переменных и место, где она остановилась, сохраняются в память в виде стейт машины. Когда асинхронная задача завершается, в очередь помещается событие, предписывающее продолжить корутину. Когда дело доходит до обработки этого события, состояние корутины восстанавливается из памяти, и её работа продолжается до следующего await-а.
Цикл обработки событий есть во всех GUI-фреймворках, будь то Windows Forms или WPF на C#, Qt на C++ или tkinter на Python. За пределами GUI-фреймворков планирование корутин обычно работает через пул потоков. Пул потоков (thread pool) — это, грубо говоря, тот же цикл обработки событий, только многопоточный. У вас по-прежнему есть очередь задач, но цикл обработки этих задач крутится в каждом из потоков, входящих в пул. Если в очередь попала задача, то какой первый поток её достанет, тот и будет её выполнять. В GUI приложениях такой подход не используется, поскольку есть архитектурное требование, чтобы весь код, затрагивающий поведение графического интерфейса, выполнялся в одном и том же потоке. Если вместо GUI-приложения вы будете писать HTTP-сервер на ASP.NET, то выполнением корутин будет заведовать планировщик пула потоков. В этом случае, часть корутины до await-а может исполняться в одном потоке, а после await-а — в другом.
Где и как будут исполняться корутины в C#, зависит от текущих настроек SynchronizationContext и TaskScheduler. Например, в Windows Forms исполнение задач планируется в однопоточном цикле обработки событий, а в обычном консольном приложении без дополнительных настроек будет использоваться планировщик по умолчанию, работающий на базе thread pool-а.
Корутину можно воспринимать как линейный блок кода, но следует помнить, что в момент вызова await состояние объектов может неожиданно измениться. Пока корутина ждёт await-а, в приложении прокручиваются циклы обработки сообщений, и произойти может всё что угодно. В C++ это ещё опаснее, чем в C#, поскольку в эти моменты могут инвалидироваться ссылки, указатели и итераторы. В C# инвалидацией ссылок занимается сборщик мусора, поэтому каждая ссылка остаётся валидной, пока она ещё кому-то нужна.
В C++ поддержка корутин ведётся начиная с версии Стандарта от 2020 года. Проблема этой версии в том, что в ней отсутствует готовая реализация awaitable-корутины (аналога класса Task в C#). Из-за этого разные команды наплодили множество кастомных реализаций, несовместимых друг с другом. Стандарт предоставил разработчикам две отдельные сущности: корутину, которая может содержать вызовы co_await, и awaitable-объект, к которому может быть применён оператор co_await. Чтобы собрать из этого корутину, которую можно await-ить из другой корутины, нужно выстроить переплетение promise-ов и awaitable-ов, в котором одна корутина ссылается на другую через handle, разные awaiter-ы планируют зависание одной корутины и запуск другой в начале ожидания, а в конце планируют зависание второй и запуск первой, возвращаемые значения прокидываются в нужный awaiter через promise, и ещё где-то во всей этой системе присутствует код, освобождающий фреймы состояния корутин, чтобы не было утечек памяти и use-after-free.
Всю эту сложность берут на себя разработчики библиотек, и обычным программистам разбираться и вникать в эти дебри не нужно. Например, QCoro добавляет поддержку корутин в популярный GUI-фреймворк Qt. В userver (фреймворк для создания веб-сервисов, что-то вроде ASP.NET) корутины появились ещё раньше, чем началась их поддержка в Стандарте, так что это не совсем то же самое, что stackless корутины в C++20. Вместо stackless, там используются stackful корутины, для каждой из которых выделяется не просто фрейм состояния, а целый стек, и управление такими корутинами происходит через манипуляции с указателем стека и другими элементами состояния выполнения. Как такое вообще можно сделать в C++, я не знаю и знать не хочу. Поддержка корутин есть в библиотеках асинхронного ввода-вывода Boost.Asio и Boost.Cobalt. Библиотеки общего назначения, такие как cppcoro и folly::coro, предоставляют свои методы работы с файлами и реализации сетевых сокетов, которые можно использовать в корутинах, а также свой пул потоков или цикл обработки событий, обеспечивающие контекст исполнения.
Отметим, что разработчикам асинхронных библиотек в C# почему-то не приходится с нуля придумывать модель корутин на основе базовых примитивов. В C++20 нет ни стандартного класса Task, ни стандартного планировщика корутин, поэтому корутины из разных библиотек нельзя использовать совместно — то есть, в общем случае нельзя await-ить одну корутину из другой или ожидать, что корутины из одной библиотеки будут обслуживаться циклом обработки событий из другой библиотеки.
Но есть хорошая новость: в C++26 появилась библиотека std::execution, которая предлагает стандартную модель асинхронности. Если все компоненты вашего проекта и все подключённые библиотеки используют эту модель, то у них не возникает разногласий, как планировать задачи и на каком пуле потоков их запускать. В частности, это позволяет совместно использовать корутины от разных разработчиков и полностью решает проблему, которую я только что описал. Стандарт C++26 приняли в этом году, так что осталось дождаться, когда в компиляторах появится реализация std::execution, и потом подождать ещё неопределённое количество лет, пока все популярные асинхронные библиотеки перейдут на эту модель, и вот тогда заживём.
Но это в случае, если они вообще имеют намерение осуществлять такой переход.
Благодаря адекватной и полноценной реализации корутин, C# победно завершает этот раздел с счётом 8:1, и кто знает — может быть это начало эпического камбэка?
Ну что ж, статья подошла к концу, и настало время подвести итог. Победителем становится C++, а C# получает почётное второе место.
Комментарии (20)

Freeman_RU
29.06.2026 15:07В C#, чтобы подключить библиотеку, вам достаточно выполнить команду
А потом оказывается, что эта библиотека не совместима с текущим рантаймом. Удачи подключать библиотеки .net framework в .net core > 3. Примерно тоже самое касается .netcore - .net standard.
Это стирает границы между платформами и позволяет переносить компоненты приложений с одной ОС на другую
но опять же, очень сильно зависит от рантайма и того, как было собрано приложение. В большинстве случаев вы всё равно наступите на грабли.
Но даже с учетом того ставить балл здесь за С++ - это конечно интересный вывод. Сову порвало :)
Из-за этого в C++ слишком легко допустить маленькую ошибку, которая приведёт к большим последствиям
Ну да, поддерживаю, к черту продавать Феррари - малейшая ошибка и ты улетел в столб (сарказм, понятное дело)
Интерфейс Disposable
А еще там есть такое замечательное исключение, как HttpClient.
то в C# операторы сравнения должны возвращать именно
boolи ничто иноеОчень спорно, как минимум есть implicit/explicit (или я не понял идею тут). Вообще вся эта секция немного притянута за уши, КМК. Тут как раз-таки C# намного более удобен и по синтаксису, и по ограничениям - при вызове функции сразу видно что в неё передавать и что ожидается, не нужно лезть в код или читать комментарии.
чтобы всё было чисто и аккуратно,
Долго смеялся )))) мы точно про C++ , где есть define? ))) А еще громче смеялся после
Нельзя объявлять классы внутри функций
:)
voidfor_each_arg(F&& f, Ts&&... xs){ (f(std::forward<Ts>(xs)), ...);}и чем это отличается от:
static void for_each_arg(Action<object> action, params object[] args) {foreach (var arg in args) {action(arg);}}for_each_arg((o) => Console.WriteLine(o), 42, 3.14, "hello");?
Формально рефлексия в C++ есть, и через несколько лет, когда её доделают, она будет лучше, чем в C#, так что это 8:0 в пользу C++
Ну и как через рефлексию достать значение приватного свойства в С++, объявленного в другой подключаемой библиотеке? А статья так хорошо начиналась.....

Janycz
29.06.2026 15:07А потом оказывается, что эта библиотека не совместима с текущим рантаймом. Удачи подключать библиотеки .net framework в .net core > 3. Примерно тоже самое касается .netcore - .net standard.
А потом оказывается, что эта библиотека тянет за собой unmanaged .dll (или .so, или .dylib), которая может иметь не ту разрядность, не ту архитектуру, использовать API операционной системы, которого может не быть в старых системах, где предполагается работа продукта, или вообще скомпилирована с использованием дополнительного набора инструкций, который не поддерживаются процессором большинства пользователей.

taydvax Автор
29.06.2026 15:07В статье я 2 раза упоминал нативные зависимости как ограничение переносимости C# библиотек. Просто в C# проблемы бинарной совместимости возникают гораздо реже, чем в C++.

taydvax Автор
29.06.2026 15:07А потом оказывается, что эта библиотека не совместима с текущим рантаймом.
Во-первых, в тексте на это есть дисклеймер: кроме случаев с разными версиями рантайма, нативными библиотеками и другими ограничениями, на которых я не буду сейчас останавливаться.
Во-вторых, цитируемый Вами фрагмент — это уже не про бинарную совместимость, а про экосистему пакетных менеджеров, которая у C# более развита. Вы это хотите оспорить?
но опять же, очень сильно зависит от рантайма и того, как было собрано приложение.
Отсылаю вас к тому же самому дисклеймеру, который находится буквально в том же предложении, которое вы цитируете. И я не говорю, что проблема бинарной совместимости вообще отсутствует — я говорю, что таких проблем гораздо меньше, чем в C++.
Цитата:
ABI-совместимость в C# доставляет гораздо меньше проблем — по крайней мере, для тех библиотек, которые не имеют нативных зависимостей.
Но даже с учетом того ставить балл здесь за С++ - это конечно интересный вывод.
А в чём проблема? Я с самого начала сказал, что собираюсь объективно и беспристрастно сравнить эти два языка, и первый же пункт статьи прекрасно демонстрирует серьёзность этого намерения.
Ну да, поддерживаю, к черту продавать Феррари
А это вообще о чём? Против какого положения статьи это должно послужить опровержением?
А еще там есть такое замечательное исключение, как HttpClient.
Да, есть и такое. Хотя это и не настолько важно, чтобы упоминать это в статье.
Очень спорно, как минимум есть implicit/explicit (или я не понял идею тут).
Это легко проверить: попробуйте написать код на C#, в котором используется функция max из статьи, и операторы сравнения возвращают тип, наявно конвертируемый в bool. И посмотрите, скомпилируется или нет (спойлер — нет).
Вообще вся эта секция немного притянута за уши, КМК. Тут как раз-таки C# намного более удобен и по синтаксису, и по ограничениям
Я привёл конкретные примеры и объяснил, чем именно в этих примерах C# неудобен. Вы считаете, что конкретно в этих примерах есть какие-то удобства, которых я не замечаю? Так расскажите о них.
А еще громче смеялся после "Нельзя объявлять классы внутри функций"
Ну приведите код, в котором внутри функции объявляется именованный класс, и который при этом компилируется.
и чем это отличается от:
Если ничем не отличается, то почему в C# сделали 17 делегатов Func вместо того чтобы использовать один, с контейнером параметров?
Ваша функция принимает контейнер, для которого выделяется память из кучи, требует делать рантайм каст к нужному типу в каком-нибудь switch, а для значимых типов используется boxing. Это всё снижает производительность, да и в коде это выглядит некрасиво.
Ну и как через рефлексию достать значение приватного свойства в С++, объявленного в другой подключаемой библиотеке?
А вы опасный человек. И часто вам приходится так делать?
Насколько я знаю, в C++ это возможно с помощью std::meta::access_context::unchecked, если объявление члена доступно компилятору.

Freeman_RU
29.06.2026 15:07А в чём проблема?
Проблемы всего две: абсолютно не объективно и уж тем более не беспристрастно. Вы описали кучу минусов линковки в С++, и поставили ему это в заслугу. Серьезно?
тип, наявно конвертируемый в bool
Так и основной вопрос - зачем? С учетом implicit\explicit можно возвращать bool, а операторы сделают своё дело. Вы решаете проблему, которой нет.
именно в этих примерах C# неудобен
неудобен кому? Лично вам? Некоторым, знаете ли, спать на кровати неудобно.
внутри функции объявляется именованный класс
Опять - зачем? Плодить простыни кода? Сейчас все стремятся наоборот выносить максимум классов в отдельные файлы. Такой PR точно не пройдёт никогда.
Если ничем не отличается, то почему в C# сделали 17 делегатов Func
Ну точно не для того примера, который вы привели.
да и в коде это выглядит некрасиво
А то есть в плюсах это выглядит красиво? Абсолютно не читаемо, и если нет комментариях и описания что же она там делает - придётся смотреть код. ИМХО конечно, но мне кажется 9 из 10 PR-ов с таким кодом не пройдёт в крупном проекте.
И часто вам приходится так делать?
Да, специфика разработки плагинов под существующие системы (что в плюсах лютый геморрой, к слову).

Freeman_RU
29.06.2026 15:07Как в С++ делается следующее:
Защита от подмены библиотеки?
Защита от dll hell, когда у вас 5 разных версий одной и тоже библиотеки лежит в разных местах?
Выбор версии библиотеки (можно использовать любую от 2.0.0.0 до 2.9.0.0, или только определенную)

Janycz
29.06.2026 15:07Можно проверить хэш-сумму библиотеки. 2+3. В Windows можно использовать LoadLibraryW (LoadLibraryExW) + GetProcAddress. Перед загрузкой библиотеки этом способом можно проверить версию библиотеки через GetFileVersionInfoSizeW + GetFileVersionInfoW + VerQueryValueW.

taydvax Автор
29.06.2026 15:07А вы с какой целью интересуетесь? Если хотите получить короткий технический ликбез, то спросите у LLM. Если ваша проблема слишком сложная, и LLM не помог, то опишите её подробнее.

Freeman_RU
29.06.2026 15:07Это был не вопрос, а намек, что это всё решено в C# (ну точнее во фреймоврках) нативно

ProgerMan
29.06.2026 15:07Ну, всё, уговорили. Теперь приведите пример минимального "Hello, world" на C++ и объясните мне, словно впервые увидевшего код, что значит каждое слово. Но так, чтобы мне захотелось продолжить изучать программирование.
А потом повторите то же на C#.

taydvax Автор
29.06.2026 15:07А в чём проблема?
#include <print> int main(){ std::println("Hello, world!"); }+4 строчки на CMake.

zanzack
29.06.2026 15:07библиотеку, собранную MinGW, нельзя подключить к проекту на MSVC — если её интерфейс не объявлен через extern "C"
А библиотеку gcc одной версии нельзя подключить к gcc другой версии, а почему? Да потому что strtol поменялся -
undefined reference to `__isoc23_strtol' from upgrade of 3.1.3 to 3.5.4 (from vcpkg)
https://github.com/openssl/openssl/issues/29121
Скрытый текст
Сидит как-то Линус Торвальдс с утреца за компом, скукотища дикая, нумерация ядер как раз на ноль поменялась, на дворе 7.0, до номера 7.21 еще огого как долго ждать. Заняться нечем от слова совсем. И захотелось ему движухи. И взял он и прибавил __isoc23 к названию strtol, чтоб никому жизнь мёдом не казалась. А глаза у него такие добрые-добрые.

Dhwtj
29.06.2026 15:07Вспоминается

Шурик, который посыпается и орёт, увидев свои же шевелящиеся пальцы

viordash
29.06.2026 15:07В чём же ошибка?
Хорошо что не просто послали подальше :)
и кстати,
autoerr = mHal.setLayerCursorPosition(mDisplay, mLayer, readSigned(), readSigned());в упоминаемом вами статическом анализаторе это не ошибка, а предупреждение. Все таки UB может быть хоть чем.

taydvax Автор
29.06.2026 15:07Хорошо что не просто послали подальше :)
Но я же объясняю, в чём ошибка, просто за пределами спойлера )
в упоминаемом вами статическом анализаторе это не ошибка, а предупреждение.
Главное, что он помогает её найти.

AmirYantimirov
29.06.2026 15:07Свои пара центов:
Я познакомился с C# на версии 1.0, еще без дженериков. Но отметил, что пишу не менее продуктивно, за счет лучшей поддержки в Студии.
Самым большим сюрпризом оказалось - как это здорово, что не надо писать отдельно файлы .h и .сpp!
Kerman
Nullable types, вроде "int?", params[], async/await, не?
Тут по каждому пункту просто дикая дичь написана. Какой ещё ContinueWith, вы чего...
Я просто вижу, что автор вообще не понимает шарп. Ну совсем. Ни капельки. Ну не понимает и не понимает, ладно. Но статью зачем писать?
taydvax Автор
Тогда разберите по пунктам. Только предметно, а не так, чтобы приходилось гадать, чем вам не нравится ContinueWith и пр.