Немного о C и C++
Немного о C и C++
В последнее время появилось много статей, авторы которых благожелательно отзываясь о таких средствах разработки, как Delphi или VisualBasic, позволяют себе излишне резкие выпады в сторону других языков программирования и инструментальных систем. Либо наоборот, стараясь привлечь внимание к чему-либо, демонстрируют поразительную безграмотность. Очень часто незаслуженная критика касается языков программирования C и C++. Вероятно, это происходит из-за того, что мало кто реально представляет себе их возможности.
Начать разговор о C/C++ хочется с переносимости этих языков. Собственно говоря, переносимость любого языка программирования заключается в наличии стандарта, которому следуют создатели средств разработки, для данного языка на различных платформах. Поэтому большинство языков программирования являются переносимыми. Но термин "переносимость" в подавляющем большинстве случаев относится к программному обеспечению, написанному на конкретном языке программирования. Подобная переносимость зависит, главным образом, от содержания программ. Например, достаточно применить в качестве разделителя имен каталогов символ '\', как программа окажется непереносимой на UNIX-системы. Поэтому даже на Java можно создавать совершенно непереносимые проекты.
В виду этого интересно понять, насколько помогает язык программирования устранять несоответствия между различными платформами. Оказывается, что препроцессор языков C/C++, который ругают создатели Java, является очень полезным инструментом. Директивы #if, #ifdef, #else, #endif позволяют поддерживать реализацию одних и тех же действий для различных сред, даже если эти действия выполняются совершенно различными средствами. Причем весь код для всех платформ можно поместить в один файл. Благодаря директивам #include появляется возможность разбивать проект на несколько уровней, самый верхний из которых является максимально независимым, а самый нижний содержит специфичный для конкретной платформы код. Ярким примером служили компиляторы, поддерживающие 16- и 32-битовый Windows. Знаменитый файл windows.h мог выглядеть таким образом:
#ifdef __WIN32__
#include
#else
#include
#endif
Хотя сама прикладная программа использовала только директиву #include и в некоторых случаях могла совершенно не беспокоиться о разрядности Windows, для которой осуществлялась компиляция.
Необходимо подчеркнуть, что преимуществом препроцессора в обеспечении переносимости является то, что для устранения как мелких, так и крупных несоответствий достаточно изменять заранее ограниченный набор исходных файлов, а не заводить новый исходный файл со специфическими исправлениями для каждого частного случая.
Не менее важную роль для переносимости играет и механизм назначения псевдонимов для типов при помощи typedef. Это особенно важно, если требуется обеспечить одинаковую разрядность используемых переменных на нескольких платформах. Так, очень часто встречаются определения вида:
#if defined(__16BIT__)
typedef unsigned long uint32_t;
#elif defined(__32BIT__) || defined(__64BIT__)
typedef unsigned int uint32_t
#elif defined(__128BIT__)
typedef unsigned short uint32_t
#endif
Другим чрезвычайно полезным применением typedef является назначение удобных имен для сложных конструкций типов, что особенно важно при использовании шаблонов. Например, следующее определение просто жизненно необходимо (при помощи STL описывается словарь, в качестве ключа которого используется целое число, в качестве элемента - дэк, элементами которого являются множества целых чисел): typedef std::map > >, std::less > my_map_t;. Очевидно, что имя my_map_t делает реальным использование подобных типов в программе.
Если продолжить тему удобства программирования, то речь зайдет не столько о самом языке программирования, сколько о средствах разработки. Оказывается, что переносимость и здесь играет немаловажную роль. Хорошо иметь единую среду разработки для нескольких платформ, но в большинстве случаев это нереально. Поэтому для каждой платформы приходится использовать собственные, не совместимые между собой, инструментальные системы. А камнем преткновения при смене инструментальной системы становятся проектные файлы, в которых перечисляются исходные файлы, результирующие файлы и настройки компилятора, т.к. каждая система применяет собственный формат проектных файлов. Получается, что оптимальным является использование компиляторов командной строки и оформление проектов в виде make-файлов. Однако, в каждой операционной системе компиляторы, линковщики и библиотекари имеют собственные имена, параметры, правила указания аргументов и порядок применения. Версии утилиты make так же сильно отличаются по своим способностям обрабатывать условные операторы. Поэтому приходится создавать и поддерживать несколько make-файлов или применять иные средства (например, автор использует собственную make-подобную утилиту, устраняющую несоответствия между различными компиляторами). Тем не менее, это плата за переносимость, и в таком положении находятся не только C и C++.
Другой важной чертой средств быстрой разработки (т.к. Delphi, C++Builder, Visual C++, Visual Age, Optima++ и др.) является их интеграция с применяемой библиотекой классов. Так, продукты фирмы Borland жестко связаны с VCL (хотя ранее использовались TurboVision и OWL), а Visual C++ тесно связан с MFC. Если же потребовалось бы отказаться от предлагаемой библиотеки, то на помощь различных Wizard-ов можно не расчитывать. Конечно, возможно использовать интегрированную среду в качестве браузера исходных текстов, однако на это тратится масса ресурсов, а идеология работы с проектами в знакомых мне средах представляется слишком примитивной и не подходящей для одновременной работы с несколькими десятками собственных модулей (библиотек, динамически-загружаемых библиотек и исполнимых модулей).
Все же, главным назначением современных RAD-систем является максимальное облегчение работы с предложенной библиотекой классов. Но, сравнение RAD-систем в этой плоскости не зависит от языка программирования. Поэтому хотелось бы заострить внимание на другом моменте. Очевидно, что подавляющая часть проектов, реализуемых в настоящее время, требует сложных многооконных средств взаимодествия с пользователем. Но не следует забывать о приложениях командной строки, как бы архаично это ни звучало. К сожалению, многие из обучающихся программированию в настоящее время просто не представляют себе, что можно писать очень сложные программы без меню, диалоговых окон, иконок и прочего украшательства. Получение параметров из командной строки и/или со стандартного потока ввода до сих пор является мощнейшим инструментом, облегчающим жизнь программиста (особенно, в сочетании с возможностями перенаправления ввода/вывода и организации конвейеров). Конечно, нельзя со 100% уверенностью говорить, что C/C++ в создании консольных приложений лучше всех (Perl, может быть, еще лучше). Но лично мне наследие C в виде printf/sprintf/ scanf/sscanf, а также C++ классы istream/ostream/strstream очень помогают.
Несмотря на кажущуюся несерьезность консольные приложения играют важную роль как в малых, так и в больших проектах. А в больших проектах (более нескольких сотен тысяч строк) C++ показывает себя с наилучшей стороны. Причем здесь в полной мере проявляются свойства самого языка.
Прежде всего, для успешного выполнения больших проектов требуется, чтобы язык программирования явно различал декларацию и реализацию классов и функций. Языки C/C++, Object Pascal, Ada поддерживают такое разделение. A Java - нет. Поэтому мне представляетcя, что реализация больших проектов на Java сопряжено с огромными трудозатратами. Например, очень сложно анализировать несколько десятков классов одновременно, поскольку их описание "загрязнено" текстом методов. Конечно, успех большого проекта в огромной степени определяется организацией работы. Тем не менее, возможность описания спецификаций классов на языке программирования и использование подобных спецификаций на всех стадиях разработки чрезвычайно важна. И тут C и C++ со своими заголовочными файлами очень удобны.
Другой особенностью C++, облегчающей реализацию больших проектов, является множественное наследование, которое подвергается незаслуженной критике сторонниками языков с одиночным наследованием и многими теоретиками объектно-ориентированного подхода. Достоинство множественного наследования в возможности распараллеливания работ. Так, одна группа разработчиков может программировать средства для отображения сложных данных, а другая группа, независимо от первой, может программировать средства сбора данных, например, с метеорологического спутника. Затем, несколько классов объединяются в один и получается средство для отображения собираемых данных. Вскоре заканчивает работу третья группа, которая создает класс для передачи данных в Internet. И опять, путем несложных манипуляций можно получить несколько полезных классов.
Конечно, для легкого использования множественного наследования требуется тщательное проектирование объектной модели. Но ведь речь идет о серьезных задачах. Вряд ли можно говорить о том, что проекты в миллионы строчек кода можно за полчаса-час спроектировать на коленке.
Чрезвычайно важной особенностью языка C++ является способность наращивания возможностей языка, точнее, библиотек, средствами самого C++. Так, в C++ не было средств ввода-вывода, множеств, стокового типа и т.д. Но сам C++ позволяет строить недостающие конструкции (ярчайший пример - потоки ввода-вывода и STL). В создании собственных инструментов сильно помогает возможность перегрузки операторов. Конечно, описать классы для представления комплексных чисел, разряженных матриц, мультимножеств и т.д. можно и на Object Pascal, и на Java, но их применение будет не совсем естественно (для перемножения двух чисел хочется использовать символ '*', а не функцию 'mul'). С перегрузкой операторов сравнения (<, >, <=, > =, ==,!=) в C++ связано удобство работы с множествами, мультимножествами, упорядоченными списками и т.п., поскольку операция сравнения реализуется над содержимым объектов, а не над хеш-кодами. Если объект в C++ содержит несколько строк, числовых значений и агрегирует объекты других классов, то оператор сравнения легко можно представить в последовательном сравнении всех строк, числовых значений и агрегированных объектов. В то же время, задача получения уникального хеш-кода для подобного объекта является значительно более сложной задачей.
До появления шаблонов и STL в C++ приходилось самостоятельно организовывать работу с контейнерами. В то же время компиляторы языка Pascal фирмы Borland комплектовались достаточно полными и мощными библиотеками. Однако, наличие готовых вспомогательных библиотек-классов скрывают один важный аспект - удобство создания собственных средств, если стандартные не подходят для применения в конкретных условиях. Например, если потребовался контейнер для хранения значений в разделяемой несколькими процессами памяти. Или потребовались средства для работы со строками, не использующие стандартную библиотеку (для модулей, встраиваемых в ядро операционной системы). Или потребовались собственные средства управления выделением и освобождением памяти.
В этих случаях C++ так же показывает себя с наилучшей стороны, и очень важную роль играет отсутствие единого корня иерархии наследования. Разработчик имеет возможность проектировать собственные объектные модели, не ограничивая себя существующими догмами. Например, библиотеку классов для отображения данных можно реализовать в виде дерева с общим корнем, а библиотеку для ввода/вывода в виде нескольких независимых деревьев. В результате появляется возможность создавать произвольно сложные пересечения деревьев наследования и организации новых библиотек, построенных на множественном наследовании. Причем создание новых библиотек значительно облегчается за счет использования уже существующего кода. В случаях с общим корнем иерархии наследования усилий понадобится значительно больше. Чтобы не сложилось впечатление о том, что речь идет о тривиальных задачах, можно предположить, что отображение заключается в формировании трехмерного представления результатов сложных статистических расчетов с возможностями изменения масштаба, вида графиков, угла обзора и т.д. с возможностью отображения в нескольких оконных средах (Windows, OS/2, Mac, X-Windows). Под вводом-выводом может пониматься обмен данными в гетерогенных сетях с применением различных средств и протоколов связи с одновременным шифрованием и проверкой прав отправителей/получателей информации. Для тех, кто знает о существовании готовых библиотек для реализации подобных задач, можно добавить еще несколько условий. Например, ограничение в вычислительных ресурсах, режим реального времени, недостаток финансовых средств, необходимость наличия исходных текстов и т.д.
Огромную роль в возможности создания собственных библиотек играет поддержка шаблонов в C++, позволяющая с исключительной легкостью создавать сложные конструкции типов, как, например, приведенный выше пример словаря дэков, содержащих множества целых чисел. Вряд ли какой-либо из других широко распространенных языков программирования (подразумеваются Java, Object Pascal и Visual Basic) позволит так же просто создать что-нибудь подобное.
Важно отметить, что C++ позволяет строить собственные решения в случаях, когда готовых рецептов не существует. Причем затраты на создание собственного инструментария в C++ представляются более низкими по сравнению с другими языками программирования. Хотя решающую роль все-таки играет опыт, здравый смысл, квалификация разработчиков и правильная организация работы.
То, что C++ произошел от низкоуровневого языка C, создает для C++ репутацию более низкоуровневого языка, чем Object Pascal, Java, Smalltalk, Ada и других. Конечно, в C++ нет встроенных строк, множеств и операторов присваивания для массивов, а оператор явного приведения типов позволяет вытворять все, что угодно. Тем не менее, C++ является языком программирования высокого уровня с развитой системой описания пользовательских типов и статическим контролем типов. Под низкоуровневостью обычно понимается необходимость прямого обращения к функциям операционной системы для выполнения некоторых "простейших" действий. Это действительно так, хотя многое зависит от используемых библиотек. В простом доступе к средствам ОС имеется много преимуществ. Для примера можно взять организацию нитей (threads). В таких языках, как Java и Modula-2, имеются встроенные средства для работы с нитями, позволяющие создавать переносимые многонитевые приложения. Однако, стоит затронуть вопрос управления приоритетами нитей и окажется, что необходимы средства ОС, поскольку различные ОС поддерживают существенно отличающиеся дисциплины диспетчеризации нитей и процессов. Понизить приоритет нити в Windows - это совсем не то, что понизить приоритет в OS/2 или Linux. Получается, что если требуется создать что-то полезное с несколькими нитями, то приходится погружаться в недра конкретной ОС и использовать средства конкретной ОС.
От многонитевых приложений можно перейти к вопросу отладки программ. Начать хочется с того, что применение отладчиков для многопоточных приложений представляется совершенно бесполезным занятием. Более того, по моему мнению, отладчики в больших (или небольших, но нетривиальных) проектах не облегчают процесс отладки, а только усложняют его. Можно выделить несколько случаев, в которых отладчики объективно малоэффективны. Во-первых, при разработке модулей, встраиваемых в ядро ОС, например, драйверов (хотя существуют средства отладки и для этих целей). Во-вторых, при передаче управления в динамически-загружаемые библиотеки, загруженные вручную, особенно если это производится неоднократно. В-третьих, при отладке комплекса из несколько взаимодествующих самостоятельных процессов, особенно если они функционируют в разных узлах сети). В-четвертых, при столкновении с ошибками компилятора, когда проект оказывается слишком нетривиальным для последнего. В-пятых, если ошибка проявляется через несколько часов (дней, недель) после старта программы.
Поэтому мне представляется наиболее эффективным способом поиска ошибок простое обдумывание поведения программы, подкрепленное отладочными печатями, если возможно. И здесь язык C++ так же имеет свои положительные стороны. Часто отмечают, что C++ требует более тщательного проектирования программ. Но это не недостаток, а достоинство, поскольку при тщательном проектировании уже выявляется большая часть ошибок, причем ошибок стратегических, способных поставить под вопрос успешность выполнения всего проекта. С другой стороны, разработчик лучше представляет себе логику работы приложения, и иногда для нахождения ошибки бывает достаточно нескольких строк отладочной печати.
Другой облегчающей отладку возможностью C++ является препроцессор. Использование макросов при отладке трудно переоценить. Взять, например, макрос assert, проверяющий истинность некоторого условия и завершающий программу, если условие не выполняется. Макросами assert можно наполнить приложение сверх всякой меры - главное обнаружить ошибку. А после нахождения ошибки не требуется изменения ни строчки кода - достаточно объявить при компиляции символ NDEBUG, и весь отладочный код будет изъят из проекта.
Для облегчения отладки программ на C++ можно использовать и другие средства языка C++. Например, возможность переопределения операторов new и delete. Это можно делать не только для обнаружения неосвобожденных блоков памяти, но и для обнаружения повторно освобождаемых блоков или случаев, когда для освобождения передается указатель на реально существующий блок, но не на начало, а на середину блока.
Так же при отладке помогает то, что управление составом проекта не является частью языка. Используемые библиотеки указываются линковщику, в то время как в Object Pascal и Java части проекта связываются на уровне исходного текста. В C/C++ имеется возможность подставить отладочные версии библиотек, не меняя ни строчки исходного кода. Например, для отладки к приложению можно прилинковать код собственных операторов new и delete, ничего не меняя в программе.
От обсуждения отладки программ можно перейти к близкой теме ошибочных указателей и сборки мусора. Автоматическая сборка мусора действительно является очень полезной возможностью, просто необходимой в ряде случаев, особенно при использовании оконных библиотек, в которых создание и удаление элементов пользовательского интерфейса скрыто от программиста. Вероятно, освобождение разработчика от необходимости следить за расходом памяти значительно повысит производительность при создании некоторых типов приложений. Но можно взглянуть на управление памятью с другой стороны. Память является таким же ресурсом, как, например, файл, дисплей или сетевое устройство. Для работы с ресурсами принято использовать действия "открыть", "записать/прочитать" и "закрыть", что можно проинтерпритировать по отношению к памяти, как "заказать", "использовать" и "освободить". Наличие сборщика мусора означает, что действие "закрыть" ("освободить") забирается у программиста и отдается сборщику мусора, т.е. программист устраняется от управления ресурсом "память". Поскольку языки C и C++ создавались для предоставления программисту полного контроля за любым ресурсом, то сборщик мусора с данной точки зрения противоречит идеалогии языков C и C++.
Проблема ошибочных указателей тесно связана с управлением памяти. Однако, она имеет свои специфические особенности, что позволяет рассматривать ее несколько в стороне от средств сборки мусора. Тремя основными источниками проблем с указателями являются не инициализированные указатели, неправильно инициализированные указатели и "повисшие" (вовремя не исправленные) указатели. Языки со встроенной сборкой мусора, например, Java, практически полностью устраняют проблемы повисших указателей, т.к. существование ссылки на объект препятствует удалению объекта. Так же существенно снижается число проблем с неинициализированными, или неправильно инициализированными указателями. Но даже в Java существует возможность обратиться по ссылке, имеющей значение null, что приведет к выдаче подробной отладочной информации в процессе выполнения программы. Предположим, что данная ошибка находилась в одной из десяти тысяч ветвей программы, управляющей жизнеобеспечением многоэтажного жилого дома. Предположим, что данная ошибка не возникала ни при тестировании, ни при опытной эксплуатации. Однако, никто не гарантирует, что она никогда не произойдет, и что никто в результате не пострадает.
Конечно, языки со сборкой мусора значительно более безопасны, чем языки с явным управлением памятью. Но C++ по этим показателям ничуть не уступает тому же Object Pascal. Поэтому хочется высказать свое недоумение в связи с появлением языка Java. Контроль указателей, защищенная область памяти, автоматическая сборка мусора и переносимость скомпилированного кода до сих пор являются мечтой огромного числа программистов. Такие возможности, как контроль указателей и сборка мусора, невозможно реализовать без поддержки со стороны компилятора. Однако, почему нельзя было вместо Java создать интерпритируемый вариант C++? Можно было бы ввести фиксированные размеры для встроенных типов, из функций управления памяти оставить только new и delete, вместо автоматической сборки мусора выдавать статистику использования памяти, создать подходящий байт-код, реализовать те же графические библиотеки, но для C++. И это при том, что на всех платформах существует огромное количество приложений на C++, а количество C++ программистов исчисляется миллионами человек. Остается надеяться, что интерпритируемый вариант C++ все же появится.
В заключение хочется сказать, что программирование - это очень тяжелая работа, требующая не только природных способностей, но и огромного напряжения и ежедневного труда. Лозунги об инструментальных средствах, позволяющих легко программировать даже непрофессионалам, являются исключительно рекламными и правдивы настолько, насколько правдива реклама расчесок для лысых. Очевидно, что сегодняшняя экономическая ситуация и уровень развития производства программного обеспечения в нашей стране благоприятствует расширению армии программистов, использующих только средства быстрой разработки. Причем использующих настолько успешно, что число статей, ругающих что-то сложное (тот же С++) и написанных разговорным сленгом растет с невероятной скоростью. Данной статьей мне хотелось показать, насколько мир программирования многообразен и сложен. К счастью, этот мир стремительно расширяется, и место в нем может найти каждый. Поэтому хочется, чтобы отзывы о различных языках и средствах программирования носили конструктивный, а не эмоциональный характер. Евгений Охотников, eao197@altavista.com (c) компьютерная газета
В последнее время появилось много статей, авторы которых благожелательно отзываясь о таких средствах разработки, как Delphi или VisualBasic, позволяют себе излишне резкие выпады в сторону других языков программирования и инструментальных систем. Либо наоборот, стараясь привлечь внимание к чему-либо, демонстрируют поразительную безграмотность. Очень часто незаслуженная критика касается языков программирования C и C++. Вероятно, это происходит из-за того, что мало кто реально представляет себе их возможности.
Начать разговор о C/C++ хочется с переносимости этих языков. Собственно говоря, переносимость любого языка программирования заключается в наличии стандарта, которому следуют создатели средств разработки, для данного языка на различных платформах. Поэтому большинство языков программирования являются переносимыми. Но термин "переносимость" в подавляющем большинстве случаев относится к программному обеспечению, написанному на конкретном языке программирования. Подобная переносимость зависит, главным образом, от содержания программ. Например, достаточно применить в качестве разделителя имен каталогов символ '\', как программа окажется непереносимой на UNIX-системы. Поэтому даже на Java можно создавать совершенно непереносимые проекты.
В виду этого интересно понять, насколько помогает язык программирования устранять несоответствия между различными платформами. Оказывается, что препроцессор языков C/C++, который ругают создатели Java, является очень полезным инструментом. Директивы #if, #ifdef, #else, #endif позволяют поддерживать реализацию одних и тех же действий для различных сред, даже если эти действия выполняются совершенно различными средствами. Причем весь код для всех платформ можно поместить в один файл. Благодаря директивам #include появляется возможность разбивать проект на несколько уровней, самый верхний из которых является максимально независимым, а самый нижний содержит специфичный для конкретной платформы код. Ярким примером служили компиляторы, поддерживающие 16- и 32-битовый Windows. Знаменитый файл windows.h мог выглядеть таким образом:
#ifdef __WIN32__
#include
#else
#include
#endif
Хотя сама прикладная программа использовала только директиву #include
Необходимо подчеркнуть, что преимуществом препроцессора в обеспечении переносимости является то, что для устранения как мелких, так и крупных несоответствий достаточно изменять заранее ограниченный набор исходных файлов, а не заводить новый исходный файл со специфическими исправлениями для каждого частного случая.
Не менее важную роль для переносимости играет и механизм назначения псевдонимов для типов при помощи typedef. Это особенно важно, если требуется обеспечить одинаковую разрядность используемых переменных на нескольких платформах. Так, очень часто встречаются определения вида:
#if defined(__16BIT__)
typedef unsigned long uint32_t;
#elif defined(__32BIT__) || defined(__64BIT__)
typedef unsigned int uint32_t
#elif defined(__128BIT__)
typedef unsigned short uint32_t
#endif
Другим чрезвычайно полезным применением typedef является назначение удобных имен для сложных конструкций типов, что особенно важно при использовании шаблонов. Например, следующее определение просто жизненно необходимо (при помощи STL описывается словарь, в качестве ключа которого используется целое число, в качестве элемента - дэк, элементами которого являются множества целых чисел): typedef std::map
Если продолжить тему удобства программирования, то речь зайдет не столько о самом языке программирования, сколько о средствах разработки. Оказывается, что переносимость и здесь играет немаловажную роль. Хорошо иметь единую среду разработки для нескольких платформ, но в большинстве случаев это нереально. Поэтому для каждой платформы приходится использовать собственные, не совместимые между собой, инструментальные системы. А камнем преткновения при смене инструментальной системы становятся проектные файлы, в которых перечисляются исходные файлы, результирующие файлы и настройки компилятора, т.к. каждая система применяет собственный формат проектных файлов. Получается, что оптимальным является использование компиляторов командной строки и оформление проектов в виде make-файлов. Однако, в каждой операционной системе компиляторы, линковщики и библиотекари имеют собственные имена, параметры, правила указания аргументов и порядок применения. Версии утилиты make так же сильно отличаются по своим способностям обрабатывать условные операторы. Поэтому приходится создавать и поддерживать несколько make-файлов или применять иные средства (например, автор использует собственную make-подобную утилиту, устраняющую несоответствия между различными компиляторами). Тем не менее, это плата за переносимость, и в таком положении находятся не только C и C++.
Другой важной чертой средств быстрой разработки (т.к. Delphi, C++Builder, Visual C++, Visual Age, Optima++ и др.) является их интеграция с применяемой библиотекой классов. Так, продукты фирмы Borland жестко связаны с VCL (хотя ранее использовались TurboVision и OWL), а Visual C++ тесно связан с MFC. Если же потребовалось бы отказаться от предлагаемой библиотеки, то на помощь различных Wizard-ов можно не расчитывать. Конечно, возможно использовать интегрированную среду в качестве браузера исходных текстов, однако на это тратится масса ресурсов, а идеология работы с проектами в знакомых мне средах представляется слишком примитивной и не подходящей для одновременной работы с несколькими десятками собственных модулей (библиотек, динамически-загружаемых библиотек и исполнимых модулей).
Все же, главным назначением современных RAD-систем является максимальное облегчение работы с предложенной библиотекой классов. Но, сравнение RAD-систем в этой плоскости не зависит от языка программирования. Поэтому хотелось бы заострить внимание на другом моменте. Очевидно, что подавляющая часть проектов, реализуемых в настоящее время, требует сложных многооконных средств взаимодествия с пользователем. Но не следует забывать о приложениях командной строки, как бы архаично это ни звучало. К сожалению, многие из обучающихся программированию в настоящее время просто не представляют себе, что можно писать очень сложные программы без меню, диалоговых окон, иконок и прочего украшательства. Получение параметров из командной строки и/или со стандартного потока ввода до сих пор является мощнейшим инструментом, облегчающим жизнь программиста (особенно, в сочетании с возможностями перенаправления ввода/вывода и организации конвейеров). Конечно, нельзя со 100% уверенностью говорить, что C/C++ в создании консольных приложений лучше всех (Perl, может быть, еще лучше). Но лично мне наследие C в виде printf/sprintf/ scanf/sscanf, а также C++ классы istream/ostream/strstream очень помогают.
Несмотря на кажущуюся несерьезность консольные приложения играют важную роль как в малых, так и в больших проектах. А в больших проектах (более нескольких сотен тысяч строк) C++ показывает себя с наилучшей стороны. Причем здесь в полной мере проявляются свойства самого языка.
Прежде всего, для успешного выполнения больших проектов требуется, чтобы язык программирования явно различал декларацию и реализацию классов и функций. Языки C/C++, Object Pascal, Ada поддерживают такое разделение. A Java - нет. Поэтому мне представляетcя, что реализация больших проектов на Java сопряжено с огромными трудозатратами. Например, очень сложно анализировать несколько десятков классов одновременно, поскольку их описание "загрязнено" текстом методов. Конечно, успех большого проекта в огромной степени определяется организацией работы. Тем не менее, возможность описания спецификаций классов на языке программирования и использование подобных спецификаций на всех стадиях разработки чрезвычайно важна. И тут C и C++ со своими заголовочными файлами очень удобны.
Другой особенностью C++, облегчающей реализацию больших проектов, является множественное наследование, которое подвергается незаслуженной критике сторонниками языков с одиночным наследованием и многими теоретиками объектно-ориентированного подхода. Достоинство множественного наследования в возможности распараллеливания работ. Так, одна группа разработчиков может программировать средства для отображения сложных данных, а другая группа, независимо от первой, может программировать средства сбора данных, например, с метеорологического спутника. Затем, несколько классов объединяются в один и получается средство для отображения собираемых данных. Вскоре заканчивает работу третья группа, которая создает класс для передачи данных в Internet. И опять, путем несложных манипуляций можно получить несколько полезных классов.
Конечно, для легкого использования множественного наследования требуется тщательное проектирование объектной модели. Но ведь речь идет о серьезных задачах. Вряд ли можно говорить о том, что проекты в миллионы строчек кода можно за полчаса-час спроектировать на коленке.
Чрезвычайно важной особенностью языка C++ является способность наращивания возможностей языка, точнее, библиотек, средствами самого C++. Так, в C++ не было средств ввода-вывода, множеств, стокового типа и т.д. Но сам C++ позволяет строить недостающие конструкции (ярчайший пример - потоки ввода-вывода и STL). В создании собственных инструментов сильно помогает возможность перегрузки операторов. Конечно, описать классы для представления комплексных чисел, разряженных матриц, мультимножеств и т.д. можно и на Object Pascal, и на Java, но их применение будет не совсем естественно (для перемножения двух чисел хочется использовать символ '*', а не функцию 'mul'). С перегрузкой операторов сравнения (<, >, <=, > =, ==,!=) в C++ связано удобство работы с множествами, мультимножествами, упорядоченными списками и т.п., поскольку операция сравнения реализуется над содержимым объектов, а не над хеш-кодами. Если объект в C++ содержит несколько строк, числовых значений и агрегирует объекты других классов, то оператор сравнения легко можно представить в последовательном сравнении всех строк, числовых значений и агрегированных объектов. В то же время, задача получения уникального хеш-кода для подобного объекта является значительно более сложной задачей.
До появления шаблонов и STL в C++ приходилось самостоятельно организовывать работу с контейнерами. В то же время компиляторы языка Pascal фирмы Borland комплектовались достаточно полными и мощными библиотеками. Однако, наличие готовых вспомогательных библиотек-классов скрывают один важный аспект - удобство создания собственных средств, если стандартные не подходят для применения в конкретных условиях. Например, если потребовался контейнер для хранения значений в разделяемой несколькими процессами памяти. Или потребовались средства для работы со строками, не использующие стандартную библиотеку (для модулей, встраиваемых в ядро операционной системы). Или потребовались собственные средства управления выделением и освобождением памяти.
В этих случаях C++ так же показывает себя с наилучшей стороны, и очень важную роль играет отсутствие единого корня иерархии наследования. Разработчик имеет возможность проектировать собственные объектные модели, не ограничивая себя существующими догмами. Например, библиотеку классов для отображения данных можно реализовать в виде дерева с общим корнем, а библиотеку для ввода/вывода в виде нескольких независимых деревьев. В результате появляется возможность создавать произвольно сложные пересечения деревьев наследования и организации новых библиотек, построенных на множественном наследовании. Причем создание новых библиотек значительно облегчается за счет использования уже существующего кода. В случаях с общим корнем иерархии наследования усилий понадобится значительно больше. Чтобы не сложилось впечатление о том, что речь идет о тривиальных задачах, можно предположить, что отображение заключается в формировании трехмерного представления результатов сложных статистических расчетов с возможностями изменения масштаба, вида графиков, угла обзора и т.д. с возможностью отображения в нескольких оконных средах (Windows, OS/2, Mac, X-Windows). Под вводом-выводом может пониматься обмен данными в гетерогенных сетях с применением различных средств и протоколов связи с одновременным шифрованием и проверкой прав отправителей/получателей информации. Для тех, кто знает о существовании готовых библиотек для реализации подобных задач, можно добавить еще несколько условий. Например, ограничение в вычислительных ресурсах, режим реального времени, недостаток финансовых средств, необходимость наличия исходных текстов и т.д.
Огромную роль в возможности создания собственных библиотек играет поддержка шаблонов в C++, позволяющая с исключительной легкостью создавать сложные конструкции типов, как, например, приведенный выше пример словаря дэков, содержащих множества целых чисел. Вряд ли какой-либо из других широко распространенных языков программирования (подразумеваются Java, Object Pascal и Visual Basic) позволит так же просто создать что-нибудь подобное.
Важно отметить, что C++ позволяет строить собственные решения в случаях, когда готовых рецептов не существует. Причем затраты на создание собственного инструментария в C++ представляются более низкими по сравнению с другими языками программирования. Хотя решающую роль все-таки играет опыт, здравый смысл, квалификация разработчиков и правильная организация работы.
То, что C++ произошел от низкоуровневого языка C, создает для C++ репутацию более низкоуровневого языка, чем Object Pascal, Java, Smalltalk, Ada и других. Конечно, в C++ нет встроенных строк, множеств и операторов присваивания для массивов, а оператор явного приведения типов позволяет вытворять все, что угодно. Тем не менее, C++ является языком программирования высокого уровня с развитой системой описания пользовательских типов и статическим контролем типов. Под низкоуровневостью обычно понимается необходимость прямого обращения к функциям операционной системы для выполнения некоторых "простейших" действий. Это действительно так, хотя многое зависит от используемых библиотек. В простом доступе к средствам ОС имеется много преимуществ. Для примера можно взять организацию нитей (threads). В таких языках, как Java и Modula-2, имеются встроенные средства для работы с нитями, позволяющие создавать переносимые многонитевые приложения. Однако, стоит затронуть вопрос управления приоритетами нитей и окажется, что необходимы средства ОС, поскольку различные ОС поддерживают существенно отличающиеся дисциплины диспетчеризации нитей и процессов. Понизить приоритет нити в Windows - это совсем не то, что понизить приоритет в OS/2 или Linux. Получается, что если требуется создать что-то полезное с несколькими нитями, то приходится погружаться в недра конкретной ОС и использовать средства конкретной ОС.
От многонитевых приложений можно перейти к вопросу отладки программ. Начать хочется с того, что применение отладчиков для многопоточных приложений представляется совершенно бесполезным занятием. Более того, по моему мнению, отладчики в больших (или небольших, но нетривиальных) проектах не облегчают процесс отладки, а только усложняют его. Можно выделить несколько случаев, в которых отладчики объективно малоэффективны. Во-первых, при разработке модулей, встраиваемых в ядро ОС, например, драйверов (хотя существуют средства отладки и для этих целей). Во-вторых, при передаче управления в динамически-загружаемые библиотеки, загруженные вручную, особенно если это производится неоднократно. В-третьих, при отладке комплекса из несколько взаимодествующих самостоятельных процессов, особенно если они функционируют в разных узлах сети). В-четвертых, при столкновении с ошибками компилятора, когда проект оказывается слишком нетривиальным для последнего. В-пятых, если ошибка проявляется через несколько часов (дней, недель) после старта программы.
Поэтому мне представляется наиболее эффективным способом поиска ошибок простое обдумывание поведения программы, подкрепленное отладочными печатями, если возможно. И здесь язык C++ так же имеет свои положительные стороны. Часто отмечают, что C++ требует более тщательного проектирования программ. Но это не недостаток, а достоинство, поскольку при тщательном проектировании уже выявляется большая часть ошибок, причем ошибок стратегических, способных поставить под вопрос успешность выполнения всего проекта. С другой стороны, разработчик лучше представляет себе логику работы приложения, и иногда для нахождения ошибки бывает достаточно нескольких строк отладочной печати.
Другой облегчающей отладку возможностью C++ является препроцессор. Использование макросов при отладке трудно переоценить. Взять, например, макрос assert, проверяющий истинность некоторого условия и завершающий программу, если условие не выполняется. Макросами assert можно наполнить приложение сверх всякой меры - главное обнаружить ошибку. А после нахождения ошибки не требуется изменения ни строчки кода - достаточно объявить при компиляции символ NDEBUG, и весь отладочный код будет изъят из проекта.
Для облегчения отладки программ на C++ можно использовать и другие средства языка C++. Например, возможность переопределения операторов new и delete. Это можно делать не только для обнаружения неосвобожденных блоков памяти, но и для обнаружения повторно освобождаемых блоков или случаев, когда для освобождения передается указатель на реально существующий блок, но не на начало, а на середину блока.
Так же при отладке помогает то, что управление составом проекта не является частью языка. Используемые библиотеки указываются линковщику, в то время как в Object Pascal и Java части проекта связываются на уровне исходного текста. В C/C++ имеется возможность подставить отладочные версии библиотек, не меняя ни строчки исходного кода. Например, для отладки к приложению можно прилинковать код собственных операторов new и delete, ничего не меняя в программе.
От обсуждения отладки программ можно перейти к близкой теме ошибочных указателей и сборки мусора. Автоматическая сборка мусора действительно является очень полезной возможностью, просто необходимой в ряде случаев, особенно при использовании оконных библиотек, в которых создание и удаление элементов пользовательского интерфейса скрыто от программиста. Вероятно, освобождение разработчика от необходимости следить за расходом памяти значительно повысит производительность при создании некоторых типов приложений. Но можно взглянуть на управление памятью с другой стороны. Память является таким же ресурсом, как, например, файл, дисплей или сетевое устройство. Для работы с ресурсами принято использовать действия "открыть", "записать/прочитать" и "закрыть", что можно проинтерпритировать по отношению к памяти, как "заказать", "использовать" и "освободить". Наличие сборщика мусора означает, что действие "закрыть" ("освободить") забирается у программиста и отдается сборщику мусора, т.е. программист устраняется от управления ресурсом "память". Поскольку языки C и C++ создавались для предоставления программисту полного контроля за любым ресурсом, то сборщик мусора с данной точки зрения противоречит идеалогии языков C и C++.
Проблема ошибочных указателей тесно связана с управлением памяти. Однако, она имеет свои специфические особенности, что позволяет рассматривать ее несколько в стороне от средств сборки мусора. Тремя основными источниками проблем с указателями являются не инициализированные указатели, неправильно инициализированные указатели и "повисшие" (вовремя не исправленные) указатели. Языки со встроенной сборкой мусора, например, Java, практически полностью устраняют проблемы повисших указателей, т.к. существование ссылки на объект препятствует удалению объекта. Так же существенно снижается число проблем с неинициализированными, или неправильно инициализированными указателями. Но даже в Java существует возможность обратиться по ссылке, имеющей значение null, что приведет к выдаче подробной отладочной информации в процессе выполнения программы. Предположим, что данная ошибка находилась в одной из десяти тысяч ветвей программы, управляющей жизнеобеспечением многоэтажного жилого дома. Предположим, что данная ошибка не возникала ни при тестировании, ни при опытной эксплуатации. Однако, никто не гарантирует, что она никогда не произойдет, и что никто в результате не пострадает.
Конечно, языки со сборкой мусора значительно более безопасны, чем языки с явным управлением памятью. Но C++ по этим показателям ничуть не уступает тому же Object Pascal. Поэтому хочется высказать свое недоумение в связи с появлением языка Java. Контроль указателей, защищенная область памяти, автоматическая сборка мусора и переносимость скомпилированного кода до сих пор являются мечтой огромного числа программистов. Такие возможности, как контроль указателей и сборка мусора, невозможно реализовать без поддержки со стороны компилятора. Однако, почему нельзя было вместо Java создать интерпритируемый вариант C++? Можно было бы ввести фиксированные размеры для встроенных типов, из функций управления памяти оставить только new и delete, вместо автоматической сборки мусора выдавать статистику использования памяти, создать подходящий байт-код, реализовать те же графические библиотеки, но для C++. И это при том, что на всех платформах существует огромное количество приложений на C++, а количество C++ программистов исчисляется миллионами человек. Остается надеяться, что интерпритируемый вариант C++ все же появится.
В заключение хочется сказать, что программирование - это очень тяжелая работа, требующая не только природных способностей, но и огромного напряжения и ежедневного труда. Лозунги об инструментальных средствах, позволяющих легко программировать даже непрофессионалам, являются исключительно рекламными и правдивы настолько, насколько правдива реклама расчесок для лысых. Очевидно, что сегодняшняя экономическая ситуация и уровень развития производства программного обеспечения в нашей стране благоприятствует расширению армии программистов, использующих только средства быстрой разработки. Причем использующих настолько успешно, что число статей, ругающих что-то сложное (тот же С++) и написанных разговорным сленгом растет с невероятной скоростью. Данной статьей мне хотелось показать, насколько мир программирования многообразен и сложен. К счастью, этот мир стремительно расширяется, и место в нем может найти каждый. Поэтому хочется, чтобы отзывы о различных языках и средствах программирования носили конструктивный, а не эмоциональный характер. Евгений Охотников, eao197@altavista.com (c) компьютерная газета
Компьютерная газета. Статья была опубликована в номере 34 за 2000 год в рубрике программирование :: разное