Как это обычно бывает у C-программистов, язык C не был ни моим первым языком, ни языком, после которого я уже не изучал ничего другого. Но мне всё ещё нравится этот язык, и когда мне нужно писать программы — я выбираю именно его. Правда, в то же время, я стараюсь быть в курсе того, что происходит в мире современных (и не очень) языков программирования. Я слежу за тенденциями в этой сфере и пишу собственный хобби-проект, связанный с мультимедийными технологиями, на Rust. Почему же я до сих пор не поменял C на что-то более современное? И при чём тут C++?
Почему C — это не самый лучший язык программирования?
Сразу скажу то, что, пожалуй, и так все знают: нет такого понятия, как «самый лучший язык программирования». С каждым языком связан набор задач, для решения которых он подходит лучше всего. Например, хотя и можно заниматься трассировкой лучей в Excel, применяя VBA, лучше будет делать это с использованием более подходящего языка. Поэтому полезно знать об ограничениях языков программирования — чтобы не жаловаться на то, что веб-серверы не пишут на Fortran, и на то, что почти нигде Perl или C++ не используется в роли внутренних скриптовых языков. C может считаться не очень хорошим языком по причинам, которые я перечислю ниже (это — помимо того, что язык этот просто очень старый и того, что его нельзя назвать активно развивающимся языком, но это — дело вкуса).
В синтаксисе C имеются неоднозначные конструкции (например, символ
*
может играть роль обычного оператора умножения, может применяться в виде унарного оператора разыменования, или может использоваться при объявлении указателей; а радости работы с typedef
достойны отдельной статьи).Этот язык не является безопасным. Например — в C-программах довольно часто встречаются ошибки, связанные с обращением к элементам массивов, которые находятся за пределами границ массивов. В языке нет проверок на ошибки такого рода, производимых во время выполнения программы. А вот, например, в Borland Pascal, не говоря уже о более современных языках, такие проверки есть (это — плюс, даже если подобные возможности можно, пользуясь параметрами компиляции, отключить ради повышения производительности). А использование в языке указателей ещё сильнее усложняет задачу программиста по поддержанию кода в хорошем состоянии. И, кроме того, C имеет и некоторые другие неприятные особенности, вроде возможности вызова функции без объявления прототипа, в результате чего функции легко можно передать аргумент неправильного типа.
У C довольно-таки скромная стандартная библиотека. В некоторых других языках программирования могут даже иметься стандартные веб-серверы (или, как минимум, всё, что нужно для создания таких серверов), а в стандартной библиотеке C нет даже описаний контейнеров.
Почему я, несмотря на все недостатки C, пользуюсь именно этим языком?
Причина, по которой мне всё ещё нравится C, заключается в том, что это — простой язык. Простой — в смысле того, что он легко позволяет мне описывать имеющиеся у меня идеи, и в смысле того, что я знаю о том, чего можно от него ожидать.
Например, если нужно получить значение элемента массива, имея два смещения, одно из которых может быть отрицательным числом, то при программировании на C можно воспользоваться такой конструкцией:
arr[off1 + off2]
. А при использовании Rust это уже будет arr[((off1 as isize) + off2) as usize]
. C-циклы часто короче, чем идиоматичные механизмы Rust, использование которых предусматривает комбинирование итераторов (конечно, обычными циклами можно пользоваться и в Rust, но это не приветствуется линтером, который всегда рекомендует заменять их итераторами). И, аналогично, мощными инструментами являются memset()
и memmove()
.В большинстве случаев при использовании C можно заранее знать о том, что будет на выходе компилятора, о том, как будут выглядеть представления объектов в памяти, о том, какими способами можно работать с этими представлениями (я виню C++ в том, что в более современных редакциях стандарта C подобные вещи усложнены, об этом мы подробнее поговорим ниже), о том, что происходит при вызове функции. С не случайно называют «переносимым языком ассемблера», и это — одна из причин, по которым мне нравится C.
Если описать это всё, прибегнув к аналогии с автомобилями, то окажется, что C похож на спортивную машину с механической коробкой передач, позволяющую выжать из двигателя всё, что только возможно. Но водитель легко может повредить и коробку передач, и даже двигатель, если перемудрит со сцеплением. Да и с дороги можно вылететь, если слишком сильно надавить на педаль газа. Но при этом, в сравнении с машинами, имеющими автоматическую трансмиссию, на колёса поступает больше мощности двигателя. Водитель лучше понимает то, чего можно ждать от автомобиля. На «ручной» машине можно делать такие вещи, которых не сделаешь на «автомате» (из-за того, что водителю, чтобы сделать что-то необычное, придётся бороться с автоматизированными системами управления автомобилем).
При чём тут C++?
Если говорить о C++, то хочу сразу сказать, что я не отношусь к тем, кто ненавидит этот язык. Если вы этим языком пользуетесь и он вам нравится — я ничего против этого не имею. Я не могу отрицать того, что C++, в сравнении с C, даёт нам два следующих преимущества. Во-первых — это улучшение структуры программ (это — поддержка пространств имён и классов; в Simula, в конце концов, есть хоть что-то хорошее). Во-вторых — это концепция RAII (если описать это в двух словах, то речь идёт о наличии конструкторов для инициализации объектов при их создании и о наличии деструкторов для очистки ресурсов после уничтожения объектов; а если глубже разработать эту идею, то можно прийти к понятию «времени жизни объекта» из Rust). Но, в то же время, у C++ есть несколько особенностей, из-за которых мне этот язык очень и очень не нравится.
Это, прежде всего — склонность языка постоянно вбирать в себя что-то новое. Если в каком-то другом языке появится какая-нибудь возможность, которая станет популярной, она окажется и в C++. В результате стандарт C++ пересматривают каждые пару лет, и при этом всякий раз в него добавляют новые возможности. Это привело к тому, что C++ представляет собой монструозный язык, которого никто не знает на 100%, язык, в котором одни возможности часто дублируют другие. И, кроме того, тут нет стандартного способа указания того, возможности из какой редакции C++ планируется использовать в коде. В Rust это поддерживается на уровне единицы компиляции. Если я правильно помню, в C++ для той же цели предлагалась концепция эпох, но эта затея не удалась. И — вот одно интересное наблюдение. Время от времени мне попадаются новости о том, что кто-то в одиночку (и за приемлемое время) написал рабочий компилятор C. Но я ни разу не встречал похожих новостей о компиляторе C++.
Вторая особенность C++, из-за которой мне не нравится этот язык, заключается в том, что это, на самом деле, не просто смесь нескольких языков. Это, кроме того, мета-язык, иначе говоря — язык, в котором используются шаблоны. Я понимаю — для чего это создано, согласен с тем, что это — лучше, чем препроцессор C для типонезависимого кода. Но, на самом деле, это приводит к появлению некрасивого громоздкого кода. Это означает переход от идеи «заголовочный файл содержит объявления, а компилируемый код — функционал» к идее «заголовочный файл содержит весь код, используемый в проекте, в состав которого входит этот файл». Мне не нравится, когда код долго компилируется, а этот подход ведёт к увеличению времени компиляции проектов.
И, наконец, я мог бы вообще не обращать внимания на C++, если бы этот язык не был бы связан с C и не оказывал бы на C плохое влияние. Я не говорю о ситуации, когда, говоря о C и C++, их объединяют, упоминая как «C/C++», и считая, что тот, кто знает C, знает и C++. Я имею в виду связь между языками, которая оказывает влияние и на стандарты, и на компиляторы. С одной стороны — C++ основан на C, что придало C++, так сказать, хорошее «начальное ускорение». А с другой стороны — сейчас C++, вероятно, лучше смотрелся бы без большей части своего «C-наследия». Конечно, от него пытаются избавиться, называя соответствующие конструкции устаревшими, но C-фундамент C++ никуда пока не делся. Да и будет ли популярным, скажем, некий «С++24», вышедший в виде самостоятельного языка, основанного на C++21 и лишённого большей части устаревших механизмов? Не думаю.
Воздействие компиляторов C++ на C
Вышеописанная связь C и C++ приводит к тому, что к C относятся как к C++, лишённому некоторых возможностей. Печально известным примером такого восприятия C является C-компилятор Microsoft, разработчики которого не позаботились о поддержке возможностей C99 до выхода версии компилятора 2015 года (и даже тогда разработчики придерживались стратегии «bug-for-bug compatibility», когда в новой реализации чего-либо воспроизводят старые известные ошибки; делалось это для того, чтобы пользователи компилятора не были бы шокированы, внезапно обнаружив, что вариадические макросы вдруг там заработали). Но тот же подход можно видеть и в стандартах, и в других компиляторах. Эти проблемы связаны друг с другом.
Принципиальная проблема тут заключается в том, что и стандарт C, и стандарт C++ создаются с учётом сведений, получаемых от разработчиков компиляторов, а это, в основном, C++-разработчики (и иногда возникает такое ощущение, что они ничего не знают о настоящем программировании, и полагают, что всё должно укладываться в их точку зрения, но это — тема для отдельного разговора). Я не слежу за процессом работы над стандартом, но я почти уверен, что самые неприятные аспекты C99 и более поздних версий стандарта стали результатом воздействия разработчиков компиляторов. Причём, их видение ситуации влияет не только на C++, где в этом видении есть какой-то смысл, но воздействует и на C ради упрощения компиляторов.
Я, конечно, говорю, о «неопределённом поведении», и о том, как оно рассматривается компиляторами. Эта тема стала популярным «пугалом» (код полагается на способ представления чисел с дополнением до двух; в результате в нём имеется неопределённое поведение и компилятор может выбросить, оптимизировать целый блок кода!).
Я полагаю, что существует четыре вида поведения программ, которых все старательно стремятся избегать, но при этом лишь половина из них достойна такого к ним отношения:
- Поведение, определяемое архитектурой системы (то есть — то, что зависит от архитектуры процессора). Сюда, в основном, входят особенности выполнения арифметических операций. Например, если я знаю, что целевая машина использует для представления чисел дополнение до двух (нет, это не CDC 6600), то почему компилятор (который, как представляется, тоже знает об особенностях целевой архитектуры) должен считать, что система будет вести себя иначе? Ведь тогда он сможет лучше выполнять некоторые теоретически возможные оптимизации. То же применимо к операциям побитового сдвига. Если я знаю о том, что в архитектуре x86 старшие биты, выходящие за пределы числа, отбрасываются, а на ARM отрицательный сдвиг влево — это сдвиг вправо, почему я не могу воспользоваться этими знаниями, разрабатывая программу для конкретной архитектуры? В конце концов, то, что на разных платформах целые числа имеют разные размеры в байтах, считается вполне приемлемым. Компилятор, обнаружив нечто, рассчитанное на конкретную платформу, может просто выдать предупреждение о том, что код нельзя будет перенести на другую платформу, и позволить мне писать код так, как я его писал.
- Неочевидные приёмы работы с указателями и каламбуры типизации. Такое ощущение, что плохое отношение к подобным вещам возникло лишь ради потенциальной возможности оптимизации кода компиляторами. Я согласен с тем, что использование
memcpy()
на перекрывающихся областях памяти может, в зависимости от реализации (в современных x86-реализациях копирование начинается с конца области) и от относительного расположения адресов, работать неправильно. Разумно будет воздержаться от подобного. А вот другие ограничения того же плана кажутся мне менее оправданными. Например — запрещение одновременной работы с одной и той же областью памяти с использованием двух указателей разных типов. Я не могу представить себе проблему, возникновение которой может предотвратить наличие подобного ограничения (это не может быть проблема, связанная с выравниванием). Возможно, сделано это для того чтобы не мешать компилятору оптимизировать код. Кульминацией всего этого является невозможность преобразования, например, целых чисел в числа с плавающей запятой с использованием объединений. Об этом уже рассуждал Линус Торвальдс, поэтому я повторяться не буду. С моей точки зрения это делается либо для улучшения возможностей компиляторов по оптимизации кода, либо из-за того, что этого требует C++ для обеспечения работы системы отслеживания типов данных (чтобы нельзя было поместить экземпляр некоего класса в объединение, а потом извлечь его как экземпляр совсем другого класса; это тоже может как-то повлиять на оптимизации). - Поведение кода, определяемое реализацией (то есть — поведение, которое не в точности отражает то, что предписано стандартом). Мой любимый пример подобной ситуации — это вызов функции: в зависимости от соглашения о вызове функций и от реализации компилятора аргументы функций могут вычисляться в совершенно произвольном порядке. Поэтому результат вызова
foo(*ptr++, *ptr++, *ptr++)
неопределён и на подобную конструкцию не стоит полагаться даже в том случае, если программисту известна целевая архитектура, на которой будет выполняться код. Если аргументы передаются в регистрах (как в архитектуре AMD64) компилятор может вычислить значение для любого регистра, который покажется ему подходящим. - Полностью неопределённое поведение кода. Это — тоже такой случай, когда сложно спорить со стандартом. Пожалуй, самый заметный пример такого поведения кода связан с нарушением правила, в соответствии с которым значение переменной в одном выражении можно менять лишь один раз. Вот как это выглядит в одном знаменитом примере:
i++ + i++
. А вот — пример, который выглядит ещё страшнее:*ptr++ = *ptr++ + *ptr++
е.
C++ — это язык более высокого уровня, чем C. В то время, как в C++ имеется большинство возможностей C, использовать эти возможности не рекомендуется. Например, вместо прямого преобразования типов надо применять
reinterpret_cast<>
, а вместо указателей надо применять ссылки. От С++-программистов не ждут того, что они будут понимать низкоуровневый код так же хорошо, как C-программисты (это, конечно, лишь «средняя температура по больнице», в реальности всё зависит от конкретного программиста). И всё же, из-за того, что существует очень много C++-программистов, и из-за того, что C и C++ часто воспринимают как «C/C++», C-компиляторы часто расширяют в расчёте на поддержку ими C++ и переписывают на C++ для того чтобы упростить реализацию сложных конструкций. Это произошло с GCC, и того, кто начнёт отлаживать с помощью GDB С++-код, находящийся в .c-файлах, ждёт много интересного. Грустно то, что для того чтобы скомпилировать код C-компилятора нужен C++-компилятор, но, чему нельзя не радоваться, всё ещё существуют чистые C-компиляторы, вроде LCC, PCC и TCC.Итоги
В итоге хочу сказать, что люблю язык C за то, что он занимает промежуточную позицию среди других языков программирования. Он позволяет без труда делать всякие низкоуровневые вещи, вроде манипуляций с содержимым памяти, но при этом даёт нам приятные возможности, характерные для языков высокого уровня (хотя и не мешает программисту делать то, что ему нужно). А моя стойкая неприязнь к C++ основана на архитектурных решениях, принятых в ходе развития этого языка (хотя тут скорее можно говорить не о чётко спланированном развитии языка, а о том, что нечто появляется в языке по воле случая). Не нравится мне и то, что C++ влияет на стандарт C и на компиляторы для C, что немного ухудшает тот язык, который я люблю.
Но, по крайней мере, нельзя заменить C90 на какой-нибудь «C90 Special Edition» и сделать вид, что C90 никогда не существовало.
Как вы относитесь к языку C?
ncr
TL;DR: «мне не нравится, что C++ зачем-то мешает мне писать г-код»
JerleShannara
«Мне не нравится, что C++ старается отодвинуть меня от процессора»
Tangeman
Никакой язык или компилятор не запретят (и не помешают) писать г-код. Да, помешают сделать очевидные ошибки, но это не самая главная проблема в приложениях, увы.
RarogCmex
Тем не менее, на том же Хаскеле или Агде писать г-код намного сложнее, ибо он не скомпилируется.
Tangeman
Неэффективно реализованный алгоритм, плохой опыт пользователя, отсутствие предварительной валидации данных и последующий вылет по этой причине в другой части и прочее - всё это, увы, отлично компилируется и выясняется только на этапе тестирования (если софт вообще тестируется) или боевого использования.
0xd34df00d
Можно доказывать выполнение тех или иных инвариантов (например, что красно-чёрное дерево действительно красно-чёрное, а не как в микрософтовской STL пару лет назад).
Это вот как раз самое оно типами обкладывать.
Это сложно, да. Ну, тут я ничего сказать не могу, опыт пользователя в том ПО, что я обычно пишу, в лучшем случае сводится к запуску сервера, и всё.
Tangeman
И как вы докажите что
strcmp(arg, "abc")
будет быстрее (или наоборот) чемuserstrcmp(arg, "abc")
? Компилятор не знает чего хочет программер, и уж тем более пользователь.У вас есть на входе аргумент (полученный из внешнего источника), и нужно проверить его на валидность - например, для иллюстрации, с помощью
strcmp(arg, "aBc") == 0
- и как компилятор (даже самый умный) догадается что на самом деле нужно былоstrcasecmp(arg, "abc") == 0
?Сколько памяти оно скушает, насколько мощный нужен сервер, как долго будет выполняться и всё такое - это тоже опыт пользователя.
0xd34df00d
А это и так сильно зависящий от кучи параметров (начиная от архитектуры машины до количества вхождений
abc
в строку) вопрос. Я бы не стал говорить о том, что какой-то подход — заведомо говнокод.Если где-то дальше вы опираетесь на то, что там нет подстроки
abc
, то это другое место потребует доказательство этого факта, а его у вас на руках не будет.Я бы сказал, что вопрос корректности приложения куда важнее вопроса того, сколько оно кушает памяти. Если корректность вас не волнует, запустите на машине
while (true) { sleep(1000); }
, и оно будет есть очень мало памяти и очень мало процессора.Tangeman
Как пример - использование чего-то типа
regexp_match(arg, /abc/)
вместоarg == "abc"
(и это когда нужно убедиться именно в последнем) - заведомо говнокод, причём встречается постоянно в js/php, да ещё и в местах которые выполняются газиллионы раз.Вот оно! А если то "другое место" писал кто-то без понимания что нужно доказательство? Или это один человек? Мы снова возвращаемся к тому что компилятор (а о нём речь) не в состоянии этого сделать без помощи человека, просто в принципе не способен, да и говнокод может появиться из говноспецификации - то есть в том нередком случае когда кодер пишет ровно как сказано а заказчик не знает точно как нужно и совсем не знает как правильно.
Выбор правильной структуры данных (или алгоритма их обработки) может резко уменьшить требования к памяти, причём вполне вероятно что работать оно будет быстрее, и этот вопрос станет особенно остр если память у вас ограничена - но вот исполнитель этого не знал и озаботился только "корректностью", причём тестировал всё это на супер-компутере а у вас маленькая малинка (привет разработчикам некоторых игр).
Разумеется, нужно правильно же балансировать и не тратить 90% усилий на оптимизацию маленького цикла, но и тянуть за собой полновесную БД ради одной таблички в 100 строк (типа "на SQL удобнее") тоже не особенно оптимально - и тоже можно считать г-кодом.
0xd34df00d
Это другое место, скорее всего, писал автор стандартной библиотеки языка, скажем, и без требования доказательства не сойдутся типы уже в нём.
В конце концов, в эту игру можно играть в обе стороны: а если это другое место непротестировано, и оно ведёт себя не так, как вы ожидаете, даже на ваших тестовых данных? Тогда что?
Замечательно. Но, ещё раз, только после того, как я убедился, что код корректен.
Tangeman
Всё это здорово, но речь шла о другом - как компилятор (сам по себе, без подсказок) может помешать написать код который не проверяет то что должен проверять или делает не то что планировал разработчик?
Вот так просто, вместо
cmd == "do-something-useful"
там вкралась опечатка и получилосьcmd == "do-something-useeful"
, или даже банальноx = x * mult
где вместоconst mult = 2
у нас получилосьconst mult = 3
- и всё, ничего не работает (или работает не так) - получили г-код, вообще без шансов на обнаружение оного со стороны компилятора, любого из существующих.unC0Rr
Я бы поспорил насчёт того, что это говнокод. Говнокод — это когда программист не парится вопросами освобождения памяти, закрытия файлов, блокировок в многопоточном приложении, использование функций не по назначению, когда пишется лапша из кода, которую невозможно рефакторить и т.п. вещи, когда программа вроде как и работает, но иногда всё же нет, и вылечить это невозможно без переписывания.
А банальная опечатка — это всё же не говнокод, а баг, причём зачастую быстро обнаруживаемый при тестировании. Впрочем, от некоторых видов опечаток компилятор всё же может помочь.
Antervis
а можно поподробнее? А то даже по меркам MS зашкварно
0xd34df00d
На хабре было.