Слово в защиту… C


Хоровое технологическое пение становится все более модным (и потому доходным) времяпрепровождением. Оно, конечно, сподручнее было бы присоединиться к хору и написать что-нибудь под звучным названием "Язык программирования C — диверсия планетарного масштаба", ввести для такой красотищи соответствующую броскую рубрику (например, "Ужас!"), и успех гарантирован.



О C с любовью

В отличие от большинства "правильных" языков C обладает весьма странным, можно сказать "немашинным", синтаксисом. C-представление программы множеством множеств (математики любят тавтологию) данных, команд и комбинированных данных/команд для своих времен было, мягко говоря, неожиданностью. Но очень удачной. C-подобность для современного языка — главное условие "модности".

О чем идет речь? В "традиционных" императивных языках программирования (оперирующих переменными с помощью команд) для описания каждого варианта множества обычно предусматривались отдельные спецификаторы начала и конца описания, например знаменитая комбинация begin/end для множества команд или команд/данных. В C все совсем не так. Здесь нотация максимально приближена к чисто математической: как и в математике, где традиционной считается запись множества в виде перечисления, заключенного в фигурные скобки, так и в C заключенное между парой символов "itc_drupal_" перечисление обозначает множество. Удивительно, что факту такой "математичности" практически никогда не уделяется внимание при попытках осознания "феномена С". А между тем, классический курс математики в том или ином объеме изучали и изучают все будущие программисты, и привычный синтаксис ненавязчиво, но крепко "въедается" в память, формируя устойчивую привычку. Кроме того, синтаксическая форма перечисления, принятая в C, удивительно проста: элементы множеств разделяются символом ";" без каких-либо дополнительных синтаксических ограничений или неоправданно сложных деталей. Для сравнения: в не менее знаменитом языке Pascal расстановка разделительных символов подчиняется куда более сложным правилам.

Наступила пора подвести первые итоги. C-программа, оказывается, — это всего лишь традиционная запись множества множеств в традиционной форме:

itc_drupal_ элемент_1 ;
элемент_2 ;
...
элемент_N ;

itc_drupal_ элемент_1' ;
элемент_2' ;
...
элемент_N' ;

При этом элементы множеств могут сами быть множествами (с некоторыми ограничениями), что означает принципиальную допустимость записи вида:

itc_drupal_ элемент_1 ;
itc_drupal_ элемент_1' ;
элемент_2' ;
...
элемент_N' ;

...
элемент_N ;

Для уточнения характера каждого множества в C используется неявный префикс—спецификатор множества. Префиксный характер спецификатора означает, что в записи множества он ставится перед описанием-перечислением, т. е. перед открывающей описание фигурной скобкой. Неявность его подразумевает, что специальных ключевых слов, вроде type или function/procedure, в языке нет. Впрочем, они и не нужны — форма записи спецификатора, опять же, максимально приближена к принятой в математике. В первую очередь это правило касается главной абстракции императивных языков — функции. В математике со школьной скамьи мы привыкли записывать функцию в форме y = f(x) или "область_значений имя_функции(область_определения)". C традиций не ломает, и спецификатор функции максимально приближен к традиционному. Он описывает именно то, что принято в математике: область значений функции, имя функции и заключенный в скобки спецификатор области определения функции.

Итак, еще одно "открытие" — форма записи функции в C:

область_значений имя_функции(область_определения)
itc_drupal_
...

Она, естественно, означает, что описанное перечислением множество в фигурных скобках является функцией, ставящей в соответствие некоторым значениям из области определения функции некоторые значения из области значений. Все это — чистая традиционная алгебра, по большому счету никакого отношения в форме записи к языкам программирования не имеющая.

Однако, как только начинаются исключения из синтаксических правил, алгебра заканчивается, начинается язык. Первое исключение — запись множеств, представляющих данные. В C она несколько отличается от записи функций. Префиксный спецификатор остается, но появляется и суффикс — имя, употребляющееся после закрывающей скобки. Второе исключение направлено на минимизацию нажатий на клавиши при программировании: для описаний множеств, состоящих из одного-единственного элемента, C-нотация предусматривает отсутствие фигурных скобок, оставляя только префиксный спецификатор и суффикс — описатель имени.

В целом, считать эти исключения из правил вопиющими нельзя, их количество минимально, а использование буквально элементарно (в следующем примере заключенные между символами "/* */" строки являются синтаксически правильными комментариями C):

1 int /* область_значений функции -- целые числа */
2 quad(int x) /* имя функции -- quad и (int x) -- область определения */
3 itc_drupal_ /* множество, заданное перечислением */
4 int tmp ; /* множество, заданное одним элементом, в фигурных скобках не
нуждается */
5
6 itc_drupal_
7 return x*x ; /* элементы множества разделяются ";" вне зависимости
от их характера */
8 ; /* элементы множества разделяются ";" вне зависимости от
их характера */
9

Этот фрагмент исключительно важной программы демонстрирует все перечисленные особенности синтаксиса C и является формально правильной написанной на ANSI C-программой. Интересно, что математический характер C-нотации, о котором мы столько говорили, подтверждается маленьким забавным тестом — попыткой поставить разделитель ";" после последней закрывающей фигурной скобки (строка 9). С математической точки зрения — это нонсенс, за пределами описания множества перечислением элементов данный разделительный знак никакого смыслового значения не несет. ANSI-стандартный транслятор C все наши рассуждения подтверждает предупреждением:

quad.c:9: warning: ANSI C does not allow extra ";" outside of
a function

что означает: стандарт не допускает использования разделителя элементов множества за пределами описания множества перечислением. Впрочем, и наше замечание об отсутствии смыслового значения символа ";" в этом случае полностью подтверждается — трансляция завершается успешно, поскольку никакого смысла в этом символе нет, он не искажает смысла и не ведет к двусмысленному толкованию нашей суперпрограммы.

Пока, по сути, мы говорили не о C, а о… школьном курсе алгебры. И что теперь удивительного можно найти в том "странном" факте, что синтаксис C фактически диктует успешность новых языков, предопределяя их популярность (достаточно упомянуть только Java, С++ и C#)? Пока мы изучаем алгебру в школе, синтаксис C будет действительно простым, исключительно удачным и ненавязчивым, и это факт.

Если предыдущие рассуждения кажутся автору статьи бесспорными, то теперь наступила пора самого спорного и неприятного, а именно, — тем самым областям значений и определений функций C, которые именуются "типами". Все дело в том, что C — фактически бестиповый язык. Не то, чтобы в нем вообще не было типов данных, нет, просто эти типы максимально приближены к абстракциям, свойственным исключительно реальным вычислительным машинам. Перечень способов построения новых типов в C минимален и состоит из единственного, уже упомянутого приема, — образования множества перечислением. У этого приема есть три вариации: собственно образование множества неоднородных данных (именуемого в терминах C структурой — struct), образование контейнера, поддерживающего хранение одного из перечисленного множеством типа (объединение — union), и наконец, образование множества однородных данных (массив и перечисление — enum).

Перечень базовых типов, на основе которых с помощью перечисленных приемов строятся новые абстракции, минимален: целые числа, числа с плавающей запятой и указатели. Последние считаются самым страшным недостатком C. Причем вовсе не из-за "ущербности" или убогости реализации указателей в C, а из-за куда более неприятного момента. Простота изучения C, высокая степень стандартизации языка, доступность инструментария приводят к тому, что множество C-программ написаны, мягко говоря, как курица лапой: начинающими программистами в период эйфории от легкости и "податливости" технологии C-программирования. Изящество и простота освоения часто играют злую шутку. Казалось бы, они высвобождают время для изучения высокоуровневой техники программирования, приемов повышения надежности, алгоритмики, в конце концов. А на деле все получается с точностью до наоборот — опыт кодирования давлеет над профессионализмом.

Впрочем, вернемся к "главной болячке" — указателям. Их концепция очень… удачная и мощная. Ведь это не только адреса объектов в памяти компьютера, это еще и представители области определения и области значений для целого ряда встроенных функций языка, а именно, — функций/операций адресной арифметики, позволяющих программисту оперировать однородными множествами объектов в памяти компьютера на уровне понятий "первый объект", "следующий объект", "N-й объект", не заботясь о физическом размере объекта в рамках минимального синтаксиса. Неоднородными множествами объектов оперировать тоже можно с подкупающей (для определенного уровня профессионализма) легкостью.

Элементарность синтаксиса определения и использования указателей (собственно весь синтаксис — это два символа "&" и "*") и их необычайная гибкость таят страшные подводные камни. С одной стороны, указатели C прекрасно укладываются в "математический строй" языка, позволяя с минимальными затратами моделировать сложные отношения между данными и делать это исключительно эффективно. С другой стороны — ошибки при работе с указателями влекут губительные последствия, как проявляющиеся на этапе отладки программ (что не слишком страшно), так и куда более ужасные, скрывающиеся годами и даже десятилетиями потенциальные "черные дыры", способные при определенном стечении обстоятельств отправить в тартарары считавшуюся очень надежной систему. И все же утверждение о том, что указатели — это "хроническая болезнь" C и источник ненадежности написанных на C программ, соответствует истине при определенных условиях.

Во-первых, C давно обладает обширным инструментальным набором, позволяющим свести опасность указателей к минимуму: есть и так называемые garbage-collectors (сборщики мусора), во многих случаях освобождающие программиста от необходимости слежения за состоянием памяти, и различные библиотеки, обеспечивающие контроль за использованием памяти, и множество прочих довольно эффективных "лекарств". Но, как обычно бывает в реальной жизни, "лекарства" начинают принимать тогда, когда уже поздно, когда проходит эйфория от простоты и легкости, а этап кодирования перерастает в этап сопровождения уже написанного кода. На самом деле, подобное "лечение болезней" свойственно не только C-миру. Это, вероятнее всего, порок всех проектов, в которых процессу кодирования уделяется куда больше внимания, чем процессу проектирования. Давайте пока запомним эту фразу — она исключительно важна для дальнейшего изложения.

Во-вторых, самая страшная "болезнь" указателей и адресной арифметики — попытка адресации несуществующего объекта из множества однородных объектов — на самом деле не является болезнью языка C. Это "болезнь" систем команд процессоров, которую давным-давно заметили и устранили разработчики знаменитого микропроцессорного семейства M680x0. Именно в этих процессорах была уникальная команда индексной адресации множества объектов в памяти, задействовавшая три 32-битовых регистра: в одном хранился собственно индексный указатель, в двух других — адреса самого младшего и самого старшего объектов множества (граничные значения). В случаях, когда значение индексного указателя становилось меньше/больше граничных значений, процессор генерировал так называемую исключительную ситуацию (фактически — прерывание). Это позволило избежать "летального исхода" программ и повысить надежность процесса отладки. К сожалению, за пределами семейства M68K о подобной возможности остается только мечтать (ведь она совсем не лишняя и для куда более строгих языков программирования).

А теперь давайте вспомним фразу, сказанную выше. Настало время в дополнение к ней процитировать высказывание из неожиданно веселой для сложной тематики книги Д. Элджера: "Все уродства C++ — это в основном наши уродства". Если для "улучшенного до неузнаваемости C" под названием C++ с этой фразой соглашается множество профессиональных программистов всего мира, то для C ее можно (и нужно) минимально изменить: "Все уродства C — это наши уродства".

Изменить хотя бы потому, что исключительно простой и эффективный язык программирования, один из самых стандартизованных (и несмотря на это, каждая его конкретная реализация обладает массой нюансов) и инструментально поддержанных языков, так и не смог переломить нашу человеческую слабость: сделать лишь бы как, но быстро как только возможно. Настоящие "болезни" C — это наши с вами болезни: неаккуратность и неряшливость, поспешность в действиях, недостаток образования, наконец. И "лечение" от них есть только одно (радикально помогающее любому программисту или коллективу программистов, использующих любой язык программирования): грамотный подход к проектированию программ и тщательная их реализация. К сожалению, что это такое для случая "программирование вообще", никто не знает…

Педантичный C

Возможно ли такое? Не называется ли это C++? Слава Богу, нет. Мы коротко поговорим о замечательной технологии, которая больше влияет на ее пользователей, чем даже на качество получаемого на ее основе продукта. Называется она "статической проверкой с аннотированием" (СПА) и отлично поддержана программно.

Итак, о чем идет речь? Простота и выразительность C — исключительно важные человеческие факторы, обеспечивающие "реактивный" стиль и темп программирования на этом языке. Но достижение надежности C-программ требует крайней педантичности проектировщиков и программистов. Это противоречие часто усугубляется еще и тем, что C классически используется в тех областях, где необходима достаточно высокая мобильность быстродействующего кода. Последний фактор означает, что потенциально будущая программа будет транслироваться на самых разных платформах, которых ее разработчик, возможно, никогда в глаза не видел. Трансляторы с C для этих платформ могут быть "очень разными", со своими особенностями реализации тонких нюансов спецификаций языка. И все это надо учитывать на этапе кодирования.

Поразительно, что столь давно существующая и хорошо работающая технология СПА практически никогда не применяется в реальных программных проектах. Ведь процесс аннотирования C-программы играет не только исключительно важную для хорошего C-программиста дисциплинирующую роль, но его можно использовать и как… весьма эффективный инструмент высокоуровневого проектирования.

Впрочем, давайте по порядку. Есть такой интересный инструмент, разработанный в Университете штата Вирджиния и называемый LCLint (lclint.cs. virginia.edu). Проект его создания — не однодневка и финансируется такими "скромными" организациями, как Исследовательский центр NASA в Лэнгли, USENIX, Национальный научный фонд США. Задачу, которую решает LCLint, неформально можно сформулировать так: он нещадно ругает программиста за мелкие (и не очень) огрехи в создаваемой программе, при этом оказывая неоценимые услуги. Во-первых, LCLint требует серьезно думать над проектом программы (действительно серьезно), во-вторых, тщательно реализовывать программу, в третьих, при анализе программы он не нуждается ни в конкретном трансляторе с C, ни в отладчике (что устраняет влияние платформы на проект). Ну и, наконец, главное — LCLint ни в коей мере не "ломает" язык C, превращая его в нечто подобное C++.

Как такое может быть? На самом деле все достаточно просто: с точки зрения программиста, LCLint не требует изменения кода программы, он работает исключительно с игнорируемыми всеми инструментальными средствами комментариями. Собственно процесс написания этих комментариев в уже готовой программе можно назвать "постаннотированием", в создающейся программе, по ходу ее написания — "аннотированием" и в самом привлекательном случае — в создании аннотаций до написания программы — "упреждающим аннотированием".

Аннотации LCLint — это более 300 специальных команд, разбитых на несколько областей применения. Самые интересные из них — архитектурные, именуемые в терминах LCLint "аннотациями контроля за использованием абстрактных типов данных (АТД)". Язык C из-за своей простоты абсолютно не навязывает никакой парадигмы программисту, что часто приводит к так называемому хэк-стилю программирования. Архитектурное же аннотирование и LCLint позволяют "остудить пыл" даже самых горячих приверженцев хэк-стиля. Здесь есть все, что присуще лучшим языкам программирования: модули, АТД, интерфейсы. Есть даже то, чего во многих языках нет: жесткий контроль за соблюдением архитектурной модели и нарушениями "правил игры".

Второй класс команд LCLint направлен на контроль за интерфейсами функций и фактически вводит в распоряжение C-программиста Ada-подобные спецификации не только отдельных параметров функции (например, выражаемые фразой "эта функция изменяет такой-то параметр, остальные должны быть гарантированно неизменными"), но даже и отдельные элементы сложных (образованных множеством) типов C.

Третий класс аннотаций помогает бороться с неряшливостью в управлении памятью: LCLint выявляет и потенциальные возможности обращения к несуществующим в памяти объектам, и забывчивость программиста в освобождении памяти, и… перечень получится слишком обширным. Более того, аннотации LCLint предусматривают не бездумное выявление потенциальных ошибок, а неназойливое навязывание программисту и проектировщику строго определенной политики (точнее — культуры) работы с памятью.

Четвертый класс аннотаций направлен на поддержку возможности, аналогичной встроенной в язык Eiffel, и обеспечивает проверку безопасности диапазонов значений параметров функций.

Пятый класс отвечает за… чистоту объектно-ориентированного проектирования C-программы: полиморфизм, итераторы и прочие удовольствия ООП по большому счету от языка программирования не зависят.

Перечислять функции LCLint можно еще очень долго. Но автор надеется, что такого обзорного знакомства вполне достаточно для того, чтобы загрузить бесплатную и распространяющуюся как в исходных тестах, так и в исполняемом виде для самых разных платформ (включая практически все версии ОС Unix и 32-битовые версии ОС Windows) программу LCLint и ее документацию. Ведь все, что нужно для ее освоения, — простой текстовый редактор, усидчивость и практика. А результаты не заставят себя ждать. Если использовать LCLint не "после того", а на всех этапах проектирования C-программы, можно не только повысить ее надежность, но и по-настоящему полюбить этот одновременно прекрасный и ужасный язык C.