|
||||
|
Введение Одно дело – изучать фундаментальные основы языка, и совсем другое – учиться проектировать и реализовывать эффективные программы. В особенности это касается C++, известного необычайно широкими возможностями и выразительностью. Работа на C++ при правильном его использовании способна доставить удовольствие. Самые разные проекты могут получить непосредственное выражение и эффективную реализацию. Тщательно выбранный и грамотно реализованный набор классов, функций и шаблонов поможет сделать программу простой, интуитивно понятной, эффективной и практически не содержащей ошибок. При наличии определенных навыков написание эффективных программ на C++ – совсем не трудное дело. Однако при неразумном использовании C++ может давать непонятный, сложный в сопровождении и попросту неправильный код. Цель этой книги – показать вам, как применять C++ эффективно. Я исхожу из того, что вы уже знакомы с C++ как языком программирования, а также имеете некоторый опыт работы с ним. Я предлагаю вашему вниманию рекомендации по применению этого языка, следование которым позволит сделать ваши программы понятными, простыми в сопровождении, переносимыми, расширяемыми, эффективными и работающими в соответствии с ожиданиями. Предлагаемые советы можно разделить на две категории: общая стратегия проектирования и практическое использование отдельных языковых конструкций. Обсуждение вопросов проектирования призвано помочь вам сделать выбор между различными подходами к решению той или иной задачи на C++. Что выбрать: наследование или шаблоны? Открытое или закрытое наследование? Закрытое наследование или композицию? Функции-члены или свободные функции? Передачу по значению или по ссылке? Важно принять правильное решение с самого начала, поскольку последствия неудачного выбора могут никак не проявляться, пока не станет слишком поздно, а переделывать будет трудно, долго и дорого. Даже когда вы точно знаете, что хотите сделать, добиться желаемых результатов бывает нелегко. Значение какого типа должен возвращать оператор присваивания? Когда деструктор должен быть виртуальным? Как себя ведет оператор new, если не может найти достаточно памяти? Исключительно важно проработать подобные детали, поскольку иначе вы почти наверняка столкнетесь с неожиданным и даже необъяснимым поведением программы. Эта книга поможет вам избежать подобных ситуаций. Конечно, эту книгу сложно назвать полным руководством по C++. Скорее, это коллекция их 55 советов (или правил), как улучшить ваши программы и проекты. Каждый параграф более или менее независим от остальных, но в большинстве есть перекрестные ссылки. Лучше всего читать эту книгу, начав с того правила, которое вас наиболее интересует, а затем следовать по ссылкам, чтобы посмотреть, куда они вас приведут. Эта книга также не является введением в C++. В главе 2, например, я рассказываю о правильной реализации конструкторов, деструкторов и операторов присваивания, но при этом предполагаю, что вы уже знаете, что эти функции делают и как они объявляются. На эту тему существует множество книг по C++. Цель этой книги – выделить те аспекты программирования на C++, которым часто не уделяют должного внимания. В других книгах описывают различные части языка. Здесь же рассказывается, как их комбинировать между собой для получения эффективных программ. В других изданиях говорится о том, как заставить программу откомпилироваться. А эта книга – о том, как избежать проблем, которых компилятор не в состоянии обнаружить. В то же время настоящая книга ограничивается только стандартным C++. Здесь используются лишь те средства языка, которые описаны в официальном стандарте. Переносимость – ключевой вопрос для этой книги, поэтому если вы ищете платформенно-зависимые трюки, обратитесь к другим изданиям. Не найдете вы в этой книге и «Евангелия от C++» – единственно верного пути к идеальной программе на C++. Каждое правило – это рекомендация по тому или иному аспекту: как отыскать более удачный дизайн, как избежать типичных ошибок, как достичь максимальной эффективности, но ни один из пунктов не является универсально применимым. Проектирование и разработка программного обеспечения – это сложная задача, на которую оказывают влияние ограничения аппаратного обеспечения, операционной системы и приложений, поэтому лучшее, что я могу сделать, – это представить рекомендации по повышению качества программ. Если вы систематически будете следовать всем рекомендациям, то маловероятно, что столкнетесь с наиболее частыми ловушками, подстерегающими вас в С++, но из любого правила есть исключения. Вот почему в каждом правиле приводятся пояснения. Они-то и составляют самую важную часть книги. Только поняв, что лежит в основе того или иного правила, вы сможете решить, насколько оно соответствует вашей программе с присущими только ей ограничениями. Лучший способ использования этой книги – постичь тайны поведения C++, понять, почему он ведет себя именно так, а не иначе, и использовать его поведение в своих целях. Слепое применение на практике всех приведенных правил совершенно неуместно, но в то же время не стоит без особых на то причин поступать вопреки этим советам. Терминология Существует небольшой словарик C++, которым должен владеть каждый программист. Следующие термины достаточно важны, поэтому имеет смысл убедиться, что мы понимаем их одинаково. Объявление (declaration) сообщает компилятору имя и тип чего-либо, опуская некоторые детали. Объявления выглядят так: extern int x; // объявление объекта std::size_t numDigits(int number); // объявление функции class Widget; // объявление класса template<typename T> // объявление шаблона class GraphNode; // (см. правило 42 о том, что такое «typename» Заметьте, что я называю целое число x «объектом», несмотря на то что это переменная встроенного типа. Некоторые люди под «объектами» понимают только переменные пользовательских типов, но я не принадлежу к их числу. Также отметим, что функция numDigits() возвращает тип std::size_t, то есть тип size_t из пространства имен std. Это то пространство имен, в котором находится почти все из стандартной библиотеки C++. Однако, поскольку стандартная библиотека C (точнее говоря, С89) также может быть использована в программе на C++, символы, унаследованные от C (такие как size_t), могут существовать в глобальном контексте, внутри std, либо в обоих местах, в зависимости от того, какие заголовочные файлы были включены директивой #include. В этой книге я предполагаю, что с помощью #include включаются заголовочные файлы C++. Вот почему я употребляю std::size_t, а не просто size_t. Когда я упоминаю компоненты стандартной библиотеки вне текста программы, то обычно опускаю ссылку на std, полагая, что вы знаете, что такие вещи, как size_t, vector и cout, находятся в пространстве имен std. В примерах же программ я всегда включаю std, потому что в противном случае код не скомпилируется. Кстати, size_t – это всего-навсего определенный директивой typedef синоним для некоторых беззнаковых типов, которые в C++ используются для разного рода счетчиков (например, количества символов в строках типа char*, количества элементов в контейнерах STL и т. п.). Это также тип, принимаемый функциями operator[] в векторах (vector), деках (deque) и строках (string). Этому соглашению мы будем следовать и при определении наших собственных функций operator[] в правиле 3. В любом объявлении функции указывается ее сигнатура, то есть типы параметров и возвращаемого значения. Можно сказать, что сигнатура функции – это ее тип. Так, сигнатурой функции numDigits является std::size_t(int), иными словами, это «функция, принимающая int и возвращающая std::size_t». Официальное определение «сигнатуры» в C++ не включает тип возвращаемого функцией значения, но в этой книге нам будет удобно считать, что он все же является частью сигнатуры. Определение (definition) сообщает компилятору детали, которые опущены в объявлении. Для объекта определение – это то место, где компилятор выделяет для него память. Для функции или шаблона функции определение содержит тело функции. В определении класса или шаблона класса перечисляются его члены: int x; // определение объекта std::size_t numDigits(int number) // определение функции { // (эта функция возвращает количество std::size_t digitsSoFar = 1; // десятичных знаков в своем параметре) while((number /= 10) != 0) ++digitsSoFar; return digitsSoFar; } class Widget { // определение класса public: Widget(); ~Widget(); ... }; template<typename T> // определение шаблона class GraphNode { public: GraphNode(); ~GraphNode(); ... }; Инициализация (initialization) – это процесс присваивания объекту начального значения. Для объектов пользовательских типов инициализация выполняется конструкторами. Конструктор по умолчанию (default constructor) – это конструктор, который может быть вызван без аргументов. Такой конструктор либо не имеет параметров вовсе, либо имеет значение по умолчанию для каждого параметра: class A { public: A(); // конструктор по умолчанию }; class B { public: explicit B(int x = 0; bool b = true); // конструктор по умолчанию, }; // см. далее объяснение // ключевого слова “explicit” class C { public: explicit C(int x); // это не конструктор по // умолчанию }; Конструкторы классов B и C объявлены в ключевым словом explicit (явный). Это предотвращает их использование для неявных преобразований типов, хотя не запрещает применения, если преобразование указано явно: void doSomething(B bObject); // функция принимает объект типа B B bObj1; // объект типа B doSomething(bObj1); // нормально, B передается doSomething B bObj(28); // нормально, создает B из целого 28 // (параметр bool по умолчанию true) doSomething(28); // ошибка! doSomething принимает B, // а не int, и не существует неявного // преобразования из int в B doSomething(B(28)); // нормально, используется конструктор // B для явного преобразования (приведения) // int в B (см. в правиле 27 информацию // о приведении типов) Конструкторы, объявленные как explicit, обычно более предпочтительны, потому что предотвращают выполнение компиляторами неявных преобразований типа (часто нежелательных). Если нет основательной причины для использования конструкторов в неявных преобразованиях типов, я всегда объявляю их explicit. Советую и вам придерживаться того же принципа. Обратите внимание, что в предшествующем примере приведение выделено. Я и дальше буду использовать такое выделение, чтобы подчеркнуть важность излагаемого материала. (Также я выделяю номера глав, но это только потому, что мне кажется, это выглядит симпатично.) Конструктор копирования (copy constructor) используется для инициализации объекта значением другого объекта того же самого типа, а копирующий оператор присваивания (copy assignment operator) применяется для копирования значения одного объекта в другой – того же типа: class Widget { public: Widget(); // конструктор по умолчанию Widget(const Widget& rhs); // конструктор копирования Widget& operator=(const Widget& rhs); // копирующий оператор присваивания ... }; Widget w1; // вызов конструктора по умолчанию Widget w2(w1); // вызов конструктора копирования w1 = w2; // вызов оператора присваивания // копированием Будьте внимательны, когда видите конструкцию, похожую на присваивание, потому что синтаксис «=» также может быть использован для вызова конструктора копирования: Widget w3 = w2; // вызов конструктора копирования! К счастью, конструктор копирования легко отличить от присваивания. Если новый объект определяется (как w3 в последнем предложении), то должен вызываться конструктор, это не может быть присваивание. Если же никакого нового объекта не создается (как в «w1=w2»), то конструктор не применяется и это – присваивание. Конструктор копирования – особенно важная функция, потому что она определяет, как объект передается по значению. Например, рассмотрим следующий фрагмент: bool hasAcceptableQuality(Widget w); ... Widget aWidget; if (hasAcceptableQuality(aWidget)) ... Параметр w передается функции hasAcceptableQuality по значению, поэтому в приведенном примере вызова aWidget копируется в w. Копирование осуществляется конструктором копирования из класса Widget. Вообще передача по значению означает вызов конструктора копирования. (Но, строго говоря, передавать пользовательские типы по значению – плохая идея. Обычно лучший вариант – передача по ссылке на константу, подробности см. в правиле 20.) STL – стандартная библиотека шаблонов (Standard Template Library) – это часть стандартной библиотеки, касающаяся контейнеров (то есть vector, list, set, map и т. д.), итераторов (то есть vector<int>::iterator, set<string>::iterator и т. д.), алгоритмов (то есть for_each, find, sort и т. д.) и всей связанной с этим функциональности. В ней очень широко используются объекты-функции (function objects), то есть объекты, ведущие себя подобно функциям. Такие объекты представлены классами, в которых перегружен оператор вызова operator(). Если вы не знакомы с STL, вам понадобится, помимо настоящей книги, какое-нибудь достойное руководство, посвященное этой теме, ведь библиотека STL настолько удобна, что не воспользоваться ее преимуществами было бы непростительно. Стоит лишь начать работать с ней, и вы сами это почувствуете. Программистам, пришедшим к C++ от языков вроде Java или C#, может показаться странным понятие неопределенного поведения. По различным причинам поведение некоторых конструкций в C++ действительно не определено: вы не можете уверенно предсказать, что произойдет во время исполнения. Вот два примера такого рода: int *p = 0; // p – нулевой указатель std::cout << *p; // разыменование нулевого указателя char name[] = “Daria” // name – массив длины 6 (не забудьте про // завершающий нуль!) char c = name[10]; // указание неправильного индекса массива // порождает неопределенное поведение Дабы подчеркнуть, что результаты неопределенного поведения невозможно предсказать и что они могут быть весьма неприятны, опытные программисты на C++ часто говорят, что программы с неопределенным поведением могут стереть содержимое жесткого диска. Это правда: такая программа может стереть ваш жесткий диск, но может этого и не сделать. Более вероятно, что она будет вести себя по-разному: иногда нормально, иногда аварийно завершаться, а иногда – просто выдавать неправильные результаты. Мудрые программисты на C++ придерживаются правила – избегать неопределенного поведения. В этой книге во многих местах я указываю, как это сделать. Иной термин, который может смутить программистов, пришедших из других языков, – это интерфейс. В Java и. NET-совместимых языках интерфейсы являются частью языка, но в C++ ничего подобного нет, хотя в правиле 31 рассматривается некоторое приближение. Когда я использую термин «интерфейс», то обычно имею в виду сигнатуры функций, доступные члены класса («открытый интерфейс», «защищенный интерфейс», «закрытый интерфейс») или выражения, допустимые в качестве параметров типа для шаблонов (см. правило 41). То есть под интерфейсом я понимаю общую концепцию проектирования. Понятие клиент – это нечто или некто, использующий написанный вами код (обычно через интерфейсы). Так, например, клиентами функции являются ее пользователи: части кода, которые вызывают функцию (или берут ее адрес), а также люди, которые пишут и сопровождают такой код. Клиентами класса или шаблона являются части программы, использующие этот класс или шаблон, а равно программисты, которые пишут или сопровождают эти части. Когда речь заходит о клиентах, я обычно имею в виду программистов, поскольку именно они могут быть введены в заблуждение или недовольство плохо разработанным интерфейсом. Коду, который они пишут, такие эмоции недоступны. Возможно, вы не привыкли думать о клиентах, но я постараюсь убедить вас в необходимости облегчить им жизнь, насколько это возможно. В конце концов, вы сами – клиент программного обеспечения, которое разрабатывал кто-то другой. Ведь вы хотели бы, чтоб его авторы облегчили вам работу? Помимо того, рано или поздно вы окажетесь в положении, когда сами станете клиентом собственного кода (то есть будете использовать код, написанный вами), и тогда оцените, что при разработке интерфейсов нужно помнить об интересах клиентов. В этой книге я часто обращаю внимание на различие между функциями и шаблонами функций, а также между классами и шаблонами классов. Это не случайно, ведь то, что справедливо для одного, часто справедливо и для другого. В ситуациях, когда это не так, я делаю различие между классами, функциями и шаблонами, из которых порождаются классы и функции. Соглашения об именах Я пытался выбирать осмысленные имена для объектов, классов, функций, шаблонов и т. п., но семантика некоторых придуманных мной имен может быть для вас неочевидна. Например, я часто использую для параметров имена lhs и rhs. Имеется в виду соответственно «левая часть» (left-hand side) и «правая часть» (right-hand side). Эти имена обычно употребляются в функциях, реализующих бинарные операторы, то есть operator== и operator*. Например, если a и b – объекты, представляющие рациональные числа, и если объекты класса Rational можно перемножать с помощью функции-нечлена operator*() (подобный случай описан в правиле 24), то выражение a*b эквивалентно вызову функции: operator*(a, b); В правиле 24 я объявляю operator* следующим образом: const Rational operator*(const Rational& lhs, const Rational& rhs); Как видите, левый операнд – a – внутри функции называется lhs, а правый – b – rhs. Для функций-членов аргумент в левой части оператора представлен указателем this, а единственный оставшийся параметр я иногда называю rhs. Возможно, вы заметили это в объявлении некоторых функций-членов класса Widget в примерах выше. «Widget» не значит ничего. Это просто имя, которое я иногда использую для того, чтобы как-то назвать пример класса. Оно не имеет никакого отношения к элементам управления (виджетам), применяемым в графических интерфейсах (GUI). Часто я именую указатели, следуя соглашению, с соответствии с которым указатель на объект типа T называется pt («pointer to T»). Вот некоторые примеры: Widget *pw; // pw = указатель на Widget class Airplane; Airplane *pa; // pa = указатель на Airplane class GameCharacter; GameCharacter *pgc; // pgc = указатель на GameCharacter Похожее соглашение применяется и для ссылок: rw может быть ссылкой на Widget, а ra – ссылкой на Airplane. Иногда для именования функции-члена я использую имя mf. Многопоточность В самом языке C++ нет представления о потоках (threads), да и вообще о каких-либо механизмах параллельного исполнения. То же относится и к стандартной библиотеке C++. Иными словами, с точки зрения C++ многопоточных программ не существует. Однако они есть. Хотя в этой книге я буду говорить преимущественно о стандартном, переносимом C++, но невозможно игнорировать тот факт, что безопасность относительно потоков – требование, с которым сталкиваются многие программисты. Признавая этот конфликт между стандартным C++ и реальностью, я буду отмечать те случаи, когда рассматриваемые конструкции могут вызвать проблемы при работе в многопоточной среде. Не надо думать, что эта книга научит вас многопоточному программированию на C++. Вовсе нет. Я рассматривал главным образом однопоточные приложения, но не игнорировал существование многопоточности и старался отмечать те случаи, когда программисты, пишущие многопоточные программы, должны следовать моим советам с осторожностью. Если вы не знакомы с концепцией многопоточности и не интересуетесь этой темой, то можете не обращать внимания на относящиеся к ней замечания. В противном случае имейте в виду, что мои комментарии – не более, чем скромный намек на то, что необходимо знать, если вы собираетесь использовать C++ для написания многопоточных программ. Библиотеки TR1 и Boost Ссылки на библиотеки TR1 и Boost вы будете встречать на протяжении всей этой книги. Каждой из них посвящено отдельное правило (54 – TR1 и 55 – Boost), но, к сожалению, они находятся в самом конце книги. При желании можете прочесть их прямо сейчас, но если вы предпочитаете читать книгу по порядку, а не с конца, то следующие замечания помогут понять, о чем идет речь: • TR1 ("Technical Report 1") – это спецификация новой функциональности, добавленной в стандартную библиотеку C++. Она оформлена в виде новых шаблонов классов и функций, предназначенных для реализации хэш-таблиц, «интеллектуальных» указателей с подсчетом ссылок, регулярных выражений и многого другого. Все компоненты TR1 находятся в пространстве имен tr1, которое вложено в пространство имен std. • Boost – это организация и Web-сайт (http://boost.org), на котором предлагаются переносимые, тщательно проверенные библиотеки C++ с открытым исходным кодом. Большая часть TR1 базируется на работе, выполненной Boost, и до тех пор, пока поставщики компиляторов не включат TR1 в дистрибутивы C++, Web-сайт Boost будет оставаться для разработчиков главным источником реализаций TR1. Boost предоставляет больше, чем включено в TR1, однако в любом случае о нем полезно знать. |
|
||
Главная | Контакты | Нашёл ошибку | Прислать материал | Добавить в избранное |
||||
|