Почему не лишено смысла писать код на C, а не на C++
11 февраля 2016
Сегодня мы поговорим на тему «C против C++». Некоторые читатели данного блога уже знакомы с моей точкой зрения по этому поводу. Когда встает вопрос с формулировкой вроде «похоже, мы решаем задачу, где очень важна скорость выполнения кода, и нужно выбрать между языками C и C++», в последнее время я склонен рекомендовать C. Многие программисты при этом недоумевают, мол «как же так, ведь С++ новее и имеет больше фичей, и вообще C входит в него как подмножество». Поэтому я хотел бы подробно объяснить свою точку зрения один раз в данном посте, так как каждый раз объяснять ее заново занимает ощутимое количество времени.
Хотелось бы начать с небольшого дисклеймера. Всегда найдутся люди, которые пишут на C++ последние лет 20, и потому (1) они искренне считают язык простым и понятным, (2) им не хочется учить что-то новое, ведь их и здесь неплохо кормят. Это, собственно, и есть так называемый C++ головного мозга. Далее я предполагаю, что читатель не страдает от этого недуга и не утратил трезвость восприятия и открытость мышления по каким-либо иным причинам. Также стоит отметить, что в вопросах «какой язык лучше» нет правых и неправых. Одни и те же объективные преимущества и недостатки воспринимаются разными людьми с разными весовыми коэффициентами, поэтому на выходе получаются разные значения функции fitness. В этой заметке мне хотелось бы пояснить причины, по которым у меня весовые коэффициенты выставлены так, как они выставлены, а не доказать, что у кого-то они выставленные неверно.
Сразу отмечу, что я не считаю, что C++ умер, что это ужасный и совершенно ни на что не годный язык или что-то в этом роде. Трудно ругать язык, на котором написаны Chromium, Skype, Sublime Text и множество других программ, которые я использую каждый день. Не говоря уже о великом множестве хороших библиотек на С++. Тут мне сразу вспоминаются, например, Assimp и wxWidgets. Более того, вы можете помнить, что в заметке Критика языка Rust и почему C/C++ никогда не умрет я отстаивал C++ и говорил, что в обозримом будущем он никуда не денется. Нельзя исключать и культурный фактор. Так игры AAA класса принято писать на C++, потому что в индустрии уже много лет все так делают. Если вы работаете в этой индустрии, то особого выбора у вас может и не быть.
Несмотря на все это, я считаю, что в третьем тысячелетии лучше не писать нового кода на C++, если, конечно, (!) у вас есть такая возможность. И далее я постараюсь более подробно объяснить эту точку зрения.
Примечание: Специально для читателей, считающих, что на C давно никто не пишет, спешу сообщить, что это не так. В первую очередь на C, конечно же, ведется разработка всех современных операционных систем, драйверов, и подобных вещей, например, систем виртуализации. Многие десктоп приложения все так же пишутся на C, например, Claws Mail, Liferea, XChat, Transmission, Gimp, Pidgin, Tox, а также оконные менеджеры и десктоп окружения — Xfce, Lxde, Awesome, i3, и другие. Серверные приложения также часто разрабатываются на C. Тут вспоминается HAProxy, lighttpd, Nginx, Nagios, Memcached, Redis и PostgreSQL. Еще можно вспомнить, например, виртуальную машину языка Erlang и интерпретатор языка Python. Все эти приложения объединяет то, что они должны использовать доступные им ресурсы максимально эффективно — десктоп приложения должны хорошо работать на бюджетных компьютерах, даже таких, как Raspberry Pi, серверные приложения должны обрабатывать как можно больше запросов в секунду, и так далее.
Итак, основная идея, пожалуй, состоит в следующем. Если вы решаете задачу, где действительно очень важна скорость (определение см далее), вы все равно не сможете использовать C++. Вы, вероятно, сможете писать на так называемом «C с классами» или «C с шаблонами». Эти диалекты языка C, бесспорно, имеют право на жизнь. И если вы называете «языком C++» эти диалекты, то я, пожалуй, с вами даже соглашусь — для задачи надо брать «язык C++», срочно! Только нужно при этом быть очень уверенным, что через год вы не выйдите за рамки «C с шаблонами». Эта действительно большая проблема на практике и она более детально описана далее.
Однако большинство людей под С++ понимают так называемый «современный C++», со счетчиками ссылок, классами, исключениями, шаблонами, лямбдами, STL, Boost, и так далее. То есть, тот C++, на котором вы пишите, почти как на Java, в котором никогда не встречаются обычные указатели, и вот это все. Если вам очень важна скорость, то писать на таком C++ вы не сможете. Если же он вам подходит, то лучше взять Java, Go или любой другой высокоуровневый язык по вкусу. В них все те же возможности реализованы намного лучше. А узкие места при необходимости, которой, впрочем, может и не возникнуть, вы всегда сможете переписать на C.
Позвольте пояснить, что я имею ввиду под задачами, где очень важна скорость. Вы едете на машине. Вдруг в нескольких метрах впереди выбегает человек. На принятие решения водителю в среднем требуется около одной секунды. Нога мелено перемещается с педали газа на педаль тормоза. Затем медленно вдавливает тормоз в пол. Расстояние между автомобилем и человеком в это время сокращается. Наконец, сигнал от педали тормоза летит в бортовой компьютер автомобиля. И вот тут ни в коем случае программа не может сказать «о, счетчик ссылок обнулился, пойду-ка я собирать мусор по всему дереву» или даже «секундочку, я только схожу в vtable… как, ее нет в L1? ой…». Программа должна обработать сигнал как можно быстрее, тут же ударив по тормозным дискам. Ни о каких смартпоинтерах и прочих видах автоматического управления памятью, ровно как и о развесистых иерархиях классов, в таких задачах и речи быть не может.
Из менее драматичных примеров можно привести любую систему, где не работает правило «90% времени выполняется 10% кода, эти 10% и будем оптимизировать». Если код, который выполняется всего лишь 10% времени, ускорить на 1/20, суммарная производительность вырастит на жалкие 0.5%. Но в масштабах компании вроде Google или широко используемого приложения вроде PostgreSQL или Nginx эти 0.5% ускорения могут означать миллионы долларов экономии. То есть, несколько месяцев работы небольшой группы программистов в этом направлении окупаются с лихвой. А раз так, почему бы, например, не отказаться от STL совсем и сразу не использовать алгоритмы и структуры данных, заточенные под конкретный случай (примеры есть далее по тексту)?
Я могу привести еще много примеров такого рода. Но идея, надеюсь, ясна. Если решаемая вами задача такова, что написать 90% кода на высокоуровневом языке и 10% на C никак нельзя, то ни на каком «C++, который почти как Java, только компилируемый в машинный код» вы писать не сможете. Если же в вашей задаче можно не париться по поводу 0.5% производительности, то скорость вам нужна не так сильно, как вы думали.
Серьезно, возьмите лучше Go, Java, или любой другой язык по вкусу. Сборка мусора там сделана намного лучше (напомню, что счетчики ссылок плохо работают для большого количества быстро умирающих объектов), прочее управление ресурсами, управление зависимостями, инструменты разработки (IDE и прочие), генерики, лямбды, неймспейсы, исключения, автоматический вывод типов, классы и интерфейсы там сделаны намного лучше. В качестве бонуса вы получите существенно большую скорость разработки, куда более читаемые сообщения об ошибках, куда более лучшую кроссплатформенность, простую отладку, высокую скорость компиляции и так далее, за что там обычно вполне заслуженно ругают С++. Да и найти достойных программистов на этом другом языке станет на порядок проще.
В дополнение к основной идее, представленной выше, хотелось бы озвучить следующие пункты, а также обещанные примеры. Воспринимайте эти пункты, как эвристические соображения, по которым написания нового кода на C++ лучше избегать, или просто как пищу для мозгов:
- Согласно книге The Design and Evolution of C++, язык C++ был создан Страустропом, как «C для крупных проектов». Однако практика показывает, что на C вполне успешно разрабатываются очень даже крупные проекты. А от многих возможностей языка C++ отказываются даже в проектах, которые не являются такими уж крупными. Потому что возможности эти зачастую не упрощают разработку, а лишь усложняют ее. То есть, C++ не только плохо решает изначальную проблему, но и усложняет ее решение, да и проблемы то, оказывается, не было вовсе.
- Лямбды и прочие ништяки не всегда получается использовать на работе, так как о C++11 там только мечтают. К сожалению, многие реальные проекты на C++ в наше время — это страшный легаси с C++98, самописным STL, форкнутым Boost, Visual Studio 6 и CVS. Может быть, я тут немного и преувеличиваю, но идея, надеюсь, ясна.
- Сложность кода. Если вы видели код на C++, реальный, а не из учебника, то знаете, что он часто он оказывается действительно очень непрост для восприятия. Из недавних примеров мне вспоминается GLM. Так выглядит его исходный код (только один из файлов, их там еще очень много), а так выглядит код на Си, который делает вообще все, что мне было нужно на самом деле. Бесспорно, после 20 лет программирования на C++, код GLM любому покажется простым, понятным и элегантным. Но у меня нет 20 лет, чтобы постигать это темное искусство, мне нужно решать задачи сегодня. Проблема еще в том, что на C++ так пишут довольно часто и, например, чтобы понять, как работает реализация алгоритма сжатия, тебе еще нужно очень хорошо знать, как в C++ работают стримы. А как ты без стримов будешь использовать свой алгоритм сжатия повторно? По теме сложности кода на C++ еще можно привести пример c chrono из статьи Продолжаем изучение OpenGL: простой вывод текста.
- Пример со стримами хорошо иллюстрирует, что C++ — словно вирус. Все начинается с использования какой-то одной маленькой его возможности, и через какое-то время весь проект кишит лямбдами, шаблонами и вот этим всем, и в этом уже никто не может нормально разобраться. Судите сами. Допустим, вы решили использовать классы. Но вот проблема — все private поля объявляются в .hpp файле и при изменении приводят к перекомпиляции половины проекта (в C такой проблемы с инкапсуляцией нет совсем). И вот в дополнение к простому и понятному классу вам уже приходится использовать не такую уж понятную и простую в реализации идиому pImpl (или фабричный метод, что еще менее производительно). А затем еще правильно перегрузить оператор присваивания, реализовать конструктор перемещения, и чтобы при этом все это добро правильно работало с STL… Мы всего лишь хотели классов, помните? Аналогично вы не можете использовать исключения, не обернув все в умные указатели, которые, напомню, являются классами и используют шаблоны, а чтобы кода было поменьше, придется еще использовать и auto. Аналогично вы не можете использовать классы и конструкторы, не используя исключения, так как нормально вернуть ошибку из конструктора можно только бросив исключение. Таким образом, не платить за то, что не используешь, вот как-то не получается — приходится использовать сразу все.
- ООП. Кажется, сегодня уже вся индустрия осознала, что объединение кода и данных — идея так себе, не говоря уже про повсеместное использование наследования. В теории это, конечно, здорово, когда есть класс животное и от него наследуется кошка и лошадь. Но на практике реальная необходимость (ООП головного мозга — тоже тяжелый недуг!) строить такие иерархии возникает очень редко, и если возникает, то дело обычно ограничивается одним интерфейсом и многими классами, реализующими этот интерфейс. Последнее, если что, очень просто делается на С. И возникает вопрос, а какой вообще смысл использовать язык, одна из главных фишек которого — возможность легко писать код так, как это делать не надо?
- STL часто преподносится так, словно в мире С нет библиотек с готовыми алгоритмами и контейнерами, что, разумеется, не так. При этом STL предлагает только одну из многих возможных реализаций (даже для простого vector их можно придумать десятки) конкретного алгоритма или контейнера. Почему кто-то за меня решил, что замедление скорости компиляции и разбухание секции кода лучше, чем, например, хранение всего по ссылке и, соответственно, быстрая компиляция и разбухание кучи? Или, например, хранение всего по значению, но с небольшим замедлением скорости выполнения кода? Следует также отметить, что контейнер, написанный с нуля и заточенный под данный конкретный случай, позволит вам повысить производительность на те самые «жалкие 0.5%», которые так важны в задачах, на решение которых претендует С++. Например, если вы знаете что-то о природе данных, которые хранятся в контейнере, то можете опустить некоторые проверки. Или, возможно, вам известно, что данные удаляются из хэш-таблицы только в порядке обратном тому, в котором они были добавлены — это тоже можно использовать.
- Из-за повсеместного использования шаблонов скорость компиляции кода на С++ просто ни на что не годится. Не говоря уже о том, что при компиляции крупных проектов нередко может потребоваться, скажем, гигов 10 оперативной памяти. Для сравнения, язык C позволяет мне компилировать по много раз на дню миллионы строк кода, используя только Raspberry Pi. Ну и если я вдруг решу, что в моем проекте имеет смысл использовать кодогенерацию, ничто не мешает ее использовать. Притом, с нормальным кэшированием результата. Понятное дело, так как шаблоны объявляются в .hpp файлах, их изменение приводит к перекомпиляции половины проекта. См также Десять причин избегать метапрограммирования.
- Как уже отмечалось, если вы берете исключения, то будьте готовы использовать для всего RAII и смартпоинтеры, а следовательно и тормозить, когда счетчики ссылок обнуляются. Иначе одно неудачно брошенное исключение приведет к тому, что все ваши ресурсы утекут. Следует также отметить, что исключения добавляют коду неявного поведения, и далеко не всем программистам это нравится. Как по мне, в задачах, где используется С и/или С++, лучше использовать старые-добрые коды возврата. Пожалуй, придется написать чуть больше кода и завести привычку всегда проверять возвращаемые значения. Зато вы будете точно знать, что и как именно делает ваш код, безо всякой магии. Не удивительно, что в том же Google в коде на С++ исключения не используются, и что в новых языках, таких, как Go и Rust, исключений не предусмотрено.
- По своему опыту могу сказать, что отлаживать код C++ — мягко говоря, удовольствие ниже среднего. Продраться через тонны смартпоинтеров и виртуальных методов, или, например, посмотреть, что же происходит внутри STL, в gdb подчас сложно настолько, что проще прибегнуть к обычному отладочному выводу. В языке C все просто и понятно. Даже весьма непростые баги можно легко поймать за пару минут. Я вам даже больше скажу, код на C можно довольно комфортно отлаживать вообще без отладочных символов. Когда-то очень давно я так и делал, просто брал OllyDbg и дебажил. Попробуйте, это правда не сложно.
- Код на C прекрасно пишется без каких-либо тяжеловесных IDE, в обычном Sublime Text или Vim с ctags. Для сколь-либо серьезного кода на C++ без нормальной IDE жизнь быстро становится очень грустной, потому что автоматический вывод типов, шаблоны, и вот это все, и потому что ctags начинает забрасывать не туда, куда нужно. К счастью, IDE для C++ существуют. CLion, например, довольно неплох. Но не все программисты согласны платить за него деньги и попрощаться с 2 Гб оперативной памяти. К тому же, CLion не все и не всегда подсвечивает правильно, и если открыть в нем сразу два проекта, то даже довольно мощный компьютер начнет тормозить. Есть и другие IDE, но у них свои проблемы, например, привязка к Windows или отсутствие важных возможностей, таких, как вывод типов.
- Нельзя упускать из виду и кадровый вопрос. Язык С сравнительно прост. По крайней мере, его реально уместить целиком в голову среднего программиста. Стандарт С11 [PDF] занимает 700 страниц со всеми приложениями и предметным указателем, а полноценный компилятор C умещается в 15-20 тысяч строк кода. Многие (не все, но многие) студенты уже на первом курсе в состоянии писать вполне сносный боевой код на C. Язык C++ в десятки раз сложнее C. Не удивительно, что его толком не знает никто. В лучшем случае, есть люди, которые знают небольшую его часть. Что намного хуже, с выходом каждого нового стандарта С++ становится еще более сложным и запутанным. Туда тянут еще какие-то концепты, корутины и прочие модные игрушки, как будто без них язык не был уже достаточно распухшим. Но хуже всего то, что правила языка часто далеко не очевидны (например) и имеют кучу исключений. Чтобы писать что-то серьезное на языке, про который неизвестно точно, как работают его компоненты и как они друг с другом взаимодействуют, нужно быть либо очень смелым, либо очень глупым.
- Ну и до кучи. (1) Далеко не везде есть компилятор C++, особенно если это какой-нибудь C++11/14/17. Так что, если вы хотите настоящей переносимости кода, пишите на С. Язык C есть реально везде. (2) Раз взаимодействие между разными языками программирования или, например, вызов процедур из динамических библиотек, все равно происходит через C API, может лучше просто писать на C? (3) Я уже говорил про совершенно нечитаемые сообщения об ошибках в C++? Попробуйте использовать тот же chrono, например. (4) Александреску в итоге ушел заниматься языком D. Мейерс тоже завязал с C++. Mozilla и Google сделали свои языки для замены C++. Наводит на размышления, согласны?
Не удивительно, что и сегодня даже для новых проектов многие программисты (Линус Торвальдс, пожалуй, является самым известным примером) выбирают язык C, а не C++. Стремление писать код на C — это стремление к максимально простому и понятному коду, стремление использовать ресурсы как можно более эффективным образом, и не в последнюю очередь это стремление к красоте. Технологии появляются и исчезают. Подходы, которые еще вчера считались общепринятой практикой, сегодня уже причисляют к антипаттернам. И только C прекрасен и вечен. На чем еще писать, если с 1972 года люди так и не придумали ничего лучше?
Метки: C/C++, Философия, Языки программирования.
Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.