Онлайн библиотека PLAM.RU


  • Правило 1: Относитесь к C++ как к конгломерату языков
  • Правило 2: Предпочитайте const, enum и inline использованию #define
  • Правило 3: Везде, где только можно используйте const
  • Константные функции-члены
  • Как избежать дублирования в константных и неконстантных функциях-членах
  • Правило 4: Прежде чем использовать объекты, убедитесь, что они инициализированы
  • Глава 1

    Приучайтесь к C++

    Независимо от опыта программирования, для того чтобы освоиться с C++, потребуется некоторое время. Это мощный язык с очень широким диапазоном возможностей, но чтобы использовать их эффективно, нужно несколько изменить свой способ мышления. Книга как раз и призвана помочь вам в этом, но какие-то вопросы являются более важными, какие-то – менее, а эта глава посвящна самым важным вещам.

    Правило 1: Относитесь к C++ как к конгломерату языков

    Поначалу C++ был просто языком C с добавлением некоторых объектно-ориентированных средств. Даже первоначальное название C++ («C с классами») отражает эту связь.

    По мере того как язык становился все более зрелым, он рос и развивался, в него включались идеи и стратегии программирования, выходящие за рамки C с классами. Исключения потребовали другого подхода к структурированию функций (см. правило 29). Шаблоны изменили наши представления о проектировании программ (см. правило 41), а библиотека STL определила подход к расширяемости, который никто ранее не мог себе представить.

    Сегодня C++ – это язык программирования с несколькими парадигмами, поддерживающий процедурное, объектно-ориентированное, функциональное, обобщенное и метапрограммирование. Эти мощь и гибкость делают C++ несравненным инструментом, однако могут привести в замешательство. У любой рекомендации по «правильному применению» есть исключения. Как найти смысл в таком языке?

    Лучше всего воспринимать C++ не как один язык, а как конгломерат взаимосвязанных языков. В пределах отдельного подъязыка правила достаточно просты, понятны и легко запоминаются. Однако когда вы переходите от одного подъязыка к другому, правила могут изменяться. Чтобы увидеть смысл в C++, вы должны распознавать его основные подъязыки. К счастью, их всего четыре:

    • C. В глубине своей C++ все еще основан на C. Блоки, предложения, препроцессор, встроенные типы данных, массивы, указатели и т. п. – все это пришло из C. Во многих случаях C++ предоставляет для решения тех или иных задач более развитые механизмы, чем C (пример см. в правиле 2 – альтернатива препроцессору и 13 – применение объектов для управления ресурсами), но когда вы начнете работать с той частью C++, которая имеет аналоги в C, то поймете, что правила эффективного программирования отражают более ограниченный характер языка C: никаких шаблонов, никаких исключений, никакой перегрузки и т. д.

    Объектно-ориентированный C++. Эта часть C++ представляет то, чем был «C с классами», включая конструкторы и деструкторы, инкапсуляцию, наследование, полиморфизм, виртуальные функции (динамическое связывание) и т. д. Это та часть C++, к которой в наибольшей степени применимы классические правила объектно-ориентированного проектирования.

    C++ с шаблонами. Эта часть C++ называется обобщенным программированием, о ней большинство программистов знают мало. Шаблоны теперь пронизывают C++ снизу доверху, и признаком хорошего тона в программировании уже стало включение конструкций, немыслимых без шаблонов (например, см. правило 46 о преобразовании типов при вызовах шаблонных функций). Фактически шаблоны, благодаря своей мощи, породили совершенно новую парадигму программирования: метапрограммирование шаблонов (template metaprogramming – TMP). В правиле 48 представлен обзор TMP, но если вы не являетесь убежденным фанатиком шаблонов, у вас нет причин чрезмерно задумываться об этом. TMP не отнесешь к самым распространенным приемам программирования на C++.

    STL. STL – это, конечно, библиотека шаблонов, но очень специализированная. Принятые в ней соглашения относительно контейнеров, итераторов, алгоритмов и функциональных объектов великолепно сочетаются между собой, но шаблоны и библиотеки можно строить и по-другому. Работая с библиотекой STL, вы обязаны следовать ее соглашениям.

    Помните об этих четырех подъязыках и не удивляйтесь, если попадете в ситуацию, когда соображения эффективности программирования потребуют от вас менять стратегию при переключении с одного подъязыка на другой. Например, для встроенных типов (в стиле C) передача параметров по значению в общем случае более эффективна, чем передача по ссылке, но если вы программируете в объектно-ориентированном стиле, то из-за наличия определенных пользователем конструкторов и деструкторов передача по ссылке на константу обычно становится более эффективной. В особенности это относится к подъязыку «C++ с шаблонами», потому что там вы обычно даже не знаете заранее типа объектов, с которыми имеете дело. Но вот вы перешли к использованию STL, и опять старое правило C о передаче по значению становится актуальным, потому что итераторы и функциональные объекты смоделированы через указатели C. (Подробно о выборе способа передачи параметров см. правило 20.)

    Таким образом, C++ не является однородным языком с единственным набором правил. Это – конгломерат подъязыков, каждый со своими собственными соглашениями. Если вы будете помнить об этих подъязыках, то обнаружите, что понять C++ намного проще.

    Что следует помнить

    • Правила эффективного программирования меняются в зависимости от части C++, которую вы используете.

    Правило 2: Предпочитайте const, enum и inline использованию #define

    Это правило лучше было бы назвать «Компилятор предпочтительнее препроцессора», поскольку #define зачастую вообще не относят к языку C++. В этом и заключается проблема. Рассмотрим простой пример; попробуйте написать что-нибудь вроде:


    #define ASPECT_RATIO 1.653


    Символическое имя ASPECT_RATIO может так и остаться неизвестным компилятору или быть удалено препроцессором до того, как код поступит на обработку компилятору. Если это произойдет, то имя ASPECT_RATIO не попадет в таблицу символов. Поэтому в ходе компиляции вы получите ошибку (в сообщении о ней будет упомянуто значение 1.653, а не ASPECT_RATIO). Это вызовет путаницу. Если имя ASPECT_RATIO было определено в заголовочном файле, который писали не вы, то вы вообще не будете знать, откуда взялось значение 1.653, и на поиски ответа потратите много времени. Та же проблема может возникнуть и при отладке, поскольку выбранное вами имя будет отсутствовать в таблице символов.

    Решение состоит в замене макроса константой:


    const double AspectRatio = 1.653; // имена, записанные большими буквами,

    // обычно применяются для макросов,

    // поэтому мы решили его изменить


    Будучи языковой константой, AspectRatio видима компилятору и, естественно, помещается в таблицу символов. К тому же в случае использования константы с плавающей точкой (как в этом примере) генерируется более компактный код, чем при использовании #define. Дело в том, что препроцессор, слепо подставляя вместо макроса ASPECT_RATIO величину 1.653, создает множество копий 1.653 в объектном коде, в то время как использование константы никогда не породит более одной копии этого значения.

    При замене #define константами нужно помнить о двух особых случаях. Первый касается константных указателей. Поскольку определения констант обычно помещаются в заголовочные файлы (где к ним получает доступ множество различных исходных файлов), важно, чтобы сам указатель был объявлен с ключевым словом const, в дополнение к объявлению const того, на что он указывает. Например, чтобы объявить в заголовочном файле константную строку типа char*, слово const нужно написать дважды:


    const char * const authorName = “Scott Meyers”;


    Более подробно о сущности и применений слова const, особенно в связке с указателями, см. в правиле 3. Но уже сейчас стоит напомнить, что объекты типа string обычно предпочтительнее своих прародителей – строк типа char *, поэтому authorName лучше определить так:


    const std::string authorName(“Scott Meyers”);


    Второе замечание касается констант, объявляемых в составе класса. Чтобы ограничить область действия константы классом, необходимо сделать ее членом класса, и чтобы гарантировать, что существует только одна копия константы, требуется сделать ее статическим членом:


    class GamePlayer {

    private:

    static const int NumTurns = 5; // объявление константы

    int scores[NumTurns]; // использование константы

    ...

    };


    То, что вы видите выше, – это объявление NumTurns, а не ее определение. Обычно C++ требует, чтобы вы представляли определение для всего, что используете, но объявленные в классе константы, которые являются статическими и имеют встроенный тип (то есть целые, символьные, булевские) – это исключение из правил. До тех пор пока вы не пытаетесь получить адрес такой константы, можете объявлять и использовать ее без предоставления определения. Если же вам нужно получить адрес либо если ваш компилятор настаивает на наличии определения, то можете написать что-то подобное:


    const int GamePlayer::NumTurns; // определение NumTurns; см. ниже,

    // почему не указывается значение


    Поместите этот код в файл реализации, а не в заголовочный файл. Поскольку начальное значение константы класса представлено там, где она объявлена (то есть NumTurns инициализировано значением 5 при объявлении), то в точке определения задавать начальное значение не требуется.

    Отметим, кстати, что нет возможности объявить в классе константу посредством #define, потому что #define не учитывает области действия. Как только макрос определен, он остается в силе для всей оставшейся части компилируемого кода (если только где-то ниже не встретится #undef). Это значит, что директива #define неприменима не только для объявления констант в классе, но вообще не может быть использована для обеспечения какой бы то ни было инкапсуляции, то есть придать смысл выражению «private #define» невозможно. В то же время константные данные-члены могут быть инкапсулированы, примером может служить NumTurns.

    Старые компиляторы могут не поддерживать показанный выше синтаксис, так как в более ранних версиях языка было запрещено задавать значения статических членов класса во время объявления. Более того, инициализация в классе допускалась только для целых типов и для констант. Если вышеприведенный синтаксис не работает, то начальное значение следует задавать в определении:


    class CostEstimate {

    private:

    static const double FudgeFactor; // объявление статической константы

    ... // класса – помещается в файл заголовка

    };

    const double // определение статической константы

    CostEstimate::FudgeFactor = 1.35; // класса – помещается в файл реализации


    Обычно ничего больше и не требуется. Единственное исключение обнаруживается тогда, когда для компиляции класса необходима константа. Например, при объявлении массива GamePlayer::scores компилятору нужно знать размер массива. Чтобы работать с компилятором, ошибочно запрещающим инициализировать статические целые константы внутри класса, можно воспользоваться способом, известным под названием «трюка с перечислением». Он основан на том, что переменные перечисляемого типа можно использовать там, где ожидаются значения типа int, поэтому GamePlayer можно определить так:


    class GamePlayer {

    private:

    enum ( NumTurns = 5 }; // “трюк с перечислением” – делает из

    // NumTurns символ со значением 5

    int scores[NumTurns]; // нормально

    ...

    };


    Этот прием стоит знать по нескольким причинам. Во-первых, поведение «трюка с перечислением» в некоторых отношениях более похоже на #define, чем на константу, а иногда это как раз то, что нужно. Например, можно получить адрес константы, но нельзя получить адрес перечисления, как нельзя получить и адрес #define. Если вы хотите запретить получать адрес или ссылку на какую-нибудь целую константу, то применение enum – хороший способ наложить такое ограничение. (Подробнее о поддержке проектных ограничений с помощью приемов кодирования можно узнать из правила 18). К тому же, хотя хорошие компиляторы не выделяют память для константных объектов целых типов (если только вы не создаете указателя или ссылки на объект), менее изощренные могут так поступать, а вам это, возможно, ни к чему. Как и #define, перечисления никогда не станут причиной подобного нежелательного распределения памяти.

    Вторая причина знать о «трюке с перечислением» чисто прагматическая. Он используется в очень многих программах, поэтому нужно уметь распознавать этот трюк, когда вы с ним сталкиваетесь. Вообще говоря, этот прием – фундаментальная техника, применяемая при метапрограммировании шаблонов (см. правило 48).

    Вернемся к препроцессору. Другой частый случай неправильного использования директивы #define – создание макросов, которые выглядят как функции, но не обременены накладными расходов, связанными с вызовом функций. Ниже представлен макрос, который вызывает некоторую функцию f c аргументом, равным максимальному из двух значений:


    // вызвать f, передав ей максимум из a и b

    #define CALL_WITH_MAX(a,b) f((a) > (b) ? (a) : (b))


    В этой строчке содержится так много недостатков, что даже не совсем понятно, с какого начать.

    Всякий раз при написании подобного макроса вы должны помнить о том, что все аргументы следует заключать в скобки. В противном случае вы рискуете столкнуться с проблемой, когда кто-нибудь вызовет его с выражением в качестве аргумента. Но даже если вы сделаете все правильно, посмотрите, какие странные вещи могут произойти:


    int a = 5, b = 0;

    CALL_WITH_MAX(++a, b); // a увеличивается дважды

    CALL_WITH_MAX(++a, b+10); // a увеличивается один раз


    Происходящее внутри max зависит от того, с чем она сравнивается!

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


    template <typename T>

    inline void callWithMax(const T& a, const T& b) // Поскольку мы не знаем,

    { // что есть T, то передаем

    f(a > b ? a : b); // его по ссылке на const -

    } // см. параграф 20


    Этот шаблон генерирует целое семейство функций, каждая из которых принимает два аргумента одного и того же типа и вызывает f с наибольшим из них. Нет необходимости заключать параметры в скобки внутри тела функции, не нужно заботиться о многократном вычислении параметров и т. д. Более того, поскольку callWithMax – настоящая функция, на нее распространяются правила областей действия и контроля доступа. Например, можно говорить о встроенной функции, являющейся закрытым членом класса. Описать нечто подобное с помощью макроса невозможно.

    Наличие const, enum и inline резко снижает потребность в препроцессоре (особенно это относится к #define), но не устраняет ее полностью. Директива #include остается существенной, а #ifdef/#ifndef продолжают играть важную роль в управлении компиляцией. Пока еще не время отказываться от препроцессора, но определенно стоит задуматься, как избавиться от него в дальнейшем.

    Что следует помнить

    • Для простых констант директиве #define следует предпочесть константные объекты и перечисления (enum).

    • Вместо имитирующих функции макросов, определенных через #define, лучше применять встроенные функции.

    Правило 3: Везде, где только можно используйте const

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

    Ключевое слово const удивительно многосторонне. Вне классов вы можете использовать его для определения констант в глобальной области или в пространстве имен (см. правило 2), а также для статических объектов (внутри файла, функции или блока). Внутри классов допустимо применять его как для статических, так и для нестатических данных-членов. Для указателей можно специфицировать, должен ли быть константным сам указатель, данные, на которые он указывает, либо и то, и другое (или ни то, ни другое):


    char greeting[] = “Hello”;

    char *p = greeting; // неконстантный указатель,

    // неконстантные данные

    const char *p = greeting; // неконстантный указатель,

    // константные данные

    char * const p = greeting; // константный указатель,

    // неконстантные данные

    const char * const p = greeting; // константный указатель,

    // константные данные


    Этот синтаксис не так страшен, как может показаться. Если слово const появляется слева от звездочки, константным является то, на что указывает указатель; если справа, то сам указатель является константным. Наконец, если же слово const появляется с обеих сторон, то константно и то, и другое.

    Когда то, на что указывается, – константа, некоторые программисты ставят const перед идентификатором типа. Другие – после идентификатора типа, но перед звездочкой. Семантической разницы здесь нет, поэтому следующие функции принимают параметр одного и того же типа:


    void f1(const Widget *pw); // f1 принимает указатель на

    // константный объект Widget

    void f1(Widget const *pw); // то же самое делает f2


    Поскольку в реальном коде встречаются обе формы, следует привыкать и к той, и к другой.

    Итераторы STL смоделированы на основе указателей, поэтому iterator ведет себя почти как указатель T*. Объявление const-итератора подобно объявлению const-указателя (то есть записи T* const): итератор не может начать указывать на что-то другое, но то, на что он указывает, может быть модифицировано. Если вы хотите иметь итератор, который указывал бы на нечто, что запрещено модифицировать (то есть STL-аналог указателя const T*), то вам понадобится константный итератор:


    std::vector<int> vec;

    ...

    const std::vector<int>::iterator iter = // iter работает как T* const

    vec.begin();

    *iter = 10; // Ok, изменяется то, на что

    // указывает iter

    ++iter; // ошибка! iter константный

    std::vector<int>::const_iterator citer = // citer работает как const T*

    vec.begin();

    *citer = 10; // ошибка! *citer константный

    ++citer; // нормально, citer изменяется


    Некоторые из наиболее интересных применений const связаны с объявлениями функций. В этом случае const может относиться к возвращаемому функцией значению, к отдельным параметрам, а для функций-членов – еще и к функции в целом.

    Если указать в объявлении функции, что она возвращает константное значение, то можно уменьшить количество ошибок в клиентских программах, не снижая уровня безопасности и эффективности. Например, рассмотрим объявление функции operator* для рациональных чисел, введенное в правиле 24:


    class Rational {…}

    const Rational operator*(const Rational& lhs, const Rational& rhs);


    Многие программисты удивятся, впервые увидев такое объявление. Почему результат функции operator* должен быть константным объектом? Потому что в противном случае пользователь получил бы возможность делать вещи, которые иначе как надругательством над здравым смыслом не назовешь:


    Rational a, b, c;

    (a*b)=c; // присваивание произведению a*b!


    Я не знаю, с какой стати программисту пришло бы в голову присваивать значение произведению двух чисел, но могу точно сказать, что иногда такое может случиться по недосмотру. Достаточно простой опечатки (при условии, что тип может быть преобразован к bool):


    if (a*b = c)... // имелось в виду сравнение!


    Такой код был бы совершенно некорректным, если бы a и b имели встроенный тип. Одним из критериев качества пользовательских типов является совместимость со встроенными (см. также правило 18), а возможность присваивания значения результату произведения двух объектов представляется мне весьма далекой от совместимости. Если же объявить, что operator* возвращает константное значение, то такая ситуация станет невозможной. Вот почему Так Следует Поступать.

    В отношении аргументов с модификатором const трудно сказать что-то новое; они ведут себя как локальные константные const-объекты. Всюду, где возможно, добавляйте этот модификатор. Если модифицировать аргумент или локальный объект нет необходимости, объявите его как const. Вам всего-то придется набрать шесть символов, зато это предотвратит досадные ошибки типа «хотел напечатать ==, а нечаянно напечатал =» (к чему это приводит, мы только что видели).

    Константные функции-члены

    Назначение модификатора const в объявлении функций-членов – определить, какие из них можно вызывать для константных объектов. Такие функции-члены важны по двум причинам. Во-первых, они облегчают понимание интерфейса класса, ведь полезно сразу видеть, какие функции могут модифицировать объект, а какие нет. Во-вторых, они обеспечивают возможность работать с константными объектами. Это очень важно для написания эффективного кода, потому что, как объясняется в правиле 20, один из основных способов повысить производительность программ на C++ – передавать объекты по ссылке на константу. Но эта техника будет работать только в случае, когда функции-члены для манипулирования константными объектами объявлены с модификатором const.

    Многие упускают из виду, что функции, отличающиеся только наличием const в объявлении, могут быть перегружены. Это, однако, важное свойство C++. Рассмотрим класс, представляющий блок текста:


    class TextBlock {

    public:

    ...

    const char& operator[](std::size_t position) const // operator[] для

    {return text[position];} // константных объектов

    char& operator[](std::size_t position) // operator[] для

    {return text[position];} // неконстантных объектов

    private:

    std::string text;

    };


    Функцию operator[] в классе TextBlock можно использовать следующим образом:


    TextBlock tb(“Hello”);

    Std::cout << tb[0]; // вызов неконстантного

    // оператора TextBlock::operator[]

    const TextBlock ctb(“World”);

    Std::cout << ctb[0]; // вызов константного

    // оператора TextBlock::operator[]


    Кстати, константные объекты чаще всего встречаются в реальных программах в результате передачи по указателю или ссылке на константу. Приведенный выше пример ctb является довольно искусственным. Но вот вам более реалистичный:


    void print(const TextBlock& ctb) // в этой функции ctb – ссылка

    // на константный объект

    {

    std::cout << ctb[0]; // вызов const TextBlock::operator[]

    ...

    }


    Перегружая operator[] и создавая различные версии с разными возвращаемыми типами, вы можете по-разному обрабатывать константные и неконстантные объекты TextBlock:


    std::cout << tb[0]; // нормально – читается

    // неконстантный TextBlock

    tb[0] = ‘x’; // нормально – пишется

    // неконстантный TextBlock

    std::cout << ctb[0]; // нормально – читается

    // константный TextBlock

    ctb[0] = ‘x’; // ошибка! – запись

    // константного TextBlock


    Отметим, что ошибка здесь связана только с типом значения, возвращаемого operator[]; сам вызов operator[] проходит нормально. Причина ошибки – в попытке присвоить значение объекту типа const char&, потому что это именно такой тип возвращается константной версией operator[].

    Отметим также, что тип, возвращаемый неконстантной версией operator[], – это ссылка на char, а не сам char. Если бы operator[] возвращал просто char, то следующее предложение не скомпилировалось бы:


    tb[0] = ‘x’;


    Это объясняется тем, что возвращаемое функцией значение встроенного типа модифицировать некорректно. Даже если бы это было допустимо, тот факт, что C++ возвращает объекты по значению (см. правило 20), означал бы следующее: модифицировалась копия tb.text[0], а не само значение tb.text[0]. Вряд ли это то, чего вы ожидаете.

    Давайте немного передохнем и пофилософствуем. Что означает для функции-члена быть константной? Существует два широко распространенных понятия: побитовая константность (также известная как физическая константность) и логическая константность.

    Сторонники побитовой константности полагают, что функция-член константна тогда и только тогда, когда она не модифицирует никакие данные-члены объекта (за исключением статических), то есть не модифицирует ни одного бита внутри объекта. Определение побитовой константности хорошо тем, что ее нарушение легко обнаружить: компилятор просто ищет присваивания членам класса. Фактически, побитовая константность – это константность, определенная в C++: функция-член с модификатором const не может модифицировать нестатические данные-члены объекта, для которого она вызвана.

    К сожалению, многие функции-члены, которые ведут себя далеко не константно, проходят побитовый тест. В частности, функция-член, которая модифицирует то, на что указывает указатель, часто не ведет себя как константная. Но если объекту принадлежит только указатель, то функция формально является побитово константной, и компилятор не станет возражать. Это может привести к неожиданному поведению. Например, предположим, что есть класс подобный Text-Block, где данные хранятся в строках типа char * вместо string, поскольку это необходимо для передачи в функции, написанные на языке C, который не понимает, что такое объекты типа string.


    class CtextBlock {

    public:

    ...

    char& operator[](std::size_t position) const // неудачное (но побитово

    { return pText[position]} // константное)

    // объявление operator[]

    private:

    char *pText;

    };


    В этом классе функция operator[] (неправильно!) объявлена как константная функция-член, хотя она возвращает ссылку на внутренние данные объекта (эта тема обсуждается в правиле 28). Оставим это пока в стороне и отметим, что реализация operator[] никак не модифицирует pText. В результате компилятор спокойно сгенерирует код для функции operator[]. Ведь она действительно является побитово константной, а это все, что компилятор может проверить. Но посмотрите, что происходит:


    const CtextBlock cctb(“Hello”); // объявление константного объекта

    char &pc = &cctb[0]; // вызов const operator[] для получения

    // указателя на данные cctb

    *pc = ‘j’; // cctb теперь имеет значение “Jello”


    Несомненно, есть что-то некорректное в том, что вы создаете константный объект с определенным значением, вызываете для него только константную функцию-член и тем не менее изменяете его значение!

    Это приводит нас к понятию логической константности. Сторонники этой философии утверждают, что функции-члены с const могут модифицировать некоторые биты вызвавшего их объекта, но только так, чтобы пользователь не мог этого обнаружить. Например, ваш класс CTextBlock мог бы кэшировать длину текстового блока при каждом запросе:


    Class CtextBlock {

    public:

    ...

    std::size_t length() const;

    private:

    char *pText;

    std::size_t textLength; // последнее вычисленное значение длины

    // текстового блока

    bool lengthIsValid; // корректна ли длина в данный момент

    };

    std::size_t CtextBlock::length() const

    {

    if(!lengthIsValid) {

    textLength = std::strlen(pText); // ошибка! Нельзя присваивать

    lengthIsValid = true; // значение textLength и

    } // lengthIsValid в константной

    // функции-члене

    return textLength;

    }


    Эта реализация length(), конечно же, не является побитово константной, поскольку может модифицировать значения членов textLength и lengthlsValid. Но в то же время со стороны кажется, что константности объектов CTextBlock это не угрожает. Однако компилятор не согласен. Он настаивает на побитовой константности. Что делать?

    Решение простое: используйте модификатор mutable. Он освобождает нестатические данные-члены от ограничений побитовой константности:


    Class CtextBlock {

    public:

    ...

    std::size_t length() const;

    private:

    char *pText;

    mutable std::size_t textLength; // Эти данные-члены всегда могут быть

    mutable bool lengthIsValid; // модифицированы, даже в константных

    }; // функциях-членах

    std::size_t CtextBlock::length() const

    {

    if(!lengthIsValid) {

    textLength = std::strlen(pText); // теперь порядок

    lengthIsValid = true; // здесь то же

    }

    return textLength;

    }

    Как избежать дублирования в константных и неконстантных функциях-членах

    Использование mutable – замечательное решение проблемы, когда побитовая константность вас не вполне устраивает, но оно не устраняет всех трудностей, связанных с const. Например, представьте, что operator[] в классе TextBlock (и CTextBlock) не только возвращает ссылку на соответствующий символ, но также проверяет выход за пределы массива, протоколирует информацию о доступе и, возможно, даже проверяет целостность данных. Помещение всей этой логики в обе версии функции operator[] – константную и неконстантную (даже если забыть, что теперь мы имеем необычно длинные встроенные функции – см. правило 30) – приводит к такому вот неуклюжему коду:


    class TextBlock {

    public:

    ...

    const char& operator[](std::size_t position) const

    {

    ... // выполнить проверку границ массива

    ... // протоколировать доступ к данным

    ... // проверить целостность данных

    return text[position];

    }

    char& operator[](std::size_t position) const

    {

    ... // выполнить проверку границ массива

    ... // протоколировать доступ к данным

    ... // проверить целостность данных

    return text[position];

    }

    private:

    std:string text;

    };


    Ох! Налицо все неприятности, связанные с дублированием кода: увеличение времени компиляции, размера программы и неудобство сопровождения. Конечно, можно переместить весь код для проверки выхода за границы массива и прочего в отдельную функцию-член (естественно, закрытую), которую будут вызывать обе версии operator[], но обращения к этой функции все же будут дублироваться.

    В действительности было бы желательно реализовать функциональность operator[] один раз, а использовать в двух местах. То есть одна версия operator[] должна вызывать другую. И это подводит нас к вопросу об отбрасывании константности.

    С самого начала отметим, отбрасывать константность нехорошо. Я посвятил целое правило 27 тому, чтобы убедить вас не делать этого, но дублирование кода – тоже не сахар. В данном случае константная версия operator[] делает в точности то же самое, что неконстантная, и отличие между ними – лишь в присутствии модификатора const. В этой ситуации отбрасывать const безопасно, поскольку пользователь, вызывающий неконстантный operator[], так или иначе должен получить неконстантный объект. Ведь в противном случае он не стал бы вызывать неконстантную функцию. Поэтому реализация неконстантного operator[] путем вызова константной версии – это безопасный способ избежать дублирования кода, даже пусть даже для этого требуется воспользоваться оператором const_cast. Ниже приведен получающийся в результате код, но он станет яснее после того, как вы прочитаете следующие далее объяснения:


    class TextBlock {

    public:

    ...

    const char& operator[](std::size_t position) const // то же, что и раньше

    {

    ...

    ...

    ...

    return text[position];

    }

    char& operator[](std::size_t position) const // теперь просто

    // вызываем const op[]

    {

    return

    const_cast<char&>( // из возвращаемого типа

    // op[] исключить const

    static_cast<const TextBlock&>(*this) // добавить const типу

    // *this

    [position] // вызвать константную

    ); // версию op[]

    }

    ...

    };


    Как видите, код включает два приведения, а не одно. Мы хотим, чтобы неконстантный operator[] вызывал константный, но если внутри неконстантного оператора [] просто вызовем operator[], то получится рекурсивный вызов. Во избежание бесконечной рекурсии нужно указать, что мы хотим вызвать const operator[], но прямого способа сделать это не существует. Поэтому мы приводим *this от типа TextBlock& к const TextBlock&. Да, мы выполняем приведение, чтобы добавить константность! Таким образом, мы имеем два приведения: одно добавляет константность *this (чтобы был вызван const operator[]), а второе – исключает const из типа возвращаемого значения.

    Приведение, которое добавляет const, выполняет безопасное преобразование (от неконстантного объекта к константному), поэтому мы используем для этой цели static_cast. Приведение же, которое отбрасывает const, может быть выполнено только с помощью const_cast, поэтому у нас здесь нет выбора. (Строго говоря, выбор есть. Приведение в стиле C также работает, но, как я объясняю в правиле 27, такие приведения редко являются правильным рещением. Если вы не знакомы с операторами static_cast или const_cast, прочитайте о них в правиле 27.)

    Помимо всего прочего, в этом примере мы вызываем оператор, поэтому синтаксис выглядит немного странно. Возможно, этот код не займет приз на конкурсе красоты, зато позволяет достичь нужного эффекта – избежать дублирования посредством реализации неконстантной версии operator[] в терминах константной. И хотя для достижения цели пришлось воспользоваться неуклюжим синтаксисом, который сможете понять только вы сами, однако техника реализации неконстантных функций-членов через неконстантные определенно заслуживает того, чтобы ее знать.

    А еще нужно иметь в виду, что решать эту задачу наоборот – путем вызова неконстантной версии из константной – неправильно. Помните, что константная функция-член обещает никогда не изменять логическое состояние объекта, а неконстантная не дает таких гарантий. Если вы вызовете неконстантную функцию из константной, то рискуете получить ситуацию, когда объект, который не должен модифицироваться, будет изменен. Вот почему этого не следует делать: чтобы объект не изменился. Фактически, чтобы получить компилируемый код, вам пришлось бы использовать const_cast для отбрасывания константности *this, а это явный признак неудачного решения. Обратная последовательность вызовов – такая, как описана выше, – безопасна. Неконстантная функция-член может делать все, что захочет с объектом, поэтому вызов из нее константной функции-члена ничем не грозит. Потому-то мы и применяем к *this оператор static_cast, отбрасывания константности при этом не происходит.

    Как я уже упоминал в начале этого правила, модификатор const – чудесная вещь. Для указателей и итераторов; для объектов, на которые ссылаются указатели, итераторы и ссылки; для параметров функций и возвращаемых ими значений; для локальных переменных, для функций-членов – всюду const ваш мощный союзник. Используйте его, где только возможно. Вам понравится!

    Что следует помнить

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

    • Компиляторы проверяют побитовую константность, но вы должны программировать, применяя логическую константность.

    • Когда константные и неконстантные функции-члены имеют, по сути, одинаковую реализацию, то дублирования кода можно избежать, заставив неконстантную версию вызывать константную.

    Правило 4: Прежде чем использовать объекты, убедитесь, что они инициализированы

    Отношение C++ к инициализации значений объектов может показаться странным. Например, если вы пишете:


    int x;


    то в некоторых контекстах переменная x будет гарантированно инициализирована нулем, а в других – нет. Если вы пишете:


    class Point {

    int x, y;

    };

    ...

    Point p;


    то члены-данные объекта p иногда будут инициализированы (нулями), а иногда – нет. Если вы перешли к C++ от языка, где неинициализированные объекты не могут существовать, обратите на это внимание.

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

    Сформулируем правила, которые описывают, когда инициализация объекта гарантируется, а когда нет. К сожалению, эти правила достаточно сложны – на мой взгляд, слишком сложны, чтобы их стоило запоминать. Вообще, если вы работаете с C-частью C++ (см. правило 1) и инициализация может стоить определенных затрат во время исполнения, то не гарантируется, что она произойдет. Это объясняет, почему содержимое массивов (в C-части C++) не обязательно инициализируется, а содержимое вектора (из STL-части C++) инициализируется всегда.

    По-видимому, лучший способ поведения в такой неопределенной ситуации – всегда инициализировать объекты, прежде чем их использовать. Для объектов встроенных типов, не являющихся членами классов, это нужно делать вручную. Например:


    int x = 0; // ручная инициализация int

    const char * text = “Строка в стиле C”; // ручная инициализация указателя

    // (см. также правило 3)

    double d; // «инициализация» чтением

    std::cin >> d; // из входного потока


    Почти во всех остальных случаях ответственность за инициализацию ложится на конструкторы. Правило простое: убедитесь, что все конструкторы инициализируют в объекте всё.

    Этому правилу легко следовать, но важно не путать присваивание с инициализацией. Рассмотрим конструктор класса, представляющего записи в адресной книге:


    class PhoneNumber {…}

    class ABEntry { // ABEntry = “Address Book Entry”

    public:

    ABEntry(const std::string& name, const std::string& address,

    const std::list<PhoneNumber>& phones);

    private:

    std::string theName;

    std::string theAddress;

    std::list<PhoneNumber> thePhones;

    int numTimesConsulted;

    };

    ABEntry(const std::string& name, const std::string& address,

    const std::list<PhoneNumber>& phones)

    {

    theName = name; // все это присваивание, а не инициализация

    theAddress = address;

    thePhones = phones;

    numTimesConsulted = 0;

    }


    Да, в результате порождаются объекты ABEntry со значениями, которых вы ожидаете, но это все же не лучший подход. Правила C++ оговаривают, что члены объекта инициируются перед входом в тело конструктора. То есть внутри конструктора ABEntry члены theName, theAddress и thePhones не инициализируются, а им присваиваются значения. Инициализация происходит ранее: когда автоматически вызываются их конструкторы перед входом в тело конструктора ABEntry. Это не касается numTimesConsulted, поскольку этот член относится к встроенному типу. Для него нет никаких гарантий того, что он вообще будет инициализирован перед присваиванием.

    Лучший способ написания конструктора ABEntry – использовать список инициализации членов вместо присваивания:


    ABEntry(const std::string& name, const std::string& address,

    const std::list<PhoneNumber>& phones)

    :theName(name), // теперь это все – инициализации

    :theAddress(address),

    thePhones(phones),

    :numTimesConsulted(0)

    {} // тело конструктора теперь пусто


    Этот конструктор дает тот же самый конечный результат, что и предыдущий, но часто оказывается более эффективным. Версия, основанная на присваиваниях, сначала вызывает конструкторы по умолчанию для инициализации theName, theAddress и thePhones, а затем сразу присваивает им новые значения, затирая те, что уже были присвоены в конструкторах по умолчанию. Таким образом, вся работа конструкторов по умолчанию тратится впустую. Подход со списком инициализации членов позволяет избежать этой проблемы, поскольку аргументы в списке инициализации используются в качестве аргументов конструкторов для различных членов-данных. В этом случае theName создается конструктором копирования из name, theAddress – из address, thePhones – из phones. Для большинства типов единственный вызов конструктора копирования более эффективен – иногда намного более эффективен, чем вызов конструкторов по умолчанию с последующим вызовом операторов присваивания.

    Для объектов встроенных типов вроде numTimesConsulted нет разницы по затратам между инициализацией и присваиванием, но для единообразия часто лучше инициировать все посредством списка инициализации членов. Такие списки можно применять даже тогда, когда данные-члены инициализируются конструкторами по умолчанию: просто не передавайте никаких аргументов соответствующему конструктору. Например, если у ABEntry есть конструктор, не принимающий параметров, то он может быть реализован примерно так:


    ABEntry()

    :theName(), // вызвать конструктор по умолчанию для theName

    :theAddress(), // сделать то же для theAddress и для thePhones;

    thePhones(), // но явно инициализировать нулем numTimesConsulted

    :numTimesConsulted(0)

    {}


    Поскольку компилятор автоматически вызывает конструкторы по умолчанию для данных-членов пользовательских типов, когда для них отсутствуют инициализаторы в списке инициализации членов, некоторые программисты считают приведенный выше код избыточным. Это понятно, но, придерживаясь политики всегда перечислять все данные-члены в списках инициализации, вы избавляете себя от необходимости помнить, какие члены будут инициализированы, если их пропустить, а какие – нет. Например, поскольку numTimesConsulted относится к встроенному типу, то исключение его из списка инициализации может открыть двери неопределенному поведению.

    Иногда список инициализации просто необходимо использовать, даже для встроенных типов. Например, данные-члены, которые являются константами либо ссылками, обязаны быть инициализированы, так как они не могут получить значения посредством присваивания (см. также правило 5). Чтобы избежать необходимости помнить, когда данные-члены должны быть инициализированы в списке инициализации, а когда это не обязательно, проще делать это всегда. Иногда это обязательно, а часто – более эффективно, чем присваивание.

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

    Один из аспектов C++, на который можно положиться, – это порядок, в котором инициализируются данные объектов. Этот порядок всегда один и тот же: базовые классы инициализируются раньше производных (см. также правило 12), а внутри класса члены-данные инициализируются в том порядке, в котором объявлены. Например, в классе ABEntry член theName всегда будет инициализирован первым, theAddress – вторым, thePhones – третьим, а numTimesConsulted – последним. Это верно даже в случае, если в списке инициализации членов они перечислены в другом порядке (что, к сожалению, не запрещено). Чтобы не вводить в заблуждение человека, читающего вашу программу, и во избежание ошибок непонятного происхождения, всегда перечисляйте данные-члены в списке инициализации в том порядке, в котором они объявлены в классе.

    Позаботившись о явной инициализации объектов встроенных типов, которые не являются членами классов, и обеспечив правильную инициализацию базовых классов и их данных-членов посредством списков инициализации, у вас останется только одна вещь, о чем нужно будет подумать. Речь идет о порядке инициализации нелокальных статических объектов, объявленных в разных единицах трансляции.

    Отнесемся к этой фразе со всем вниманием.

    Статический объект существует от момента, когда был сконструирован, и до конца работы программы. Объекты, размещенные в стеке и в «куче», к статическим не относятся. Статическими являются глобальные объекты, объекты, объявленные в области действия пространства имен, объекты, объявленные с ключевым словом static внутри классов и функций, а также в области действия отдельного файла с исходным текстом. Статические объекты, объявленные внутри функций, известны как локальные статические объекты (поскольку они локальны по отношению к функции), а все прочие называют нелокальными статическими объектами. Статические объекты автоматически уничтожаются при завершении программы, то есть при выходе из функции main() автоматически вызываются их деструкторы.

    Единица трансляции (translation unit) – это исходный код, который порождает отдельный объектный файл. Обычно это один исходный файл плюс все файлы, включенные в него директивой #include.

    Проблема возникает, когда есть, по крайней мере, два отдельно компилируемых исходных файла, каждый из которых содержит, по крайней мере, один нелокальный статический объект (то есть глобальный объект либо объявленный в области действия пространства имен, класса или файла). Суть ее в том, что если инициализация нелокального статического объекта происходит в одной единице трансляции, а используется он в другой, то такой объект может оказаться неинициализированным в момент использования, поскольку относительный порядок инициализации нестатических локальных объектов, определенных в разных единицах трансляции, не определен.

    Рассмотрим пример. Предположим, у вас есть класс FileSystem, который делает файлы из Internet неотличимыми от локальных. Поскольку ваш класс представляет мир как единую файловую систему, вы могли бы создать в глобальной области действия или в пространстве имен соответствующий ей специальный объект:


    class FileSystem { // из вашей библиотеки

    public:

    ...

    std::size_t numDisks() const; // одна из многих функций-членов

    ...

    };

    extern FileSystem tfs; // объект для использования клиентами

    // “tfs” = “the file system”


    Класс FileSystem определенно не тривиален, поэтому использование объекта theFileSystem до того, как он будет сконструирован, приведет к катастрофическим последствиям.

    Теперь предположим, что некий пользователь создает класс, описывающий каталоги файловой системы. Естественно, его класс будет использовать объект theFileSystem:


    class Directory { // создан пользователем

    public:

    Directory( params );

    ...

    };

    Directory::Directory( params )

    {

    ...

    std::size_t disks = tfs.numDisks(); // использование объекта tfs

    ...

    }


    Далее предположим, что пользователь решает создать отдельный глобальный объект класса Directory, представляющий каталог для временных файлов:


    Directory tempDir( params ); // каталог для временных файлов


    Теперь проблема порядка инициализации становится очевидной: если объект tfs не инициализирован раньше, чем tempDir, то конструктор tempDir попытается использовать tfs до его инициализации. Но tfs и tempDir были созданы разными людьми в разное время и находятся в разных исходных файлах – это нелокальные статические объекты, определенные в разных единицах трансляции. Как вы можете быть уверены, что tfs будет инициализирован раньше, чем tempDir?

    Да никак! Еще раз повторю: относительный порядок инициализации нестатических локальных объектов, определенных в разных единицах трансляции, не определен. На то есть своя причина. Определить «правильный» порядок инициализации нелокальных статических объектов трудно. Очень трудно. Неразрешимо трудно. В наиболее общем случае – при наличии многих единиц трансляции и нелокальных статических объектов, сгенерированных путем неявной конкретизации шаблонов (которые и сами могут быть результатом неявной конкретизации других шаблонов) – не только невозможно определить правильный порядок инициализации, но обычно даже не стоит искать частные случаи, когда этот порядок в принципе определить можно.

    К счастью, небольшое изменение в проекте программы позволяет полностью устранить эту проблему. Нужно лишь переместить каждый нелокальный статический объект в отдельную функцию, в которой он будет объявлен статическим. Эти функции возвращают ссылки на объекты, которые в них содержатся. Клиенты затем вызывают функции вместо непосредственного обращения к объектам. Другими словами, нелокальные статические объекты заменяются локальными статическими объектами (знакомые с паттернами проектирования легко узнают в этом описании типичную реализацию паттерна Singleton).

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

    Вот как этот прием применяется к объектам tfs и tempDir:


    class FileSystem {...}; // как раньше

    FileSystem& tfs() // эта функция заменяет объект tfs, она может

    { // быть статической в классе FileSystem

    static FileSystem fs; // определение и инициализация локального

    // статического объекта

    return fs; // возврат ссылки на него

    }

    class Directory {...}; // как раньше

    Directory::Directory( params ) // как раньше, но вместо ссылки на tfs

    { // вызов tfs()

    ...

    std::size_t disks = tfs().numDisks();

    ...

    }

    Directory& tempDir() // эта функция заменяет объект tempDir,

    { // может быть статической в классе Directory

    static Directory td; // определение/инициализация локального

    // статического объекта

    return td; // возврат ссылки на него

    }


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

    Функции, которые в соответствии с данной схемой возвращают ссылки, всегда просты: определить и инициализировать локальный статический объект в строке 1 и вернуть его в строке 2. В связи с этим у вас может возникнуть искушение объявить их встроенными, особенно, если они часто вызываются (см. правило 30). С другой стороны, тот факт, что эти функции содержат в себе статические объекты, усложняет их применение в многопоточных системах. Но тут никуда не деться: неконстантные статические объекты любого рода – локальные или нелокальные – представляют проблему в случае наличия в программе нескольких потоков. Решить ее можно, например, вызвав самостоятельно все функции, возвращающие ссылки, на этапе запуска программы, когда еще работает только один поток. Это исключит неопределенность в ходе инициализации.

    Конечно, применимость идеи функций, возвращающих ссылки, для предотвращения проблем, связанных с порядком инициализации, зависит от того, существует ли в принципе разумный порядок инициализации ваших объектов. Если вы напишете код, в котором объект A должен быть инициализирован прежде, чем объект B, и одновременно сделаете инициализацию A зависимой от инициализации B, то вас ждут проблемы – и поделом! Если, однако, вы будете избегать таких патологических ситуаций, то описанная схема сослужит вам добрую службу, по крайней мере, в однопоточных приложениях.

    Таким образом, чтобы избежать использования объектов до их инициализации, вам следует сделать три вещи. Первое: вручную инициализировать не являющиеся членами объекты встроенных типов. Второе: использовать списки инициализации членов для всех частей объекта. И наконец, третье: обойти за счет правильного проектирования проблему негарантированного порядка инициализации нелокальных статических объектов, определенных в разных единицах трансляции.

    Что следует помнить

    • Всегда вручную инициализировать объекты встроенных типов, поскольку C++ делает это, только не всегда.

    • В конструкторе отдавать предпочтение применению списков инициализации членов перед прямым присваиванием значений в теле конструктора. Перечисляйте данные-члены в списке инициализации в том же порядке, в каком они объявлены в классе.

    • Избегайте проблем с порядком инициализации в разных единицах трансляции, заменяя нелокальные статические объекты локальными статическими объектами.









    Главная | Контакты | Нашёл ошибку | Прислать материал | Добавить в избранное

    Все материалы представлены для ознакомления и принадлежат их авторам.