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


  • Правило 26: Откладывайте определение переменных насколько возможно
  • Правило 27: Не злоупотребляйте приведением типов
  • Правило 28: Избегайте возвращения «дескрипторов» внутренних данных
  • Правило 29: Стремитесь, чтобы программа была безопасна относительно исключений
  • Правило 30: Тщательно обдумывайте использование встроенных функций
  • Правило 31: Уменьшайте зависимости файлов при компиляции
  • Глава 5

    Реализация

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

    Правило 26: Откладывайте определение переменных насколько возможно

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

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


    // эта функция объявляет переменную encrypted слишком рано

    std::string encryptPassword(const std::string& password)

    {

    using namespace std;

    string encrypted;

    if(password.length() < MinimumPasswordLength) {

    throw logic_error(“Слишком короткий пароль”);

    }

    ... // сделать все, что необходимо для помещения

    // зашифрованного пароля в переменную encrypted

    return encrypted;

    }


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


    // в этой функции определение переменной encrypted отложено до момента,

    // когда в ней возникает надобность

    std::string encryptPassword(const std::string& password)

    {

    using namespace std;

    if(password.length() < MinimumPasswordLength) {

    throw logic_error(“Слишком короткий пароль”);

    }

    string encrypted;

    ... // сделать все, что необходимо для помещения

    // зашифрованного пароля в переменную encrypted

    return encrypted;

    }


    Этот код все еще не настолько компактный, как мог бы быть, потому что переменная encrypted определена без начального значения. А значит, будет использован ее конструктор по умолчанию. Часто первое, что нужно сделать с объектом, – это дать ему какое-то значение, нередко посредством присваивания. В правиле 4 объяснено, почему конструирование объектов по умолчанию с последующим присваиванием значения менее эффективно, чем инициализация нужным значением с самого начала. Это относится и к данному случаю. Например, предположим, что для выполнения «трудной» части работы функция encryptPassword вызывает следующую функцию:


    void encrypt(std::string& s); // шифрует s по месту


    Тогда encryptPassword может быть реализована следующим образом, хотя и это еще не оптимальный способ:


    // в этой функции определение переменной encrypted отложено до момента,

    // когда в ней возникает надобность, но и этот вариант еще недостаточно

    // эффективен

    std::string encryptPassword(const std::string& password)

    {

    ... // проверка длины

    string encrypted; // конструктор по умолчанию

    encrypted = password; // присваивание encrypted

    encrypt(encrypted);

    return encrypted;

    }


    Еще лучше инициализировать encrypted параметром password, избежав таким образом потенциально дорогостоящего конструктора по умолчанию:


    // а это оптимальный способ определения и инициализации encrypted

    std::string encryptPassword(const std::string& password)

    {

    ... // проверка длины

    string encrypted(password); // определение и инициализация

    // конструктором копирования

    encrypt(encrypted);

    return encrypted;

    }


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

    «А как насчет циклов?» – можете удивиться вы. Если переменная используется только внутри цикла, то что лучше: определить ее вне цикла и выполнять присваивание на каждой итерации или определить ее внутри цикла? Другими словами, какая из следующих конструкций предпочтительнее?


    // Подход A: определение вне цикла

    Widget w;

    for(int i=0; i<n; ++i) {

    w = некоторое значение, зависящее от i;

    ...

    }

    // Подход B: определение внутри цикла

    for(int i=0; i<n; ++i) {

    Widget w(некоторое значение, зависящее от i);

    ...

    }


    Здесь я перехожу от объекта типа string к объекту типа Widget, чтобы избежать любых предположений относительно стоимости конструирования, разрушения и присваивания.

    В терминах операций Widget накладные расходы вычисляются так:

    • Подход A: 1 конструктор + 1 деструктор + n присваиваний

    • Подход B: n конструкторов + n деструкторов

    Для классов, в которых стоимость операции присваивания меньше, чем пары конструктор-деструктор, подход A обычно более эффективен. Особенно это верно, когда значение n достаточно велико. В противном случае, возможно, подход B лучше. Более того, в случае A имя w видимо в более широкой области (включающей в себя цикл), чем в случае B, а иногда это делает программу менее понятной и удобной для сопровождения. Поэтому если (1) нет априорной информации о том, что присваивание обходится дешевле, чем пара конструктор-деструктор, и (2) речь идет о части программы, производительность которой критична, то по умолчанию рекомендуется использовать подход B.

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

    • Откладывайте определение переменных насколько возможно. Это делает программы яснее и повышает их эффективность.

    Правило 27: Не злоупотребляйте приведением типов

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

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

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


    (T) expression // привести expression к типу T


    Функциональный синтаксис приведения таков:


    T( expression) // привести expression к типу T


    Между этими двумя формами нет ощутимого различия, просто скобки расставляются по-разному. Я называю эти формы приведениями в старом стиле.

    C++ также представляет четыре новые формы приведения типов (часто называемые приведениями в стиле С++):


    const_cast<T>(expression)

    dynamic_cast<T>(expression)

    reinterpret_cast<T>(expression)

    static_cast<T>(expression)


    У каждой из них свое назначение:

    • const_cast обычно применяется для того, чтобы отбросить константность объекта. Никакое другое приведение в стиле C++ не позволяет это сделать;

    • dynamic_cast применяется главным образом для выполнения «безопасного понижающего приведения» (downcasting). Этот оператор позволяет определить, принадлежит ли объект данного типа некоторой иерархии наследования. Это единственный вид приведения, который не может быть выполнен с использованием старого синтаксиса. Это также единственное приведение, которое может потребовать ощутимых затрат во время исполнения (подробнее позже);

    • reinterpret_cast предназначен для низкоуровневых приведений, которые порождают зависимые от реализации (то есть непереносимые) результаты, например приведение указателя к int. Вне низкоуровневого кода такое приведение должно использоваться редко. Я использовал его в этой книге лишь однажды, когда обсуждал написание отладочного распределителя памяти (см. правило 50);

    • static_cast может быть использован для явного преобразования типов (например, неконстантных объектов к константным (как в правиле 3), int к double и т. п.). Он также может быть использован для выполнения обратных преобразований (например, указателей void* к типизированным указателям, указателей на базовый класс к указателю на производный). Но привести константный объект к неконстантному этот оператор не может (это вотчина const_cast).

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

    Я использую приведение в старом стиле только тогда, когда хочу вызвать explicit конструктор, чтобы передать объект в качестве параметра функции. Например:


    class Widget {

    public:

    explicit Widget(int size);

    ...

    };

    void doSomeWork(const Widget& w);

    doSomeWork(Widget(15)); // создать Widget из int

    // с функциональным приведением

    doSomeWork(static_cast<Widget>(15)); // создать Widget из int

    // с приведением в стиле C++


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

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


    int x, y;

    ...

    double d = static_cast<double>(x)/y; // деление x на y с использованием

    // деления с плавающей точкой


    Приведение int x к типу double почти наверняка порождает исполняемый код, потому что в большинстве архитектур внутреннее представление int отличается от представления double. Если это вас не особенно удивило, но взгляните на следующий пример:


    class Base {...};

    class Derived: public Base {...};

    Derived d;

    Base *pb = &d; // неявное преобразование Derived*

    // в Base*


    Здесь мы всего лишь создали указатель базового класса на объект производного, но иногда эти два указателя указывают вовсе не на одно и то же. В таком случае во время исполнения к указателю Derived* прибавляется смещение, чтобы получить правильное значение указателя Base*.

    Последний пример демонстрирует, что один и тот же объект (например, объект типа Derived) может иметь более одного адреса (например, адрес при указании на него как на Base* отличается от адреса при указании как на Derived*). Такое невозможно в C. Такое невозможно в Java. Такого не бывает в C#. Но это случается в C++. Фактически, когда применяется множественное наследование, такое случается сплошь и рядом, но может произойти и при одиночном наследовании. Это ко всему прочему означает, что, программируя на C++, вы не должны строить предположений о том, как объекты располагаются в памяти, и уж тем более не должны выполнять приведение типов на базе этих предположений. Например, приведение адреса объекта к типу char* и последующее использование арифметических операций над указателями почти всегда становятся причиной неопределенного поведения.

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

    Интересный момент, касающийся приведений, – еще в том, что легко написать код, который выглядит правильным (и может быть правильным на других языках), но на самом деле правильным не является. Например, во многих каркасах для разработки приложений требуется, чтобы виртуальные функции-члены, определенные в производных классах, вначале вызывали соответствующие функции из базовых классов. Предположим, что у нас есть базовый класс Window и производный от него класс SpecialWindow, причем в обоих определена виртуальная функция onResize. Далее предположим, что onResize из SpecialWindow будет вызывать сначала onResize из Window. Следующая реализация выглядит хорошо, но по сути неправильна:


    class Window { // базовый класс

    public:

    virtual void onResize() {...} // реализация onResize в базовом

    ... // классе

    };

    class SpecialWindow: public Window { // производный класс

    public:

    virtual void onResize() { // реализация onResize

    static_cast<Window>(*this).onResize(); // в производном классе;

    // приведение *this к Window,

    // затем вызов его onResize;

    // это не работает!

    ... // выполнение специфической для

    } // SpecialWindow части onResize

    ...

    };


    Я выделил в этом коде приведение типа. (Это приведение в новом стиле, но использование старого стиля ничего не меняет.) Как и ожидается, *this приводит к типу Window. Поэтому обращение к onResize приводит к вызову Window::onResize. Вот только эта функция не будет вызвана для текущего объекта! Неожиданно, не правда ли? Вместо этого оператор приведения создаст новую, временную копию части базового класса *this и вызовет onResize для этой копии! Приведенный выше код не вызовет Window::onResize для текущего объекта с последующим выполнением специфичных для SpecialWindow действий – он выполнит Window::onResize для копии части базового класса текущего объекта перед выполнением специфичных для SpecialWindow действий для данного объекта. Если Window::onResize модифицирует объект (что вполне возможно, так как onResize – не константная функция-член), то текущий объект не будет модифицирован. Вместо этого будет модифицирована копия этого объекта. Однако если SpecialWindow::onResize модифицирует объект, то будет модифицирован именно текущий объект. И в результате текущий объект остается в несогласованном состоянии, потому что модификация той его части, что принадлежит базовому классу, не будет выполнена, а модификация части, принадлежащей производному классу, будет.

    Решение проблемы в том, чтобы исключить приведение типа, заменив его тем, что вы действительно имели в виду. Нет необходимости выполнять какие-то трюки с компилятором, заставляя его интерпретировать *this как объект базового класса. Вы хотите вызвать версию onResize базового класса для текущего объекта. Так поступите следующим образом:


    class SpecialWindow: public Window {

    public:

    virtual void onResize() {

    Window::onResize(); // вызов Window::onResize на *this

    ...

    }

    ...

    };


    Приведенный пример также демонстрирует, что коль скоро вы ощущаете желание выполнить приведение типа, это знак того, что вы, возможно, на ложном пути. Особенно это касается оператора dynamic_cast.

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

    Необходимость в dynamic_cast обычно появляется из-за того, что вы хотите выполнить операции, определенные в производном классе, для объекта, который, как вы полагаете, принадлежит производному классу, но при этом у вас есть только указатель или ссылка на базовый класс, посредством которой нужно манипулировать объектом. Есть два основных способа избежать этой проблемы.

    Первый – используйте контейнеры для хранения указателей (часто «интеллектуальных», см. правило 13) на сами объекты производных классов, тогда отпадет необходимость манипулировать этими объектами через интерфейсы базового класса. Например, если в нашей иерархии Window/SpecialWindow только SpecialWindow поддерживает мерцание (blinking), то вместо:


    class Window { ...};

    class SpecialWindow {

    public:

    void blink();

    ...

    };

    typedef // см. правило 13

    std::vector<std::tr1::shared_ptr<Window>>VPW; // о tr1::shared_ptr

    VPW winPtrs;

    ...

    for (VPW::iterator iter = winPtrs.begin(); // нежелательный код:

    iter!=winPtrs.end(); // применяется dynamic_cast

    ++iter){

    if(SpecialWindow psw = dynamic_cast<SpecialWindow>(iter->get()))

    psw->blink();

    }


    попробуйте сделать так:


    typedef std::vector<std::tr1::shared_ptr<SpecialWindow>> VPSW;

    VPSW winPtrs;

    ...

    for (VPSW::iterator iter = winPtrs.begin(); // это лучше:

    iter != winPtrs.end(); // не использует dynamic_cast

    ++iter)

    (*iter)->blink();


    Конечно, такой подход не позволит вам хранить указатели на объекты всех возможных производных от Window классов в одном и том же контейнере. Чтобы работать с разными типами окон и обеспечить безопасность по отношению к типам, вам может понадобиться несколько контейнеров.

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


    class Window {

    public:

    virtual void blink() {} // реализация по умолчанию – пустая

    ... // операция, см. в правиле 34 – почему

    }; // наличие реализации по умолчанию

    // может оказаться неудачной идеей

    class SpecialWindow: public Window {

    public:

    virtual void blink() {...}

    ...

    };

    typedef std::vector<std::tr1::shared_ptr<Window>>VPW;

    VPW winPtrs; // контейнер содержит

    // (указатели на) все возможные

    ... // типы окон

    for(VPW::iterator iter = winPtrs.begin();

    iter != winPtrs.end();

    ++iter) // dynamic_cast не используется

    (*iter)->blink();


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

    Но вот чего стоит избегать всегда – это каскадов из операторов dynamic_cast, то есть чего-то вроде такого кода:


    class Window {...};

    ... // здесь определены производные классы

    typedef std::vector<std::tr1::shared_ptr<Window>> VPW;

    VPW winPtrs;

    ...

    for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)

    {

    if (SpecialWindow1 *psw1=

    dynamic_cast<SpecialWindow1>(iter->get())) {...}

    else if (SpecialWindow2 *psw2=

    dynamic_cast<SpecialWindow2>(iter->get())) {...}

    else if (SpecialWindow2 *psw2=

    dynamic_cast<SpecialWindow2>(iter->get())) {...}

    ...

    }


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

    В хорошей программе на C++ приведения типов используются очень редко, но полностью отказываться от них тоже не стоит. Так, показанное выше приведение int к double является разумным, хотя и не абсолютно необходимым (код может быть переписан с объявлением новой переменной типа double, инициируемой значением x). Как и большинство сомнительных конструкций, приведения типов должны быть изолированы насколько возможно. Обычно они помещаются внутрь функций, чей интерфейс скрывает от пользователей те некрасивые дела, что творятся внутри.

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

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

    • Когда приведение типа необходимо, постарайтесь скрыть его внутри функции. Тогда пользователи смогут вызывать эту функцию вместо помещения приведения в их собственный код.

    • Предпочитайте приведения в стиле C++ старому стилю. Их легче увидеть, и они более избирательны.

    Правило 28: Избегайте возвращения «дескрипторов» внутренних данных

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


    class Point { // класс, представляющий точки

    public:

    Point(int x, int y);

    ...

    void setX(int newVal);

    void setY(int newVal);

    ...

    };

    struct RectData { // точки, определяющие Rectangle

    Point ulhc; // ulhc – верхний левый угол

    Point lrhc; // lrhc – нижний правый угол

    };

    class Rectangle {

    ...

    private:

    std::tr1::shared_ptr<RectData> pData; // см. в правиле 13

    }; // информацию о tr1::shared_ptr


    Поскольку пользователям класса Rectangle понадобится определять его координаты, то класс предоставляет функции upperLeft и lowerRight. Однако Point – это определенный пользователем тип, поэтому, помня о том, что передача таких типов по ссылке обычно более эффективна, чем передача по значению (см. правило 20), эти функции возвращают ссылки на внутренние объекты Point:


    class Rectangle {

    public:

    ...

    Point& upperLeft() const { return pData->ulhc;}

    Point& lowerRight() const { return pData->lrhc;}

    ...

    };


    Такой вариант откомпилируется, но он неправильный! Фактически он внутренне противоречив. С одной стороны, upperLeft и lowerRight объявлены как константные функции-члены, поскольку они предназначены только для того, чтобы предоставить клиенту способ получить информацию о точках Rectangle, не давая ему возможности модифицировать объект Rectangle (см. правило 3). С другой стороны, обе функции возвращают ссылки на закрытые внутренние данные – ссылки, которые пользователь может затем использовать для модификации этих внутренних данных! Например:


    Point coord1(0, 0);

    Point coord2(100,100);

    const Rectangle rec(coord1, coord2); // rec – константный прямоугольник

    // от (0, 0) до (100, 100)

    rec.upperLeft().setX(50); // теперь rec лежит между

    // (50, 0) и (100, 100)!


    Обратите внимание, что пользователь функции upperLeft может использовать возвращенную ссылку на один из данных-членов внутреннего объекта Point для модификации этого члена. Но ведь ожидается, что rec – константа!

    Из этого примера следует извлечь два урока. Первый – член данных инкапсулирован лишь настолько, насколько доступна функция, возвращающая ссылку на него. В данном случае хотя ulhc и lrhc объявлены закрытыми, но на самом деле они открыты, потому что на них возвращают ссылки открытые функции upperLeft и lowerRight. Второй урок в том, что если константная функция-член возвращает ссылку на данные, ассоциированные с объектом, но хранящиеся вне самого объекта, то код, вызывающий эту функцию, может модифицировать данные. (Все это последствия ограничений побитовой константности – см. правило 3.)

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

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

    Впрочем, функции, которые возвращают указатели на функции-члены, встречаются нечасто, поэтому вернемся к классу Rectangle и его функциям-членам upperLeft и lowerRight. Обе проблемы, которые мы идентифицировали для этих функций, могут быть исключены простым применением квалификатора const к их возвращаемому типу:


    class Rectangle {

    public:

    ...

    const Point& upperLeft() const { return pData->ulhc;}

    const Point& lowerRight() const { return pData->lrhc;}

    ...

    };


    В результате такого изменения пользователи смогут читать объекты Point, определяющие прямоугольник, но не смогут изменять их. Это значит, что объявление константными функций upperLeft и lowerRight больше не является ложью, так как они более не позволяют клиентам модифицировать состояние объекта. Что касается проблемы инкапсуляции, то мы с самого начала намеревались дать клиентам возможность видеть объекты Point, определяющие Rectangle, поэтому в данном случае ослабление инкапсуляции намеренное. К тому же это лишь частичное ослабление: рассматриваемые функции дают только доступ для чтения. Доступ для записи по-прежнему запрещен.

    Но даже и так upperLeft и lowerRight по-прежнему возвращают «дескрипторы» внутренних данных объекта, и это может вызвать проблемы иного свойства. В частности, возможно появление «висячих дескрипторов» (dangling handles), то есть дескрипторов, ссылающихся на части уже не существующих объектов. Наиболее типичный источник таких исчезнувших объектов – значения, возвращаемые функциями. Например, рассмотрим функцию, которая возвращает ограничивающий прямоугольник объекта GUI:


    class GUIObject {...};

    const Rectangle // возвращает прямоугольник по значению;

    boundBox(const GUIObject& obj); // см. в правиле 3, почему const


    Теперь посмотрим, как пользователь может применить эту функцию:


    GUIObject *pgo; // pgo указывает на некий объект

    ... // GUIObject

    const Point *pUpperLeft = // получить указатель на верхний левый

    &(boundingBox(*pgo).upperLeft()); // угол его рамки


    Вызов boundingBox вернет новый временный объект Rectangle. Этот объект не имеет имени, поэтому назовем его temp. Затем вызывается функция-член upperLeft объекта temp, и этот вызов возвращает ссылку на внутренние данные temp, в данном случае на один из объектов Point. В результате pUpperLeft указывает на этот объект Point. До сих пор все шло хорошо, но мы еще не закончили, поскольку в конце предложения возвращенное boundingBox значение – temp – будет разрушено, а это приведет к разрушению объектов Point, принадлежавших temp. То есть pUpperLeft теперь указывает на объект, который более не существует. Указатель PUpperLeft становится «висячим» уже в конце предложения, где он создан!

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

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

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

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

    Правило 29: Стремитесь, чтобы программа была безопасна относительно исключений

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

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


    class PrettyMenu {

    public:

    ...

    void changeBackground(std::istream& imgSrc); // сменить фоновую

    ... // картинку

    private:

    Mutex mutex; // мьютекс объекта

    Image *bgImage; // текущая фоновая картинка

    int imageChanges; // сколько раз картинка менялась

    };


    Рассмотрим следующую возможную реализацию функции-члена change-Background:


    void PrettyMenu::changeBackground(std::istream& imgSrc)

    {

    lock(&mutex); // захватить мьютекс

    delete bgImage; // избавиться от старой картинки

    ++imageChanges; // обновить счетчик изменений картинки

    bgImage = new Image(imgSrc); // установить новый фон

    unlock(&mutex); // освободить мьютекс

    }


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

    Когда возбуждается исключение, то безопасная относительно исключений функция:

    Не допускает утечки ресурсов. Приведенный код не проходит этот тест, потому что если выражение «new Image(imgSrc)» возбудит исключение, то вызов unlock никогда не выполнится, и мьютекс окажется захваченным навсегда.

    Не допускает повреждения структур данных. Если «new Image(imgSrc)» возбудит исключение, в bgImage останется указатель на удаленный объект. Кроме того, счетчик imageChanges увеличивается, несмотря на то что новая картинка не установлена. (С другой стороны, старая картинка уже полностью удалена, так что трудно сделать вид, будто ничего не изменилось.)

    Справиться с утечкой ресурсов легко – в правиле 13 объяснено, как пользоваться объектами, управляющими ресурсами, а в правиле 14 представлен класс Lock, гарантирующий своеременное освобождение мьютексов:


    void PrettyMenu::changeBackground(std::istream& imgSrc)

    {

    Lock ml(mutex); // из правила 14: захватить мьютекс

    // и гарантировать его последующее освобождение

    delete bgImage;

    ++imageChanges;

    bgImage = new Image(imgSrc);

    }


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

    От утечки ресурсов перейдем к проблеме возможного повреждения данных. Здесь у нас есть выбор, но прежде чем его сделать, нужно уточнить терминологию.

    Безопасные относительно исключений функции предоставляют одну из трех гарантий.

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

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

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

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


    int doSomething() throw(); // обратите внимание на пустую

    // спецификацию исключений


    Это объявление не говорит о том, что doSomething никогда не возбуждает исключений. Утверждается лишь, что если doSomething возбудит исключение, значит, произошла серьезная ошибка и должна быть вызвана функция unexpected[3]. Фактически doSomething может вообще не представлять никаких гарантий относительно исключений. Объявление функции (включающее ее спецификацию исключений) ничего не сообщает относительно того, является ли она корректной, переносима, эффективной, какие гарантии безопасности исключений она предоставляет и предоставляет ли их вообще. Все эти характеристики определяются реализацией функции, а не ее объявлением.

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

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

    Для функции changeBackground предоставить почти строгую гарантию нетрудно. Во-первых, измените тип данных bgImage в классе PrettyMenu со встроенного указателя *Image на один из «интеллектуальных» управляющих ресурсами указателей, описанных в правиле 13. Откровенно говоря, это в любом случае неплохо, поскольку позволяет избежать утечек ресурсов. Тот факт, что это заодно помогает обеспечить строгую гарантию безопасности исключений, просто подтверждает приведенные в правиле 13 аргументы в пользу применения объектов (наподобие интеллектуальных указателей) для управления ресурсами. Ниже я воспользовался классом tr1::shared_ptr, потому что он ведет себя более естественно при копировании, чем auto_ptr.

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

    Вот что получается в результате:


    class PrettyMenu {

    ...

    std::tr1::shared_ptr<Image> bgImage;

    ...

    };

    void PrettyMenu::changeBackground(std::istream& imgSrc)

    {

    Lock ml(mutex);

    BgImage.reset(new Image(imgSrc)); // заменить внутренний указатель

    // bgImage результатом выражения

    // “new Image”

    ++imageChanges;

    }


    Отметим, что больше нет необходимости вручную удалять старую картинку, потому что это делает «интеллектуальный» указатель. Более того, удаление происходит только в том случае, если новая картинка успешно создана. Точнее говоря, функция tr1::shared_ptr::reset будет вызвана, только в том случае, когда ее параметр (результат вычисления «new Image(imgSrc)») успешно создан. Оператор delete используется только внутри вызова reset, поэтому если функция не получает управления, то и delete не вызывается. Отметим также, что использование объекта (tr1::shared_ptr) для управления ресурсом (динамически выделенным объектом Image) ко всему прочему уменьшает размер функции changeBackground.

    Как я сказал, эти два изменения позволяют changeBackground предоставлять почти строгую гарантию безопасности исключений. Так чего же не хватает? Дело в параметре imgSrc. Если конструктор Image возбудит исключение, может случиться, что указатель чтения из входного потока сместится, и такое смещение может оказаться изменением состояния, видимым остальной части программы. До тех пор пока у функции changeBackground есть этот недостаток, она предоставляет только базовую гарантию безопасности исключений.

    Но оставим в стороне этот нюанс и будем считать, что changeBackground представляет строгую гарантию безопасности. (По секрету сообщу, что есть способ добиться этого, изменив тип параметра с istream на имя файла, содержащего данные картинки.) Существует общая стратегия проектирования, которая обеспечивает строгую гарантию, и важно ее знать. Стратегия называется «скопировать и обменять» (copy and swap). В принципе, это очень просто. Сделайте копию объекта, который собираетесь модифицировать, затем внесите все необходимые изменения в копию. Если любая из операций модификации возбудит исключение, исходный объект останется неизменным. Когда все изменения будут успешно внесены, обменяйте модифицированный объект с исходным с помощью операции, не возбуждающей исключений.

    Обычно это реализуется помещением всех имеющих отношение к объекту данных из «реального» объекта в отдельный внутренний объект, на который в «реальном» объекте имеется указатель. Часто этот прием называют «идиома pimpl», и в правиле 31 он описывается более подробно. Для класса PrettyMenu это может выглядеть примерно так:


    struct PMImpl { // PMImpl = “PrettyMenu Impl”:

    std::tr1::shared_ptr<Image> bgImage; // см. далее – почему это

    int imageChanges; // структура, а не класс

    }

    class PrettyMenu {

    ...

    private:

    Mutex mutex;

    std::tr1::shared_ptr<PMImpl> pimpl;

    };

    void PrettyMenu::changeBackground(std::istream& imgSrc)

    {

    using std::swap; // см. правило 25

    Lock ml(&mutex); // захватить мьютекс

    std::tr1::shared_ptr<PMImpl> // копировать данные obj

    pNew(new PMImpl(*pimpl));

    pNew->bgImage.reset(new Image(imgSrc)); // модифицировать копию

    ++pNew->imageChanges;

    swap(pimpl, pNew); // обменять значения

    } // освободить мьютекс


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

    Стратегия копирования и обмена – это отличный способ внести изменения в состояние объекта по принципу «все или ничего», но в общем случае при этом не гарантируется, что вся функция в целом строго безопасна относительно исключений. Чтобы понять почему, абстрагируемся от функции changeBackground и рассмотрим вместо нее некоторую функцию someFunc, которая использует копирование с обменом, но еще и обращается к двум другим функциям: f1 и f2.


    void someFunc()

    {

    ... // скопировать локальное состояние

    f1();

    f2();

    ... // обменять модифицированное состояние с копией

    }


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

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

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

    Подобные ситуации могут помешать предоставлению строгой гарантии безопасности для функции, даже если вы хотели бы это сделать. Кроме того, надо принять во внимание эффективность. Смысл «копирования и обмена» в том, чтобы модифицировать копию данных объекта, а затем обменять модифицированные и исходные данные операцией, которая не возбуждает исключений. Для этого нужно сделать копию каждого объекта, который подлежит модификации, что потребует времени и памяти, которыми вы, возможно, не располагаете. Строгая гарантия весьма желательна, и вы должны обеспечивать ее, когда это разумно и практично, но не обязательно во всех случаях.

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

    Совсем другое дело, если вы пишете функцию, которая вообще не представляет никаких гарантий безопасности исключений. Тут вступает в силу презумпция виновности: подсудимый считается виновным, пока не докажет обратного. Вы должны писать код, безопасный относительно исключений. Однако у вас есть право на защиту. Рассмотрим еще раз реализацию функции someFunc, которая вызывает f1 и f2. Предположим, что f2 не представляет никаких гарантий безопасности исключений, даже базовой. Это значит, что если f2 возбудит исключение, то возможна утечка ресурсов внутри f2. Это также означает, что f2 может повредить структуры данных, например отсортированные массивы могут стать неотсортированными, объект, который копировался из одной структуры в другую, может потеряться и т. д. Функция someFunc ничего не может с этим поделать. Если вызываемые из someFunc функции не гарантируют безопасности относительно исключений, то и someFunc не может предоставить никаких гарантий.

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

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

    Сорок лет назад код, изобилующий операторами goto, считался вполне приемлемым. Теперь же мы стараемся писать структурированные программы. Двенадцать лет назад глобальные данные ни у кого не вызывали возражений. Теперь мы стремимся данные инкапсулировать. Десять лет назад написание функций без учета влияния исключений было нормой. А сейчас мы боремся за достижение безопасности относительно исключений.

    Времена меняются. Мы живем. Мы учимся.

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

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

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

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

    Правило 30: Тщательно обдумывайте использование встроенных функций

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

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

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

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

    Имейте в виду, что директива inline – это совет, а не команда компилятору. Совет может быть сформулирован явно или неявно. Неявный способ заключается в определении встроенной функции внутри определения класса:


    class Person {

    public:

    ...

    int age() const { return theAge;} // неявный запрос на встраивание;

    ... // функция age определена внутри класса

    private:

    int theAge;

    };


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

    Явно объявить встроенную функцию можно, предварив ее определение ключевым словом inline. Например, вот как обычно реализован стандартный шаблон max (из заголовочного файла <algorithm>):


    template <typename T> // явный запрос на

    inline const T& std::max(const T& a, const T& b) // встраивание: функции

    { return a < b ? b : c;} // std::max предшествует

    // слово inline


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

    Встроенные функции обычно должны находиться в заголовочных файлах, поскольку большинство разработки программ выполняют встраивание во время компиляции. Чтобы заменить вызовы функции встраиванием ее тела, компилятор должен увидеть эту функцию. (Некоторые среды могут встраивать функции во время компоновки, а есть и такие – например, среды разработки на базе. NET Common Language Infrastructure (CLI), – которые осуществляют встраивание во время исполнения. Но это скорее исключение, чем правило. Встраивание функций в большинстве программ на C++ происходит во время компиляции.)

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

    Конкретизация шаблонов никак не связана со встраиванием. Если вы полагаете, что все функции, конкретизированные из вашего шаблона, должны быть встроенными, объявите шаблон встроенным (inline); именно так разработчики стандартной библиотеки поступили с шаблоном std::max (см. пример выше). Но если вы пишете шаблон для функции, которую нет смысла делать встроенной, не объявляйте встроенным и ее шаблон (явно или неявно). Встраивание обходится дорого, и вряд ли вы захотите платить за это без должного размышления. Мы уже упоминали, что встраивание раздувает код (особенно это важно при разработке шаблонов – см. правило 44), но есть и другие затраты, которые мы скоро обсудим.

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

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

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


    inline void f() {...} // предположим, что компилятор может встроить вызовы f

    void (*pf)() = f; // pf указывает на f

    ...

    f(); // этот вызов будет встроенным, потому что он

    // «нормальный»

    pf(); // этот вызов, вероятно, не будет встроен, потому что

    // функция вызвана по указателю


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

    Фактически конструкторы и деструкторы часто являются наихудшими кандидатами для встраивания. Например, рассмотрим конструктор класса Derived:


    class Base {

    public:

    ...

    private:

    std::string bm1, bm2; // члены базового класса 1 и 2

    };

    class Derived: public Base {

    public:

    Derived(){} // конструктор Derived пуст – не так ли?

    ...

    private:

    std::string dm1, dm2, dm3; // члены производного класса 1–3

    };


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

    C++ дает различные гарантии о том, что должно происходить при конструировании и разрушении объектов. Например, когда вы используете оператор new, динамически создаваемые объекты автоматически инициализируются своими конструкторами, а при обращении к delete вызываются соответствующие деструкторы. Когда вы создаете объект, то автоматически конструируются члены всех его базовых классов, а равно его собственные данные-члены, а во время удаления объекта автоматически происходит обратный процесс. Если во время конструирования объекта возбуждается исключение, то все части объекта, которые были к этому моменту сконструированы, автоматически разрушаются. Во всех этих случаях C++ говорит, что должно случиться, но не говорит – как. Это зависит от реализации компилятора, но должно быть понятно, что такие вещи не происходят сами по себе. В вашей программе должен быть какой-то код, который все это реализует, и этот код, который генерируется компилятором и вставляется в вашу программу, должен где-то находиться. Иногда он помещается в конструкторы и деструкторы, поэтому можем представить себе следующую реализацию сгенерированного кода в якобы пустом конструкторе класса Derived:


    Derived::Derived() // концептуальная реализация

    { // «пустого» конструктора класса Derived

    Base::Base(); // инициализировать часть Base

    try {dm1.std::string::string();} // попытка сконструировать dm1

    catch(…) { // если возбуждается исключение,

    Base::~Base(); // разрушить часть базового класса

    throw; // распространить исключение выше

    }

    try {dm2.std::string::string();} // попытка сконструировать dm2

    catch(…){ // если возбуждается исключение,

    dm1.std::string::~string(); // разрушить dm1

    Base::~Base(); // разрушить часть базового класса

    throw; // распространить исключение

    }

    try {dm3.std::string::string();} // сконструировать dm3

    catch(…){ // если возбуждается исключение,

    dm2.std::string::~string(); // разрушить dm2

    dm1.std::string::~string(); // разрушить dm1

    Base::~Base(); // разрушить часть базового класса

    throw; // распространить исключение

    }

    }


    В действительности это не совсем тот код, который порождает компилятор, потому что реальные компиляторы обрабатывают исключения более сложным образом. И все же этот пример довольно точно отражает поведение «пустого» конструктора класса Derived. Независимо от того, насколько хитроумно обходится с исключениями компилятор, конструктор Derived должен, по крайней мере, вызывать конструкторы своих данных-членов и базового класса, и эти вызовы (которые сами по себе могут быть встроенными) могут свести преимущества встраивания на нет.

    То же самое относится и к конструктору класса Base, поэтому если он встроенный, то весь вставленный в него код вставляется также и в конструктор Derived (поскольку конструктор Derived вызывает конструктор Base). И если конструктор класса string тоже окажется встроенным, то в конструктор Derived его код войдет пять раз – по одному для каждой из пяти имеющихся в классе Derived строк (две унаследованные и три, объявленные в нем самом). Наверное, теперь вам ясно, почему решений о встраивании конструктора Derived не стоит принимать с легким сердцем. Аналогично обстоят дела и с деструктором класса Derived, который каким-то образом должен гарантировать правильное уничтожение всех объектов, инициализированных конструктором.

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

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

    Это приводит нас к следующей стратегии выбора функций, подходящих для встраивания. Поначалу откажитесь от встроенных функций вовсе, или, по крайней мере, ограничьтесь теми, которые обязаны быть встроенными (см. правило 46) либо являются тривиальными (такие как Person::age выше). Применяя встроенные функции с должной аккуратностью, вы не только получаете возможность пользоваться отладчиком, но и определяете встраиванию подобающее место: тонкая оптимизация вручную. Не забывайте об эмпирическом правиле «80–20», которое утверждает, что типичная программа тратит 80 % времени на исполнение 20 % кода. Это важное правило, поскольку оно напоминает, что цель разработчика программного обеспечения – идентифицировать те 20 % кода, которые действительно способны повысить производительность программы. Можно до бесконечности оптимизировать и объявлять функции inline, но все это будет пустой тратой времени, если только вы не сосредоточите усилия на нужных функциях.

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

    • Делайте встраиваемыми только небольшие, часто вызываемые функции. Это облегчит отладку, даст возможность выполнять обновления библиотек на двоичном уровне, уменьшит эффект «разбухания» кода и поможет повысить быстродействие программы.

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

    Правило 31: Уменьшайте зависимости файлов при компиляции

    Рассмотрим самую обыкновенную ситуацию. Вы открываете свою программу на C++ и вносите незначительные изменения в реализацию класса. Заметьте, не в интерфейс класса, а просто в реализацию – только в закрытые члены. После этого вы начинаете заново собирать программу, рассчитывая, что это займет лишь несколько секунд. В конце концов, ведь вы модифицировали всего один класс. Вы щелкаете по кнопке Build или набираете make (либо какой-то эквивалент), и… удивлены, а затем – подавлены, когда обнаруживаете, что перекомпилируется и заново компонуется весь мир! Не правда ли, вам это скоро надоест?

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


    class Person {

    public:

    Person(const std::string& name, const Date& birthday,

    const Address& addr);

    std::string name() const;

    std::string birthDate() const;

    std::string address() const;

    ...

    private:

    std::string theName; // деталь реализации

    Date theBirthDate; // деталь реализации

    Address theAddress; // деталь реализации

    };


    Класс Person нельзя скомпилировать, не имея доступа к определению классов, с помощью которых он реализуется, а именно string, Date и Address. Такие определения обычно предоставляются посредством директивы #include, поэтому весьма вероятно, что в начале файла, определяющего класс Person, вы найдете нечто вроде:


    #include <string>

    #include “date.h”

    #include “address.h”


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

    Можно задаться вопросом, почему C++ настаивает на размещении деталей реализации класса в определении класса. Например, почему нельзя определить Person следующим образом:


    namespace std {

    class string; // опережающее объявление

    } // (некорректно – см. далее)

    class Date; // опережающее объявление

    class Address; // опережающее объявление

    class Person {

    public:

    Person(const std::string& name, const Date& birthday,

    const Address& addr);

    std::string name() const;

    std::string birthDate() const;

    std::string address() const;

    ...

    };


    Если бы такое было возможно, то пользователи класса Person должны были перекомпилировать свои программы только при изменении его интерфейса.

    Увы, при реализации этой идеи мы наталкиваемся на две проблемы. Первая: string – это не класс, а typedef (синоним шаблона basic_string<char>). Поэтому опережающее объявление string некорректно. Правильное объявление гораздо сложнее, так как в нем участвуют дополнительные шаблоны. Впрочем, это не важно, потому что вы в любом случае не должны вручную объявлять какие-либо части стандартной библиотеки. Вместо этого просто включите с помощью #include правильные заголовки и успокойтесь. Стандартные заголовки вряд ли станут узким местом при компиляции, особенно если ваша среда разработки поддерживает предкомпилированные заголовочные файлы. Если на компиляцию стандартных заголовков все же уходит много времени, то может понадобиться изменить дизайн и избежать использования тех частей стандартной библиотеки, которые включать нежелательно.

    Вторая (и более существенная) неприятность, связанная с опережающим объявлением, состоит в том, что компилятору необходимо знать размер объектов во время компиляции. Рассмотрим пример:


    int main()

    {

    int x; // определяем int

    Person p(params); // определяем Person

    ...

    }


    Когда компилятор видит определение x, он понимает, что должен выделить достаточно места (обычно в стеке) для размещения int. Нет проблем: каждый компилятор знает, какова длина int. Встречая определение p, компилятор учитывает, что нужно выделить место для Person, но откуда ему знать, сколько именно места потребуется? Единственный способ получить эту информацию – справиться в определении класса, но если бы в определениях классов можно было опускать детали реализации, как компилятор выяснил бы, сколько памяти необходимо выделить?

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


    int main()

    {

    int x; // определяем int

    Person *p; // определяем указатель на Person

    ...

    }


    Это вполне законная конструкция на C++, поэтому вы и сами сможете имитировать «сокрытие реализации объекта за указателем». В случае класса Person это можно сделать, например, разделив его на два класса: один – для представления интерфейса, а другой – для его реализации. Если класс, содержащий реализацию, назвать Personlmpl, то Person должен быть написан следующим образом:


    #include <string> // компоненты стандартной библиотеки

    // не могут быть объявлены предварительно

    #include <memory> // для tr1::shared_ptr; см. далее

    class PersonImpl; // опережающее объявление PersonImpl

    class Date; // опережающее объявление классов,

    class Address; // используемых в интерфейсе Person

    class Person {

    public:

    Person(const std::string& name, const Date& birthday,

    const Address& addr);

    std::string name() const;

    std::string birthDate() const;

    std::string address() const;

    ...

    private: // указатель на реализацию:

    std::tr1::shared_ptr<PersonImpl> pImpl; // см. в правиле 13 информацию

    }; // о std::tr1::shared_ptr


    Здесь главный класс (Person) не содержит никаких данных-членов, кроме указателя (в данном случае tr1::shared_ptr – см. правило 13) на свой класс реализации (Personlmpl). Такой дизайн часто называют «идиомой pimpl» («pointer to implementation» – указатель на реализацию). В подобных классах указатели часто называют pImpl, как в приведенном примере.

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

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

    Избегайте использования объектов, если есть шанс обойтись ссылками или указателями. Вы можете определить ссылки и указатели, имея только объявление типа. Определение объектов требует наличия определения типа.

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


    class Date; // объявление класса

    Date today(); // правильно, необходимость

    void clearAppointments(Date d); // в определении Date отсутствует


    Конечно, передача по значению – не очень хорошая идея (см. правило 20), но если по той или иной причине вы будете вынуждены ею воспользоваться, это никак не оправдает введения ненужных зависимостей. Не исключено, что возможность объявить функции today и clearAppoinments без определения Date повергла вас в удивление, но на самом деле это не так уж странно. Определение Date должно быть доступно в момент вызова этих функций. Да, я знаю, о чем вы думаете: зачем объявлять функции, которых никто не вызывает? Ответ прост. Дело не в том, что никто не вызывает их, а в том, что их вызывают не все. Например, если имеется библиотека, содержащая десятки объявлений функций, то маловероятно, что каждый пользователь вызывает каждую функцию. Перенося бремя ответственности за предоставление определений класса с ваших заголовочных файлов, содержащих объявления функций, на пользовательские файлы, содержащие их вызовы, вы исключаете искусственную зависимость пользователя от определений типов, которые им в действительности не нужны.

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

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


    #include “datefwd.h” // заголочный файл, в котором объявлен

    // (но не определен) класс Date

    Date today(); // как раньше

    void clearAppointments(Date d);


    Файл с объявлениями назван «datefwd.h» по аналогии с заголовочным файлом <iosfwd> из стандартной библиотеки C++ (см. правило 54). <iosfwd> содержит объявления компонентов iostream, определения которых находятся в нескольких разных заголовках, включая <sstream>, <streambuf>, <fstream> и <iostream>.

    Пример <iosfwd> поучителен еще и по другой причине. Из него следует, что совет этого правила относится в равной мере к шаблонным и обычным классам. Хотя в правиле 30 объяснено, что во многих средах разработки программ определения шаблонов обычно находятся в заголовочных файлах, но в некоторых продуктах допускается размещение определений шаблонов и в других местах, поэтому все же имеет смысл предоставить заголовочные файлы, содержащие только объявления, и для шаблонов. <iosfwd> – как раз пример такого файла.

    В C++ есть также ключевое слово export, позволяющее отделить объявления шаблонов от их определений. К сожалению, поддержка компиляторами этой возможности ограничена, а практический опыт его применения совсем невелик. Сейчас еще слишком рано говорить, какую роль будет играть слово export в эффективном программировании на C++. Классы, подобные Person, в которых используется идиома pimpl, часто называют классами-дескрипторами (handle classes). Ответ на вопрос, каким образом работают такие классы, прост: они переадресовывают все вызовы функций соответствующим классам реализаций, которые и выполняют всю реальную работу. Например, вот как могут быть реализованы две функции-члена Person:


    #include “Person.h” // поскольку мы реализуем класс Person,

    // то должны включить его определение

    #include “PersonImpl.h” // мы должны также включить определение класса

    // PersonImpl, иначе не сможем вызывать его

    // функции-члены; отметим, что PersonImpl имеет

    // в точности те же функции-члены, что и

    // Person: их интерфейсы идентичны

    Person::Person(const std::string& name, const Date& birthday,

    const Address& addr)

    : pImpl(new Person(name, birthday, addr))

    {}

    std::string Person::name() const

    {

    return pImpl->name();

    }


    Обратите внимание на то, как конструктор Person вызывает конструктор Personlmpl (используя new – см. правило 16), и как Person::name вызывает PersonImpl::name. Это важный момент. Превращение Person в класс-дескриптор не меняет его поведения – изменяется только место, в котором это поведение реализовано.

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

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

    Интерфейсный класс Person может выглядеть примерно так:


    class Person {

    public:

    virtual ~Person();

    virtual std::string name() const = 0;

    virtual std::string birthDate() const = 0;

    virtual std::string address() const = 0;

    ...

    };


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

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


    class Person {

    public:

    ...

    static std::tr1::shared_ptr<Person> // возвращает tr1::shared_ptr

    create(const std::string& name, // на новый экземпляр Person,

    const Date& birthday, // инициализированный заданными

    const Address& addr); // параметрами: см. в правиле 18,

    ... // почему возвращается

    }; // tr1::shared_ptr


    а используют так:


    std::string name;

    Date datefBirth;

    Address address;

    ...

    // создать объект, поддерживающий интерфейс Person

    std::tr1::shared_ptr<Person> pp(Person::create(name, dateOfBrth, address));

    ...

    std::cout << pp->name() // использовать объект через

    << “ родился ” // интерфейс Person

    << pp->birthDate()

    << “ и теперь живет по адресу ”

    << pp->address();

    ... // объект автоматически

    // удаляется, когда pp выходит

    // из контекста – см. правило 13


    Разумеется, где-то должны быть определены конкретные классы, поддерживающие интерфейс такого интерфейсного класса, и вызваны реальные конструкторы. Все это происходит «за кулисами», внутри файлов, содержащих реализацию виртуальных конструкторов. Например, интерфейсный класс Person может иметь конкретный производный класс RealPerson, предоставляющий реализацию унаследованных виртуальных функций:


    class RealPerson public Person {

    public:

    RealPerson(const std::string& name, const Date& birthday,

    const Address& addr)

    : theName(name), theBirthDate(birthday), theAddress(addr)

    {}

    virtual ~RealPerson() {}

    std::string name() const; // реализация этих функций

    std::string birthDate() const; // не показана, но ее

    std::string address() const; // легко представить

    private:

    std::string theName;

    Date theBirthDaye;

    Address theAddress;

    };


    Имея класс RealPerson, очень легко написать Person::create:


    std::tr1::shared_ptr<Person> create( const std::string& name,

    const Date& birthday,

    const Address& addr)

    {

    return std::tr1::shared_ptr<Person>(new RealPerson(name, birthday, addr));

    }


    Более реалистическая реализация Person::create должна создавать разные типы объектов классов-наследников, в зависимости, например, от дополнительных параметров функции, данных, прочитанных из файла или базы данных, переменных окружения и т. п.

    RealPerson демонстрирует один из двух наиболее распространенных механизмов реализации интерфейсных классов: он наследует спецификации своего интерфейса от интерфейсного класса Person, а затем реализует функции этого интерфейса. Второй способ реализации интерфейсного класса предполагает использование множественного наследования (см. правило 40).

    Итак, классы-дескрипторы и интерфейсные классы отделяют интерфейс от реализации, уменьшая тем самым зависимости между файлами на этапе компиляции. Теперь, я уверен, вы ждете примечания мелким шрифтом: «Во сколько обойдется этот хитрый фокус?» Цена вполне обычная в мире программирования: некоторое уменьшение скорости выполнения программы плюс дополнительный расход памяти на каждый объект.

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

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

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

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

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

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

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









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

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