Как это обычно бывает у 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 это поддерживается на уровне единицы компиляции. В IIRC 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?
Комментариев нет:
Отправить комментарий