// Подход A: определение вне цикла
Widget w;
for(int i=0; i w = … } // Подход B: определение внутри цикла for(int i=0; i Widget w( … Здесь я перехожу от объекта типа 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)
Функциональный синтаксис приведения таков:
T(
Между этими двумя формами нет ощутимого различия, просто скобки расставляются по-разному. Я называю эти формы
const_cast dynamic_cast reinterpret_cast
У каждой из них свое назначение:
• 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
Но намеренное создание объекта не «ощущается» как приведение типа, поэтому в данном случае, наверное, лучше применить функциональное приведение вместо static_cast. Да и вообще, код, ведущий к аварийному завершению, обычно выглядит совершенно разумным, когда вы его пишете, поэтому лучше не обращать внимания на ощущения и всегда пользоваться приведениями в новом стиле. Многие программисты полагают, что приведение типа всего лишь говорит компилятору, что нужно трактовать один тип как другой, но они заблуждаются. Преобразования типа любого рода (как явные, посредством приведения, так и неявные, выполняемые самим компилятором) часто приводят к появлению кода, исполняемого во время работы программы. Рассмотрим пример:
int x, y;
…
double d = static_cast
Приведение int x к типу double почти наверняка порождает исполняемый код, потому что в большинстве архитектур внутреннее представление int отличается от представления double. Если это вас не особенно удивило, но взгляните на следующий пример:
class Base {…};
class Derived: public Base {…};
Derived d;
Base *pb = &d // неявное преобразование 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
// приведение *this к Window,
// затем вызов его onResize;
//
… // выполнение специфической для
} // SpecialWindow части onResize
…
};Я выделил в этом коде приведение типа. (Это приведение в новом стиле, но использование старого стиля ничего не меняет.) Как и ожидается, *this приводит к типу Window. Поэтому обращение к onResize приводит к вызову Window::onResize. Вот только эта функция не будет вызвана для текущего объекта! Неожиданно, не правда ли? Вместо этого оператор приведения создаст новую, временную копию части базового класса *this и вызовет onResize для этой копии! Приведенный выше код не вызовет Window::onResize для текущего объекта с последующим выполнением специфичных для SpecialWindow действий – он выполнит Window::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
VPW winPtrs;
…
for (VPW::iterator iter = winPtrs.begin(); // нежелательный код:
iter!=winPtrs.end(); // применяется dynamic_cast
++iter){
if(SpecialWindow psw = dynamic_cast
psw->blink();
}попробуйте сделать так:
typedef std::vector VPSW winPtrs; … for (VPSW::iterator iter = winPtrs.begin(); // это лучше: iter != winPtrs.end(); // не использует dynamic_cast ++iter)
Конечно, такой подход не позволит вам хранить указатели на объекты всех возможных производных от Window классов в одном и том же контейнере. Чтобы работать с разными типами окон и обеспечить безопасность по отношению к типам, вам может понадобиться несколько контейнеров. Альтернатива, которая позволит манипулировать объектами всех возможных производных от Window классов через интерфейс базового класса, – это предусмотреть виртуальные функции в базовом классе, которые позволят вам делать именно то, что вам нужно. Например, хотя только SpecialWindow умеет мерцать, может быть, имеет смысл объявить функцию в базовом классе и обеспечить там реализацию по умолчанию, которая не делает ничего:
class Window {
public:
virtual void blink() {} // реализация по умолчанию – пустая
… // операция, см. в правиле 34 – почему
}; // наличие реализации по умолчанию
// может оказаться неудачной идеей
class SpecialWindow: public Window {
public:
virtual void blink() {…}
…
};
typedef std::vector
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
VPW winPtrs;
…
for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
{
if (SpecialWindow1 *psw1=
dynamic_cast
else if (SpecialWindow2 *psw2=
dynamic_cast
else if (SpecialWindow2 *psw2=
dynamic_cast
…
}В этом случае генерируется объемный и медленный код, к тому же он нестабилен, потому что при каждом изменении иерархии классов 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
Поскольку пользователям класса 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, поэтому в данном случае ослабление инкапсуляции намеренное. К тому же это лишь
class GUIObject {…};
const Rectangle // возвращает прямоугольник по значению;
boundBox(const GUIObject& obj); // см. в правиле 3, почему constТеперь посмотрим, как пользователь может применить эту функцию:
GUIObject *pgo; // pgo указывает на некий объект
… // GUIObject
const Point *pUpperLeft = // получить указатель на верхний левый
&(boundingBox(*pgo).upperLeft()); // угол его рамкиВызов boundingBox вернет новый временный объект Rectangle. Этот объект не имеет имени, поэтому назовем его
Вот почему опасна любая функция, которая возвращает «дескриптор» внутренних данных объекта. При этом не важно, является ли «дескриптор» ссылкой, указателем или итератором. Не важно, что она квалифицирована const. Не важно, что сама функция-член, возвращающая «дескриптор», является константной. Имеет значение лишь тот факт, что «дескриптор» возвращен, поскольку возникает опасность, что он «переживет» объект, с которым связан.
Это не значит, что
Правило 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 никогда не возбуждает исключений. Утверждается лишь, что
Безопасный относительно исключений код должен представлять одну из трех описанных гарантий. Если он этого не делает, он не является безопасным. Выбор, таким образом, в том, чтобы определить, какой тип гарантии должна представлять каждая из написанных вами функций. Если не считать унаследованный код, небезопасный относительно исключений (об этом мы поговорим далее в настоящем правиле), то отсутствие гарантий допустимо лишь, если в результате анализа требований было решено, что приложение просто обязано допускать утечку ресурсов и работать с поврежденными структурами данных.
Вообще говоря, нужно стремиться предоставить максимально строгие гарантии. С точки зрения безопасности исключений функции, не возбуждающие исключений, чудесны, но очень трудно, не оставаясь в рамках языка C, обойтись без вызова функций, возбуждающих исключения. Любой класс, в котором используется динамическое распределение памяти (например, STL-контейнеры), может возбуждать исключение bad_alloc, когда не удается найти достаточного объема свободной памяти (см. правило 49). Предоставляйте гарантии отсутствия исключений, когда можете, но для большинства функций есть только выбор между базовой и строгой гарантией.
Для функции changeBackground предоставить
Во-вторых, нужно изменить порядок предложений в функции changeBackground так, чтобы значение счетчика imageChanges не увеличивалось до тех пор, пока картинка не будет заменена. Общее правило таково: помечайте в объекте, что произошло некоторое изменение, только после того, как это изменение действительно выполнено.
Вот что получается в результате:class PrettyMenu {
…
std::tr1::shared_ptr
…
};
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 предоставлять
Но оставим в стороне этот нюанс и будем считать, что changeBackground представляет строгую гарантию безопасности. (По секрету сообщу, что есть способ добиться этого, изменив тип параметра с istream на имя файла, содержащего данные картинки.) Существует общая стратегия проектирования, которая обеспечивает строгую гарантию, и важно ее знать. Стратегия называется «скопировать и обменять» (copy and swap). В принципе, это очень просто. Сделайте копию объекта, который собираетесь модифицировать, затем внесите все необходимые изменения в копию. Если любая из операций модификации возбудит исключение, исходный объект останется неизменным. Когда все изменения будут успешно внесены, обменяйте модифицированный объект с исходным с помощью операции, не возбуждающей исключений.
Обычно это реализуется помещением всех имеющих отношение к объекту данных из «реального» объекта в отдельный внутренний объект, на который в «реальном» объекте имеется указатель. Часто этот прием называют «идиома pimpl», и в правиле 31 он описывается более подробно. Для класса PrettyMenu это может выглядеть примерно так:struct PMImpl { // PMImpl = “PrettyMenu Impl”:
std::tr1::shared_ptr
int imageChanges; // структура, а не класс
}
class PrettyMenu {
…
private:
Mutex mutex;
std::tr1::shared_ptr
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
using std::swap; // см. правило 25
Lock ml(&mutex); // захватить мьютекс
std::tr1::shared_ptr
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. Не существует способа отменить модификацию базы данных, которая уже была совершена: другие клиенты могли уже увидеть новое состояние.
Подобные ситуации могут помешать предоставлению строгой гарантии безопасности для функции, даже если вы хотели бы это сделать. Кроме того, надо принять во внимание эффективность. Смысл «копирования и обмена» в том, чтобы модифицировать копию данных объекта, а затем обменять модифицированные и исходные данные операцией, которая не возбуждает исключений. Для этого нужно сделать копию каждого объекта, который подлежит модификации, что потребует времени и памяти, которыми вы, возможно, не располагаете. Строгая гарантия весьма желательна, и вы должны обеспечивать ее, когда это разумно и практично, но не обязательно во всех случаях.
Когда невозможно предоставить строгую гарантию, вы должны обеспечить базовую. На практике может оказаться так, что для некоторых функций можно обеспечить строгую гарантию, тогда как для многих других это неразумно из соображений эффективности и сложности. Если вы сделали все возможное для обеспечения строгой гарантии там, где это оправдано, никто не вправе критиковать вас за то, что в остальных случаях вы представляете только базовую гарантию. Для многих функций базовая гарантия – совершенно разумный выбор.
Совсем другое дело, если вы пишете функцию, которая вообще не представляет никаких гарантий безопасности исключений. Тут вступает в силу презумпция виновности: подсудимый считается виновным, пока не докажет обратного. Вы
Вот теперь мы можем вернуться к теме беременности. Женщина либо беременна, либо нет. Невозможно быть чуть-чуть беременной. Аналогично программная система является либо безопасной по исключениям, либо нет. Нет такого понятия, как частично безопасная система. Если система имеет всего одну небезопасную относительно исключений функцию, то она небезопасна и в целом, потому что вызов этой функции может привести к утечке ресурсов и повреждению структур данных. К несчастью, большинство унаследованного кода на C++ было написано без учета требований безопасности исключений, поэтому многие системы на сегодня являются в этом отношении небезопасными. Они включают код, написанный в небезопасной манере.
Но нет причин сохранять такое положение дел навсегда. При написании нового кода или модификации существующего тщательно продумывайте способы достижения безопасности исключений. Начните с применения объектов управления ресурсами (см. правило 13). Это предотвратит утечку ресурсов. Затем определите, какую максимальную из трех гарантий безопасности исключений вы можете обеспечить для разрабатываемых функций, оставляя их небезопасными только в том случае, когда вызовы унаследованного кода не оставляют другого выбора. Документируйте ваши решения как для пользователей ваших функций, так и для сопровождения в будущем. Гарантия безопасности исключений функции – это видимая часть ее интерфейса, поэтому вы должны подходить к ней столь же ответственно, как и к другим аспектам интерфейса.
Сорок лет назад код, изобилующий операторами goto, считался вполне приемлемым. Теперь же мы стараемся писать структурированные программы. Двенадцать лет назад глобальные данные ни у кого не вызывали возражений. Теперь мы стремимся данные инкапсулировать. Десять лет назад написание функций без учета влияния исключений было нормой. А сейчас мы боремся за достижение безопасности относительно исключений.
Времена меняются. Мы живем. Мы учимся.
• Безопасные относительно исключений функции не допускают утечки ресурсов и повреждения структур данных, даже в случае возбуждения исключений. Такие функции предоставляют базовую гарантию, строгую гарантию либо гарантию полного отсутствия исключений.
• Строгая гарантия часто может быть реализована посредством копирования и обмена, но предоставлять ее для всех функций непрактично.
• Функция обычно может предоставить гарантию не строже, чем самая слабая гарантия, обеспечиваемая вызываемыми из нее функциями.Правило 30: Тщательно обдумывайте использование встроенных функций
Встроенные функции – какая
В действительности вы получаете больше, чем рассчитывали, потому что возможность избежать затрат на вызов функции – это только полдела. Оптимизация, выполняемая компилятором, обычно наиболее эффективна на участке кода, не содержащем вызовов функций. Таким образом, вы даете компилятору возможность оптимизации тела встроенной функции в зависимости от объемлющего контекста. При использовании «обычного» функционального вызова большинство компиляторов такой оптимизации на обычных не выполняют.
Все же давайте не будем слишком увлекаться. В программировании, как и в реальной жизни, не бывает «бесплатных завтраков», и встроенные функции – не исключение. Идея их использования состоит в замене каждого вызова такой функции ее телом. Не нужно быть доктором математических наук, чтобы заметить, что это увеличит общий размер вашего объектного кода. Слишком частое применение встроенных функций на машинах с ограниченной памятью может привести к созданию программы, которая превосходит доступную память. Даже при наличии виртуальной памяти «разбухание» кода, вызванное применением встроенных функций, может привести к дополнительному обмену с диском, уменьшить коэффициент попадания команд в кэш и, следовательно, снизить производительность программы.
С другой стороны, если тело встроенной функции
Имейте в виду, что директива inline – это
class Person {
public:
…
int age() const { return theAge;} // неявный запрос на встраивание;
… // функция age определена внутри класса
private:
int theAge;
};Такие функции обычно являются функциями-членами, но в правиле 46 объясняется, что функции-друзья тоже могут быть определены внутри класса. В этом случае они также неявно считаются встроенными. Явно объявить встроенную функцию можно, предварив ее определение ключевым словом inline. Например, вот как обычно реализован стандартный шаблон max (из заголовочного файла
template inline const T& std::max(const T& a, const T& b) // встраивание: функции { return a < b ? b : c;} // std::max предшествует
Тот факт, что 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
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 его код войдет
Разработчики библиотек должны принимать во внимание, что произойдет при объявлении функций встроенными, потому что невозможно предоставить двоичное обновление видимых клиенту встроенных библиотечных функций. Другими словами, если 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 #include “date.h”
К сожалению, это устанавливает зависимости времени компиляции между файлом определения 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
int main()
{
int x; // определяем int
Person p(
…
}Когда компилятор видит определение x, он понимает, что должен выделить достаточно места (обычно в стеке) для размещения int. Нет проблем: каждый компилятор знает, какова длина int. Встречая определение p, компилятор учитывает, что нужно выделить место для Person, но откуда ему знать, сколько именно места потребуется? Единственный способ получить эту информацию – справиться в определении класса, но если бы в определениях классов можно было опускать детали реализации, как компилятор выяснил бы, сколько памяти необходимо выделить? Такой вопрос не возникает в языках типа SmallTalk или Java, потому что при определении объекта компиляторы выделяют только память, достаточную для хранения
int main()
{
int x; // определяем int
Person *p; // определяем указатель на Person
…
}Это вполне законная конструкция на C++, поэтому вы и сами сможете имитировать «сокрытие реализации объекта за указателем». В случае класса Person это можно сделать, например, разделив его на два класса: один – для представления интерфейса, а другой – для его реализации. Если класс, содержащий реализацию, назвать Personlmpl, то Person должен быть написан следующим образом:
#include // не могут быть объявлены предварительно #include 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
Здесь главный класс (Person) не содержит никаких данных-членов, кроме указателя (в данном случае tr1::shared_ptr – см. правило 13) на свой класс реализации (Personlmpl). Такой дизайн часто называют «идиомой pimpl» («pointer to implementation» – указатель на реализацию). В подобных классах указатели часто называют pImpl, как в приведенном примере.
При таком дизайне пользователи класса Person не видят никаких деталей – дат, адресов и имен. Реализация может быть модифицирована как угодно, при этом перекомпилировать программы, в которых используется Person, не придется. Кроме того, поскольку пользователи не знают деталей реализации Person, они вряд ли напишут код, который каким-то образом будет зависеть от этих деталей. Вот это я и называю отделением интерфейса от реализации.
Ключом к этому разделению служит замена зависимости от
• Избегайте использования объектов, если есть шанс обойтись ссылками или указателями. Вы можете определить ссылки и указатели, имея только
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» по аналогии с заголовочным файлом Пример
#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 абстрактным базовым классом специального вида, называемым
Интерфейсные классы сродни интерфейсам 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), или
class Person {
public:
…
static std::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
…
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 const Date& birthday, const Address& addr) { return std::tr1::shared_ptr
Более реалистическая реализация Person::create должна создавать разные типы объектов классов-наследников, в зависимости, например, от дополнительных параметров функции, данных, прочитанных из файла или базы данных, переменных окружения и т. п.
RealPerson демонстрирует один из двух наиболее распространенных механизмов реализации интерфейсных классов: он наследует спецификации своего интерфейса от интерфейсного класса Person, а затем реализует функции этого интерфейса. Второй способ реализации интерфейсного класса предполагает использование множественного наследования (см. правило 40).
Итак, классы-дескрипторы и интерфейсные классы отделяют интерфейс от реализации, уменьшая тем самым зависимости между файлами на этапе компиляции. Теперь, я уверен, вы ждете примечания мелким шрифтом: «Во сколько обойдется этот хитрый фокус?» Цена вполне обычная в мире программирования: некоторое уменьшение скорости выполнения программы плюс дополнительный расход памяти на каждый объект.
Применительно к классам-дескрипторам функции-члены должны использовать указатель на реализацию (pImpl), чтобы добраться до данных самого объекта. Для каждого обращения это добавляет один уровень косвенной адресации. Кроме того, к объему памяти, необходимому для хранения каждого объекта, нужно добавить размер указателя. И наконец, указатель на реализацию должен быть инициализирован (в конструкторе класса-дескриптора), чтобы он указывал на динамически распределенный объект реализации; следовательно, вы навлекаете на себя еще и накладные расходы, сопровождающие динамическое выделение памяти и последующее ее освобождение, а также возможность возникновения исключений bad_alloc (из-за недостатка памяти).
Для интерфейсных классов каждый вызов функции будет виртуальным, поэтому всякий раз вы платите за косвенный переход (см. правило 7). Кроме того, классы, производные от интерфейсного класса, должны содержать указатель на таблицу виртуальных функций (и снова см. правило 7). Этот указатель может увеличить объем памяти, необходимый для хранения объекта, в зависимости от того, является ли интерфейсный класс единственным источником виртуальных функций для объекта.
И наконец, ни классы-дескрипторы, ни интерфейсные классы не могут извлечь выгоду из использования встроенных функций. В правиле 30 объяснено, почему тела потенциально встраиваемых функций должны быть в заголовочных файлах, но классы-дескрипторы и интерфейсные классы специально предназначены для того, чтобы скрыть такие детали реализации, как тело функций.
Однако было бы серьезной ошибкой отказываться от классов-дескрипторов и интерфейсных классов только потому, что их использование связано с дополнительными расходами. То же самое можно сказать и о виртуальных функциях, но вы ведь не отказываетесь от их применения. (В противном случае вы читаете не ту книгу.) Рассмотрите возможность использования предлагаемых приемов по мере эволюции ваших программ. Применяйте классы-дескрипторы и интерфейсные классы в процессе разработки, чтобы уменьшить влияние изменений в реализации на пользователей. Если вы можете показать, что различие в скорости и/или размере программы настолько существенно, что во имя повышения эффективности оно оправдывает увеличение зависимости между классами, то на конечной стадии реализации заменяйте их конкретными классами.
• Основная идея уменьшения зависимостей на этапе компиляции состоит в том, чтобы заменить зависимость от определения зависимостью от объявления. Эта идея лежит в основе двух подходов: классов-дескрипторов и интерфейсных классов.
• Заголовочные файлы библиотек должны существовать в обеих формах: полной и содержащей только объявления. Это справедливо независимо от того, включают они шаблоны или нет.Глава 6 Наследование и объектно-ориентированное проектирование
Объектно-ориентированное программирование (ООП) существует почти 20 лет, поэтому, вероятно, вы имеете некоторое представление о наследовании, производных классах и виртуальных функциях. Даже если вы программировали только на C, ничего не слышать об ООП вы просто не могли.
И все же ООП в C++, скорее всего, несколько отличается от того, к чему вы привыкли. Наследование может быть одиночным и множественным, а отдельный путь наследования может быть открытым (public), защищенным (protected) или закрытым (private). Путь также может быть виртуальным или невиртуальным. Для функций-членов тоже есть варианты. Виртуальные? Невиртуальные? Чисто виртуальные? Добавьте сюда взаимодействие с другими средствами языка. Как соотносятся параметры по умолчанию с виртуальными функциями? Как влияет наследование на правила разрешения имен в C++? И что можно сказать по поводу методов проектирования? Если поведение класса должно быть модифицируемым, являются ли виртуальные функции лучшим способом достижения этого?
Обо всем этом пойдет речь в настоящей главе. Я объясню, что на самом деле стоит за теми или иными возможностями C++: какую мысль вы
Когда вы поймете истинное назначение различных средств C++, то обнаружите, что ваш взгляд на ООП изменился. Вместо простого упражнения в нахождении отличий между языками это станет средством выражения того, что вы хотите сказать о своей программной системе. А поняв, что же вы в действительности имеете в виду, уже не составит большого труда перевести свои мысли этого на C++.
Правило 32: Используйте открытое наследование для моделирования отношения «является»
Вильям Демент (William Dement) в своей книге «Кто-то должен бодрствовать, пока остальные спят» (W. H. Freeman and Company, 1974) рассказывает о том, как он пытался донести до студентов наиболее важные идеи своего курса. Утверждается, говорил он своей группе, что средний британский школьник помнит из уроков истории лишь то, что битва при Хастингсе произошла в 1066 году. Даже если ученик почти ничего не запомнил из курса истории, подчеркивает Демент, 1066 год остается в его памяти. Демент пытался внушить слушателям несколько основных идей, в частности ту любопытную истину, что снотворное вызывает бессонницу. Он призывал своих студентов запомнить ряд ключевых фактов, даже если забудется все, что обсуждалось на протяжении курса, и в течение семестра возвращался к нескольким фундаментальным заповедям.
Последним на заключительном экзамене был вопрос: «Напишите, какой факт из тех, что обсуждались на лекциях, вы запомните на всю жизнь». Проверяя работы, Демент был ошеломлен. Почти все упомянули 1066 год.
Теперь я с трепетом хочу провозгласить, что самое важное правило в объектно-ориентированном программировании на C++ звучит так: открытое наследование означает «является». Твердо запомните это.
Если вы пишете класс D (derived – «производный») открыто наследует классу B («base» – «базовый»), то тем самым сообщаете компилятору C++ (а заодно и людям, читающим ваш код), что каждый объект типа D является также объектом типа B, но
Такой интерпретации открытого наследования придерживается C++. Рассмотрим следующий пример:
class Person {…}; class Student: public Person {…};
Здравый смысл и опыт подсказывают нам, что каждый студент – человек, но не каждый человек – студент. Именно такую связь подразумевает данная иерархия. Мы ожидаем, что всякое утверждение, справедливое для человека – например, что у него есть дата рождения, – справедливо и для студента, но не все, что верно для студента – например, что он учится в каком-то определенном институте, – верно для человека в общем случае. Применительно к C++ это выглядит следующим образом: любая функция, которая принимает аргумент типа Person (или указатель на Person, или ссылку на Person), примет объект типа Student (или указатель на Student, или ссылку на Student):
void eat(const Person& p); // все люди могут есть
void study(const Student& s); // только студент учится
Person p; // p – человек
Student s; // s – студент
eat(p); // правильно, p есть человек
eat(s); // правильно, s – это студент,
// и студент также является человеком
study(s); // правильно
study(p); // ошибка! p – не студентВсе сказанное верно только для
class Bird {
public:
virtual void fly(); // птицы умеют летать
…
};
class Penguin: public Bird { // пингвины – птицы
…
};Неожиданно мы столкнулись с затруднением. Утверждается, что пингвины могут летать, что, как известно, неверно. В чем тут дело? В данном случае нас подвела неточность разговорного языка. Когда мы говорим, что птицы умеют летать, то не имеем в виду, что
class Bird {
… // функция fly не объявлена
};
class FlyingBird: public Bird {
public:
virtual void fly();
…
};
class Penguin: public Bird {
… // функция fly не объявлена
};Данная иерархия гораздо точнее отражает реальность, чем первоначальная. Но и теперь еще не все закончено с «птичьими делами», потому что для некоторых приложений может и не быть необходимости делать различие между летающими и нелетающими птицами. Так, если ваше приложение в основном имеет дело с клювами и крыльями и никак не отражает способность пернатых летать, вполне сойдет и исходная иерархия. Это наблюдение, сообственно, является лишь подтверждением того, что не существует идеального проекта, который подходил бы для всех видов программных систем. Выбор проекта зависит от того, что система должна делать – как сейчас, так и в будущем. Если ваше приложение никак не связано с полетами и не предполагается, что оно будет связано с ними в дальнейшем, то вполне можно не принимать во внимание различий между летающими и нелетающими птицами. На самом деле даже лучше не проводить таких различий, потому что его нет в мире, который вы пытаетесь моделировать. Существует другая школа, иначе относящаяся к рассматриваемой проблеме. Она предлагает переопределить для пингвинов функцию fly() так, чтобы во время исполнения она возвращала ошибку:
void error(const std::string& msg); // определено в другом месте
class Penguin: public Bird {
public:
virtual void fly() {error(“Попытка заставить пингвина летать!”);}
…
};Важно понимать, что это здесь имеется в виду не совсем то, что вам могло показаться. Мы не говорим: «Пингвины не могут летать», а лишь сообщаем: «Пингвины могут летать, но с их стороны было бы ошибкой это делать».
В чем разница? Во времени обнаружения ошибки. Утверждение «пингвины не могут летать» может быть поддержано на уровне компилятора, а соответствие утверждения «попытка полета ошибочна для пингвинов» реальному положению дел может быть обнаружено во время выполнения программы.
Чтобы обозначить ограничение «пингвины не могут летать – и точка», следует убедиться, что для объектов Penguin функция fly() не определена:class Bird {
… // функция fly не объявлена
};
class Penguin: public Bird {
… // функция fly не объявлена
};Если теперь вы попробуете заставить пингвина взлететь, компилятор сделает вам выговор за нарушение правил:
Penguin p; p.fly(); // ошибка!
Это сильно отличается от поведения, которое получается, если применить подход, генерирующий ошибку времени исполнения. Ведь в таком случае компилятор ничего не может сказать о вызове p.fly(). В правиле 18 объясняется, что хороший интерфейс предотвращает компиляцию неверного кода, поэтому лучше выбрать проект, который отвергает попытки пингвинов полетать во время компиляции, а не во время исполнения.
Возможно, вы решите, что вам недостает интуиции орнитолога, но вполне можете положиться на свои познания в элементарной геометрии, не так ли? Тогда ответьте на следующий простой вопрос: должен ли класс Square (квадрат) открыто наследовать классу Rectangle (прямоугольник)?
class Rectangle {
public:
virtual void setHeight(int newHeight);
virtual void setWidth(int newWidth);
virtual int height() const; // возвращают текущие значения
virtual int width() const;
…
};
void makeBigger(Rectangle& r) // функция увеличивает площадь r
{
int oldHeight = r.height();
r.setWidth(r.width() + 10); // увеличить ширину r на 10
assert(r.height() == oldHeight); // убедиться, что высота r
} // не измениласьЯсно, что утверждение assert никогда не должно нарушаться. Функция make-Bigger изменяет только ширину r. Высота остается постоянной. Теперь рассмотрим код, который посредством открытого наследования позволяет рассматривать квадрат как частный случай прямоугольника:
class Square: public Rectangle {…};
Square s;
…
assert(s.width() == s.height()); // должно быть справедливо для
// всех квадратов
makeBigger(s); // из-за наследования, s является
// Rectangle, поэтому мы можем
// увеличить его площадь
assert(s.width() == s.height()); // По-прежнему должно быть справедливо
// для всех квадратовКак и в предыдущем примере, что второе утверждение также никогда не должно быть нарушено. По определению, ширина квадрата равна его высоте.
Но теперь перед нами встает проблема. Как примирить следующие утверждения?
• Перед вызовом makeBigger высота s равна ширине.
• Внутри makeBigger ширина s изменяется, а высота – нет.
• После возврата из makeBigger высота s снова равна ширине (отметим, что s передается по ссылке, поэтому makeBigger модифицирует именно s, а не его копию).
Так что же?
Добро пожаловать в удивительный мир открытого наследования, где интуиция, приобретенная вами в других областях знания, включая математику, иногда оказывается плохим помощником. Основная трудность в данном случае заключается в том, что некоторые утверждения, справедливые для прямоугольника (его ширина может быть изменена независимо от высоты), не выполняются для квадрата (его ширина и высота должны быть одинаковы). Но открытое наследование предполагает, что все, что применимо к объектам базового класса, –
Все же не стоит беспокоиться, что приобретенная вами за многие годы разработки программного обеспечения интуиция окажется бесполезной при переходе к объектно-ориентированному программированию. Все ваши знания по-прежнему актуальны, но теперь, когда вы добавили к своему арсеналу наследование, вам придется дополнить свою интуицию новым пониманием, позволяющим создавать приложения с использованием наследования. Со временем идея наследования Penguin от Bird или Square от Rectangle будет казаться вам столь же забавной, как функция объемом в несколько страниц. Такое решение
Отношение «является» – не единственное, возможное между классами. Два других, достаточно распространенных отношения – это «содержит» и «реализован посредством». Они рассматриваются в правилах 38 и 39. Очень часто при проектировании на C++ весь проект идет вкривь и вкось из-за того, что эти взаимосвязи моделируются отношением «является». Поэтому вы должны быть уверены, что понимаете различия между этими отношениями и знаете, каким образом их лучше всего моделировать в C++.
Правило 33: Не скрывайте унаследованные имена
Шекспир много размышлял об именах. Он писал: «Что в имени тебе? Роза пахнет розой, хоть розой назови ее, хоть нет». И еще писал бард: «Кто доброе мое похитит имя, несчастным сделает меня вовек…» Правильно. И это заставляет нас обратить взор на унаследованные имена в C++.
Вообще-то эта тема относится не столько к наследованию, сколько к областям видимости. Все мы знаем, что в таком коде:
int x; // глобальная переменная
void someFunc()
{
double x; // локальная переменная
std::cin >> x; // прочитать новое значение локальной переменной x
}имя x в предложении считывания относится к локальной, а не к глобальной переменной, потому что имена во вложенной области видимости скрывают («затеняют») имена из внешних областей. Мы можем представить эту ситуацию визуально:
Когда компилятор встречает имя x внутри функции someFunc, он смотрит, определено ли что-то с таким именем в локальной области видимости. Если да, то объемлющие области видимости не просматриваются. В данном случае имя x в функции someFunc принадлежит переменной типа double, а глобальная переменная с тем же именем x имеет тип int, но это несущественно. Правила сокрытия имен в C++ предназначены для одной-единственной цели: скрывать
Вернемся к наследованию. Мы знаем, что когда находимся внутри функции-члена производного класса и ссылаемся на что-то из базового класса (например, функцию-член, typedef или член данных), компилятор сможет найти то, на что мы ссылаемся, потому что производные классы наследуют свойства, объявленные в базовых классах. Механизм основан на том, что область видимости производного класса вложена в область видимости базового класса. Например:
class Base {
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf2();
void mf3();
…
};
class Derived: public Base {
public:
virtual void mf1()
void mf4();
…
};В этом примере встречаются как открытые, так и закрытые имена, как имена членов данных, так и функций-членов. Одна из функций-членов – чисто виртуальная, другая – просто виртуальная, а третья – невиртуальная. Это я к тому, что мы говорим именно об
void Derived::mf4()
{
…
mf2();
…
}Когда компилятор видит имя mf2, он должен понять, на что оно ссылается. Для этого в различных областях видимости производится поиск имени mf2. Сначала оно ищется в локальной области видимости (то есть внутри mf4), но там такого имени нет. Тогда просматривается объемлющая область видимости, то есть область видимости класса Derived. И здесь такое имя отсутствует, поэтому компилятор переходит к следующей область видимости, которой является базовый класс. И находит там нечто по имени mf2, после чего поиск завершается. Если бы mf2 не было и в классе Base, то поиск продолжился бы сначала в пространстве имен, содержащем Base, если таковое имеется, и, наконец, в глобальной области видимости.
Данное мной описание правильно, хотя и исчерпывает всю сложность процесса поиска имен в C++. Наша цель, однако, не в том, чтобы узнать о поиске имен столько, чтобы самостоятельно написать компилятор. Достаточно будет, если мы сумеем избежать неприятных сюрпризов, а для этого изложенной информации должно хватить.
Снова вернемся к предыдущему примеру, но на этот раз перегрузим функции mf1 и mf3, а также добавим версию mf3 в класс Derived. Как объясняется в правиле 36, перегрузка mf3 в производном классе Derived (когда наследуется невиртуальная функция) сама по себе подозрительна, но чтобы лучше разобраться с видимостью имен, закроем на это глаза.
class Base {
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf1(int);
virtual void mf2();
void mf3();
void mf3(double);
…
};
class Derived: public Base {
public:
virtual void mf1()
void mf3();
void mf4();
…
};Этот код приводит к поведению, которое удивит любого программиста C++, впервые столкнувшегося с ним. Основанное на областях видимости правило сокрытия имен никуда не делось, поэтому
Derived d;
int x;
…
d.mf1(); // правильно, вызывается Derived::mf1
d.mf1(x); // ошибка! Derived::mf1 скрывает Base::mf1
d.mf2(); // правильно, вызывается Base::mf2
d.mf3(); // правильно, вызывается Derived::mf3
d.mf3(x); // ошибка! Derived::mf3 скрывает Base::mf3Как видите, это касается даже тех случаев, когда функции в базовом и производном классах принимают параметры разных типов, независимо от того, идет ли речь о виртуальных или невиртуальных функциях. И точно так же, как в нашем первом примере double x внутри функции someFunc скрывает int x из глобального контекста, так и здесь функция mf3 в классе Derived скрывает функцию mf3 из класса Base, которая имеет другой тип.
Обоснование такого поведения в том, что оно не дает нечаянно унаследовать перегруженные функции из базового класса, расположенного много выше в иерархии наследования, упрятанной в библиотеке или каркасе приложения. К сожалению, обычно вы
Это можно сделать с помощью using-объявлений:
class Base {
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf1(int);
virtual void mf2();
void mf3();
void mf3(double);
…
};
class Derived: public Base {
public:
using Base::mf1; // обеспечить видимость всех (открытых) имен
using Base::mf3; // mf1 и mf3 из класса Base в классе Derived
virtual void mf1()
void mf3();
void mf4();
…
};Теперь наследование будет работать, как и ожидается.
Derived d;
int x;
…
d.mf1(); // по-прежнему правильно, вызывается Derived::mf1
d.mf1(x); // теперь правильно, вызывается Base::mf1
d.mf2(); // по-прежнему правильно, вызывается Base::mf2
d.mf3(); // по-прежнему правильно, вызывается Derived::mf3
d.mf3(x); // теперь правильно, вызывается Base::mf3Это означает, что если вы наследуете базовому классу с перегруженными функциями и хотите переопределить только некоторые из них, то должны включить using-объявление для каждого имени, иначе оно будет скрыто. Можно представить себе ситуацию, когда вы не хотите наследовать все функции из базовых классов. При открытом наследовании такое никогда не должно происходить, так как это противоречит смыслу отношения «является» между базовым классом и производным от него. Вот почему using-объявление находится в секции public объявления производного класса; имена, которые открыты в базовом классе, должны оставаться открытыми и в открыто унаследованном от него. Но при закрытом наследовании (см. правило 39) такое желание иногда осмыслено. Например, предположим, что класс Derived закрыто наследует классу Base, и единственная версия mfl, которую Derived хочет унаследовать, – это та, что не принимает параметров. Using-объявление в этом случае не поможет, поскольку оно делает видимыми в производном классе
class Base {
public:
virtual void mf1() = 0;
virtual void mf1(int);
… // как раньше
};
class Derived: private Base {
public:
virtual void mf1() // перенаправляющая функция
{ Base::mf1();} // неявно встроена (см. правило 30)
…
};
…
Derived d;
Int x;
d.mf1(); // правильно, вызывается Derived::mf1
d.mf1(x); // ошибка! Base::mf1 скрытаДругое применение встроенных перенаправляющих функций – обойти дефект в тех устаревших компиляторах, которые не поддерживают using-объявления для импорта унаследованных имен в область видимости производного класса.
Это все, что можно сказать о наследовании и сокрытии имен. Впрочем, когда наследование сочетается с шаблонами, возникает совсем другой вариант проблемы «сокрытия унаследованных имен». Все подробности, касающиеся шаблонов, см. в правиле 43.
• Имена в производных классах скрывают имена из базовых классов. При открытом наследовании это всегда нежелательно.
• Чтобы сделать скрытые имена видимыми, используйте using-объявления либо перенаправляющие функции.Правило 34: Различайте наследование интерфейса и наследование реализации
Внешне простая идея открытого наследования при ближайшем рассмотрении оказывается состоящей из двух различных частей: наследования интерфейса функций и наследования их реализации. Различие между этими двумя видами наследования соответствует различию между объявлениями и определениями функций, обсуждавшемуся во введении к этой книге.
При разработке классов иногда требуется, чтобы производные классы наследовали только интерфейс (объявления) функций-членов. В других случаях необходимо, чтобы производные классы наследовали и интерфейс, и реализацию функций, но могли переопределять унаследованную реализацию. А иногда вам может понадобиться использование наследования интерфейса и реализации, но без возможности что-либо переопределять.
Чтобы лучше почувствовать различия между этими вариантами, рассмотрим иерархию классов для представления геометрических фигур в графическом приложении:
class Shape {
public:
virtual void draw() const = 0;
virtual void error(const std::string& msg);
int objectID() const;
…
};
class Rectangle: public Shape {…};
class Ellipse: public Shape {…};Shape – это абстрактный класс; таковым его делает чисто виртуальная функция draw. В результате пользователи не могут создавать объекты класса Shape, а лишь классов, производных от него. Несмотря на это, Shape оказывает сильное влияние на все открыто наследующие ему классы по следующей причине:
В классе Shape объявлены три функции. Первая, draw, выводит текущий объект на дисплей, подразумеваемый по умолчанию. Вторая, error, вызывается функциями-членами, если необходимо сообщить об ошибке. Третья, objectID, возвращает уникальный целочисленный идентификатор текущего объекта. Каждая из трех функций объявлена по-разному: draw – как чисто виртуальная; error – как просто виртуальная; а objectID – как невиртуальная функция. Каковы практические последствия этих различий?
Рассмотрим первую чисто виртуальную функцию draw:class Shape {
public:
virtual void draw() const = 0;
…
};Две наиболее заметные характеристики чисто виртуальных функций – они
• Цель объявления чисто виртуальной функции состоит в том, чтобы производные классы наследовали
Это в полной мере относится к функции Shape::draw, поскольку наиболее разумное требование ко всем объектам класса Shape заключается в том, что они должны быть отображены на дисплее, но Shape не может обеспечить разумной реализации этой функции по умолчанию. Алгоритм рисования эллипса очень сильно отличается от алгоритма рисования прямоугольника. Объявление Shape::draw можно интерпретировать как следующее сообщение разработчикам конкретных подклассов: «Вы должны обеспечить наличие функции draw, но у меня нет ни малейшего представления, как вы это собираетесь сделать».
Между прочим, дать определение чисто виртуальной функцииShape *ps = new Shape; // ошибка! Shape – абстрактный
Shape *ps1 = new Rectangle; // правильно
ps1->draw(); // вызов Rectangle::draw
Shape *ps2 = new Ellipse; // правильно
Ps2->draw(); // вызов Ellipse::draw
ps1->Shape::draw(); // вызов Shape::draw
ps2->Shape::draw(); // вызов Shape::drawКроме перспективы блеснуть перед приятелями-программистами во время вечеринки, знание этой особенности вряд ли даст вам что-то ценное. Тем не менее, как вы увидите ниже, возможность определения чисто виртуальной функции может быть использована в качестве механизма обеспечения более безопасной реализации по умолчанию обычных виртуальных функций.
Ситуация с обычными виртуальными функциями несколько отличается от ситуации с чисто виртуальными функциями. Как всегда, производные классы наследуют интерфейс функции, но обычные виртуальные функции традиционно обеспечивают реализацию, которую подклассы могут переопределить. Если вы на минуту задумаетесь над этим, то поймете, что:
• Цель объявлений обычной виртуальной функции – наследовать в производных классах
class Shape {
public:
virtual void error(const std::string& msg);
…
};Интерфейс говорит о том, что каждый класс должен поддерживать функцию, которую необходимо вызывать при возникновении ошибки, но каждый класс волен обрабатывать ошибки наиболее подходящим для себя образом. Если класс не предполагает производить специальные действия, он может просто положиться на обработку ошибок по умолчанию, которую предоставляет класс Shape. То есть объявление Shape::error говорит разработчикам производных классов: «Вы должны поддерживать функцию error, но если не хотите писать свою собственную, то можете рассчитывать просто использовать версию по умолчанию из класса Shape». Оказывается, иногда может быть опасно использовать обычные виртуальные функции, которые обеспечивают как интерфейс функции, так и ее реализацию по умолчанию. Для того чтобы понять, почему имеется такая вероятность, рассмотрим иерархию самолетов в компании XYZ Airlines. XYZ располагает самолетами только двух типов: модель A и модель B, и оба летают одинаково. В связи с этим разработчики XYZ проектирует такую иерархию:
class Airport {…}; // представляет аэропорты
class Airplane {
public:
virtual void fly(const Airport& destination);
…
};
void Airplane::fly(const Airport& destination)
{
}
class ModelA: public Airplane {…};
class ModelB: public Airplane {…};Чтобы выразить тот факт, что все самолеты должны поддерживать функцию fly, и для того чтобы засвидетельствовать, что для разных моделей, в принципе, могут потребоваться различные реализации fly, функция Airplane::fly объявлена виртуальной. При этом во избежание написания идентичного кода в классах ModelA и ModelB в качестве стандартного поведения используется тело функции Airplane::fly, которую наследуют как ModelA, так и ModelB.
Это классический пример объектно-ориентированного проектирования. Два класса имеют общее свойство (способ реализации fly), поэтому оно реализуется в базовом классе и наследуется обоими подклассами. Благодаря этому проект явным образом выделяет общие свойства, что позволяет избежать дублирования, благоприятствует проведению будущих модернизаций и упрощает долгосрочную эксплуатацию – иными словами, обеспечивает все, за что так ценится объектно-ориентированная технология. Программисты компании XYZ Airlines могут собой гордиться.
А теперь предположим, что дела XYZ идут в гору, и компания решает приобрести новый самолет модели C. Эта модель отличается от моделей A и B, в частности, тем, что летает по-другому.
Программисты компании XYZ добавляют в иерархию класс ModelC, но в спешке забывают переопределить функцию fly:class ModelB: public Airplane {
… // функция fly не объявлена
};В своем коде потом они пишут что-то вроде этого:
Airport PDX(…); // PDX – аэропорт возле моего дома
Airplane *pa = new ModelC;
…
pa->fly(PDX); // вызывается Airplane::fly!Назревает катастрофа: делается попытка отправить в полет объект ModelC, как если бы он принадлежал одному из классов ModelA или ModelB. Такой образ действия вряд ли может внушить доверие пассажирам. Проблема здесь заключается не в том, что Airplane::fly ведет себя определенным образом по умолчанию, а в том, что такое наследование допускает неявное применение этой функции для ModelC. К счастью, легко можно предложить подклассам поведение по умолчанию, но не предоставлять его, если они сами об этом не попросят. Трюк состоит в том, чтобы разделить
class Airplane {
public:
virtual void fly(const Airport& destination) = 0;
…
protected:
void defaultFly(const Airport& destination);
};
void Airplane::defaultFly(const Airport& destination)
{
Обратите внимание, что функция Airplane::fly преобразовна в чисто виртуальную. Она предоставляет интерфейс для полета. В классе Airplane присутствует и реализация по умолчанию, но теперь она представлена в форме независимой функции defaultFly. Классы, подобные ModelA и ModelB, которые хотят использовать поведение по умолчанию, просто выполняют встроенный вызов defaultFly внутри fly (см. также правило 30 о взаимодействии встраивания и виртуальных функций):
class ModelA: public Airplane {
public:
virtual void fly(const Airport& destination)
{ defaultFly(destination};}
…
};
class ModelB: public Airplane {
public:
virtual void fly(const Airport& destination)
{ defaultFly(destination};}
…
};Теперь для класса ModelC возможность случайно унаследовать некорректную реализацию fly исключена, поскольку чисто виртуальная функция в Airplane вынуждает ModelC создавать свою собственную версию fly.
class ModelC: public Airplane {
public:
virtual void fly(const Airport& destination)
…
};
void ModelC::fly(const Airport& destination)
{
Эта схема не обеспечивает «защиту от дурака» (программисты все же могут создать себе проблемы копированием/вставкой), но она более надежна, чем исходная. Что же касается функции Airplane::defaultFly, то она объявлена защищенной, поскольку действительно является деталью реализации класса Airplane и производных от него. Пассажиры теперь должны беспокоиться только о том, чтобы улететь, а не о том, как происходит полет.
Важно также то, что Airplane::defaultFly объявлена как невиртуальная функция. Это связано с тем, что никакой подкласс не должен ее переопределять – обстоятельство, которому посвящено правило 36. Если бы defaultFly была виртуальной, перед вами снова встала бы та же самая проблема: что, если некоторые подклассы забудут переопределить defaultFly должным образом?
Иногда высказываются возражения против идеи разделения функций на обеспечивающие интерфейс и реализацию по умолчанию, такие, например, как fly и defaultFly. Прежде всего, отмечают противники этой идеи, это засоряет пространство имен класса близкими названиями функций. Все же они соглашаются с тем, что интерфейс и реализация по умолчанию должны быть разделены. Как разрешить кажущееся противоречие? Для этого используется тот факт, что производные классы должны переопределять чисто виртуальные функции и при необходимости предоставлять свои собственные реализации. Вот как можно было бы использовать возможность определения чисто виртуальных функций в иерархии Airplane:class Airplane {
public:
virtual void fly(const Airport& destination) = 0;
…
};
void Airplane::fly(const Airport& destination) // реализация чисто
{ // виртуальной функции
}
class ModelA: pubic Airplane {
public:
virtual void fly(const Airport& destination)
{ Airplane::fly(destination);}
…
};
class ModelB: pubic Airplane {
public:
virtual void fly(const Airport& destination)
{ Airplane::fly(destination);}
…
};
class ModelC: pubic Airplane {
public:
virtual void fly(const Airport& destination);
…
};
void ModelC::fly(const Airport& destination)
{
Это практически такой же подход, как и прежде, за исключением того, что тело чисто виртуальной функции Airplane::fly заменяет собой независимую функцию Airplane::defaultFly. По существу, fly разбита на две основные составляющие. Объявление задает интерфейс (который
class Shape {
public:
int objectID() const;
…
};Когда функция-член объявлена невиртуальной, не предполагается, что она будет вести себя иначе в производных классах. В действительности невиртуальные функции-члены выражают
• Цель объявления невиртуальной функции – заставить производные классы наследовать как ее
Вы можете представлять себе объявление Shape::objectID как утверждение: «Каждый объект Shape имеет функцию, которая дает идентификатор объекта, и этот идентификатор всегда вычисляется одним и тем же способом. Этот способ задается определением функции Shape::objectID, и никакой производный класс не должен его изменять». Поскольку невиртуальная функция определяет инвариант относительно специализации, ее не следует переопределять в производных классах (см. правило 36).
Разница в объявлениях чисто виртуальных, просто виртуальных и невиртуальных функций позволяет точно указать, что, по вашему замыслу, должны наследовать производные классы: только интерфейс, интерфейс и реализацию по умолчанию либо интерфейс и обязательную реализацию соответственно. Поскольку эти типы объявлений обозначают принципиально разные вещи, следует тщательно подходить к выбору подходящего варианта при объявлянии функции-члена. При этом вы должны избегать двух ошибок, чаще всего совершаемых неопытными проектировщиками классов.
Первая ошибка – объявление всех функций невиртуальными. Это не оставляет возможности для маневров в производных классах; при этом больше всего проблем вызывают невиртуальные деструкторы (см. правило 7). Конечно, нет ничего плохого в проектировании классов, которые не предполагается использовать в качестве базовых. В этом случае вполне уместен набор из одних только невиртуальных функций-членов. Однако очень часто такие классы объявляются либо из-за незнания различий между виртуальными и невиртуальными функциями, либо в результате необоснованного беспокойства по поводу потери производительности при использовании виртуальных функций. Факт остается фактом: практически любой класс, который должен использоваться как базовый, будет содержать виртуальные функции (см. правило 7).
Если вы обеспокоены тем, во что обходится использование виртуальных функций, позвольте мне напомнить вам эмпирическое правило «80–20» (см. также правило 30), которое утверждает, что в типичной программе 80 % времени исполнения затрачивается на 20 % кода. Это правило крайне важно, потому что оно означает, что в среднем 80 % ваших функций могут быть виртуальными, не оказывая ощутимого влияния на общую производительность программы. Прежде чем начать беспокоиться о том, можете ли вы позволить себе использование виртуальных функций, убедитесь, что вы имеете дело с теми 20 % программы, для которых ваше решение окажет существенное влияние на производительность.
Другая распространенная ошибка – объявление
• Наследование интерфейса отличается от наследования реализации. При открытом наследовании производные классы всегда наследуют интерфейсы базовых классов.
• Чисто виртуальные функции означают, что наследуется только интерфейс.
• Обычные виртуальные функции означают, что наследуются интерфейс и реализация по умолчанию.
• Невиртуальные функции означают, что наследуются интерфейс и обязательная реализация.Правило 35: Рассмотрите альтернативы виртуальным функциям
Предположим, что вы работаете над видеоигрой и проектируете иерархию игровых персонажей. В вашей игре будут использоваться разные варианты сражений, персонажи могут подвергаться ранениям или иначе терять жизненные силы. Поэтому вы решаете включить в класс функцию-член healthValue, которая возвращает целочисленное значение, показывающее, сколько жизненных сил осталось у персонажа. Поскольку разные персонажи могут вычислять свою жизненную силу по-разному, то представляется естественным объявить функцию healthValue следующим образом:
class GameCharacter {
public:
virtual void healthValue() const; // возвращает жизненную силу персонажа
… // в производных классах можно
}; // переопределитьТот факт, что healthValue не объявлена как чисто виртуальная, наводит на мысль, что существует алгоритм вычисления жизненной силы по умолчанию (см. правило 34).
Это очевидный подход к проектированию, и в каком-то смысле в очевидности и заключается его слабость. Поскольку решение кажется совершенно естественным, не исключено, что вы забудете уделить должное внимание рассмотрению альтернатив. Чтобы помочь вам выбраться из колеи, рассмотрим некоторые другие подходы к проблеме.
Реализация паттерна««Шаблонный метод» с помощью идиомы невиртуального интерфейса
Начнем с интересной концепции, которая утверждает, что виртуальные функции почти всегда должны быть закрытыми. Сторонники этой школы предполагают, что правильно было бы оставить функцию-член healthValue открытой, но сделать ее невиртуальной и заставить вызывать закрытую виртуальную функцию, которая и выполнит реальную работу. Назовем эту функцию doHealthValue:class GameCharacter {
public:
int healthValue() const // производные классы не переопределяют
{ // эту функцию, см. правило 36
… // выполнить предварительные действия –
// см. ниже
int retVal = doHealthValue(); // выполнить реальную работу
… // выполнить завершающие действия –
// см. ниже
return retVal;
}
…
private:
virtual int doHealthValue() const // производные классы могут
{ // переопределить эту функцию
… // алгоритм по умолчанию для вычисления
} // жизненной силы персонажа
};В этом коде (и ниже в данном правиле) я привожу тела функций в определениях классов. Как следует из правила 30, тем самым они неявно объявляются встроенными. Я поступаю так лишь для того, чтобы смысл кода было проще понять. Описываемый подход к проектированию никак не зависит от того, будут ли функции встроенными или нет.
Основная идея этого подхода – дать возможность клиентам вызывать закрытые виртуальные функции опосредованно, через открытые невиртуальные функции-члены – известен под названием
Преимущество идиомы NVI таится в коде, скрытом за комментариями «выполнить предварительные действия» и «выполнить завершающие действия». Подразумевается, что некоторый код гарантированно будет выполнен перед вызовом виртуальной функции, выполняющей реальную работу, и после возврата из нее. Таким образом, обертка настроит контекст перед вызовом виртуальной функции создания, а после возврата произведет очистку. Например, «предварительные действия» могут заключаться в захвате мьютекса, записи в протокол, проверке инвариантов класса и выполнении предусловий и т. п. В состав «завершающих действий» могут входить освобождение мьютекса, проверка постусловий функции, повторная проверка инвариантов класса и т. п. Будет затруднительно проделать все это, если вы позволите клиентам вызывать виртуальную функцию непосредственно.
Возможно, вас поразила следующая странность: идиома NVI предполагает, что производные классы-наследники переопределяют закрытые виртуальные функции, которых они и вызывать-то не могут! Но здесь нет противоречия. Переопределяя виртуальную функцию, мы говорим,
Реализация паттерна «Стратегия» посредством указателей на функции
Идиома NVI – это интересная альтернатива открытым виртуальным функциям, но с точки зрения проектирования она дает не слишком много. В конце концов, мы по-прежнему используем виртуальные функции для вычисления жизненной силы каждого персонажа. С точки зрения проектирования гораздо более сильным было бы утверждение о том, что вычисление жизненной силы персонажа не зависит от типа персонажа, что такие вычисления вообще не являются свойством персонажа как такового. Например, мы можем потребовать, чтобы конструктору каждого персонажа передавался указатель на функцию, которая вызывалась бы для вычисления его жизненной силы:
class GameCharacter; // опережающее объявление
// функция алгоритма по умолчанию для вычисления жизненной силы персонажа
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter {
public:
typedef int (*HealthCalcFunc)(const GameCharacter&);
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
: healthFunc(hcf)
{}
int healthValue() const
{ return healthFunc(*this);}
…
private:
HealthCalcFunc healthFunc;
};Это простой пример применения другого распространенного паттерна проектирования – «Стратегия» (Strategy). По сравнению с подходами, основанными на виртуальных функциях в иерархии GameCharacter, он предоставляет некоторые любопытные возможности, повышающие гибкость: • Разные экземпляры персонажей одного и того же типа могут иметь разные функции вычисления жизненной силы. Например:
class EvilBadGay: public GameCharacter {
public:
explicit EvilBadGay(HealthCalcFunc hcf = defaultHealthCalc)
: GameCharacter(hcf)
{…}
…
};
int loseHealthQuickly(const GameCharacter&); // функции вычисления
int loseHealthSlowly(const GameCharacter&); // жизненной силы
// с разным поведением
EvilBadGay ebg1(loseHealthQuickly); // однотипные персонажи
EvilBadGay ebg2(loseHealthSlowly); // с разным поведением
// относительно здоровья• Функция вычисления жизненной силы для одного и того же персонажа может изменяться во время исполнения. Например, класс GameCharacter мог бы предложить функцию-член setHealthCalculator, которая позволяет заменить текущую функцию вычисления жизненной силы.
С другой стороны, тот факт, что функция вычисления жизненной силы больше не является функцией-членом иерархии GameCharacter, означает, что она не имеет специального доступа к внутреннему состоянию объекта, чью жизненную силу вычисляет. Например, defaultHealthCalc не имеет доступа к закрытым частям EvilBadGay. Это не страшно, если жизненная сила персонажа может быть вычислена с помощью его открытого интерфейса, но для максимально точных расчетов может понадобиться доступ к закрытой информации. На самом деле такая проблема может возникать всегда, когда некоторая функциональность выносится из класса наружу (например, из функций-членов в свободные функции, не являющиеся друзьями класса, или в функции-члены другого класса, не дружественного данному). Она будет встречаться в настоящем правиле и далее, потому что все прочие проектные решения, которые нам еще предстоит рассмотреть, тоже включают использование функций, находящихся вне иерархии GameCharacter.
Общее правило таково: единственный способ рарешить функциям, не являющимся членами класса, доступ к его закрытой части – ослабить степень инкапсуляции. Например, класс может объявлять функции-нечлены в качестве друзей либо предоставлять открытые функции для доступа к тем частям реализации, которые лучше было бы оставить закрытыми. Имеет ли смысл жертвовать инкапсуляцией ради выгоды от использования указателей на функции вместо виртуальных функций (например, чтобы иметь разные функции жизненной силы для разных объектов и динамически менять их), решать вам в каждом конкретном случае.
Реализация паттерна «Стратегия» посредством класса tr::function
Если вы привыкли к шаблонам и их применению для построения неявных интерфейсов (см. правило 41), то применение указателей на функции покажется вам не слишком гибким решением. Почему вообще для вычисления жизненной силы нужно обязательно использовать функцию, а не что-то
class GameCharacter; // как раньше
int defaultHealthCalc(const GameCharacter& gc); // как раньше
class GameCharacter {
public:
// HealthCalcFunction – это любая вызываемая сущность, которой можно
// передать в качестве параметра нечто, совместимое с GameCharacter,
// и которая возвращает нечто, совместимое с int; подробности см. ниже
typedef std::tr1::function
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
: healthFunc(hcf)
{}
int healthValue() const
{ return healthFunc(*this);}
…
private:
HealthCalcFunc healthFunc;
};Как видите, HealthCalcFunc – это typedef, описывающий конкретизацию шаблона tr1::function. А значит, он работает как обобщенный указатель на функцию. Посмотрим внимательнее, как определен тип HealthCalcFunc:
std::tr1::function
Здесь я выделил «целевую сигнатуру» данной конкретизации tr1::function. Словами ее можно описать так: «функция, принимающая ссылку на объект типа const GameCharacter и возвращающая int». Объект типа HealthCalcFunc может содержать любую вызываемую сущность, чья сигнатура совместима с заданной. Быть совместимой в данном случае означает, что параметр можно неявно преобразовать в const GameCharacter&, а тип возвращаемого значения неявно конвертируется в int. Если сравнить с предыдущим вариантом дизайна (где GameCharacter включал в себя указатель на функцию), то вы не обнаружите почти никаких отличий. Единственная разница в том, что GameCharacter теперь содержит объект типа tr1::function –
short calcHealth(const gameCharacter&); // функция вычисления
// жизненной силы;
// она возвращает не int
stuct HealthCalculator { // класс функциональных
int operator()(const GameCharacter&) const // объектов, вычисляющих
{…} // жизненную силу
};
class GameLevel {
public:
float health(const GameCharacter&) const; // функция-член для
… // вычисления жизненной
}; // силы; возвращает не int
class EvilBadGay: public GameCharacter { // как раньше
…
};
class EyeCandyCharacter: public GameCharacter { // другой тип персонажей;
… // предполагается такой же
}; // конструктор как
// у EvilBadGay
EvilBadGay ebg1(calcHealh); // персонаж использует
// функцию вычисления
// жизненной силы
EyeCandyCharacter ecc1(HealthCalculator()); // персонаж использует
// функциональный объект
// вычисления жизненной
// силы
GameLevel currentLevel;
…
EvilBadGay ebg2( // персонаж использует
std::tr1::bind(&GameLevel::health, // функцию-член для
currentLevel, // вычисления жизненной
_1) // силы; подробности
); // см. нижеЛично я поражаюсь тому, какие удивительные вещи позволяет делать шаблон tr1::function. Если вы не разделяете моих чувств, то не исключено, что просто не понимаете, для чего используется tr1::bind в определении ebg2. Позвольте мне объяснить.
Мы хотим сказать, что для вычисления жизненной силы персонажа ebg2 следует использовать функцию-член класса GameLevel. Но из объявления GameLevel::health следует, что она должна принимать один параметр (ссылку на GameCharacter), а на самом деле она принимает два, потому что имеется еще неявный параметр типа GameLevel – тот, на который внутри нее указывает this. Все функции вычисления жизненной силы принимают лишь один параметр: ссылку на персонажа GameCharacter, чья жизненная сила вычисляется. Если мы используем функцию GameLevel::health, то должны каким-то образом «адаптировать» ее, чтобы вместо двух параметров (GameCharacter и GameLevel) она принимала только один (GameCharacter). В этом примере мы хотим для вычисления здоровья ebg2 в качестве параметра типа GameLevel всегда использовать объект currentLevel, поэтому «привязываем» его как первый параметр при вызове GameLevel::health. Именно в этом и заключается смысл вызова tr1::bind: указать, что функция вычисления жизненной силы персонажа ebg2 должна в качестве объекта типа GameLevel использовать currentLevel.
Я пропускаю целый ряд подробностей, к примеру: почему «_1» означает «использовать currentLevel в качестве объекта GameLevel при вызове GameLevel::health для ebg2». Эти детали не столь сложны, к тому же они не имеют прямого отношения к основной идее, которую я хочу продемонстрировать, а именно: используя tr1::function вместо указателя на функцию, мы позволяем пользователям применять
«Классический» паттерн «Стратегия»
Если вас больше интересуют паттерны проектирования, чем собственно язык C++, то более традиционный подход к реализации паттерна «Стратегия» состоит в том, чтобы сделать функцию вычисления жизненной силы виртуальной функцией-членом в классах, принадлежащих отдельной иерархии. Эта иерархия может выглядеть примерно так:
class GameCharacter; // опережающее объявление
class HealthCalcFunc {
public:
…
virtual int calc(const GameCharacter& gc) const
{…}
…
};
HealthCalcFunc defaultHealthCalc;
class GameCharacter {
public:
explicit GameCharacter(HealhCalcFunc *phfc = &defaultHealthCalc)
:pHealtCalc(pfhc)
{}
int healthValue() const
{ return pHealthCalc->calc(*this);}
…
private:
HealhCalcFunc * pHealtCalc;
};Этот подход привлекателен тем, что программисты, знакомые со «стандартной» реализацией паттерна «Стратегия», сразу видят, что к чему. К тому же он предоставляет возможность модифицировать существующий алгоритм вычисления жизненной силы путем добавления производных классов в иерархию HealthCalcFunc.
Резюме
Из этого правила вы должны извлечь одну практическую рекомендацию: размышляя над тем, как решить стоящую перед вами задачу, имеет смысл рассматривать не только виртуальные функции. Вот краткий перечень предложенных альтернатив:
• Применение идиомы невиртуального интерфейса (NVI), варианта паттерна проектирования «Шаблонный Метод». Смысл ее в том, чтобы обернуть открытыми невиртуальными функциями-членами вызовы менее доступных виртуальных функций.
• Замена виртуальных функций членами данных – указателями на функции. Это упрощенное проявление паттерна проектирования «Стратегия».
• Замена виртуальных функций членами данных – tr1::function. Это позволяет применять любую вызываемую сущность, сигнатура которой совместима с той, что вам нужна. Это тоже форма паттерна проектирования «Стратегия».
• Замена виртуальных функций из одной иерархии виртуальными функциями из другой иерархии. Это традиционная реализация паттерна проектирования «Стратегия».
Это не исчерпывающий список альтернатив виртуальным функциям, но его должно хватить, чтобы убедить вас в том, что такие альтернативы
Чтобы не застрять в колее на дороге объектно-ориентированного проектирования, стоит время от времени резко поворачивать руль. Путей много. Потратьте время на знакомство с ними.
• К числу альтернатив виртуальным функциям относятся идиома NVI и различные формы паттерна проектирования «Стратегия». Идиома NVI сама по себе – это пример реализации паттерна «Шаблонный Метод».
• Недостаток переноса функциональности из функций-членов вовне класса заключается в том, что функциям-нечленам недостает прав доступа к закрытым членам класса.
• Объекты tr1::function работают как обобщенные указатели на функции. Такие объекты поддерживают все вызываемые сущности, совместимые с сигнатурой целевой функции.Правило 36: Никогда не переопределяйте наследуемые невиртуальные функции
Предположим, я сообщаю вам, что класс D открыто наследует классу B и что в классе B определена открытая функция-член mf. Ее параметры и тип возвращаемого значения не важны, поэтому давайте просто предположим, что это void. Другими словами, я говорю следующее:
class B {
public:
void mf();
…
};
class D: public B {…};Даже ничего не зная о B, D или mf, имея объект x типа D,
D x; // x – объект типа D
вы, наверное, удивитесь, когда код
B *pB = &x // получить указатель на x PB->mf(); // вызвать mf с помощью ука
поведет себя иначе, чем
D *pD = &x // получить указатель на x PD->mf(); // вызвать mf через указатель
Ведь в обоих случаях вы вызываете функцию-член объекта x. Поскольку вы имеете дело с одной и той же функцией и одним и тем же объектом, поведение в обоих случаях должно быть одинаково, не так ли? Да, так должно быть, но не всегда бывает. В частности, вы получите иной результат, если mf невиртуальна, а D определяет собственную версию mf:
class D: public B {
public:
void mf(); // скрывает B:mf; см. правило 33
…
};
PB->mf(); // вызвать B::mf
PD->mf(); // вызвать D::mfПричина такого «двуличного» поведения заключается в том, что невиртуальные функции, подобные B::mf и D::mf, связываются статически (см. правило 37). Это означает, что когда pB объявляется как указатель на объект тип B, невиртуальные функции, вызываемые посредством pB, – это
С другой стороны,
В итоге, если вы пишете класс D и переопределяете невиртуальную функцию mf, наследуемую от класса B, есть вероятность, что объекты D будут вести себя совершенно непредсказуемо. В частности, любой конкретный объект D может вести себя при вызове mf либо как B, либо как D, причем определяющим фактором будет не тип самого объекта, а лишь тип указателя на него. При этом ссылки в этом отношении ведут себя ничем не лучше указателей.
Это все, что относится к «прагматической» аргументации. Теперь, я уверен, требуется некоторое теоретическое обоснование запрета на переопределение наследуемых невиртуальных функций. С удовольствием его представлю.
В правиле 32 объясняется, что открытое наследование всегда означает «является разновидностью», а в правиле 34 говорится, почему объявление невиртуальной функции в классе определяет инвариант относительно специализации этого класса. Если вы примените эти наблюдения к классам B и D и невиртуальной функции B: mf, то получите следующее:
• Все, что применимо к объектам B, применимо и к объектам D, поскольку каждый объект D также является объектом B;
• Подклассы B должны наследовать как интерфейс, так и реализацию mf, потому что mf невиртуальна в B.
Теперь, если D переопределяет mf, возникает противоречие. Если класс D
Независимо от того, какой из аргументов применим в вашем случае, чем-то придется пожертвовать, но при любых обстоятельствах запрет на переопределение наследуемых невиртуальных функций остается в силе.
Если при чтении этого правила у вас возникло ощущение
Правило 37: Никогда не переопределяйте наследуемое значение аргумента функции по умолчанию
Давайте с самого начала упростим обсуждение. Есть только два типа функций, которые можно наследовать: виртуальные и невиртуальные. Но переопределять наследуемые невиртуальные функции в любом случае ошибочно (см. правило 36), поэтому мы вполне можем ограничить наше обсуждение случаем наследования
В этих обстоятельствах мотивировка настоящего правила становится достаточно очевидной: виртуальные функции связываются динамически, а значения аргументов по умолчанию – статически.
Что это значит? Вы говорите, что уже позабыли, в чем заключается разница между статическим и динамическим связыванием? (Кстати, статическое связывание называют еще
// классы для представления геометрических фигур
class Shape {
public:
enum ShapeColor { Red, Green, Blue };
// все фигуры должны предоставлять функцию для рисования
virtual void draw(ShapeColor color = Red) const = 0;
…
};
class Rectangle: public Shape {
public:
// заметьте, другое значение параметра по умолчанию – плохо!
virtual void draw(ShapeColor color = Green) const;
…
};
class Circle: public Shape {
public:
virtual void draw(ShapeColor color) const;
…
};Графически это можно представить так:
Shape *ps; // статический тип – Shape*
Shape *pc = new Circle; // статический тип – Shape*
Shape *pr = new Rectangle; // статический тип – Shape*В этом примере ps, pc и pr объявлены как указатели на Shape, так что для всех них он и будет выступать в роли статического типа. Отметим, что не совершенно безразлично, на что они указывают
ps = pc; // динамический тип ps теперь Circle* ps = pr; // динамический тип ps теперь Rectangle*
Виртуальные функции
pc->draw(Shape::Red); // вызывается Circle::draw(Shape::Red) pr->draw(Shape::Red); // вызывается Rectangle::draw(Shape::Red)
Я знаю, что все это давно известно, и вы, несомненно, разбираетесь в виртуальных функциях. Самое интересное начинается, когда мы подходим к виртуальным функциям с аргументами, принимающими значения по умолчанию, поскольку, как я уже сказал, виртуальные функции связываются динамически, а аргументы по умолчанию – статически. Следовательно, вы можете прийти к тому, что будете вызывать виртуальную функцию, определенную в
pr->draw(); // вызывается Rectangle::draw(Shape::Red)!
В этом случае динамический тип pr – это Rectangle*, поэтому, как вы и ожидали, вызывается виртуальная функция класса Rectangle. Для функции Rectangle::draw значение аргумента по умолчанию – Green. Но поскольку статический тип pr – Shape*, то значения аргумента по умолчанию берутся из класса Shape, а не Rectangle! В результате получаем вызов, состоящий из странной, совершенно неожиданной комбинации объявлений draw из классов Shape и Rectangle.
Тот факт, что ps, pc и pr являются указателями, не играет никакой роли. Будь они ссылками, результат остался бы таким же. Важно лишь, что draw – виртуальная функция, и значение по умолчанию одного из ее аргументов переопределено в производном классе.
Почему C++ настаивает на таком диковинном поведении? Ответ на этот вопрос связан с эффективностью исполнения программы. Если бы значения аргументов по умолчанию связывались динамически, то компилятору пришлось бы найти способ во время исполнения определять, какое значение по умолчанию должно быть у параметра виртуальной функции, что медленнее и технически сложнее нынешнего механизма. Решение было принято в пользу скорости и простоты реализации, в результате чего вы можете пользоваться преимуществами эффективного выполнения кода программы. Но если не последуете совету, изложенному в настоящем правиле, то программа будет вести себя нелогично.
Все это прекрасно, но посмотрите, что получится, если, пытаясь следовать этому правилу, вы включите аргументы со значениями по умолчанию в функцию-член, объявленную и в базовом, и в производном классах:class Shape {
public:
enum ShapeColor { Red, Green, Blue };
virtual void draw(ShapeColor color = Red) const = 0;
…
};
class Rectangle: public Shape {
public:
virtual void draw(ShapeColor color = Red) const;
…
};Гм, дублирование кода! Хуже того: дублирование кода с зависимостями: если значение аргумента по умолчанию изменится в Shape, придется изменить его и во всех производных классах. В противном случае дело закончится переопределением наследуемого значения по умолчанию. Что делать? Когда у вас возникает проблема с тем, чтобы заставить виртуальную функцию вести себя так, как вы хотите, то благоразумнее рассмотреть альтернативные решения, и в правиле 35 таких альтернатив приведено немало. Одна из них –
class Shape {
public:
enum ShapeColor( Red, Green, Blue };
void draw(ShapeColor color = Red) const // теперь – невиртуальная
{
doDraw(color); // вызов виртуальной функции
}
…
private:
virtual void doDraw(ShapeColor color) const = 0; // реальная работа
}; // выполняется
// в этой функции
class Rectangle: public Shape {
public:
…
private:
virtual void doDraw(ShapeColor color) const // обратите внимание
… // на отсутствие у аргумента
}; // значения по умолчаниюПоскольку невиртуальные функции никогда не должны переопределяться в производных классах (см. правило 36), то ясно, что при таком подходе значение по умолчанию для параметра color функции draw всегда будет Red.
Правило 38: Моделируйте отношение «содержит» или «реализуется посредством» с помощью композиции
class Address {…}; // адрес проживания
class PhoneNumber {…};
class Person {
public:
…
private:
std::string name; // вложенный объект
Address address; // то же
PhoneNumber voiceNumber; // то же
PhoneNumber faxNumber; // то же
};В данном случае объекты класса Person включают в себя объекты классов string, Address и PhoneNumber. Термин
В правиле 32 объясняется, что открытое наследование означает «класс является разновидностью другого класса». У композиции тоже есть семантика, даже две: «содержит» или «реализуется посредством». Дело в том, что в своих программах вы имеете дело с двумя различными областями. Некоторые программные объекты описывают сущности из моделируемого мира: людей, автомобили, видеокадры и т. п. Такие объекты являются частью
Вышеприведенный класс Person демонстрирует отношение типа «содержит». Объект Person имеет имя, адрес, номера телефона и факса. Нельзя сказать, что человек
Чуть сложнее провести различие между отношениями «является» и «реализуется посредством». Например, предположим, что вам нужен шаблон для классов, представляющих множества произвольных объектов, то есть наборов без дубликатов. Поскольку повторное использование – прекрасная вещь, то сразу возникает желание обратиться к шаблону set из стандартной библиотеки. В конце концов, зачем писать новый шаблон, когда есть возможность использовать уже готовый?
К сожалению, реализации set обычно влекут за собой накладные расходы – по три указателя на элемент. Связано это с тем, что множества обычно реализованы в виде сбалансированных деревьев поиска, гарантирующих логарифмическое время поиска, вставки и удаления. Когда быстродействие важнее, чем объем занимаемой памяти, это вполне разумное решение, но конкретно для вашего приложения выясняется, что экономия памяти более существенна. Поэтому стандартный шаблон set для вас неприемлем. Похоже, нужно писать свой собственный.
Тем не менее повторное использование – прекрасная вещь. Будучи экспертом в области структур данных, вы знаете, что среди многих вариантов реализации множеств есть и такой, который базируется на применении связанных списков. Вы также знаете, что в стандартной библиотеке C++ есть шаблон list, поэтому решаете им воспользоваться (повторно).
В частности, вы решаете, что создаваемый вами шаблон Set должен наследовать от list. То есть Settemplate
До сих пор все вроде бы шло хорошо, но, если присмотреться, в код вкралась ошибка. Как объясняется в правиле 32, если D является разновидностью B, то все, что верно для B, должно быть верно также и для D. Однако объект list может содержать дубликаты, поэтому если значение 3051 вставляется в list
template class Set { // для определения Set public: bool member(const T& item) const; void insert(const T& item); void remove(const T& item); std::size_t size() const; private: std::list
Функции-члены класса Set могут опереться на функциональность, предоставляемую list и другими частями стандартной библиотеки, поэтому их реализацию нетрудно написать, коль скоро вам знакомы основы программирования с применением библиотеки STL:
template bool Set { return std::find(rep.begin(), rel.end(), item) != rep.end(); } template void Set { if(!member(item)) rep.push_back(item); } template void Set { typename std::list std::find(rep.begin(), rep.end(), item); // информацию о “typename” if(it != rep.end()) rep.erase(it); } template std::size_t Set { return rep.size();
Эти функции достаточно просты, чтобы стать кандидатами для встраивания, хотя перед принятием окончательного решения стоит еще раз прочитать правило 30.
Стоит отметить, что интерфейс Set лучше отвечал бы требованиям правила 18 (проектировать интерфейсы так, чтобы их легко было использовать правильно и трудно – неправильно), если бы он следовал соглашениям, принятым для STL-контейнеров, но для этого пришлось бы добавить в класс Set столько кода, что в нем потонула бы основная идея: проиллюстрировать взаимосвязь между Set и list. Поскольку тема настоящего правила – именно эта взаимосвязь, то мы пожертвуем совместимостью с STL ради наглядности. Недостатки интерфейса Set не должны, однако, затенять тот неоспоримый факт, что отношение между классами Set и list – не «является» (как это вначале могло показаться), а «реализовано посредством».
• Семантика композиции кардинально отличается от семантики открытого наследования.
• В предметной области композиция означает «содержит». В области реализации она означает «реализовано посредством».Правило 39: Продумывайте подход к использованию закрытого наследования
В правиле 32 показано, что C++ рассматривает открытое наследование как отношение типа «является». В частности, говорится, что компиляторы, столкнувшись с иерархией, где класс Student открыто наследует классу Person, неявно преобразуют объект класса Student в объект класса Person, если это необходимо для вызова функций. Очевидно, стоит еще раз привести фрагмент кода, заменив в нем открытое наследование закрытым:
class Person {…}
class Student: private Person {…} // теперь наследование закрытое
void eat(const Person& p); // все люди могут есть
void study(const Student& s); // только студенты учатся
Person p; // p – человек (Person)
Student s; // s – студент (Student)
eat(p); // нормально, p – типа Person
eat(s); // ошибка! Student не является объектом
// PersonЯсно, что закрытое наследование не означает «является». А что же тогда оно означает?
«Стоп! – восклицаете вы. – Прежде чем говорить о значении, давайте поговорим о поведении. Как ведет себя закрытое наследование?» Первое из правил, регламентирующих закрытое наследование, вы только что наблюдали в действии: в противоположность открытому наследованию компиляторы в общем случае не преобразуют объекты производного класса (такие как Student) в объекты базового класса (такие как Person). Вот почему вызов eat для объекта s ошибочен. Второе правило состоит в том, что члены, наследуемые от закрытого базового класса, становятся закрытыми, даже если в базовом классе они были объявлены как защищенные или открытые.
Это то, что касается поведения. А теперь вернемся к значению. Закрытое наследование означает «реализовано посредством…». Делая класс D закрытым наследником класса B, вы поступаете так потому, что заинтересованы в использовании некоторого когда, уже написанного для B, а не потому, что между объектами B и D существует некая концептуальная взаимосвязь. Таким образом, закрытое наследование – это исключительно прием реализации. (Вот почему все унаследованное от закрытого базового класса становится закрытым и в вашем классе: это не более чем деталь реализации). Используя терминологию из правила 34, можно сказать, что закрытое наследование означает наследование
Утверждение, что закрытое наследование означает «реализован посредством», вероятно, слегка вас озадачит, поскольку в правиле 38 указывалось, что композиция может означать то же самое. Как же сделать выбор между ними? Ответ прост: используйте композицию, когда можете, а закрытое наследование – когда обязаны так поступить. А в каких случаях вы
Предположим, что вы работаете над приложением, в котором есть объекты класса Widget, и решили как следует разобраться с тем, как они используются. Например, интересно не только знать, насколько часто вызываются функции-члены Widget, но еще и как частота обращений к ним изменяется во времени. Программы, в которых есть несколько разных фаз исполнения, могут вести себя по-разному в каждой фазе. Например, функции, используемые компилятором на этапе синтаксического анализа, значительно отличаются от функций, вызываемых во время оптимизации и генерации кода.
Мы решаем модифицировать класс Widget так, чтобы отслеживать, сколько раз вызывалась каждая функция-член. Во время исполнения мы будем периодически считывать эту информацию, возможно, вместе со значениями каждого объекта Widget и другими данными, которые сочтем необходимым. Для этого понадобится установить таймер, который будет извещать нас о том, когда наступает время собирать статистику использования.
Предпочитая повторное использование существующего кода написанию нового, мы тщательно просмотрим наш набор инструментов и найдем следующий класс:class Timer {
public:
explicit Timer(int tickFrequency);
virtual void onTick() const; // автоматически вызывается
// при каждом тике
…
};Это как раз то, что мы искали. Объект Timer можно настроить для срабатывания с любой частотой, и при каждом «тике» будет вызываться виртуальная функция. Мы можем переопределить эту виртуальную функцию так, чтобы она проверяла текущее состояние Widget. Отлично!
Для того чтобы класс Widget переопределял виртуальную функцию Timer, он должен наследовать Timer. Но открытое наследование в данном случае не подходит. Ведь Widget не является разновидностью Timer. Пользователи Widget не должны иметь возможности вызывать onTick для объекта Widget, потому что эта функция не является частью концептуального интерфейса этого класса. Если разрешить вызов подобной функции, то пользователи получат возможность работать с интерфейсом Widget некорректно, что очевидно нарушает рекомендацию из правила 18 о том, что интерфейсы должно быть легко применять правильно и трудно – неправильно. Открытое наследование в данном случае не подходит.
Потому мы будем наследовать закрыто:class Widget: private Timer {
private:
virtual void onTick() const; // просмотр данных об использовании
… // Widget и т. п.
};Благодаря закрытому наследованию открытая функция onTick класса Timer становится закрытой в Widget, и после переопределения мы ее такой и оставим. Опять же, если поместить onTick в секцию public, то это введет в заблуждение пользователей, заставляя их думать, будто ее можно вызвать, а это идет вразрез с правилом 18.
Это неплохое решение, но стоит отметить, что закрытое наследование не является здесь строго необходимым. Никто не мешает вместо него использовать композицию. Мы просто объявим закрытый вложенный класс внутри Widget, который будет открыто наследовать классу Timer и переопределять onTick, а затем поместим объект этого типа внутрь Widget. Вот эскиз такого подхода:
class Widget {
private:
class WidgetTimer: public Timer {
public:
virtual void onTick() const;
…
};
WidgetTimer timer;
…
};Этот дизайн сложнее того, что использует только закрытое наследование, потому что здесь используются и открытое наследование, и композиция, а ко всему еще и новый класс (WidgetTimer). Честно говоря, я показал этот вариант в первую очередь для того, чтобы напомнить о существовании различных подходов к решению одной задачи. Стоит привыкать к тому, чтобы не ограничиваться единственным решением (см. также правило 35). Тем не менее я могу представить две причины, по которым иногда имеет смысл предпочесть открытое наследование в сочетании с композицией закрытому наследованию.
Во-первых, вы можете спроектировать класс Widget так, чтобы ему можно было наследовать, но при этом запретить производным классам переопределять функцию onTick. Если Widget наследуется от Timer, то это невозможно, даже в случае закрытого наследования. (Напомню, что согласно правилу 35 производные классы могут переопределять виртуальные функции, даже если не могут вызывать их). Но если WidgetTimer – это закрытый класс внутри Widget, который наследует Timer, то производные от Widget классы не имеют доступа к WidgetTimer, а значит, не могут ни наследовать ему, ни переопределять его виртуальные функции. Если вам приходилось программировать на языках Java или C# и вы не обратили внимания на то, как можно запретить производным классам переопределять функции базового (с помощью ключевого слова final в Java или sealed в C#), то теперь вы знаете, как добиться примерно того же эффекта в C++.
Во-вторых, вы можете захотеть минимизировать зависимости Widget на этапе компиляции. Если Widget наследует классу Timer, то определение Timer должно быть доступно во время компиляции Widget, поэтому файл, определяющий Widget, вероятно, должен содержать директиву #include "Timer.h". С другой стороны, если WidgetTimer вынести из Widget, а в Widget оставить только указатель на WidgetTimer, тогда Widget сможет обойтись простым объявлением класса WidgetTimer; так что необходимость включать заголовочный файл для Timer будет устранена. Для больших систем такая развязка может оказаться важной. Подробнее о минимизации зависимостей на этапе компиляциии см. правило 31.
Я уже отмечал, что закрытое наследование удобно прежде всего тогда, когда предполагаемым производным классам нужен доступ к защищенным частям базового класса или у них может возникнуть потребность в переопределении одной или более виртуальных функций, но концептуальное отношение между этими классами выражается не словами «является разновидностью», а «реализован посредством». Я также говорил, что существуют ситуации, в частности, связанные с оптимизацией использования памяти, когда закрытое наследование оказывается предпочтительнее композиции.
Граничный случай – действительно граничный: речь идет о классах, в которых вообще нет никаких данных. Такие классы не имеют ни нестатических членов-данных, ни виртуальных функций (поскольку наличие этих функций означает добавление указателя vptr в каждый объект – см. правило 7), ни виртуальных базовых классов (поскольку в этом случае тоже имеют место дополнительные расходы памяти – см. правило 40). Концептуально, объекты такихclass Empty {}; // не имеет данных, поэтому объекты
// не должны занимать памяти
class HoldsAnInt { // память, по идее, нужна только для int
private:
int x;
Empty e; // не должен занимать память
};оказывается, что sizeof(HoldsAnlnt) > sizeof(int); член данных Empty занимает какую-то память. Для большинства компиляторов sizeof(Empty) будет равно 1, потому что требование C++ о том, что не должно быть объектов нулевой длины, обычно удовлетворяется молчаливой вставкой одного байта (char) в такой «пустой» объект. Однако из-за необходимости выравнивания (см. правило 50) компилятор может оказаться вынужден дополнить классы, подобные HoldsAnInt, поэтому вполне вероятно, что размер объектов HoldsAnInt увеличится больше чем на char, скорее всего, речь может идти о росте на размер int. На всех компиляторах, где я тестировал, происходило именно так. Возможно, вы обратили внимание, что, говоря о ненулевом размере, я упомянул «автономные» объекты. Это ограничение не относится к тем частям производного класса, которые унаследованы от базового, поскольку они уже не считаются «автономными». Если вы наследуете Empty вместо того, чтоб включать его,
class HoldsAnInt: private Empty {
private:
int x;
};то почти наверняка обнаружите, что sizeof(HoldsAnlnt) = sizeof(int). Это явление известно как
На практике «пустые» классы на самом деле не совсем пусты. Хотя они и не содержат нестатических данных-членов, но часто включают typedefbi, перечисления, статические члены-данные, или невиртуальные функции. В библиотеке STL есть много технически пустых классов, которые содержат полезные члены (обычно typedef). К их числу относятся, в частности, базовые классы unary_function и binary_function, которым обычно наследуют классы определяемых пользователями функциональных объектов. Благодаря широкому распространению реализаций EBO такое наследование редко увеличивает размеры производных классов.
Но вернемся к основам. Большинство классов не пусты, поэтому EBO редко может служить оправданием закрытому наследованию. Более того, в большинстве случаев наследование выражает отношение «является», а это признак открытого, а не закрытого наследования. Как композиция, так и закрытое наследование выражают отношение «реализован посредством», но композиция проще для понимания, поэтому использует ее всюду, где возможно.
Закрытое наследование чаще всего оказывается разумной стратегией проектирования, когда вы имеете дело с двумя классами, не связанными отношением «является», причем один из них либо нуждается в доступе к защищенным членам другого, либо должен переопределять одну или несколько виртуальных функций последнего. И даже в этом случае мы видели, что сочетание открытого наследования и композиции часто помогают реализовать желаемое поведение, хотя и ценой некоторого усложнения. Говоря о
• Закрытое наследование означает «реализован посредством». Обычно этот вариант хуже композиции, но все же приобретает смысл, когда производный класс нуждается в доступе к защищенным членам базового класса или должен переопределять унаследованные виртуальные функции.
• В отличие от композиции, закрытое наследование позволяет проводить оптимизацию пустого базового класса. Это может оказаться важным для разработчиков библиотек, которые стремятся минимизировать размеры объектов.Правило 40: Продумывайте подход к использованию множественного наследования
Когда речь заходит о множественном наследовании (multiple inheritance – MI), сообщество разработчиков на C++ разделяется на два больших лагеря. Одни полагают, что раз одиночное исследование (SI) – это хорошо, то множественное наследование должно быть еще лучше. Другие говорят, что одиночное наследование – это на самом деле хорошо, а множественное не стоит хлопот. В этом правиле мы постараемся разобраться в обеих точках зрения.
Первое, что нужно уяснить для себя о множественном наследовании, – это появляющаяся возможность унаследовать одно и то же имя (функции, typedef и т. п.) от нескольких базовых классов. Это может стать причиной неоднозначности. Например:
class BorrowableItem { // нечто, что можно позаимствовать
// из библиотеки
public:
void checkOut();
…
};
class ElectronicGadget {
private:
bool checkOut() const; // выполняет самотестирование, возвращает
… // признак успешности теста
};
class MP3Player: // здесь множественное наследование (в некоторых
public BorrowableItem, // библиотеках реализована функциональность,
public ElectronicGadget // необходимая для MP3-плееров)
{…} // определение класса не важно
MP3Player mp;
mp.checkout(); // неоднозначность! какой checkOut?Отметим, что в этом примере вызов функции checkOut неоднозначен, несмотря на то что доступна лишь одна из двух функций. (checkOut открыта в классе Borrowableltem и закрыта в классе ElectronicGadget.) И это согласуется с правилами разрешения имен перегруженных функций в C++: прежде чем проверять права доступа, C++ находит функцию, которая наиболее соответствует вызову. И только потом проверяется, доступна ли наиболее подходящая функция. В данном случае оба варианта функции checkOut одинаково хорошо соответствуют вызову, то есть ни одна из них не подходит лучше, чем другая. А стало быть, до проверки доступности ElectronicGadget::checkOut дело не доходит. Чтобы разрешить неоднозначность, вы можете указать имя базового класса, чью функцию нужно вызвать:
mp.BorrowableItem::checkOut(); // вот какая checkOut мне нужна!
Вы, конечно, также можете попытаться явно вызвать ElectronicGadget::check-Out, но тогда вместо ошибки неоднозначности получите другую: «вы пытаетесь вызвать закрытую функцию-член».
Множественное наследование просто означает наследование более, чем от одного базового класса, но вполне может возникать также и в иерархиях, содержащих более двух уровней. Это может привести к «ромбовидному наследованию»:
class File {…};
class InputFile: public File {…};
class OutputFile: public File {…};
class IOFile: public InputFile,
public OutputFile
{…};Всякий раз, когда вы строите иерархию наследования, в которой от базового класса к производному ведет более одного пути (как в приведенном примере: от File к IOFile можно пройти как через InputFile, так и через OutputFile), вам приходится сталкиваться с вопросом о том, должны ли данные-члены базового класса дублироваться в объекте подкласса столько раз, сколько имеется путей. Например, предположим, что в классе File есть член filename. Сколько копий этого поля должно быть в классе IOFile? С одной стороны, он наследует по одной копии от каждого из своих базовых классов, следовательно, всего будет два члена данных с именем fileName. С другой стороны, простая логика подсказывает, что объект IOFile имеет только одно имя файла, поэтому поле fileName, наследуемое от двух базовых классов, не должно дублироваться.
C++ не принимает ничью сторону в этом споре. Он успешно поддерживает оба варианта, хотя по умолчанию предполагается дублирование. Если это не то, что вам нужно, сделайте класс, содержащий данные (то есть File),
class File {…};
class InputFile: virtual public File {…};
class OutputFile: virtual public File {…};
class IOFile: public InputFile,
public OutputFile
{…};В стандартной библиотеке C++ есть похожая иерархия, только классы в ней являются шаблонными и называются basic_ios, basic_istream, basic_ostream и basic_iostream, в не File, InputFile, OutputFile и IOFile.
С точки зрения корректности, открытое наследование всегда должно быть виртуальным. Если бы это была единственная точка зрения, то правило было бы простым: всякий раз при открытом наследовании используйте
Оно обходится не бесплатно еще и по другой причине. Правила, определяющие инициализацию виртуальных базовых классов, сложнее и интуитивно не так понятны, как правила для невиртуальных базовых классов. Ответственность за инициализацию виртуального базового класса ложится на
Мой совет относительно виртуальных базовых классов (то есть виртуального наследования) прост. Во-первых, не применяйте виртуальных базовых классов до тех пор, пока в этом не возникнет настоятельная потребность. По умолчанию используйте невиртуальное наследование. Во-вторых, если все же избежать виртуальных базовых классов не удается, старайтесь не размещать в них данных. Тогда можно будет забыть о странностях правил инициализации (да, кстати, и присваивания) таких классов. Неспроста интерфейсы Java и. NET, которые во многом подобны виртуальным базовым классам C++, не могут содержать никаких данных.
Теперь рассмотрим следующий интерфейсный класс C++ (см. правило 31) для моделирования физических лиц:class IPerson {
public:
virtual ~IPerson();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
};Пользователи IPerson должны программировать в терминах указателей и ссылок на IPerson, поскольку создавать объекты абстрактных классов запрещено. Для создания объектов, которыми можно манипулировать как объектами IPerson, используются функции-фабрики (опять же см. правило 31), которые порождают объекты конкретных классов, производных от IPerson:
// функция-фабрика для создания объекта Person по уникальному
// идентификатору из базы данных; см. в правиле 18,
// почему возвращаемый тип – не обычный указатель
std::tr1::shared_ptr
// функция для запроса идентификатора у пользователя
DatabaseID askUserForDtabaseID();
DatabaseID id(askUserForDtabaseID());
std::tr1::shared_ptr
// поддерживающий
// интерфейс IPerson
… // манипулировать *pp
// через функции-члены
// IPersonНо как makePerson создает объекты, на которые возвращает указатель? Ясно, что должен быть какой-то конкретный класс, унаследованный от IPerson, который makePerson может инстанцировать. Предположим, этот класс называется CPerson. Будучи конкретным классом, CPerson должен предоставлять реализацию чисто виртуальных функций, унаследованных от IPerson. Можно написать его «с нуля», но лучше воспользоваться уже готовыми компонентами, которые делают большую часть работы. Например, предположим, что старый, ориентированный только на базы данных класс Person-Info предоставляет почти все необходимое CPerson:
class PersonInfo {
public:
explicit PersonInfo(DatabaseID pid)
virtual ~PersonInfo();
virtual const char *theName() const;
virtual const char *theBirthDate() const;
…
private:
virtual const char *valeDelimOpen() const; // ñì. íèæå
virtual const char *valeDelimClose() const;
…
};Понять, что этот класс старый, можно хотя бы потому, что функции-члены возвращают const char* вместо объектов string. Но если ботинки подходят, почему бы не носить их? Имена функций-членов класса наводят на мысль, что результат может оказаться вполне удовлетворительным. Вскоре вы приходите к выводу, что класс PersonInfo был спроектирован для печати полей базы данных в различных форматах, с выделением начала и конца каждого поля специальными строками-разделителями. По умолчанию открывающим и закрывающим разделителями служат квадратные скобки, поэтому значение поля «Ring-tailed Lemur» будет отформатировано так:
[Ring-tailed Lemur]
Учитывая тот факт, что квадратные скобки не всегда приемлемы для пользователей PersonInfo, в классе предусмотрены виртуальные функции valeDelimOpen и valeDelimClose, позволяющие производным классам задать другие открывающие и закрывающие строки-разделители. Функции-члены PersonInfo вызывают эти виртуальные функции для добавления разделителей к возвращаемым значениям. Так, функция PersonInfo::theName могла бы выглядеть следующим образом:
const char *PersonInfo::valueDelimOpen() const
{
return “[“; // открывающий разделитель по умолчанию
}
const char *PersonInfo::valueDelimClose() const
{
return “]“; // закрывающий разделитель по умолчанию
}
const char * PersonInfo::theName() const
{
// резервирование буфера для возвращаемого значения; поскольку он
// статический, автоматически инициализируется нулями
static char value[Max_Formatted_Field_Value_Length];
// скопировать открывающий разделитель
std::strcpy(value, valueDelimOpen());
// скопировать закрывающий разделитель
std::strcpy(value, valueDelimClose());
return value;
}Кто-то может посетовать на устаревший подход к реализации PersonInfo::theName (особенно это касается использования статического буфера фиксированного размера, опасного возможностью переполнения и потенциальными проблемами в многопоточной среде – см. правило 21), но оставим этот вопрос в стороне и сосредоточимся вот на чем: функция theName вызывает valueDelimOpen для получения открывающего разделителя, вставляемого в возвращаемую строку, затем дописывает имя и в конце вызывает valueDelimClose.
Поскольку valueDelimOpen и valueDelimClose – виртуальные функции, возвращаемый результат theName зависит не только от PersonInfo, но и от классов, производных от него.
Для разработчика CPerson это хорошая новость, потому что, внимательно просматривая документацию по функциям печати из класса IPerson, вы обнаруживаете, что функции name и birthDate должны возвращать неформатированные значения, то есть без добавления разделителей. Другими словами, если человека зовут Homer, то вызов функции name должен возвращать «Homer», а не «[Homer]».
Взаимосвязь между CPerson и PersonInfo можно описать так: PersonInfo упрощает реализацию некоторых функций CPerson. И это все! Стало быть, речь идет об отношении «реализован посредством», и, как мы знаем, такое отношение можно представить двумя способами: с помощью композиции (см. правило 38) или закрытого наследования (см. правило 39). В правиле 39 отмечено, что композиция в общем случае более предпочтительна, но если нужно переопределять виртуальные функции, то требуется наследование. В данном случае CPerson должен переопределить valueDelimOpen и valueDelimClose – задача, которая с помощью композиции не решается. Самое очевидное решение – применить закрытое наследование CPerson от PersonInfo, хотя, как объясняется в правиле 39, это потребует несколько больше работы. Можно также при реализации CPerson воспользоваться сочетанием композиции и наследования с целью переопределения виртуальных функций PersonInfo. Но мы остановимся просто на закрытом наследовании.
Однако CPerson также должен реализовать интерфейс IPerson, а для этого требуется открытое наследование. Вот мы и пришли к множественному наследованию: сочетанию открытого наследования интерфейса с закрытым наследованием реализации:class IPerson { // класс описывает интерфейс,
public: // который должен быть реализован
virtual ~IPerson();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
};
class DatabaseID {…}; // используется далее;
// детали не существенны
class PersonInfo { // в этом классе имеет функции,
public: // помогающие при реализации
explicit PersonInfo(DatabaseID pid) // интерфейса IPerson
virtual ~PersonInfo();
virtual const char *theName() const;
virtual const char *theBirthDate() const;
virtual const char *valeDelimOpen() const;
virtual const char *valeDelimClose() const;
…
};
class CPerson: public IPerson, private PersonInfo { // используется
public: // множественное
explicit CPerson(DatabaseID pid): PersonInfo(pid) {} // наследование
virtual std::string name() const // реализации
{ return PersonInfo::theName();} // функций-членов
// из интерфейса
// IPerson
virtual std::string birthDate() const
{ return PersonInfo::theBirthDate();}
private: // переопределения
const char * valeDelimOpen() const { return “”;} // унаследованных
const char * valeDelimClose() const { return “”;} // виртуальных
}; // функций,
// возвращающих
// строки-разделителиВ нотации UML это решение выглядит так:
Рассмотренный пример показывает, что множественное наследование может быть и удобным, и понятным.
Замечу, что множественное наследование – просто еще один инструмент в объектно-ориентированном инструментарии. По сравнению с одиночным наследованием оно несколько труднее для понимания и применения, поэтому если вы можете спроектировать программу с одним лишь одиночным наследованием, который более или менее эквивалентен варианту с множественным наследованием, то, скорее всего, предпочтение следует отдать первому подходу. Если вам кажется, что единственно возможный вариант дизайна требует применения множественного наследования, то рекомендую как следует подумать – почти наверняка найдется способ обойтись одиночным. В то же время иногда множественное наследование – это самый ясный, простой для сопровождения и разумный способ достижения цели. В таких случаях не бойтесь применять его. Просто делайте это, тщательно обдумав все последствия.
• Множественное наследование сложнее одиночного. Оно может привести к неоднозначности и необходимости применять виртуальное наследование.
• Цена виртуального наследования – дополнительные затраты памяти, снижение быстродействия и усложнение операций инициализации и присваивания. На практике его разумно применять, когда виртуальные базовые классы не содержат данных.
• Множественное наследование вполне законно. Один из сценариев включает комбинацию открытого наследования интерфейсного класса и закрытого наследования класса, помогающего в реализации.Глава 7 Шаблоны и обобщенное программирование
Изначально шаблоны в C++ появились для того, чтобы можно было реализовать безопасные относительно типов контейнеры: vector, list, map и им подобные. Однако по мере обретения опыта работы с шаблонами стали обнаруживаться все новые и новые способы их применения. Контейнеры были хороши сами по себе, но обобщенное программирование – возможность писать код, не зависящий от типа объектов, которыми он манипулирует, – оказалось еще лучше. Примерами такого программирования являются алгоритмы STL, такие как for_each, find и merge. В конечном итоге выяснилось, что механизм шаблонов C++ сам по себе является машиной Тьюринга: он может быть использован для вычисления любых вычисляемых значений. Это привело к метапрограммированию шаблонов: созданию программ, которые исполняются внутри компилятора C++ и завершают свою работу вместе с окончанием компиляции. В наши дни контейнеры – это лишь малая толика того, на что способны шаблоны C++. Но, несмотря на огромное разнообразие применений, в основе программирования шаблонов лежит небольшое число базовых идей. Именно им и посвящена настоящая глава.
Я не ставлю себе целью сделать из вас эксперта по программированию шаблонов, но, прочитав эту главу, вы станете лучше разбираться в этом вопросе. К тому же в ней достаточно информации для того, чтобы раздвинуть границы ваших представлений о программировании шаблонов – настолько широко, насколько вы пожелаете.
Правило 41: Разберитесь в том, что такое неявные интерфейсы и полиморфизм на этапе компиляции
В мире объектно-ориентированного программирования преобладают
class Widget {
public:
Widget();
virtual ~Widget();
virtual std::size_t size() const;
virtual void normalize();
void swap(Widget& other); // см. правило 25
…
};и столь же бессмысленную функцию:
void doProcessing(Widget& w)
{
if(w.size() > 10 && w != someNastyWidget) {
Widget temp(w);
temp.normalize();
temp.swap(w);
}
}Вот что мы можем сказать о переменной w в функции doProcessing:
• Поскольку объявлено, что переменная w имеет тип Widget, то w должна поддерживать интерфейс Widget. Мы можем найти точное описание этого интерфейса в исходном коде (например, в заголовочном файле для Widget), поэтому я называю его
• Поскольку некоторые из функций-членов Widget являются виртуальными, то вызовы этих функций посредством w являются примером полиморфизма времени исполнения: конкретная функция, которую нужно вызвать, определяется во время исполнения на основании динамического типа w (см. правило 37).
Мир шаблонного и обобщенного программирования принципиально отличается. В этом мире явные интерфейсы и полиморфизм времени исполнения продолжают существовать, но они менее важны. Вместо них на передний план выходятtemplate void doProcessing(T& w) { if(w.size() > 10 && w != someNastyWidget) { T temp(w); temp.normalize(); temp.swap(w); }
Что теперь можно сказать о переменной w в шаблоне doProcessing?
• Теперь интерфейс, который должна поддерживать переменная w, определяется операциями, выполняемыми над w в шаблоне. В данном случае видно, что тип переменной w (а именно T) должен поддерживать функции-члены size, normalize и swap; конструктор копирования (для создания temp), а также операцию сравнения на равенство (для сравнения с someNastyWidget). Скоро мы увидим, что это не совсем точно, но на данный момент достаточно. Важно, что набор выражений, которые должны быть корректны для того, чтобы шаблон компилировался, представляет собой неявный интерфейс, который тип T должен поддерживать.
• Для успешного вызова функций, в которых участвует w, таких как operator> и operator!=, может потребоваться конкретизировать шаблон. Такая конкретизация происходит во время компиляции. Поскольку конкретизация шаблонов функций с разными шаблонными параметрами приводит к вызову разных функций, мы называем это
Даже если вы никогда не пользовались шаблонами, разница между полиморфизмом времени исполнения и полиморфизмом времени компиляции должна быть вам знакома, поскольку она напоминает разницу между процедурой определения того, какую из перегруженных функций вызывать (это происходит во время компиляции) и динамическим связыванием при вызове виртуальных функций (которое происходит во время исполнения). Однако разница между явными и неявными интерфейсами – понятие, характерное только для шаблонов, поэтому остановимся на нем более подробно.
Явные интерфейсы обычно состоят из сигнатур функций, то есть имен функций, типов параметров, возвращаемого значения и т. д. Так, открытый интерфейс класса Widgetclass Widget {
public:
Widget();
virtual ~Widget();
virtual std::size_t size() const;
virtual void normalize();
void swap(Widget& other);
};состоит из конструктора, деструктора и функций size, normalize и swap вместе с типами их параметров, возвращаемых значений и признаков константности (интерфейс также включает генерируемые компилятором конструктор копирования и оператор присваивания – см. правило 5). В состав интерфейса могут входить также typedefbi. Неявный интерфейс несколько отличается. Он не базируется на сигнатурах функций. Вместо этого он состоит из корректных
template void doProcessing(T& w) { if(w.size() > 10 && w != someNastyWidget) {
Неявному интерфейсу T (типа переменной w) присущи следующие ограничения:
• Он должен предоставлять функцию-член по имени size, которая возвращает целое значение.
• Он должен поддерживать функцию operator!=, которая сравнивает два объекта типа T. (Здесь мы предполагаем, что someNastyWidget имеет тип T.)
Благодаря возможности перегрузки операторов ни одно из этих требований не должно удовлетворяться в обязательном порядке. Да, T должен поддерживать функцию-член size, хотя стоит упомянуть, что эта функция может быть унаследована от базового класса. Но эта функция не обязана возвращать целочисленный тип. Она даже может вообще не возвращать числовой тип. Вообще-то она даже не обязана возвращать тип, для которого определен operator>! Нужно лишь, чтобы она возвращала объект такого типа X, что может быть вызван operator>, которому передаются параметры типа X и int (потому что 10 имеет тип int). При этом функция operator> может и не принимать параметра, тип которого в точности совпадает с X; достаточно, если тип ее параметра Y может быть неявно преобразован к типу X!
Аналогично не требуется, чтобы тип T поддерживал operator!=, достаточно будет и того, чтобы функция operator!= принимала один объект типа X и один объект типа Y. Если T можно преобразовать в X, а someNastyWidget в Y, то вызов operator!= будет корректным.
(Кстати говоря: мы не принимаем во внимание возможность перегрузки operator&&, в результате которой семантика приведенного выражения может стать уже не конъюнкцией, а чем-то совершенно иным.)
У большинства людей голова идет кругом, когда они начинают задумываться о неявных интерфейсах, но на самом деле ничего страшного в них нет. Неявные интерфейсы – это просто набор корректных выражений. Сами по себе выражения могут показаться сложными, но налагаемые ими ограничения достаточно очевидны.if(w.size() > 10 && w != someNastyWidget)…
Мало что можно сказать об ограничениях, налагаемых функциями size, operator>, operator&& или operator!=, но идентифицировать ограничения всего выражения в целом легко. Условная часть предложения if должна быть булевским выражением, поэтому независимо от конкретных типов результат вычисления (w.size() > 10 && w!= someNastyWidget) должен быть совместим с bool. Это та часть неявного интерфейса, которую шаблон doProcessing налагает на свой параметр типа T. Кроме того, для работы doProcessing необходимо, чтобы интерфейс типа T допускал обращения к конструктору копирования, а также функциям normalize, size и swap.
Ограничения, налагаемые неявными интерфейсами на параметры шаблона, так же реальны, как ограничения, налагаемые явными интерфейсами на объекты класса: и те, и другие проверяются на этапе компиляции. Вы не можете использовать объекты способами, противоречащими явным интерфейсам их классов (такой код не скомпилируется), и точно так же вы не пытайтесь использовать в шаблоне объект, не поддерживающий неявный интерфейс, которого требует шаблон (опять же, код не скомпилируется).
• И классы, и шаблоны поддерживают интерфейсы и полиморфизм.
• Для классов интерфейсы определены явно и включают главным образом сигнатуры функций. Полиморфизм проявляется во время исполнения – через виртуальные функции.
• Для параметров шаблонов интерфейсы неявны и основаны на корректных выражениях. Полиморфизм проявляется во время компиляции – через конкретизацию и разрешение перегрузки функций.Правило 42: Усвойте оба значения ключевого слова typename
template
Ответ: никакой. Когда в шаблоне объявляется параметр типа, class и type-name означают абсолютно одно и то же. Некоторые программисты предпочитают всегда писать class, потому что это слово короче. Другие (включая меня) предпочитают typename, поскольку оно говорит о том, что параметром не обязательно должен быть тип класса. Некоторые разработчики используют typename, когда допускается любой тип, и резервируют слово class для случаев, когда допускается только тип, определяемый пользователем. Но с точки зрения C++, class и typename в объявлении параметра шаблона означают в точности одно и то же.
Однако не всегда в C++ ключевые слова class и typename эквивалентны. Иногда вы обязаны использовать typename. Чтобы понять – когда именно, поговорим о двух типах имен, на которые можно ссылаться в шаблоне.
Предположим, что у нас есть шаблон функции, принимающей в качестве параметра совместимый с STL-контейнер, содержащий объекты, которые могут быть присвоены величинам типа int. Далее предположим, что эта функция просто печатает значение второго элемента. Это не очень содержательная функция, которая к тому же и реализована по-дурацки. Как я уже говорил, она даже не будет компилироваться, но забудьте об этом на время – все это не так глупо, как кажется:template void print2nd(const C& container) // элемент контейнера { // if (container.size() >= 2) { C::const_iterator iter(container.begin()); // получить итератор, // указывающий на первый // элемент ++iter; // сместиться на второй // элемент int value = *iter; // скопировать элемент в int std::cout << value; // напечатать int }
Я выделил в этой функции две локальные переменные – iter и value. Типом iter является C::const_iterator – он зависит от параметра шаблона C. Имена в шаблоне, которые зависят от параметра шаблона, называются
Другая локальная переменная в print2nd – value – имеет тип int, а int – это имя, которое не зависит ни от какого параметра шаблона. Такие имена называются
template void print2nd(const C& container) // { C::const_iterator *x; …
Выглядит так, будто мы объявили x как локальную переменную – указатель на C::const_iterator. Но это только видимость, поскольку мы «знаем», что C::const_iterator является типом. А что, если в классе C есть статический член данных по имени const_iterator и что, если x будет именем глобальной переменной? В этом случае приведенный код не будет объявлять локальную переменную, а окажется умножением C::const_iterator на x! Звучит невероятно, но это
Пока о C ничего не известно, мы не можем узнать, является ли C::const_iterator типом или нет, а во время разбора шаблона print2nd компилятор ничего о C не знает. В C++ предусмотрено правило, разрешающее эту неопределенность: если синтаксический анализатор встречает вложенное зависимое имя в шаблоне, он предполагает, что это
template void print2nd(const C& container) { if (container.size() >= 2) { C::const_iterator iter(container.begin()); // предполагается, что
Теперь должно быть ясно, почему это некорректный C++. Объявление iter имеет смысл только в случае, если C::const_iterator является типом, но мы не сообщили C++ об этом, потому C++ предполагает, что это не так. Чтобы исправить ситуацию, мы должны сообщить C++, что C::const_iterator – это тип. Для этого мы помещаем ключевое слово typename непосредственно перед ним:
template void print2nd(const C& container) { if (container.size() >= 2) { typename C::const_iterator iter(container.begin()); … }
Общее правило просто: всякий раз, когда вы обращаетесь к вложенному зависимому имени в шаблоне, вы должны предварить его словом typename (скоро я опишу исключение). Слово typename следует использовать для идентификации только вложенных зависимых имен типов; для других имен оно не применяется. Вот пример шаблона функции, который принимает и контейнер, и итератор для этого контейнера:
template void f(const C& container, // typename не допускается
C не является вложенным зависимым именем типа (оно не вложено внутрь чего-либо, зависимого от параметра шаблона), поэтому его не нужно предварять словом typename при объявлении контейнера, но C::iterator – это вложенное зависимое имя типа, поэтому перед ним следует поставить typename. Из правила «typename должно предварять вложенные зависимые имена типов» есть исключение: typename не должно предварять вложенные зависимые имена типов в списке базовых классов или в идентификаторе базового класса в списке инициализации членов. Например:
template class Derived: public Base public: // explicit Derived(int x) :Base { // в списке инициализации членов: // typename Base … // зависимого имени типа не как } // идентификатора базового … // класса в списке инициализации
Такая несогласованность несколько раздражает, но по мере приобретения опыта вы перестанете ее замечать. Рассмотрим еще один пример использования typename, потому нечто подобное можно встретить в реальном коде. Предположим, что мы пишем шаблон функции, которая принимает итератор, и хотим сделать локальную копию – temp – объекта, на который этот итератор указывает. Это можно сделать примерно так:
template void workWithIterator(IterT iter) { typename std::iterator_traits …
Не пугайтесь при виде выражения std::iterator_traits
template void workWithIterator(IterT iter) { typedef typename std::iterator_traits value_type temp(*iter); …
Многих программистов соседство typedef и typename поначалу раздражает, но это логическое следствие из правила обращения к вложенным зависимым именам типов. Вы скоро привыкнете. К тому же у вас есть на то веские причины. Сколько раз вы готовы напечатать std::iterator_traits В качестве заключительного замечания я должен упомянуть, что не все компиляторы настаивают на строгом выполнении правил, касающихся ключевого слова typename. Некоторые принимают код, в котором typename требуется, но пропущено; некоторые принимают код, где typename присутствует, но не допускается; и некоторые (обычно это касается старых компиляторов) отвергают typename даже там, где оно необходимо. Это значит, что взаимосвязи между typename и вложенными зависимыми имен типов могут стать причиной некоторых не очень серьезных ошибок при переносе программ на другую платформу. • В объявлениях параметров шаблона ключевые слова class и typename взаимозаменяемы.
Правило 43: Необходимо знать, как обращаться к именам в шаблонных базовых классах
Предположим, что нам нужно написать программу, которая будет посылать сообщения нескольким компаниям. Сообщения должны отправляться как в зашифрованной форме, так и в форме открытого текста. Если во время компиляции у нас достаточно информации для определения того, какие сообщения должны быть отправлены каким компаниям, то мы можем прибегнуть к решению, основанному на шаблонах:
class CompanyA {
public:
…
void sendClearText(const std::string& msg);
void sendEncryptedText(const std::string& msg);
…
};
class CompanyB{
public:
…
void sendClearText(const std::string& msg);
void sendEncryptedText(const std::string& msg);
…
};
… // классы для других компаний
class MsgInfo {…}; // класс, содержащий информацию,
// используемую для создания
// сообщения
template
class MsgSender {
public:
… // конструктор, деструктор и т. п.
void sendClear(const MsgInfo& info)
{
std::string msg;
Company c;
c.sendClearText(msg);
}
void sendSecret(const MsgInfo& info) // аналогично sendClear, но вызывает
{…} // c.sendEncrypted
};Эта программа будет работать. Но предположим, что иногда мы хотим протоколировать некоторую информацию при отправке сообщений. Такую возможность легко добавить, написав производный класс, и, на первый взгляд, разумно это сделать следующим образом:
template class LoggingMsgSender: public MsgSender public: … void sendClearMsg(const MsgInfo& info) { sendClear(info); // вызвать функцию из базового класса // } …
Отметим, что функция, отправляющая сообщение, в производном классе называется иначе (sendClearMsg), чем в базовом (sendClear). Это хорошее решение, потому что таким образом мы обходим проблему сокрытия унаследованных имен (см. правило 33), а равно сложности, возникающие при переопределении наследуемых невиртуальных функций (см. правило 36). Но этот код не будет компилироваться, по крайней мере, компилятором, совместимым со стандартом. Такой компилятор решит, что функции sendClear не существует. Мы видим, что эта функция определена в базовом классе, но компилятор не станет искать ее там. Попытаемся понять – почему.
Проблема в том, что когда компилятор встречает определение шаблона класса LoggingMsgSender, он не знает, какому классу тот наследует. Понятно, что классу MsgSender
class CompanyZ { // этот класс не представляет
public: // функции sendCleartext
…
void sendEncrypted(const std::string& msg);
…
};Общий шаблон MsgSender не подходит для CompanyZ, потому что в нем определена функция sendClear, которая для объектов класса CompanyZ не имеет смысла. Чтобы решить эту проблему, мы можем создать специализированную версию MsgSender для CompanyZ:
template <> // полная специализация MsgSender;
class MsgSender
public: // только отсутствием функции
… // sendCleartext
void sendSecret(const MsgInfo& info)
{…}
};Обратите внимание на синтаксическую конструкцию «template<>» в начале определения класса. Она означает, что это и не шаблон, и не автономный класс. Это специализированная версия шаблона MsgSender, которая должна использоваться, если параметром шаблона является CompanyZ. Называется это
template class LoggingMsgSender: public MsgSender public: … void sendClearMsg(const MsgInfo& info) { sendClear(info); // если Company == CompanyZ, // то этой функции не существует } …
Как следует из комментария, этот код просто не имеет смысла, если базовым классом является MsgSender
template class LoggingMsgSender: public MsgSender public: … void sendClearMsg(const MsgInfo& info) { ; this->sendClear(info); // порядок! Предполагается, что // sendClear будет унаследована ; } …
Во-вторых, можно воспользоваться using-объявлением. Мы уже обсуждали эту тему в правиле 33, где было сказано, что using-объявлением делает скрытые имена из базового класса видимыми в производном классе. Поэтому мы можем переписать sendClearMsg следующим образом:
template class LoggingMsgSender: public MsgSender public: using MsgSender … // sendClear есть в базовом классе void sendClearMsg(const MsgInfo& info) { … sendClear(info); // нормально, предполагается, что … // sendClear будет унаследована } …
Хотя using-объявление будет работать как здесь, так и в правиле 33, но используются они для решения разных задач. Здесь проблема не в том, что имена из базового класса скрыты за именами, объявленными в производном классе, а в том, что компилятор вообще не станет производить поиск в области видимости базового класса, если только вы явно не попросите его об этом. И последний способ заставить ваш код компилироваться – явно указать, что вызываемая функция находится в базовом классе:
template class LoggingMsgSender: public MsgSender pubilc: … void sendClearMsg(const MsgInfo& info) { … MsgSender … // sendClear будет унаследована } …
Но этот способ хуже прочих, посколько если вызываемая функция виртуальна, то явная квалификация отключает динамическое связывание. С точки зрения видимости имен, все три подхода эквивалентны: они обещают компилятору, что любая специализация шаблона базового класса будет поддерживать интерфейс, предоставленный общим шаблоном. Такое обещание – это все, что необходимо компилятору во время синтаксического анализа производного шаблонного класса, подобного LoggingMsgSender, но если данное обещание не будет выполнено, истина всплывет позже. Например, если в программе есть такой код:
LoggingMsgSender MsgInfo msgData; … // поместить info в msgData
то вызов sendClearMsg не скомпилируется, потому что в этой точке компилятор знает, что базовый класс – это специализация шаблона MsgSender Таким образом, суть дела в том, когда компилятор диагностирует неправильные обращения к членам базового класса – раньше (когда анализируются определения шаблонов производного класса) или позже (когда эти шаблоны конкретизируются переданными в шаблон аргументами). C++ предпочитает раннюю диагностику, и поэтому предполагает, что о содержимом базовых классов, конкретизируемых из шаблонов, не известно ничего.
Правило 44: Размещайте независимый от параметров код вне шаблонов
Шаблоны – чудесный способ сэкономить время и избежать дублирования кода. Вместо того чтобы вводить код 20 похожих классов, в каждом из которых по 15 функций-членов, вы набираете текст одного шаблона и поручаете компилятору сгенерировать 20 конкретных классов и все 300 необходимых вам функций. (Функции-члены шаблонов классов неявно генерируются, только когда программа к ним обращается, поэтому все 300 функций-членов вы получите, лишь если будете все их использовать.) Шаблоны функций не менее привлекательны. Вместо написания множества однотипных функций вы пишете один шаблон и позволяете компиляторам проделать все остальное. Ну разве не восхитительная технология?
Да… иногда. Если вы не будете внимательны, то использование шаблонов может привести к
Основной инструмент для этого – анализ
Когда вы пишете функцию и обнаруживаете, что некоторая часть ее реализации мало чем отличается от реализации другой функции, разве вы дублируете код? Конечно, нет. Вы исключаете общую часть из обеих функций, помещаете ее в третью, а первые две вызывают эту третью функцию. Иными словами, вы анализируете эти две функции на предмет выявления общих и отличающихся частей, перемещаете общие части в новую функцию, а отличающиеся части оставляете на месте. Аналогично, если вы пишете класс и выясняется, что некоторые части этого класса в точности совпадают с частями другого класса, вы не станете их дублировать, а просто вынесете общие части в новый класс, а затем воспользуетесь наследованием или композицией (см. правила 32, 38 и 39), предоставив исходному классу доступ к общим средствам. Отличающиеся части исходных классов остаются на месте.
При написании шаблонов выполняется такой же анализ, и способы борьбы с дублированием аналогичны. Однако имеются новые особенности. В нешаблонном коде дублирование видно сразу: трудно не заметить повторения кода в двух функциях или классах. В шаблонном коде дублирование не бросается в глаза: есть только одна копия исходного кода шаблона, поэтому вам нужно тренироваться, чтобы легко находить места, где в результате конкретизации шаблона может возникнуть дублирование.
Предположим, например, что вы хотите написать шаблон для квадратных матриц фиксированного размера, которые, помимо всего прочего, поддерживают операцию обращения матрицы.
template class SquareMatrix { // состоящей из объектов типа T; public: // см. ниже информацию о параметре size_t … void invert(); // обращение матрицы на месте
Этот шаблон принимает параметр типа T, а также параметр типа size_t,
SquareMatrix … sm1.invert(); // вызов SquareMatrix SquareMatrix …
Здесь будут конкретизированы две копии функции invert. Они не идентичны, потому что одна из них работает с матрицами 5x5, а другая – с матрицами 10x10, но во всем остальном, кроме констант 5 и 10, эти функции ничем не отличаются. Это – классический пример разбухания кода в результате применения шаблонов. Что вы делаете, когда есть две функции, абсолютно одинаковые, за исключением того, что в одной используется константа 5, а в другой – 10? Естественно, вы создаете функцию, которая принимает параметр, а затем вызываете ее, один раз передавая в качестве параметра 5, а другой раз – 10. Вот первая попытка проделать тот же трюк в реализации шаблона SquareMatrix:
template class SquareMatrixBase { // от размерности матрицы protected: … void invert(std::size_t matrixSize); // обратить матрицу заданной … // размерности }; template class SquareMatrix: private SquareMatrixBase private: using SquareMatrixBase // версии invert; см. правило 33 public: … void invert() {this->invert(n);} // встроенный вызов версии invert }; // из базового класса // см. ниже – почему
Как видите, параметризованная версия функции invert находится в базовом классе – SquareMatrixBase. Как и SquareMatrix, SquareMatrixBase – шаблон, но в отличие от SquareMatrix, он имеет только один параметр – тип объектов в матрице, но не имеет параметра size. Поэтому все матрицы, содержащие объекты заданного типа, будут разделять общий класс SquareMatrixBase. И, значит, все они разделят единственную копию функции invert из данного класса.
Назначение SquareMatrixBase::invert – помочь избежать дублирования кода в производных классах, поэтому using-объявление помещено в секцию protected, а не public. Дополнительные расходы на вызов этой функции нулевые, поскольку в производных классах ее вызовы invert встроены (встраивание неявное – см. правило 30). Во встроенных функциях применяется нотация «this->», потому что в противном случае, как следует из правила 43, имена функций из шаблонного базового класса (SquareMatrixBase
До сих пор все шло хорошо, но имеется одна проблема, которую нам еще предстоит решить. Откуда класс SquareMatrixBase узнает, с какими данными он должен работать? Размерность матрицы ему известна из параметра, но как узнать, где находятся сами данные конкретной матрицы? По-видимому, это известно только производному классу. А как производный класс может передать эту информацию базовому, чтобы тот мог выполнить обращение матрицы?
Один из возможных способов – добавить дополнительный параметр в функцию SquareMatrixBase::invert, скажем, указатель на начало участка памяти, где размещаются данные матрицы. Это будет работать, но, скорее всего, invert – не единственная функция в классе SquareMatrix, которая может быть написана так, что не будет зависеть от размерности, и перенесена в класс SquareMatrixBase. Если таких функций будет несколько, всем им понадобится знать, где находятся данные матрицы. Нам придется в каждую добавлять новый параметр, и получится, что мы многократно передаем SquareMatrixBase одну и ту же информацию. Как-то неправильно это.
Есть альтернатива – хранить указатель на данные матрицы в SquareMatrixBase. И там же можно хранить размерность матрицы. Получается такой код:template class SquareMatrixBase { protected: SquareMatrixBase(std::size_t n, T pMem) // сохраняет размерность :size(n), pData(pMem){} // и указатель на данные матрицы void setData(T *ptr) { pData = ptr;} // присвоить значение pData … private: std::size_t size; // размерность матрицы T *pData; // указатель на данные матрицы
Это позволяет производным классам решать, как выделять память. Возможна, в частности, реализация, при которой данные матрицы сохраняются прямо в объекте SquareMatrix:
template class SquareMatrix: private SquareMatrixBase { public: SquareMatrix() // передать базовому классу размерность :SquareMatrixBase … private: T data(n*n);
Объекты такого типа не нуждаются в динамическом выделении памяти, но зато могут быть очень большими. Вместо этого можно выделять память для данных матрицы из кучи:
template class SquareMatrix: private SquareMatrixBase { public: SquareMatrix() // присвоить указателю на данные :SquareMatrixBase pData(new T(n*n)) // выделить память для данных матрицы, {this->setDataPtr(pData.get();} // сохранить указатель на нее и передать … // его копию базовому классу private: boost::scoped_array
Независимо от того, где хранятся данные, с точки зрения «разбухания» кода важно лишь, что теперь многие (быть может, все) функции-члены SquareMatrix оказываются просто встроенными вызовами их версий из базового класса, которые теперь будут разделяются всеми матрицами, содержащими данные одного и того же типа, независимо от их размера. В то же время объекты SquareMatrix разных размеров относятся к разным типам. Поэтому, несмотря на то что классы SquareMatrix Да, хорошо, но не бесплатно. Для функции invert с жестко «зашитой» в исходный текст размерностью матрицы, скорее всего, был бы сгенерирован более эффективный код, чем разделяемой функции, которой размерность передается в качестве параметра либо хранится в самом объекте. Например, «зашитая» размерность может быть константой времени компиляции, так что к ней будут применимы различные виды оптимизации, в частности встраивание константы непосредственно в машинную команду в виде непосредственного операнда. Для функции, не зависящей от размерности, такой номер не пройдет. С другой стороны, наличие только одной версии invert для разных размерностей уменьшает объем исполняемого кода, а это, в свою очередь, уменьшит размер рабочего множества программы и улучшит локальность ссылок в кэше команд. Это может ускорить исполнение программы настолько, что все потери эффективности по сравнению с зависящей от размерности версией будут с лихвой компенсированы. Какой эффект окажется доминирующим? Единственный способ получить ответ – попробовать оба варианта и исследовать поведение на вашей конкретной платформе с репрезентативными наборами данных. Другой фактор, влияющий на эффективность, – это размеры объектов. Если вы не будете внимательны, то перенос независимых от размерности функций в базовый класс может привести к увеличению размера каждого объекта. Например, в только что приведенном коде для каждого объекта SquareMatrix имеется указатель на его данные в классе SquareMatrixBase, несмотря даже на то, что производный класс и так может получить эти данные. Это увеличивает размер каждого объекта SquareMatrix, по крайней мере, на размер указателя. Можно модифицировать класс так, чтобы необходимость в этих указателях отпала, но это компромисс. Например, если завести в базовом классе защищенный член для хранения указателя на данные матрицы, то мы пожертвуем инкапсуляцией (см. правило 22). Это также может привести к усложнению алгоритмов управления ресурсами. Если в базовом классе хранится указатель на данные матрицы, то память для этих данных может быть либо выделена динамически, либо физически находиться внутри объекта производного класса (как мы видели). Так как же базовый класс определит, следует ли удалять указатель? Ответы на такие вопросы существуют, но чем изощреннее ваш дизайн, тем сложнее все получается. В некоторый момент умеренное дублирование кода может даже показаться спасением.
Что следует помнить
• Шаблоны генерируют множество классов и функций, поэтому любой встречающийся в шаблоне код, который не зависит от параметров шаблона, приводит к разбуханию кода.
• Разбухания из-за параметров шаблонов, не являющихся типами, часто можно избежать, заменив параметры шаблонов параметрами функций или данными-членами класса.
• Разбухание из-за параметров-типов можно ограничить, обеспечив общие реализации для случаев, когда шаблон конкретизируется типами с одинаковым двоичным представлением.
Правило 45: Разрабатывайте шаблоны функций-членов так, чтобы они принимали «все совместимые типы»
Для чего обычные указатели хороши – так это для поддержки неявных преобразований типов. Указатели на объекты производных классов неявно преобразуются в указатели на объекты базовых классов, указатели на неконстантные объекты – в указатели на константные и т. п. Например, рассмотрим некоторые преобразования, которые могут происходить в трехуровневой иерархии:
class Top {…};
class Middle: public Top {…};
class Bottom: public Middle {…};
Top *pt1 = new Middle; // преобразует Middle* в Top*
Top *pt2 = new Bottom; // преобразует Middle* в Bottom*
Const Top *pct2 = pt1; // преобразует Top* в const Top*Эмулировать такие преобразования с помощью определяемых пользователем «интеллектуальных» указателей не просто. Для этого нужно, чтобы компилировался такой код:
Template class SmartPtr { public: explicit SmartPtr(T *realPtr); // интеллектуальные указатели обычно … // инициализируются встроенными }; // указателями SmartPtr SmartPtr SmartPtr SmartPtr
Разные конкретизации одного шаблона не связаны каким-либо отношением, поэтому компилятор считает, что SmartPtr
class BelowBottom: public Bottom {…};
то нужно будет поддержать создание объектов SmartPtr
template class SmartPtr { public: template SmartPtr(const SmartPtr& other); // для «обобщенного … // конструктора копирования»
Здесь говорится, что для каждой пары типов T и U класс SmartPtr Обобщенный конструктор копирования в приведенном выше примере не объявлен с модификатором explicit. И это сделано намеренно. Преобразования типов между встроенными типами указателей (например, из указателя на производный класс к указателю на базовый класс) происходят неявно и не требуют приведения, поэтому разумно и для интеллектуальных указателей эмулировать такое поведение. Именно поэтому и не указано слово explicit в объявлении обобщенного конструктора шаблона. Будучи объявлен описанным выше образом, обобщенный конструктор копирования для SmartPtr предоставляет больше, чем нам нужно. Да, мы хотим иметь возможность создавать SmartPtr
template class SmartPtr { public: template SmartPtr(const SmartPtr& other) // инициировать этот хранимый :heldPtr(other.get()) {…} // указатель указателем, хранящимся // в другом объекте T *get() const { return heldPtr;} … private: // встроенный указатель, T *heldPtr; // хранящийся в «интеллектуальном»
Мы используем список инициализации членов, чтобы инициализировать член данных SmartPtr
template public: template explicit shared_ptr(Y *p); // любого совместимого template shared_ptr(shared_ptr template explicit shared_ptr(weak_ptr template explicit shared_ptr(auto_ptr template explicit shared_ptr& operator=(shared_ptr template explicit shared_ptr& operator=(auto_ptr … // auto_ptr
Все эти конструкторы объявлены как explicit, за исключением обобщенного конструктора копирования. Это значит, что неявные преобразования от одного типа shared_ptr к другому допускаются, но
Шаблонные функции-члены – чудесная вещь, но они не отменяют основных правил языка. В правиле 5 объясняется, что две из четырех функций-членов, которые компиляторы могут генерировать автоматически, – это конструктор копирования и оператор присваивания. В классе tr1::shared_ptr объявлен обобщенный конструктор копирования, и ясно, что в случае совпадения типов T и Y конкретизация обобщенного конструктора копирования может быть сведена к созданию «обычного» конструктора копирования. Поэтому возникает вопрос, что будет делать компилятор в случае, когда один объект tr1::shared_ptr конструируется из другого объекта того же типа: генерировать обычный конструктор копирования для tr1::shared_ptr или конкретизировать обобщенный конструктор копирования из шаблона?
Как я сказал, шаблонные члены не отменяют основных правил языка, а из этих правил следует, что если конструктор копирования нужен, а вы не объявляете его, то он будет сгенерирован автоматически. Объявление в классе обобщенного конструктора копирования (шаблонного члена) не предотвращает генерацию компилятором обычного конструктора копирования. Поэтому если вы хотите полностью контролировать все аспекты конструирования путем копирования, то должны объявить как обобщенный конструктор копирования, так и обычный. То же касается присваивания. Приведем фрагмент определения класса tr1::shared_ptr, который иллюстрирует это положение:template public: shared_ptr(shared_ptr const& r); // конструктор копирования template shared_ptr(shared_ptr shared_ptr& operator=(shared_ptr const& r); // оператор присваивания template shared_ptr& operator=(shared_ptr …
• Используйте шаблонные функции-члены для генерации функций, принимающих все совместимые типы.
• Если вы объявляете шаблоны обобщенных конструкторов копирования или обобщенного оператора присваивания, то по-прежнему должны объявить обычный конструктор копирования и оператор присваивания.Правило 46: Определяйте внутри шаблонов функции, не являющиеся членами, когда желательны преобразования типа
В правиле 24 объясняется, почему только к свободным функциям применяются неявные преобразования типов всех аргументов. В качестве примера была приведена функция operator* для класса Rational. Прежде чем продолжить чтение, рекомендую вам освежить этот пример в памяти, потому что сейчас мы вернемся к этой теме, рассмотрев безобидные, на первый взгляд, модификации примера из правила 24. Отличие только в том, что и класс Rational, и operator* в нем сделаны шаблонами:
template class Rational { public: Rational(const T& numerator = 0, // см. в правиле 20 – почему const T& denominator = 1); // параметр передается по ссылке const T numerator() const; // см. в правиле 28 – почему const T denominator() const; // результат возвращается по … // значению, а в правиле 3 – // почему они константны }; template const Rational const Rational
Как и в правиле 24, мы собираемся поддерживать смешанную арифметику, поэтому хотелось бы, чтобы приведенный ниже код компилировался. Мы не ожидаем подвохов, потому что аналогичный код в правиле 24 работал. Единственное отличие в том, что класс Rational и функция-член operator* теперь шаблоны:
Raional // но Rational – теперь шаблон
Тот факт, что этот код не компилируется, наводит на мысль, что в шаблоне Rational есть нечто, отличающее его от нешаблонной версии. И это на самом деле так. В правиле 24 компилятор знал, какую функцию мы пытаемся вызвать (operator*, принимающую два параметра типа Rational), здесь же ему об этом ничего не известно. Поэтому компилятор пытается Пытаясь вывести T, компилятор смотрит на типы аргументов, переданных при вызове operator*. В данном случае это Rational Вывод на основе типа oneHalf сделать легко. Первый параметр operator* объявлен как Rational
template class Rational { public: … friend // объявление функции const Rational operator*(const Rational& lhs, // operator* const Rational& rhs); // (подробности см. ниже) }; template const Rational const Rational
Теперь вызовы operator* с аргументами разных типов скомпилируются, потому что при объявлении объект oneHalf типа Rational К сожалению, фраза «сумеет разобраться» в данном контексте имеет иронический оттенок, поскольку хотя код и компилируется, но не компонуется. Вскоре мы займемся этой проблемой, но сначала я хочу сделать одно замечание о синтаксисе, используемом для объявления функции operator* в классе Rational.
template class Rational { public: … friend const Rational const Rational …
Однако проще (и часто так и делается) использовать сокращенную форму.
Теперь вернемся к проблеме компоновки. Код, содержащий вызов с параметрами различных типов, компилируется, потому что компилятор знает, что мы хотим вызвать вполне определенную функцию (operator*, принимающую параметры типа Rational
template class Rational { public: … friend Rational operator*(const Rational& lhs, const Rational& rhs) { return Rational(lhs.numerator() * rhs.numerator(), // та же lhs.denominator () * rhs.denominator()); // реализация, } // что и // в правиле 24
Наконец-то все работает как нужно: вызовы operator* с параметрами смешанных типов компилируются, компонуются и запускаются. Ура!
Интересное наблюдение, касающееся этой техники: использование отношения дружественности никак не связано с желанием получить доступ к закрытой части класса. Чтобы сделать возможными преобразования типа для всех аргументов, нам нужна функция, не являющаяся членом (см. правило 24); а для того чтобы получить автоматическую конкретизацию правильной функции, нам нужно объявить ее внутри класса. Единственный способ объявить свободную функцию внутри класса – сделать ее другом (friend). Что мы и делаем. Необычно? Да. Эффективно? Вне всяких сомнений.
Как объясняется в правиле 30, функции, определенные внутри класса, неявно объявляются встроенными; это касается и функций-друзей, подобных нашей operator*. Вы можете минимизировать эффект от неявного встраивания, сделав так, чтобы operator* не делала ничего, помимо вызова вспомогательной функции, определенной вне класса. В данном случае в этом нет особой необходимости, потому что функция operator* и так состоит всего из одной строки, но для более сложных функций с телом это может оказаться желательным. Поэтому стоит иметь в виду идиому «иметь друга, вызывающего вспомогательную функцию».
Тот факт, что Rational – это шаблонный класс, означает, что вспомогательная функция обычно также будет шаблоном, поэтому код в заголовочном файле, определяющем Rational, обычно выглядит примерно так:template // шаблона Rational template const Rational const Rational // функции template class Rational { public: … friend const Rational operator*( const Rational& lhs, const Rational& rhs) // друг объявляет { return doMultiply(lhs, rhs};} // вспомогательную … // функцию
Многие компиляторы требуют, чтобы все определения шаблонов находились в заголовочных файлах, поэтому может понадобиться определить в заголовке еще и функцию doMultiply. Как объясняется в правиле 30, такие шаблоны не обязаны быть встроенными. Вот как это может выглядеть:
template const Rational const Rational { // в заголовочном файле return Rational(lhs.numerator() * rhs.numerator(), // при необходимости lhs.denominator () * rhs.denominator());
Конечно, будучи шаблоном, doMultiply не поддерживает умножения значений разного типа, но ей это и не нужно. Она вызывается только из operator*, который обеспечивает поддержку параметров смешанного типа! По существу,
Правило 47: Используйте классы-характеристики для предоставления информации о типах
В основном библиотека STL содержит шаблоны контейнеров, итераторов и алгоритмов, но есть в ней и некоторые служебные шаблоны. Один из них называется advance. Шаблон advance перемещает указанный итератор на заданное расстояние:
template void advance(Iter T& iter, DistT d); // на d элементов вперед // если d < 0, то перемещает iter
Концептуально advance делает то же самое, что предложение iter+=d, но таким способом advance не может быть реализован, потому что только итераторы с произвольным доступом поддерживают операцию +=. Для менее мощных итераторов advance реализуется путем повторения операции ++ или – ровно d раз.
А вы не помните, какие есть категории итераторов в STL? Не страшно, дадим краткий обзор. Существует пять категорий итераторов, соответствующих операциям, которые они поддерживают.
Более мощная категория итераторов состоит из
Наиболее мощная категория итераторов – это
struct input_iterator_tag {};
struct output_iterator_tag {};
struct forward_iterator_tag: public input_iterator_tag {};
struct bidirectional_iterator_tag: public forward_iterator_tag {};
struct random_access_iterator_teg: public bidirectional_iterator_tag {};Отношения наследования между этими структурами корректно выражают взаимосвязь типа «является» (см. правило 32): верно, что все однонаправленные итераторы являются также итераторами ввода и т. д. Вскоре мы увидим примеры использования такого наследования.
Но вернемся к операции advance. Поскольку у разных итераторов возможности различны, то можно было при реализации advance воспользоваться «наименьшим общим знаменателем», то есть организовать цикл, в котором итератор увеличивается или уменьшается на единицу. Но такой подход требует линейных затрат времени. Итераторы с произвольным доступом обеспечивают доступ к любому элементу контейнера за постоянное время, и, конечно, мы бы хотели воспользоваться этим преимуществом, коль скоро оно имеется.
В действительности хотелось бы реализовать advance как-то так:template void advance(IterT& iter, DistT d) { if ( iter += d; // использовать итераторную арифметику } // для итераторов с произвольным доступом else { if(d>=0) {while (d–) ++iter;} // вызывать ++ или – в цикле else {while(d++) –iter;} // для итераторов других категорий }
Но для этого нужно уметь определять, является ли iter итератором с произвольным доступом, что, в свою очередь, требует знания о том, что его тип – IterT – относится к категории итераторов с произвольным доступом. Другими словами, нам нужно получить некоторую информацию о типе. Именно для этого и служат характеристики (traits): получить информацию о типе во время компиляции.
Traits – это не ключевое слово и не предопределенная конструкция в C++; это техника и соглашение, которому следуют программисты. Одним из требований, предъявляемых к ней, является то, что она должна одинаково хорошо работать и для встроенных типов, и для типов, определяемых пользователем. Например, при вызове для обычного указателя (типа const char*) или значения типа int операция advance должна работать, а это значит, что техника характеристик должна быть применима и к встроенным типам.
Тот факт, что характеристики должны работать со встроенными типами, означает, что нельзя рассчитывать на размещение специальной информации внутри типа, потому что в указателях никакую информацию не разместишь. Поэтому характеристическая информация о типе должна быть внешней по отношению к типу. Стандартная техника заключается в помещении ее в шаблон, для которого существует одна или несколько специализаций. Для итераторов в стандартной библиотеке существует шаблон iterator_traits:template
Как видите, iterator_traits – это структура. По соглашению характеристики всегда реализуются в виде структур. Другое соглашение заключается в том, что структуры, используемые для их реализации, почему-то называются
Смысл iterator_traits состоит в том, что для каждого типа IterT определяется псевдоним typedef iterator_category для структуры iterator_traits
template <…>
class deque {
public:
class iterator {
public:
typedef random_access_iterator_tag iterator_category;
…
};
…
};Итераторы для контейнеров list являются двунаправленными, поэтому для них объявление выглядит так:
template <…>
class list {
public:
class iterator {
public:
typedef bidirectional_iterator_tag iterator_category;
};
…
};В шаблоне iterator_traits просто повторен находящийся внутри класса итератора typedef:
// iterator_category для типа IterT – это то, что сообщает о нем сам IterT
// см. в правиле 42 информацию об использовании “typedef typename”
template
struct iterator_traits {
typedef typename IterT::iterator_category iterator_category;
…
};Это работает с пользовательскими типами, но не подходит для итераторов, которые являются указателями, потому что не существует указателей с вложенными typedef. Поэтому во второй части шаблона iterator_traits реализована поддержка итераторов, являющихся указателями. С этой целью iterator_traits представляет
template struct iterator_traits { typedef random_access_iterator_tar iterator_category; …
Теперь вы должны понимать, как проектируется и реализуется класс-характеристика:
• Идентифицировать информацию о типе, которую вы хотели бы сделать доступной (например, для итераторов – это их категория).
• Выбрать имя для обозначения этой информации (например, iterator_category).
• Предоставить шаблон и набор его специализаций (например, iterator_traits), которые содержат информацию о типах, которые вы хотите поддерживать.
Имея шаблон iterator_traits, – на самом деле std::iterator_traits, потому что он является частью стандартной библиотеки C++, – мы можем уточнить наш псевдокод для advance:template void advance(IterT& iter, DistT d) { if (typeid(typename std::iterator_traits typeid(std::random_access_iterator_tag)) …
Выглядит многообещающе, но это не совсем то, что нужно. Во-первых, возникнет проблема при компиляции, но ее мы рассмотрим в правиле 48; а пока остановимся на более фундаментальном обстоятельстве. Тип IterT известен на этапе компиляции, поэтому iterator_traits Что нам нужно – так это условная конструкция (например, предложение if..else) для типов, которая вычислялась бы во время компиляции. К счастью, в C++ есть необходимые нам средства. Это не что иное, как перегрузка.
template void doAdvance(IterT& iter, DistT d, // реализацию для std::random_access_iterator_tag) // итераторов { // с произвольным доступом iter += d; } template void doAdvance(IterT& iter, DistT d, // реализацию для std::bidirectional_iterator_tag) // двунаправленных { // итераторов if(d >= 0) {while(d–) ++iter;} else {while (d++) –iter;} } template void doAdvance(IterT& iter, DistT d, // эту реализацию std::input_iterator_tag) // для итераторов { // ввода if(d < 0) { throw std::out_of_range(“Отрицательное направление”); // см. ниже } while (d–) ++iter;
Поскольку forward_iterator_tag наследует input_iterator_tag, то версия do-Advance для input_iterator_tag будет работать и с однонаправленными итераторами. Это дополнительный аргумент в пользу наследования между разными структурами iterator_tag. Фактически это аргумент в пользу
Спецификация advance допускает как положительные, так и отрицательные значения сдвига для итераторов с произвольным доступом и двунаправленных итераторов, но поведение не определено, если вы попытаетесь сдвинуть на отрицательное расстояние итератор ввода или однонаправленный итератор. Реализации, которые я проверял, просто предполагают, что d – не отрицательно, поэтому входят в
template void advance(IterT& iter, DistT d) { doAdvance( // вызвать версию iter, d, // doAdvance typename // соответствующую std::iterator_traits ); // итератора iter
Подведем итоги – как нужно использовать класс-характеристику:
• Создать набор перегруженных «рабочих» функций либо шаблонов функций (например, doAdvance), которые отличаются параметром-характеристикой. Реализовать каждую функцию в соответствии с переданной характеристикой.
• Создать «ведущую» функцию либо шаблон функции (например, advance), которая вызывает рабочие функции, передавая информацию, предоставленную классом-характеристикой.
Классы-характеристики широко используются в стандартной библиотеке. Так, класс iterator_traits, помимо iterator_category, представляет еще четыре вида информации об итераторах (наиболее часто используется value_type; в правиле 42 показан пример его применения). Есть еще char_traits, который содержит информацию о символьных типах, и numeric_limits, который хранит информацию о числовых типах, например минимальных и максимальных значениях и т. п. Имя numeric_limits немного сбивает с толку, поскольку нарушает соглашение, в соответствии с которыми имена классов-характеристик должны оканчиваться на «traits», но тут уж ничего не поделаешь, придется смириться.
В библиотеке TR1 (см. правило 54) есть целый ряд новых классов-характеристик, которые предоставляют информацию о типах, включая is_fundamental
• Классы-характеристики делают доступной информацию о типах во время компиляции. Они реализованы с применением шаблонов и их специализаций.
• В сочетании с перегрузкой классы-характеристики позволяют проверять типы во время компиляции.Правило 48: Изучите метапрограммирование шаблонов
Метапрограммирование шаблонов (template metaprogramming – TMP) – это процесс написания основанных на шаблонах программ на C++, исполняемых во время компиляции. На минуту задумайтесь об этом: шаблонная метапрограмма – это программа, написанная на C++, которая исполняется
Если эта идея не поразила вас до глубины души, значит, вы недостаточно напряженно думали о ней.
C++ не предназначался для метапрограммирования шаблонов, но с тех пор, как технология TMP была открыта в начале 90-х годов, она оказалась настолько полезной, что, вероятно, и в сам язык, и в стандартную библиотеку будут включены расширения, облегчающие работу с TMP. Да, TMP было именно открыто, а не придумано. Средства, лежащие в основе TMP, появились в C++ вместе с шаблонами. Нужно было только, чтобы кто-то заметил, как они могут быть использованы изобретательным и неожиданным образом.
Технология TMP дает два преимущества. Во-первых, она позволяет делать такие вещи, которые иными способами сделать было бы трудно либо вообще невозможно. Во-вторых, поскольку шаблонные метапрограммы исполняются во время компиляции C++, они могут переместить часть работы со стадии исполнения на стадию компиляции. В частности, некоторые ошибки, которые обычно всплывают во время исполнения, можно было бы обнаружить при компиляции. Другое преимущество – это то, что программы C++, написанные с использованием TMP, можно сделать эффективными почти во всех смыслах: компактность исполняемого, код быстродействия, потребления памяти. Но коль скоро часть работы переносится на стадию компиляции, то, очевидно, компиляция займет больше времени. Для компиляции программ, в которых применяется технология TMP, может потребоваться
Рассмотрим псевдокод шаблонной функции advance, представленный на стр. 227 (см. правило 47; возможно, имеет смысл перечитать это правило сейчас, поскольку ниже я предполагаю, что вы знакомы с изложенным в нем материалом). Я выделил в этом фрагменте часть, написанную на псевдокоде:
template void advance(IterT& iter, DistT d) { if ( iter += d; // использовать итераторную арифметику } // для итераторов с произвольным доступом else { if(d>=0) {while (d–) ++iter;} // вызывать ++ или – в цикле else {while(d++) –iter;} // для итераторов других категорий }
Мы можем использовать typeid, чтобы заменить псевдокод реальным кодом. Тогда задача будет решена «нормальным» для C++ способом – вся работа выполняется во время исполнения:
template void advance(IterT& iter, DistT d) { if (typeid(typename std::iterator_traits typeid(std::random_access_iterator_tag)) iter += d; // использовать итеративную арифметику } // для итераторов с произвольным доступом else { if(d>=0) {while (d–) ++iter;} // вызывать ++ или – в цикле else {while(d++) –iter;} // для итераторов других категорий }
В правиле 47 отмечено, что подход, основанный на typeid, менее эффективен, чем при использовании классов-характеристик, поскольку в этом случае: (1) проверка типа происходит во время исполнения, а не во время компиляции, и (2) код, выполняющий проверку типа, должен быть включен в исполняемую программу. Фактически этот пример показывает, как технология TMP может порождать более эффективные программы на C++, потому что характеристики – это и
std::list … advance(iter, 10); // сдвинуть iter на 10 элементов вперед // не скомпилируется для приведенной
Рассмотрим версию advance, которая будет сгенерирована для этого вызова. После подстановки типов iter и 10 в качестве параметров шаблона IterT и DistT мы получим следующее:
void advance(std::list { if (typeid(std::iterator_traits typeid(std::random_access_iterator_tag)) iter += d; // ошибка! } else { if(d>=0) {while (d–) ++iter;} else {while(d++) –iter;} }
Проблема в выделенной строке, где встречается оператор +=. В данном случае мы пытаемся использовать += для типа list Было доказано, что технология TMP представляет собой полную машину Тьюринга, то есть обладает достаточной мощью для любых вычислений. Используя TMP, вы можете объявлять переменные, выполнять циклы, писать и вызывать функции и т. д. Но такие конструкции выглядят совершенно иначе, чем их аналоги из «нормального» C++. Например, в правиле 47 показано, как в TMP условные предложения if…else выражаются с помощью шаблонов и их специализаций. Но такие конструкции можно назвать «TMP уровня ассемблера». В библиотеках для работы с TMP (например, MPL из Boost – см. правило 55) предлагается более высокоуровневый синтаксис, хотя его также нельзя принять за «нормальный» С++. Чтобы взглянуть на TMP с другого боку, посмотрим, как там выглядят циклы. Технология TMP не предоставляет настоящих циклических конструкций, поэтому цикл моделируется с помощью рекурсии. (Если вы не очень уверенно владеете рекурсией, придется освоиться с ней прежде, чем приступать к использованию TMP. Ведь TMP – по существу функциональный язык, а для таких языков рекурсия – то же, что телевидение для американской поп-культуры – неотъемлемая принадлежность.) Но и рекурсия-то не совсем обычная, поскольку при реализации циклов TMP нет рекурсивных вызовов функций, а есть рекурсивные
template struct Factorial { // произведение n и Factorial enum { value = n*Factorial }; template<> // частный случай: значение Factorial<0> – struct Factorial<0> { // это 1 enum { value = 1 };
Имея такую шаблонную метапрограмму (на самом деле просто единственную шаблонную метафункцию Factorial), вы получаете значение факториала n, обращаясь к Factorial Циклическая часть кода возникает там, где конкретизация шаблона Factorial Каждая конкретизация шаблона Factorial является структурой struct, и в каждой структуре используется «трюк с перечислением» (см. правило 2) для объявления переменной TMP с именем value. В переменной value хранится текущее значение факториала. Если бы в TMP были настоящие циклы, то значение value обновлялось бы на каждой итерации цикла. Но поскольку в TMP место циклов заменяет рекурсивная конкретизация шаблонов, то каждая конкретизация получает свою собственную копию value, и значение копии соответствует «итерации цикла».
int main()
{
std::cout << Factorial<5>::value; // печатается 120
std::cout << Factorial<10>::value; // печатается 3628800
}Если вы находите описанный прием элегантным, значит, вы стали на путь превращения в метапрограммиста шаблонов. Если же все эти шаблоны, специализации, рекурсивные конкретизации, трюк с перечислением и необходимость набирать нечто вроде Factorial Конечно, шаблон Factorial в такой же мере демонстрирует полезность TMP, как «Hello World» – полезность любого обычного языка программирования. Чтобы понять, почему о TMP стоит знать, важно представлять себе, чего можно достичь с помощью этой технологии. Вот три примера: • Обеспечение корректности единиц измерения. В научных и инженерных приложениях важно, чтобы единицы измерения (например, массы, расстояния, времени и т. п.) правильно сочетались. Присваивание переменной, представляющей массу, значения переменной, представляющей скорость, – это ошибка, но деление переменной расстояния на переменную времени и присваивание результата переменной скорости правильно. Используя TMP, можно обеспечить (во время компиляции), что все комбинации единиц измерения в программе будут корректны, независимо от того, насколько сложны вычисления. (Это пример того, как можно использовать TMP для ранней диагностики ошибок.) Одним интересным аспектом такого использования TMP может быть поддержка вычисления дробных степеней. Смысл в том, чтобы дроби сокращались во время компиляции, то есть чтобы компилятор мог подтвердить, например, что единица времени в степени 1/2 – это то же самое, что единица времени в степени 4/8.
typedef SquareMatrix BigMatrix m1, m2, m3, m4, m5; // создать матрицы … // и присвоить им значения
Вычисление result «нормальным» способом приводит к созданию четырех временных матриц, по одной для каждого вызова operator*. Более того, независимые операции умножения порождают последовательность из четырех циклов по элементам матрицы. Но применение передовой шаблонной технологии, тесно связанной с TMP и получившей название
• Генерация специализированных реализаций паттернов проектирования. Паттерны проектирования, подобные Strategy (см. правило 35), Observer, Visitor и т. п., могут быть реализованы многими способами. Используя основанную на TMP технологию, называемую
Технология TMP предназначена не для всех. Применяемый в ней синтаксис интуитивно не очевиден, а поддерживающий инструментарий не развит. (Отладчики для шаблонных метапрограмм? Ну насмешили, право!) Поскольку это вспомогательный язык, открытый сравнительно недавно, то применяемые в нем соглашения носят пока экспериментальный характер. Тем не менее повышение эффективности за счет переноса части со стадии исполнения на стадию компиляции может оказаться значительным, а возможность выразить поведение, которое трудно или невозможно реализовать во время исполнения, также весьма привлекательно.
Поддержка TMP растет. Вероятно, в следующей версии C++ будет реализована явная поддержка этой технологии, в TR1 это уже декларировано (см. правило 54). Начали появляться книги, посвященные этой теме, а информация о TMP в Internet становится все богаче. Видимо, TMP никогда не станет главным направлением развития, но для некоторых программистов (особенно разработчиков библиотек) она почти наверняка займет важное место.
• Метапрограммирование шаблонов позволяет перенести часть работы со стадии исполнения на стадию компиляции. За счет этого можно раньше обнаружить ошибки и повысить производительность программ.
• Технология TMP может быть использована для генерации кода на основе комбинации политик, а также чтобы предотвратить генерацию кода, некорректного для определенных типов данных.Глава 8 Настройка new и delete
В наши дни, когда вычислительные среды снабжены встроенной поддержкой «сборки мусора» (как, например, Java и. NET), ручной подход C++ к управлению памятью может показаться несколько устаревшим. Однако многие разработчики, создающие требовательные к ресурсам прикладные программы, выбирают C++
Для этого нужно понимать, как организованы процедуры управления памятью в C++. Именно этой теме и посвящена настоящая глава. Два основных компонента здесь – это процедура выделения и освобождения памяти (операторы new и delete), а вспомогательная роль отводится обработчику new – функции, которая вызывается, когда new не может удовлетворить запрос на выделение памяти.
С управлением памятью в многопоточной среде связаны дополнительные сложности, не возникающие в однопоточных системах, поскольку «куча» – это модифицируемый глобальный ресурс, доступ к которому должен быть синхронизирован. Во многих правилах из настоящей главы идет речь об использовании модифицируемых статических данных. Эта тема всегда настораживает программистов, разрабатывающих многопоточные программы. Без правильной синхронизации, отсутствия взаимных блокировок в алгоритмах и тщательного проектирования с целью предотвращения одновременного доступа, обращения к процедурам работы с памятью могут легко привести к повреждению структур данных, управляющих кучей. Вместо того чтобы постоянно напоминать вам об этой опасности, я говорю об этом только здесь и предполагаю, что вы будете помнить об этом при чтении остальной части главы.
Следует помнить и о том, что операторы new и delete применимы только к выделению и освобождению одиночных объектов. Память для массивов выделяет operator new[] и освобождает operator delete[] (в обоих случаях «[]» является частью имен функций). Если явно не оговорено противное, то все сказанное об операторах new и delete касается также new[] и delete[].
И наконец, отмечу, что в случае STL-контейнеров выделением памяти из кучи управляют объекты-распределители, ассоциированные с самими контейнерами, а не напрямую new и delete. В этой главе ничего не говорится о распределителях памяти в STL.
Правило 49: Разберитесь в поведении обработчика new
Когда оператор new не может удовлетворить запрос на выделение памяти, он возбуждает исключение. Когда-то он возвращал нулевой указатель, и некоторые старые компиляторы все еще так и поступают. Вы можете столкнуться с таким устаревшим поведением, но я отложу его обсуждение до конца правила.
Прежде чем возбудить исключение в ответ на невозможность удовлетворить запрос на выделение памяти, оператор new вызывает определенную пользователем функцию, называемую
namespace std {
typedef void (*new_handler)();
new_handler set_new_handler(new_handler p) throw();
}Как видите, new_handler – это typedef для указателя на функцию, которая ничего не принимает и не возвращает, а set_new_handler – функция, которая принимает и возвращает new_handler (конструкция throw() в конце объявления set_new_handler – это спецификация исключения; по существу, она сообщает, что функция не возбуждает никаких исключений, хотя на самом деле все несколько интереснее; подробности см. в правиле 29).
Параметр set_new_handler – это указатель на функцию, которую operator new вызывает при невозможности выделить запрошенную память. Возвращаемое set_new_handler значение – указатель на функцию, которая использовалась для этой цели перед вызовом set_new_handler.
Используется set_new_handler следующим образом:// функция, которая должна быть вызвана, если operator new
// не может выделить память
void outOfMem()
{
std::cerr << “Невозможно удовлетворить запрос на выделение памяти\n”;
std::abort();
}
int main()
{
std::set_new_handler(outOfMem);
int *pBigDataArray = new int[100000000L];
…
}Если operator new не может выделить память для размещения 100 000 000 целых чисел, будет вызвана функция outOfMem, и программа завершится, выдав сообщение об ошибке. (Кстати, подумайте, что случится, если память должна быть динамически выделена во время вывода сообщения об ошибке в cerr…)
Когда operator new не может удовлетворить запрос в памяти, он будет вызывать обработчик new до тех пор, пока он не
• Найти дополнительную память. В результате следующая попытка выделить память внутри operator new может завершиться успешно. Один из способов реализовать такую стратегию – выделить большой блок памяти в начале работы программы, в затем освободить его при первом вызове обработчика new.
• Установить другой обработчик new. Если текущий обработчик не может выделить память, то, возможно, ему известен какой-то другой, который сможет это сделать. Если так, то текущий обработчик может установить вместо себя другой (вызвав set_new_handler). В следующий раз, когда operator new обратится к обработчику, будет вызван последний установленный. (В качестве альтернативы обработчик может изменить
• Убрать обработчик new, то есть передать нулевой указатель set_new_handler. Если обработчик не установлен, то operator new сразу возбудит исключение при неудачной попытке выделить память.
• Возбудить исключение типа bad_alloc либо некоторого типа, унаследованного от bad_alloc. Такие исключения не перехватывает operator new, поэтому они распространяются до того места, где была запрошена память.
• Не возвращать управление – обычно вызвав abort или exit.
Эти варианты выбора обеспечивают вам достаточную гибкость в реализации функций-обработчиков new.
Иногда обработать ошибки при выделении памяти можно и другими способами, зависящими от класса распределяемого объекта:class X {
public:
static void outOfMemory();
…
};
class Y {
public:
static void outOfMemory();
…
};
X *p1 = new X; // если выделить память не удалось,
// вызвать X::outOfMemory
Y *p2 = new Y; // если выделить память не удалось,
// вызвать Y::outOfMemoryС++ не поддерживает специфичных для класса обработчиков new, но он и не нуждается в них. Вы можете реализовать такое поведение самостоятельно. Для этого просто в каждом классе определяете собственную версию set_new_handler и operator new. Определенная в классе функция set_new_handler класса позволит пользователям задать обработчик new для класса (точно так же, как обычный set_new_handler устанавливает глобальный обработчик new). Принадлежащий классу operator new гарантирует, что при выделении памяти для объектов этого класса вместо глобального обработчика new будет использован тот, что определен в данном классе. Предположим, вы хотите обработать ошибки выделения памяти для класса Widget. Понадобится функция, которая будет вызываться, когда operator new не может выделить достаточно памяти для объекта Widget, поэтому вы объявляете статический член типа new_handler для хранения указателя на обработчик new для класса. Тогда Widget будет выглядеть примерно так:
class Widget {
public:
static std::new_handler set_new_handler(std::new_handler p) throw();
static void *operator new(std::size_t size) throw(std::bad_alloc);
private:
static std::new_handler currentHandler;
};Статические члены класса должны быть определены вне самого класса (если только они не константные целые – см. правило 2), поэтому:
std::new_handler Widget::currentHandler = 0; // инициализировать нулем // в файле реализации класса
Функция set_new_handler в классе Widget сохранит переданный ей указатель и вернет тот указатель на функцию, действовавшую ранее. Так же поступает и стандартная версия set_new_handler:
static std::new_handler set_new_handler(std::new_handler p) throw()
{
std::new_handler oldHandler = currentHandler;
currentHandler = p;
return oldHandler;
}А вот что должен делать operator new из класса Widget.
1. Вызвать стандартный set_new_handler, указав в качестве параметра функцию-обработчик ошибок из класса Widget. В результате обработчик new из класса Widget будет установлен в качестве глобального.
2. Вызвать глобальный operator new для реального выделения памяти. Если произойдет ошибка, глобальный operator new вызовет обработчик new, принадлежащий Widget, поскольку эта функция была установлена в качестве глобального обработчика. Если это ни к чему не приведет, то глобальный operator new возбудит исключение bad_alloc. В этом случае operator new из класса Widget должен восстановить исходный обработчик new, а затем распространить исключение. Чтобы гарантировать, что исходный обработчик всегда восстанавливается, класс Widget трактует его как ресурс и следует совету правила 13 об использовании управляющих ресурсами объектов для предотвращения утечек.
3. Если глобальный operator new в состоянии выделить достаточно памяти для объекта Widget, то operator new класса Widget возвращает указатель на выделенную память. Деструктор объекта, самостоятельно управляющего глобальным обработчиком new, автоматически восстанавливает тот глобальный обработчик, который был установлен перед вызовом operator new класса Widget.
Теперь посмотрим, как все это выразить на C++. Начнем с класса, управляющего ресурсами, который не содержит ничего, кроме основных операций, диктуемых идиомой RAII: захват ресурса во время конструирования объекта и освобождение при его уничтожении (см. правило 13):class NewHandlerHolder {
public:
explicit NewHandlerHolder(std::new_handler nh) // получить текущий
:handler(nh) {} // обработчик new
~NewHandlerHolder() // освободить его
{ std::set_new_handler(handler);}
private:
std::new_handler handler; // запомнить его
NewHandlerHolder(const NewHandlerHolder&); // предотвратить
NewHandlerHolder& // копирование
operator=(const NewHandlerHolder&); // (см. правило 14)
};Это делает реализацию оператора new для Widget совсем простой:
void Widget::orerator new(std::size_td size) throw(std::bad_aloc)
{
NewHandlerHolder // установить обработчик
h(std::set_new_handler(currentHandler)); // new из класса Widget
return ::operator new(size); // выделить память или
// возбудить исключение
} // восстановить глобальный
// обработчик newПользователи класса Widget применяют эти средства следующим образом:
void outOfMem(); // объявление функции, которую нужно
// вызвать, если выделить память
// для Widget не удается
Widget::set_new_handler(outOfmem); // установка outOfMem в качестве
// обработчика new для Widget
Widget *pw1 = new Widget; // если выделить память не удалось,
// вызывается outOfMem
std::string *ps = new std::string; // если выделить память не удалось,
// вызывается глобальный обработчик new
// (если есть)
Widget::set_new_handler(0); // отменяет обработчик new
Widget *pw1 = new Widget; // если выделить память не удалось,
// сразу же возбуждается исключение (никакого
// обработчика new сейчас нет)Код, реализующий эту схему, один и тот же (независимо от класса), поэтому разумно было бы повторно использовать его в других местах. Простой способ сделать это – создать «присоединяемый» базовый класс, то есть базовый класс, который предназначен для того, чтобы подклассы могли унаследовать одно-единственное средство, в данном случае способность устанавливать специфичный для класса обработчик new. Затем превратите базовый класс в шаблон, чтобы каждый производный класс мог получать разные копии данных. При таком подходе принадлежащая базовому классу часть позволяет подклассам наследовать необходимые им функции set_new_handler и operator new, а шаблонная часть гарантирует, что у каждого подкласса будет собственный член данных currentHandler. Звучит сложновато, но код выглядит обнадеживающе знакомым. Фактически единственным отличием является то, что теперь он доступен любому классу:
template class NewHandlerSupport { // поддержки специфичной для класса public: // функции set_new_handler static std::new_handler set_new_handler(std::new_handler p) throw(); static void *operator new(std::size_t size) throw(std::bad_alloc); … // другие версии оператора new – см. правило 52 private: static std::new_handler currentHandler; }; template std::new_handler NewHandlerSupport { std::new_handler oldHandler = currentHandler; currentHandler = p; return oldHandler; } template void *NewHandlerSupport throw(std::bad_alloc) { NewHandlerHolder h(std::set_new_handler(currentHandler); return ::operator new(size); } // currentHandler в любом классе инициализируется значением null template
С этим шаблоном класса добавление поддержки set_new_handler к Widget очень просто: Widget просто наследуется от NewHandlerSupport
class Widget: public NewHandlerSupport … // как раньше, но без декларации
Это все, что нужно сделать в классе Widget, чтобы предоставить специфичный для класса обработчик set_new_handler.
Но может быть, вас беспокоит тот факт, что Widget наследует классу New-HandlerSupport
А что касается того, что Widget наследует шаблонному базовому классу, который принимает Widget как параметр типа, не пугайтесь, это только поначалу кажется непривычным. На практике это очень удобная техника, имеющая собственное название, которое отражает тот факт, что никому из тех, кто видит ее в первый раз, она не кажется естественной. А называется она
Однажды я опубликовал статью, в которой писал, что лучше было бы это назвать ее «Сделай Это Для Меня», потому что Widget наследует NewHandler-Support
Шаблоны, подобные NewHandlerSupport, упрощают добавление специфичных для класса обработчиков new к любому классу, которому это нужно. Однако наследование присоединяемому классу приводит к множественному наследованию, и прежде чем вставать на этот путь, вам, возможно, стоит перечитать правило 40.
До 1993 года C++ требовал, чтобы оператор new возвращал нулевой указатель, если не мог выделить нужную память. Теперь же operator new в этом случае должен возбуждать исключение bad_alloc. Но огромный объем кода на C++ был написан до того, как компиляторы стали поддерживать новую спецификацию. Комитет по стандартизации C++ не хотел отметать весь код, основанный на сравнении с null, поэтому было решено предоставить альтернативные формы operator new, обеспечивающие традиционное поведение с возвращением null при неудаче. Эти формы известны под названием «nothrow» (не возбуждающие исключений), потому что в них используются объекты nothrow (определенные в заголовкеclass Widget {…};
Widget *pw1 = new Widget; // возбуждает bad_alloc, если
// выделить память не удалось
if(pw1 == 0) … // эта проверка
// завершиться неудачно
Widget *pw2 = new (std::nothrow)Widget; // возвращает 0, если выделить
// память не удалось
if(pw2 == 0) … // эта проверка может
// завершиться успешноНе возбуждающий исключений оператор new предоставляет менее надежные гарантии относительно исключений, чем кажется на первый взгляд. В выражении «new (std::nothrow)Widget» происходят две вещи. Во-первых, nothrow-версия оператора new вызывается для выделения памяти, достаточной для размещения объекта Widget. Если получить память не удалось, то оператор new возвращает нулевой указатель, как нам и хотелось. Если же память выделить удалось, то вызывается конструктор Widget, и в этой точке все гарантии заканчиваются. Конструктор Widget может делать все, что угодно. Он может сам по себе запросить с помощью new какую-то память, и если он это делает, никто не заставляет его использовать nothrow. Хотя вызов оператора new в «new (std::nothrow)Widget» не возбуждает исключений, к конструктору Widget это не относится. И если он возбудит исключение, то оно будет распространяться как обычно. Вывод? Применение nothrow new гарантирует только то, что данный operator new не возбудит исключений, но не дает никаких гарантий относительно выражения, подобного «new (std::nothrow)Widget». А потому вряд ли стоит вообще прибегать к nothrow new.
Независимо от того, используете вы «нормальный» (возбуждающий исключения) new или же вариант nothrow, важно, чтобы вы понимали поведение обработчика new, поскольку он вызывается обеими формами.
• set_new_handler позволяет указать функцию, которая должна быть вызвана, если запрос на выделение памяти не может быть удовлетворен.
• Полезность nothrow new ограничена, поскольку эта форма применимо только для выделения памяти; последующие вызовы конструктора могут по-прежнему возбуждать исключения.Правило 50: Когда имеет смысл заменять new и delete
Вернемся к основам. Прежде всего зачем кому-то может понадобиться подменять предлагаемые компилятором версии operator new и operator delete? Существуют, по крайней мере, три распространенные причины.
• Чтобы обнаруживать ошибки применения. Если не освобождать (с помощью оператора delete) память, выделенную оператором new, это приведет к утечкам памяти. Вызов delete более одного раза для одного и того же участка памяти, выделенного new, приведет к неопределенному поведению программы. Если оператор new будет вести список выделенных адресов, а оператор delete удалять адреса освобожденных областей из списка, то такие ошибки легко обнаружить. Аналогично различные ошибки в программе могут приводить к записи за концом выделенного блока (переполнению буфера) либо с адреса, предшествующего началу выделенного блока. Специализированная версия оператора new может запрашивать блоки большего размера и в неиспользуемое место до и после области, доступной пользователям, записывать некоторую комбинацию битов («сигнатуры»). При этом оператор delete может проверять наличие такой сигнатуры. Если ее нет, значит, память была затерта, и оператор delete может запротоколировать этот факт вместе со значением указателя, для которого это обнаружилось.
• Чтобы повысить эффективность. Версии операторов new и delete, поставляемые вместе с компилятором, универсальны. Они должны быть приемлемы как для долго работающих программ (например, Web-серверов), так и для программ, работающих менее одной секунды. Они должны уметь обрабатывать серии запросов на выделение больших блоков памяти, малых блоков, а также смеси тех и других. Они должны адаптироваться к широкому диапазону вариантов использования – от динамического выделения нескольких блоков большого размера, которые существуют на протяжении всего времени работы программы, до выделения и освобождения памяти для большого количества мелких объектов с малым временем жизни. Они должны предотвращать фрагментацию «кучи», ибо если этого не делать, то в конце концов будет невозможно удовлетворить запрос на выделение большого блока памяти, даже если суммарно такой объем имеется, но разнесен по множеству мелких участков.
Учитывая все требования, предъявляемые к менеджерам памяти, неудивительно, что поставляемые с компиляторами операторы new и delete придерживаются усредненной стратегии. Они работают достаточно хорошо для всех, но оптимально – ни для кого. Если вы хорошо представляете, как динамическая память используется в вашей программе, вы сможете написать собственные версии операторов new и delete, превосходящие по эффективности стандартные. Под «превосходством» я подразумеваю, что они работают быстрее (иногда на много порядков) и требуют меньше памяти (до 50 %). Для некоторых, но отнюдь не для всех, приложений замена поставляемых new и delete собственными версиями – простой способ ощутимого повышения производительности.
• Чтобы собирать статистику использования. Прежде чем перейти к написанию собственных new и delete, благоразумно собрать информацию о том, как ваша программа использует динамическую память. Как распределены выделяемые блоки по размерам? Как распределяется их время жизни? Порядок выделения и освобождения в основном следует принципу FIFO («первым вошел – первым вышел») или же LIFO («последним вошел – первым вышел»)? Или никакой закономерности не наблюдается? Изменяется ли характер использования памяти со временем, то есть существует ли разница в порядке выделения-освобождения памяти между разными стадиями исполнения? Какой максимальный объем динамически выделенной памяти используется в каждый момент времени?
По существу, написание пользовательских версий new и delete – довольно простая задача. Например, рассмотрим вкратце, как можно реализовать глобальный оператор new с контролем записи за границами выделенного блока. Правда, в нем есть множество дефектов, но пока не будем обращать на них внимания.
static const int signature = 0xDEADBEEF;
typedef unsigned char Byte;
// в этом коде есть несколько дефектов – см. ниже
void *operator new(std:size_t size) throw(std::bad_alloc)
{
using namespace std;
size_t realSize=size+2*sizeof(int); // увеличить размер запрошенного
// блока, чтобы можно было разместить
// сигнатуры
void *pMem = malloc(realSize); // вызвать malloc для получения памяти
if(!pMem) throw(bad_alloc);
// записать сигнатуру в первое и последнее слово выделенного блока
*(static_cast
*(reinterpret_cast
signature;
// вернуть указатель на память сразу за начальной сигнатурой
return static_cast
Большинство недостатков этой версии оператора new связаны с тем, что он не вполне соответствует соглашениям C++ относительно функций с таким именем. Например, в правиле 51 объясняется, что все операторы new должны включать цикл вызова функции-обработчика new, чего этот вариант не делает. Этому соглашению посвящено правило 51, поэтому сейчас я хочу сосредоточиться на более тонком моменте:
Многие компьютерные архитектуры требуют, чтобы данные конкретных типов были размещены в памяти по вполне определенным адресам. Например, архитектура может требовать, чтобы указатели размещались по адресам, кратным четырем (то есть были выровнены на границу четырехбайтового слова), а данные типа double начинались с адреса, кратного восьми. Если не придерживаться этого соглашения, то возможны аппаратные ошибки во время исполнения. Другие архитектуры более терпимы, хотя и могут демонстрировать более высокую производительность, если удовлетворены требования выравнивания. Например, на архитектуре Intel x86 значения типа double могут быть выровнены по границе любого байта, но доступ к ним будет значительно быстрее, если они выровнены по восьмибайтовым границам.
Выравнивание важно, потому что C++ требует, чтобы все указатели, возвращаемые оператором new, были выровнены для
Внимание к подобным деталям отличает менеджеры памяти профессионального качества от тех, что делают на скорую руку программисты, вынужденные отвлекаться на другие задачи. Написать собственный менеджер памяти, который почти работает, достаточно просто. Написать такой, который работает
Во многих случаях ее нет. Некоторые компиляторы имеют переключатели, позволяющие отлаживать и протоколировать работу функций управления памятью. Поверхностное знакомство с документацией по вашему компилятору может исключить необходимость в написании собственных версий new и delete. На многих платформах доступны коммерческие продукты, позволяющие заменить функции управления памятью, поставляемые с компиляторами. Чтобы воспользоваться их расширенной функциональностью и (предположительно) повышенной производительностью, придется лишь заново компоновать программу (ну и, само собой, заплатить).
Другой вариант – менеджеры памяти с открытым кодом. Они есть для многих платформ, поэтому можете скачать и попробовать. Один из таких распределителей памяти с открытым кодом – библиотека Pool из проекта Boost (см. правило 55). Библиотека Pool предлагает распределители памяти, оптимизированные для использования в одной из наиболее часто встречающихся ситуаций, где может быть оказаться полезным нестандартный менеджер памяти: распределение памяти для большого количества мелких объектов. Во многих книгах по C++, включая и ранние редакции этой, приводится код высокопроизводительного распределителя памяти для мелких объектов, но часто опускаются такие «скучные» детали, как переносимость, соглашения о выравнивании, безопасность относительно потоков и т. п. В реальные библиотеки включен гораздо более устойчивый код. Даже если вы решите написать собственные new и delete, знакомство версий с открытым кодом, вероятно, даст вам понимание тех деталей, которые отличают «почти работающие» системы от действительно работающих. Выравнивание – одна из таких деталей. Стоило бы отметить, что в отчет TR1 (см. правило 54) включена поддержка для выявления требований выравнивания, специфичных для конкретного типа.
Тема настоящего правила – вопрос о том, когда имеет смысл подменять версии new и delete по умолчанию – на глобальном уровне или на уровне класса. Теперь мы можем ответить на этот вопрос более подробно.
• Чтобы обнаруживать ошибки использования (как было сказано выше).
• Чтобы собирать статистику об использовании динамически распределенной памяти (также было сказано выше).
• Для ускорения процесса распределения и освобождения памяти. Распределители общего назначения часто (хотя и не всегда) работают намного медленнее, чем оптимизированные версии, особенно если последние специально разработаны для объектов определенного типа. Специфичные для класса распределители являются примерами выделения блоков фиксированного размера, вроде тех, что представляет библиотека Pool из проекта Boost. Если ваше приложение однопоточное, но менеджер памяти, поставляемый с компилятором, по умолчанию потокобезопасный, то вы можете получить заметный рост производительности, написав менеджер памяти для однопоточных приложений. Конечно, прежде чем решить, что нужно переписывать операторы new и delete для повышения скорости, убедитесь с помощью профилирования, что эти функции действительно являются узким местом.
• Чтобы уменьшить накладные расходы, характерные для стандартного менеджера памяти. Менеджеры памяти общего назначения часто (хотя не всегда) не только медленнее оптимизированных версий, но и потребляют больше памяти. Это происходит из-за того, что с каждым выделенным блоком связаны некоторые накладные расходы. Распределители, оптимизированные для мелких объектов (как, например, Pool), позволяют почти избавиться от этих расходов.
• Чтобы компенсировать субоптимальное выравнивание в распределителях по умолчанию. Как я уже упоминал, самый быстрый доступ к значениям double на архитектуре x86 получается тогда, когда они выровнены по восьмибайтным границам. К сожалению, операторы new, поставляемые с некоторыми компиляторами, не гарантируют восьмибайтового выравнивания при динамическом выделении double. В этих случаях замена оператора new по умолчанию на специальный, который гарантирует такое выравнивание, может дать заметный рост производительности программы.
• Чтобы сгруппировать взаимосвязанные объекты друг с другом. Если вы знаете, что определенные структуры данных обычно используются вместе, и хотите минимизировать частоту ошибок из-за отсутствия страницы в физической памяти при работе с такими данными, то, возможно, имеет смысл создать отдельную кучу для подобных структур, чтобы они были собраны вместе, или на столь небольшом числе страниц, насколько возможно. Версии операторов new и delete с размещением (см. правило 52) могут обеспечить такую группировку.• Чтобы получить нестандартное поведение. Иногда может понадобиться, чтобы операторы new и delete делали нечто, чего поставляемые с компилятором версии делать не умеют. Например, вам нужно распределять и освобождать блоки памяти в разделяемой памяти, но для операций с такой памятью у вас только программный интерфейс C. Написание специальных версий new и delete (возможно, с размещением – см. правило 52) позволит вам обернуть C API в классы C++. Вы также можете написать специальный оператор delete, который заполняет освобождаемую память нулями, чтобы повысить степень защиты данных в приложении.
Что следует помнить
• Есть много причин для написания специальных версий new и delete, включая повышение производительности, отладку ошибок при работе с кучей, а также сбор информации об использовании памяти.
Правило 51: Придерживайтесь принятых соглашений при написании new и delete
В правиле 50 объясняется, зачем могут понадобиться собственные версии операторов new и delete, но ничего не говорится о соглашениях, которых следует придерживаться при их написании. Следовать этим соглашениям не так уж сложно, но некоторые из них противоречат интуиции, поэтому знать о них необходимо.
Начнем с оператора new. От отвечающего стандарту оператора new требуется, чтобы он возвращал правильное значение, вызывал обработчика new, когда запрошенную память не удается выделить (см. правило 49), и правильно обрабатывал запросы на выделения нуля байтов. Кроме того, надо принять меры к тому, чтобы нечаянно не скрыть «нормальную» форму new, хотя это в большей мере касается интерфейса класса, чем требований реализации (см. правило 52).
Обеспечить правильность возвращаемого оператором new значения легко. Если вы можете выделить запрошенную память, то возвращаете указатель на нее. Если не можете, то следуете рекомендациям из правила 49 и возбуждаете исключение типа bad_alloc.
Однако все не так просто, потому что оператор new пытается выделить память не один раз, и после каждой неудачи вызывает функцию-обработчик new. Предполагается, что это сможет что-то сделать для освобождения некоторого объема памяти. Только тогда, когда указатель на обработчик new равен нулю, оператор new возбуждает исключение.
Забавно, но C++ требует, чтобы оператор new возвращал корректный указатель даже тогда, когда запрошено 0 байтов памяти. (Такое странное поведение упрощает реализацию некоторых вещей в других местах языка.) С учетом этого случая псевдокод для оператора new (нечлена класса) выглядит так:
void *operator new(std::size_t size) throw(std::bad_alloc)
{ // ваш оператор new может принимать
using namespace std; // дополнительные параметры
if (size == 0) { // обработать запрос на 0 байтов,
size = 1; // считая, что нужно выделить 1 байт
}
while(true) {
if(
return (
// выделить память не удалось; проверить, установлена ли
// функция-обработчик new (см. ниже)
new_handler globalHandler = set_new_handler(0);
set_new_handler(globalHandler);
if(globalHandler) (*globalHandler)();
else throw std::bad_alloc();
}
}Трактовка запроса на 0 байтов так, как если бы запрашивался 1 байт, выглядит сомнительно, но это просто, это корректно, это работает, к тому же на сколько часто вы собираетесь запрашивать 0 байтов?
Вам также может не понравиться то место в псевдокоде, где указатель на функцию-обработчик устанавливается в нуль, а затем восстанавливается его прежнее значение. К сожалению, нет способа непосредственно получить указатель на текущий обработчик new, поэтому приходится вызывать set_new_handler, чтобы получить его текущее значение. Грубо, но тоже эффективно, по крайней мере, в однопоточной программе. В многопоточной среде, возможно, понадобится какой-то механизм синхронизации для безопасного манипулирования (глобальными) структурами данных, связанными с функцией-обработчиком new.
В правиле 49 отмечено, что оператор new содержит бесконечный цикл, и в приведенном выше коде этот цикл присутствует: «while(true)». Единственный способ выйти из цикла – успешно выделить память либо выполнить в функции-обработчике одно из описанных в правиле 49 действий: сделать доступной больше памяти, установить другой обработчик, убрать текущий обработчик, возбудить исключение типа, производного от bad_alloc, либо не возвращать управления вовсе. Теперь вам должно быть ясно, почему обработчик new должен вести себя подобным образом. Если он нарушит это соглашение, то цикл внутри оператора new никогда не завершится.
Многие не понимают, что функция-член operator new наследуется производными классами. Это может привести к некоторым интересным осложнениям. Заметьте, что в приведенном псевдокоде operator new производится попытка выделить size байтов (если только size не равно нулю). Естественно, ведь size – это аргумент, переданный функции. Однако, как объясняется в правиле 50, одной из причин написания специального менеджера памяти является оптимизация размещения объектовclass Base {
public:
static void *operator new(std::size_t size) throw(std::bad_alloc);
…
};
class Derived: public Base // в подклассе не объявлен operator new
{…};
Derived *p = new Derived; // вызывается Base::operator new!Если определенный в классе Base оператор new не был спроектирован с учетом этой проблемы (а такая вероятность есть), то для корректной работы в случае, когда поступил запрос на выделение памяти «неправильного» размера, лучше всего обратиться к стандартному оператору new:
void *Base::operator new(std::size_t) throw(std::bad_alloc)
{
if(size != sizeof(Base)) // если size «неправильный»
return ::operator new(size); // вызвать стандартный оператор new
// для обработки запроса
… // в противном случае обработать запрос
// здесь
}Я слышу возгласы: «Подождите! Вы забыли проверить патологический случай с нулевым размером!» На самом деле нет. Проверка присутствует, просто она является частью сравнения с sizeof(Base). C++ иногда предъявляется странные требования, например все автономные объекты должны иметь ненулевой размер (см. правило 39). По определению, sizeof(Base) никогда не может вернуть нуль, поэтому если size равно нулю, то запрос будет переадесован::operator new, и обязанность правильно обработать запрос возлагается на него.
Если вы хотите управлять распределением памяти для массивов на уровне класса, то нужно будет реализовать оператор new[] – специально для массивов. (Эта функция обычно называется «new для массивов», потому что трудно представить, как надо произносить «operator new[]»). Если вы решите написать operator new[], то помните, что она должна лишь выделить блок неформатированной памяти. Вы не можете ничего делать с еще не существующими объектами в этом массиве. Фактически вы даже не можете определить, сколько объектов будет в этом массиве. Во-первых, вы не знаете размер объекта. А ведь из-за наследования может быть вызван оператор new[] базового класса для выделения памяти под массив объектов производного класса, которые обычно больше объектов базового класса. Поэтому вы не можете предполагать внутри Base::operator new[], что размер каждого объекта равен sizeof(Base), а значит, нельзя предполагать, что общее количество объектов в массиве будет равно
void operator delete(void *rawMemory) throw()
{
if(rawMemory == 0) return; // ничего не делать, если передан нулевой
// указатель
;
}Версия этой функции, являющаяся членом класса, также проста, за исключением того, что нужно проверить размер того, что вы собираетесь освобождать. Предполагая, что оператор new, определенный в классе, передает запрос на выделение «неправильного» количества байтов глобальному::operator new, вы также должны передать информацию о «неверном» размере функции::operator delete:
class Base { // то же, что и раньше, но добавлено
public: // объявление operator delete
static void *operator new(std::size_t size) throw(std::bad_alloc);
static void operator delete(void *rawMemory, std::size_t size) throw();
…
};
void Base::operator delete(void *rawMemory, std::size_t size) throw()
{
if(rawMemory == 0) return; // проверка на нулевой указатель
if(size != sizeof(Base)) { // если размер «неверный»,
::operator delete(rawMemory); // вызвать стандартный оператор
return; // delete для обработки запроса
}
;
return;
}Интересно, что значение типа size_t, которое C++ передает оператору delete, может быть неправильным, если удаляется объект, производный от класса, в котором нет виртуального деструктора. Одного этого уже достаточно, чтобы требовать от базового класса наличия виртуального деструктора, но в правиле 7 описана и другая, более существенная причина. Пока просто отметьте, что если вы опустили виртуальный деструктор в базовом классе, то функция operator delete может работать неправильно.
• Оператор new должен содержать бесконечный цикл, который пытается выделить память, должен вызывать функцию-обработчик new, если не удается удовлетворить запрос на выделение памяти, и должен обрабатывать запрос на выделение нуля байтов. Версии оператора new уровня класса должны обрабатывать запросы на выделение блоков большего размера, чем ожидается.
• Оператор delete не должен ничего делать при передаче ему нулевого указателя. Версии оператора delete уровня класса должны обрабатывать запросы на освобождение блоков, которые больше, чем ожидается.Правило 52: Если вы написали оператор new с размещением, напишите и соответствующий оператор delete
Операторы new и delete с размещением встречаются в C++ не слишком часто, поэтому в том, что вы с ними не знакомы, нет ничего страшного. Вспомните (правила 16 и 17), что когда вы пишете такое выражение new:
Widget *pw = new Widget;
то вызываются две функции: оператор new, чтобы выделить память, и конструктор Widget по умолчанию.
Предположим, что первый вызов завершился успешно, а второй возбудил исключение. В этом случае необходимо отменить выделение памяти, выполненное на шаге 1. В противном случае мы получим утечку памяти. Пользовательский код не может освободить память, потому что конструктор Widget возбудил исключение и pw ничего так и не было присвоено. Следовательно, пользователь так и не получил указатель на память, которая должна быть освобождена. Поэтому ответственность за отмену шага 1 возлагается на систему времени исполнения C++.
Исполняющая система рада бы вызвать оператор delete, соответствующий использованному на шаге 1 оператору new, но сделать это может лишь тогда, когда знает, какой именно вариант оператора delete – а их много – нужно вызвать. Это не проблема, если вы пользуетесь формами new и delete с обычными сигнатурами, потому что обычный оператор new:void *operator new(std::size_t size) throw(std::bad_alloc);
соответствует обычному оператору delete:
void operator delete(void *rawMemory) throw(); // обычная сигнатура
// в глобальной области
// видимости
void operator delete(void *rawMemory, // наиболее распространенная
std::size_t size) throw(); // сигнатура в области
// видимости классаЕсли вы пользуетесь только обычными формами new и delete, то исполняющая система легко найдет тот вариант delete, который знает, как отменить действие, выполненное оператором new. Проблема поиска правильного варианта delete возникает тогда, когда вы объявляете необычные формы оператора new – такие, которые принимают дополнительные параметры. Например, предположим, что вы написали оператор new уровня класса, который требует задания потока ofstream, куда должна выводиться отладочная информация о выделении памяти, и вместе с ним написали также обычный оператор delete уровня класса:
class Widget {
public:
…
static void *operator new(std:size_t size, // необычная
std::ostream& logStream) // форма new
throw(std::bad_alloc);
static void operator delete(void *pMemory, // обычная
std:size_t size) throw(); // форма delete
// уровня класса
…
};Такое решение наверняка приведет к ошибкам, но чтобы понять, почему это так, придется познакомиться с некоторыми терминами. Функция operator new, принимающая дополнительные параметры (помимо обязательного аргумента size_t), называется
void *operator new(std::size_t, void *pMemory) throw(); // “размещающий new”
Эта версия new является частью стандартной библиотеки C++, и вы получаете к ней доступ, включая в исходный текст директиву #include
Widget *pw = new (std::cerr) Widget; // вызвать оператор new, передав cerr
// в качестве параметра типа ofstream;
//
//
Если выделение памяти прошло успешно, но конструктор Widget возбуждает исключение, то исполняющая система отвечает за освобождение той памяти, которую успел выделить оператор new. Исполняющая система понятия не имеет, как работает вызванная версия оператора new, поэтому не может отменить результат операции самостоятельно. Вместо этого исполняющая система ищет версию оператора delete, которая принимает
void operator delete(void *, std::ostream&) throw();
По аналогии с размещающими версиями new версии оператора delete, которые принимают дополнительные параметры, называются
class Widget {
public:
…
static void *operator new(std:size_t size, std::ostream& logStream)
throw(std::bad_alloc);
static void operator delete(void *pMemory) throw();
static void operator delete(void *pMemory, std::ostream& logStream)
throw();
…
};С этим изменением, если конструктор Widget возбудит исключение в предложении
Widget *pw = new (std::cerr) Widget; // как раньше, но теперь никаких // утечек
то автоматически будет вызван соответственный размещающий оператор delete, так что Widget гарантирует, что никаких утечек памяти по этой причине не будет. Посмотрим, что произойдет, если никаких исключений нет (как обычно и бывает), а в пользовательском коде присутствует явный вызов delete:
delete pw; // вызов обычного оператора delete
Как сказано в комментарии, здесь вызывается обычный оператор delete, а не размещающая версия. Размещающий delete вызывается,
Это значит, что для предотвращения всех утечек памяти, ассоциированных с размещающей версией new, вы должны также предоставить и обычный оператор delete (на случай, если в конструкторе не возникнет исключений), и размещающую версию с теми же дополнительными аргументами, что и у размещающего new (если таковой имеется). Поступайте так, и вы никогда не потеряете сон из-за неуловимых утечек памяти. Ну, по крайней мере, из-за утечек памяти
class Base {
public:
…
static void *operator new(std::size_t size, // скрывает обычные
std::ostream& logStream) // глобальные формы
throw(std::bad_alloc);
…
};
Base *pb = new Base; // ошибка! Обычная форма
// оператора new скрыта
Base *pb = new (std::cerr)Base; // правильно, вызывается
// размещающий new из BaseАналогично оператор new в производных классах скрывает и глобальную, и унаследованную версии оператора new:
class Derived: public Base {
public:
…
static void *operator new(std::size_t size) // переопределяет
throw(std::bad_alloc); // обычную форму new
…
};
Derived *pd = new (std::cerr)Derived; // ошибка! заменяющая
// форма теперь скрыта
Derived *pd = new Derived; // правильно, вызывается
// оператор new из DerivedВ правиле 33 достаточно подробно рассмотрен этот вид сокрытия имен в классе, но при написании функций распределения памяти нужно помнить, что по умолчанию C++ представляет следующие формы оператора new в глобальной области видимости:
void operator new(std::size_t) throw(bad_alloc); // обычный new
void operator new(std::size_t, void*) throw(bad_alloc); // размещающий new
void operator new(std::size_t, // new, не возбуждающий
const std::nothrow_t&) throw(); // исключений –
// см. правило 49Если вы объявляете любой оператор new в классе, то тем самым скрываете все эти стандартные формы. Убедитесь, что вы сделали их доступными в дополнение к любым специальным формам new, объявленным вами в классе, если только в ваши намерения не входит запретить использование этих форм пользователям класса. И для каждого оператора new, к которому вы даете доступ, должен быть также предоставлен соответствующий оператор delete. Если вы хотите, чтобы эти функции вели себя обычным образом, просто вызывайте соответствующие глобальные их версии из своих функций. Самый простой способ – создать базовый класс, содержащий все нормальные формы new и delete:
class StandardNewDeleteForms {
public:
// нормальные new/delete
static void *operator new(std::size_t size) throw(bad_alloc)
{ return ::operator new(size);}
static void operator delete(void *pMemory) throw()
{ ::operator delete(pMemory);}
// размещающие new/delete
static void *operator new(std::size_t size, void *ptr) throw(bad_alloc)
{ return ::operator new(size, ptr);}
static void operator delete(void *pMemory, void *ptr) throw()
{ ::operator delete(pMemory, ptr);}
// не возбуждающие исключений new/delete
static void *operator new(std::size_t, const std::nothrow_t& nt) throw()
{ return ::operator new(size, nt)}
static void operator delete(void *pMemory, const std::nothrow_t&) throw()
{ ::operator delete(pMemory, nt);}
};Пользователи, которые хотят пополнить свой арсенал специальными формами new, применяют наследование и using-объявления (см. правило 33), чтобы получить доступ к стандартным формам:
class Widget: public StandardNewDeleteForms { // наследование
public: // стандартных форм
using StandardNewDeleteForms::operator new; // сделать эти формы
using StandardNewDeleteForms::operator delete; // видимыми
static void *operator new(std::size_t size, // добавляется
std::ostream& logStream) // специальный
throw(bad_alloc); // размещающий new
static void operator delete(void *pMemory, // добавляется
std::ostream& logStream) // соответствующий
throw(); // размещающий delete
…
};• Когда вы пишете размещающую версию оператора new, убедитесь, что не забыли о соответственном размещающем операторе delete. Если его не будет, то в вашей программе могут возникать тонкие, трудноуловимые утечки памяти.
• Объявляя размещающие версии new и delete, позаботьтесь о том, чтобы нечаянно не скрыть нормальных версий этих функций.Глава 9 Разное
Несмотря на то что эта глава состоит всего из трех правил, все они очень важны.
В первом правиле подчеркивается, что предупреждения компилятора – не пустяк, на который можно не обращать внимания. По крайней мере, если вы хотите, чтобы ваши программы вели себя правильно. Во втором представлен обзор стандартной библиотеки C++, включая и новую функциональность, предложенную в отчете TR1. И наконец, в последнем правиле представлен обзор проекта Boost – возможно, наиболее важного Web-сайта, посвященного общим вопросам применения C++. Игнорируя советы, изложенные в этих правилах, писать эффективные программы на C++ как минимум нелегко.
Правило 53: Обращайте внимание на предупреждения компилятора
Многие программисты зачастую игнорируют предупреждения компилятора. В конце концов, если бы проблема была по-настоящему серьезной, компилятор выдал бы ошибку! Подобные рассуждения могут быть сравнительно безвредными при работе с какими-нибудь другими языками, но в отношении C++ можно поручиться, что создатели компиляторов точнее вас оценивают истинное положение дел. Например, ниже приведена ошибка, которую рано или поздно допускает каждый из нас:
class B {
public:
virtual void f() const;
};
class D: public B {
public:
virtual void f();
};Предполагается, что функция D::f будет переопределять виртуальную функцию B::f, но ошибка состоит в следующем: в классе B функция-член f – константная, а в D она не объявляется как const. Один из известных мне компиляторов сообщает следующее:
warning: D::f() hides virtual B::f() (предупреждение: D::f() скрывает virtual B::f())
Многие неопытные программисты, получив подобное сообщение, говорят себе: «Конечно, D::f скрывает B::f – так и должно быть!» Они неправы. Вот что пытается сказать компилятор: f, объявленная в B, не была объявлена повторно в D, а полностью спрятана (объяснение причины этого явления см. в правиле 33). Если оставить без внимания данное предупреждение, это почти наверняка приведет к ошибочному поведению программы, и, чтобы найти причину, потребуются долгие часы отладки – при том, что компилятор давно уже все обнаружил.
По мере того как вы приобретете опыт работы с предупреждениями конкретного компилятора, уже нетрудно будет понимать, что означают различные сообщения (к сожалению, нередко реальное значение сообщения кардинально отличается от
Раз уж мы затронули тему предупреждений, стоит заметить, что они по своей природе зависимы от реализации, поэтому не следует слишком расслабляться и перекладывать на компилятор обнаружение ваших ошибок. Например, код с сокрытием функции, приведенный выше, проходит через другой (к сожалению, широко распространенный) компилятор без каких-либо предупреждений.
• Принимайте всерьез предупреждения компилятора и старайтесь добиться того, чтобы ваш код вообще не вызывал предупреждений, даже при задании максимального уровня диагностики.
• Не впадайте в зависимость от предупреждений компилятора, потому что разные компиляторы предупреждают о разных вещах. При переходе на новый компилятор могут пропасть некоторые предупреждения, на которые вы привыкли полагаться.Правило 54: Ознакомьтесь со стандартной библиотекой, включая TR1
Стандарт C++ (документ, описывающий язык и его библиотеку) был ратифицирован в 1998 году. В 2003 году были внесены небольшие изменения, исправляющие ошибки. Комитет по стандартизации, однако, продолжает работать, и появление «Версии 2.0» стандарта C++ ожидается примерно в 2008 году. Неопределенность относительно точной даты объясняет, почему обычно при ссылке на следующую версию C++ говорят «С++0х» (версию C++ 200х-го года).
Предположительно, C++0x будет описывать некоторые интересные дополнения к самому языку, но большая часть новой функциональности C++ будет иметь вид добавлений к стандартной библиотеке. Мы уже знаем кое-что из того, что появится в библиотеке, потому что это специфицировано в документе, известном под названием TR1 («Technical Report 1»), созданном рабочей группой по библиотеке C++. Комитет по стандартизации сохраняет за собой право модифицировать описанную в TR1 функциональность, прежде чем она будет включена в официальный стандарт C++0x, но существенные изменения маловероятны. С практической точки зрения, TR1 возвещает начало новой редакции C++, которую можно было бы назвать стандартом C++ 1.1. Нельзя быть эффективно работающим программистом C++, не будучи знакомым с функциональностью, описанной в TR1, потому что она полезна для библиотек и приложений почти любого типа.
Прежде чем дать краткий обзор того, что включено в TR1, стоит вспомнить основные части стандартной библиотеки C++, специфицированные в C++98:
• Стандартная библиотека шаблонов (STL), включающая контейнеры (vector, string, map и т. п.); итераторы; алгоритмы (find, sort, transform и т. п.); функциональные объекты (less, greater и т. п.) и различные адаптеры контейнеров и функциональных объектов (stack, priority_queue, mem_fun, not1 и т. п.).
• Потоки ввода-вывода (iostreams), включая поддержку определенной пользователем буферизации, интернационализацию ввода-вывода и предопределенные объекты – cin, cout, cerr и clog.
• Поддержка интернационализации, включая возможность иметь несколько активных локалей. Типы наподобие wchar_t (обычно 16-битные char) и wstring (строки, состоящие из wchar_t), облегчающие работу с кодировкой Unicode.
• Поддержка численных методов, включая шаблоны для комплексных чисел (complex) и массивы чистых значений (valarray).
• Иерархия исключений, включая базовый класс exception, производные от него – logic_error и runtime_error, а также разнообразные классы, наследующие этим.
• Стандартная библиотека C89. Все, что есть в стандартной библиотеке C 1989 года, есть и в C++.
Если что-то из перечисленного вам незнакомо, я советую найти время и исправить ситуацию, обратившись к вашему любимому руководству по C++.
TR1 специфицирует 14 новых компонентов библиотеки. Все они находятся в пространстве имен std, точнее, во вложенном пространстве tr1. Таким образом, полное наименование компонента TR1 shared_ptr (см. ниже) – std::tr1::shared_ptr. В этой книге я иногда пропускаю std::, когда говорю о компонентах стандартной библиотеки, но всегда указываю префикс tr1::.
В настоящей книге были приведены примеры следующих компонентов TR1:
• «Интеллектуальные» указатели tr1::shared_ptr и tr1::weak_ptr. tr1::shared_ptr работает как встроенный указатель, но отслеживает, сколько экземпляров tr1::shared_ptr указывает на объект. Этот прием называется
tr1::shared_ptr, может быть, наиболее полезный компонент TR1. Я многократно прибегал к нему в этой книге, в том числе в правиле 13, где объяснял, почему это так важно. (К сожалению, в книге не нашлось места для tr1::weak_ptr.)
• tr1::function дает возможность представить любую
void registerCallback(std::string func(int)); // типом параметра
// является функция
// принимающая int и
// возвращающая stringИмя параметра – func – необязательно, поэтому registerCallback может быть объявлена и так:
void registerCallback(std::string (int)); // то же, что выше; имя // параметра опущено
Отметим, что «std::string (int)» – это сигнатура функции. tr1::function позволяет сделать функцию registerCallback намного более гибкой за счет того, что ее аргументом может быть любая вызываемая сущность, которая принимает параметр int или
void registerCallback(std::tr1::function // параметр func – это любая вызываемая // сущность с сигнатурой, совместимой
Гибкость такого рода удивительно удобна. Я постарался продемонстрировать ее в правиле 35.
• tr1::bind делает все, на что способны адаптеры-связыватели STL bind1st и bind2nd, плюс многое другое. В отличие от связывателей, существовавших до TR1, tr1::bind может работать как с константными, так и с неконстантными функциями-членами. Допускаются также параметры, передаваемые по ссылке. Кроме того, в отличие от старых связывателей, tr1::bind не нуждается в помощи со стороны при работе с указателями на функции, поэтому обращаться к ptr_fun, mem_fun или mem_fun_ref перед вызовом tr1::bind больше нет нужды. Проще говоря, tr1::bind – это связыватель второго поколения, которое существенно лучше своих предшественников. Пример использования я привел в правиле 35.
Прочие компоненты TR1 я разделил на две группы. Первая группа представляет довольно дискретную, самостоятельную функциональность:
• Хэш-таблицы используются для реализации контейнеров, подобных set, multiset, map и multimap. Интерфейсы новых контейнеров смоделированы на основе соответствующего компонента из предыдущей версии библиотеки. Наиболее удивительны в хэш-таблицах TR1 имена: tr1::unordered_set, tr1::unordered_multiset, tr1::unordered_map, tr1::unordered_multimap. Они отражают тот факт, что в отличие от set, multiset, map или multimap, элементы кэшированных контейнеров TR1 никак не упорядочены.
• Регулярные выражения, включая возможность поиска и замены в строках, перебора соответствий и т. п.
• Кортежи (tuples) – изящные обобщения шаблона pair, уже имеющегося в стандартной библиотеке. Если объект типа pair может содержать только два объекта, то объект tr1::tuple может служить вместилищем для произвольного числа других объектов. Эмигранты из стран Python и Eiffel, возрадуйтесь! Теперь в C++ появилась горсть и вашей родной земли.
• tr1::array – по существу, «STL-изированный» массив, то есть массив, поддерживающий такие функции-члены, как begin и end. Размер tr1::array фиксируется при компиляции; этот объект не использует динамической памяти.
• tr1::mem_fn – синтаксически унифицированный способ адаптации указателей на функции-члены. Как tr1::bind обобщает связыватели bind1st и bind2nd из библиотеки C++98, так и tr1::mem_fn расширяет возможности mem_fn и mem_fn_ref.
• tr1::reference_wrapper – средство, предназначенное для того, чтобы придать ссылкам большее сходство с объектами. В частности, это дает возможность создавать контейнеры, которые ведут себя так, будто содержат ссылки (в действительности контейнер может содержать только объекты или указатели).
• Генератор случайных чисел – средство, намного превосходящее функцию rand, которую C++ унаследовал от стандартной библиотеки C.
• Специальные математические функции, включая полиномы Лагерра, функции Бесселя, полные эллиптические интегралы и многое другое.
• Расширения, совместимые с C99, – набор функций и шаблонов, предназначенных для включения в C++ многих новых средств из библиотеки C99.
Второй набор компонентов TR1 обеспечивает поддержку более изощренной техники программирования с применением шаблонов, включая и метапрограммирование шаблонов (см. правило 48):
• Характеристики типов (type traits) – набор классов для предоставления информации о типах во время компиляции (см. правило 47). По данному типу T классы-характеристики TR1 могут узнать, является ли он встроенным, обладает ли виртуальным деструктором, представляет ли пустой класс (см. правило 39), может ли быть неявно преобразован в некоторый другой тип U и многое другое. Классы-характеристики TR1 также могут также определить правильное выравнивание для данного типа, что очень важно при написании специализированных функций распределения памяти (см. правило 50).
• tr1::result_of – шаблон, позволяющий вывести тип значения, возвращаемого функцией. При написании шаблонов часто важно иметь возможность ссылаться на тип объекта, возвращаемого при вызове функции (шаблона), но этот тип может сложным образом зависеть от типов параметров. tr1::result_of упрощает определение возвращаемого типа значения, возвращаемого функцией… tr1::result_of используется и во многих местах в самой библиотеке TR1.
Несмотря на то что некоторые части TR1 (в частности, tr1::bind и tr1::mem_fn) обобщают ранее существовавшие компоненты, все же TR1 содержит и немало совсем новых возможностей. Ни один из компонентов TR1 не заменяет существующих, поэтому унаследованный код будет продолжать работать.
Отчет TR1 сам по себе – всего лишь документ [4] . Чтобы воспользоваться преимуществами описанной в нем функциональности, необходим доступ к ее реализации. Рано или поздно код будет поставляться вместе с компиляторами, но в 2005 году, когда писалась настоящая книга, вероятно, не все включенное в TR1 вошло в состав имеющейся у вас реализации стандартной библиотеки. К счастью, нужные компоненты можно найти и в других местах: 10 из 14 компонентов TR1 основаны на библиотеках, доступных на сайте Boost (см. правило 55), поэтому это отличный источник TR1-подобной функциональности. Я говорю «TR1-подобной», потому что хотя значительная часть того, что описано в TR1, и базируется на библиотеках Boost, есть некоторые моменты, в которых нынешние версии Boost не вполне соответствуют спецификации TR1. Возможно, когда вы будете читать эту главу, Boost не только будет предоставлять полностью соответствующую TR1 реализацию, но также и те четыре компонента, которые вошли в TR1 независимо.
Если вы предпочитаете применять TR1-подобные библиотеки Boost в качестве временной меры, до тех пор, пока вместе с компиляторами не начнут поставляться собственные реализации TR1, возможно, вам придется применить трюк с пространствами имен. Все компоненты Boost находятся в пространстве имен boost, тогда как компоненты TR1 должны находиться в пространстве std::tr1. Вы можете указать компилятору, чтобы он воспринимал ссылки на пространство std::tr1 как на boost. Вот как это делается:namespace std {
namespace tr1 = ::boost; // std::tr1 – псевдоним для пространства boost
}Технически такое поведение считается неопределенным, потому что, как объяснено в правиле 25, запрещается добавлять что-либо в пространство имен std. На практике, однако, возникновение проблем маловероятно. Когда ваш компилятор предоставит собственную реализацию TR1, вам нужно будет только удалить показанный выше псевдоним пространства имен. Код, ссылающийся на std::tr1, останется правильным.
Возможно, наиболее важная часть TR1, которая не базируется на библиотеках Boost, – это хэш-таблицы. Но хэш-таблицы доступны уже много лет из нескольких источников под именами hash_set, hash_multiset, hash_map и hash_ multimap. Есть неплохой шанс, что библиотеки, поставляемые с вашим компилятором, уже содержат эти шаблоны. Если нет, попросите вашу любимую поисковую машину найти эти имена (как и их аналоги в TR1). Наверняка вы найдете несколько источников – как коммерческих, так и открытых.
• Основная функциональность стандартной библиотеки C++ состоит из STL, потоков iostream и локалей. Также включена стандартная библиотека C99.
• TR1 добавляет поддержку «интеллектуальных» указателей (например, tr1::shared_ptr), обобщенных указателей на функции (tr1::function), кэшированных контейнеров, регулярных выражений и еще 10 компонентов.
• Отчет TR1 сам по себе – всего лишь спецификация. Чтобы воспользоваться преимуществами TR1, понадобится реализация. Одним из источников реализаций компонентов TR1 является проект Boost.Правило 55: Познакомьтесь с Boost
Вы ищете высококачественные библиотеки с открытым кодом, независимые от платформ и компиляторов? Boost к вашим услугам. Вы хотели бы присоединиться к сообществу амбициозных, талантливых программистов на C++, работающих в русле современных представлений о проектировании и реализации библиотек? Boost к вашим услугам. Хотите одним глазком взглянуть на то, как будет выглядеть C++ в будущем? Boost к вашим услугам.
Проект Boost – это одновременно сообщество разработчиков и набор свободно распространяемых библиотек на C++. Его Web-сайт находится по адресу http://boost.org. Сделайте закладку немедленно!
Существует множество организаций и Web-сайтов, посвященных C++, но Boost обладает двумя уникальными особенностями. Во-первых, он имеет тесные связи с комитетом по стандартизации C++ и способен влиять на его решения. Boost был основан членами этого комитета, и участники одного часто являются также членами другого. Вдобавок к этому Boost всегда провозглашал одной из своих целей служить платформой для тестирования средств, которые могут быть добавлены в Стандарт C++. Одним из результатов таких отношений стало то, что из 14 новых библиотек, предложенных для включения в C++ в отчете TR1 (см. правило 54), более двух третей основаны на работе, проделанной в Boost.
Вторая особенность Boost – процедура приема библиотек. В ее основе лежит публичное обсуждение исходного текста всеми заинтересованными лицами. Если вы хотите предложить библиотеку для Boost, начинайте с отправки письма в список рассылки для разработчиков Boost, чтобы оценить, насколько велик интерес к вашей работе, и инициировать процесс ее предварительного обсуждения. С этого начинается цикл, который на Web-сайте называется «Обсудить, улучшить, подать на рассмотрение снова. Повторять, пока не будет достигнут удовлетворительный результат».
В конечном итоге вы решаете, что ваша библиотека готова для формального внесения на рассмотрение. Менеджер по приемке подтверждает, что она удовлетворяет минимальным требованиям Boost. Например, она должна компилироваться как минимум двумя компиляторами (чтобы продемонстрировать переносимость). Вы также должны подтвердить, что библиотека может быть доступна на приемлемых условиях лицензирования (например, быть бесплатна для коммерческого и некоммерческого использования). Затем ваше предложение предоставляется на официальное рассмотрение сообщества Boost. Во время периода рассмотрения добровольцы изучают представленные вами материалы (исходный код, проектную документацию, пользовательскую документацию и т. п.) и задаются следующими вопросами:
• Насколько хороши проект и реализация?
• Является ли код переносимым между компиляторами и операционными системами?
• Будет ли библиотека использоваться теми, для кого предназначена, то есть людьми, работающими в соответствующей предметной области?
• Является ли документация ясной, полной и точной?
Замечания отправляются в список рассылки Boost, чтобы все могли с ними ознакомиться и прокомментировать. В конце периода обсуждения менеджер по приемке решает, является ли ваша библиотека принятой, условно принятой либо отвергнутой.
Открытое обсуждение позволяет оградить Boost от плохо написанных библиотек, но также помогает авторам уяснить для себя, что входит в понятие проектирования, реализации и документирования кросс-платформенных библиотек промышленного уровня. Для многих библиотек требуется более одного официального рассмотрения, прежде чем их сочтут достойными одобрения.
Boost содержит десятки библиотек, и к ним постоянно добавляются новые. Время от времени та или иная библиотека исключается, как правило, потому, что ее функциональность перекрывается более новой библиотекой, предоставляющей более широкий диапазон возможностей или лучше спроектированной (то есть более гибкой или эффективной).
Библиотеки сильно отличаются по размерам и областям применения. На одном полюсе находятся библиотеки, концептуально требующие лишь нескольких строк кода (но обычно после добавления обработки ошибок и обеспечения переносимости они становятся намного длиннее). Одной из таких библиотек является Conversion, которая представляет безопасные и более удобные операторы приведения. Например, входящая в нее функция numeric_cast возбуждает исключение, если преобразование одного числового типа в другой приводит к переполнению, потере значимости либо другим подобным проблемам, а функция lexical_cast позволяет привести любой тип, поддерживающий operator<<, к строке, что очень удобно для диагностики, протоколирования и т. п. Другую крайность составляют библиотеки, представляющие настолько широкие возможности, что им можно посвящать целые книги. Это относится к библиотеке Boost Graph Library (для программирования произвольных структур графов), и Boost MPL Library («библиотека метапрограммирования»).
Библиотеки Boost посвящены самым разным темам, сгрупированным в несколько основных категорий:
• Обработка строк и текстов. Сюда входят библиотеки для безопасного по отношению к типам форматирования (по аналогии с printf), работы с регулярными выражениями (легли в основу соответствующей функциональности TR1 – см. правило 54), а также лексического и грамматического анализа.
• Контейнеры. Сюда входят библиотеки для работы с массивами фиксированной длины с STL-подобным интерфейсом (см. правило 54), битовыми наборами произвольной длины, а также многомерными массивами.
• Функциональные объекты и высокоуровневое программирование. Эта категория объединяет несколько библиотек, которые лежат в основе функциональности TR1. Одной из наиболее интересных является библиотека Lambda, которая настолько упрощает создание функциональных объектов на лету, что вы вряд ли даже осознаете, что происходит:
using namespace boost::lambada; // включить средства
// из библиотеки Lambda
std::vector
…
std::for_each(v.begin(), v_end(), // для каждого элемента x
std::cout <<_1*2+10<<”\n”); // в v напечатать x*2+10;
// “_1” – место для
// подстановки текущего
// элемента• Обобщенное программирование. Сюда входит широкий набор классов-характеристик (см. правило 47). • Метапрограммирование шаблонов (TMP – см. правило 48). Включает библиотеку утверждений (assertions) времени компиляции, а также библиотеку Boost MPL Library. Среди прочего она поддерживает STL-подобные структуры данных, описывающие сущности времени компиляции, к примеру
// создать контейнер времени компиляции, подобный списку, содержащий
// три типа (float, double и long double), и назвать его “floats”
typedef boost::mpl::list
// создать новый контейнер времени компиляции, содержащий типы
// из “floats”, плюс “int”, вставленный в начало; назвать новый
// контейнер “types”
typedef boost::mpl::push_frontТакие контейнеры типов (их часто называют
• Математика и численные методы. Сюда входят библиотеки для работы с рациональными числами, поиска наибольшего общего делителя и наименьшего общего кратного, а также для операций со случайными числами (еще одна библиотека, оказавшая влияние на включение соответствующей функциональности в отчет TR1).
• Корректность и тестирование. Сюда входят библиотеки для формализации неявных шаблонных интерфейсов (см. правило 41) и поддержки программирования на основе методологии «тестирования с самого начала».
• Структуры данных. Сюда отнесены библиотеки для поддержки безопасных по отношению к типам объединений (то есть «любых» неоднородных типов) и библиотека кортежей, которая нашла применение в TR1.
• Межъязыковая поддержка. Содержит библиотеку, обеспечивающую «бесшовное» взаимодействие между программами, написанными на языках C++ и Python.
• Память. Сюда входит библиотека Pool для высокопроизводительных распределителей памяти блоками фиксированного размера (см. правило 50), а также целый ряд «интеллектуальных» указателей (см. правило 13), включая те, что вошли в TR1 (но не только). Одними из таких «интеллектуальных» указателей, не включенных в TR1, являются scoped_array – похожая на auto_ptr конструкция для динамически выделенных массивов; в правиле 44 приведен пример его использования.
• Разное. К этой категории отнесены библиотеки для вычисления CRC, манипуляций с датами и временем, а также прохода по файловой системе.
Это всего лишь небольшая часть библиотек, которые имеются на сайте проекта Boost. Список далеко не полный.
Boost предлагает библиотеки для решения самых разных задач, но они, конечно, не покрывают всех тем, которыми занимаются программисты. Так, например, нет библиотеки для разработки графических интерфейсов, как нет и библиотек для доступа к базам данных. По крайней мере, их нет сейчас (когда я пишу эти строки). Но к тому времени, когда вы будете читать эту книгу, они могут появиться. Единственный способ узнать точно – зайти на сайт и проверить. Надеюсь, вы сделаете это прямо сейчас: http://boost.org. Даже если вы не найдете там в точности того, что ищете, все равно обязательно обнаружите что-то интересное для себя.
• Boost – это сообщество и Web-сайт для разработки бесплатных библиотек на C++ с открытыми исходными текстами, подвергающихся публичному обсуждению. Boost оказывает немалое влияние на процедуру стандартизации C++.
• Boost предоставляет реализацию многих компонентов TR1, но – кроме того – и множество других библиотек.Приложение А За пределами «Эффективного использования C++»
В книгу «Эффективное использование C++» вошло то, что я считаю наиболее важными рекомендациями для практикующих программистов на C++. Если вы интересуетесь дополнительными возможностями повысить эффективность своей работы, я рекомендую ознакомиться с другими моими книгами: «Наиболее эффективное использование C++» и «Эффективное использование STL».
В книгу «Наиболее эффективное использование C++» включены дополнительные рекомендации и подробно рассмотрены такие темы, как эффективность и программирование с учетом исключений. Кроме того, в ней описываются такие важные приемы программирования на C++, как «интеллектуальные» указатели, подсчет ссылок и прокси-объекты.
«Эффективное использование STL» – это тоже набор рекомендаций, организованный подобно «Эффективному использованию C++», но основное внимание в ней уделено применению стандартной библиотеки шаблонов.
Содержание обеих книг приведено ниже.
Наиболее эффективное использование C++
Основы
Параграф 1: Различайте указатели и ссылки
Параграф 2: Предпочитайте приведение типов в стиле C++
Параграф 3: Никогда не используйте полиморфизм в массивах
Параграф 4: Избегайте неоправданных конструкторов по умолчанию
Операторы
Параграф 5: Опасайтесь определяемых пользователем функций преобразования типов
Параграф 6: Различайте префиксную и постфиксную формы операторов инкремента и декремента
Параграф 7: Никогда не перегружайте «&&», «||» или «,»
Параграф 8: Различайте значение операторов new и delete
Исключения
Параграф 9: Чтобы избежать утечки ресурсов, используйте деструкторы
Параграф 10: Не допускайте утечки ресурсов в конструкторах
Параграф 11: Не распространяйте обработку исключений за пределы деструктора
Параграф 12: Отличайте генерацию исключения от передачи параметра или вызова виртуальной функции
Параграф 13: Перехватывайте исключения, передаваемые по ссылке
Параграф 14: Разумно используйте спецификации исключений
Параграф 15: Оценивайте затраты на обработку исключений
Эффективность
Параграф 16: Не забывайте о правиле «80–20»
Параграф 17: Используйте отложенные вычисления
Параграф 18: Снижайте затраты на ожидаемые вычисления
Параграф 19: Изучите причины возникновения временных объектов
Параграф 20: Облегчайте оптимизацию возвращаемого значения
Параграф 21: Используйте перегрузку, чтобы избежать неявного преобразования типов
Параграф 22: По возможности применяйте оператор присваивания вместо отдельного оператора
Параграф 23: Используйте разные библиотеки
Параграф 24: Учитывайте затраты, связанные с виртуальными функциями, множественным наследованием, виртуальными базовыми классами и RTTI
Приемы
Параграф 25: Делайте виртуальными конструкторы и функции, не являющиеся членами класса
Параграф 26: Ограничивайте числа объектов в классе
Параграф 27: В зависимости от ситуации требуйте или запрещайте размещать объекты в куче
Параграф 28: Используйте интеллектуальные указатели
Параграф 29: Используйте подсчет ссылок
Параграф 30: Применяйте прокси-классы
Параграф 31: Создавайте функции, виртуальные по отношению более чем к одному объекту
Разное
Параграф 32: Программируйте, заглядывая в будущее
Параграф 33: Делайте нетерминальные классы абстрактными
Параграф 34: Умейте использовать C++ и C в одной программе
Параграф 35: Ознакомьтесь со стандартом языка
Эффективное использование STL
Глава 1: Контейнеры
Параграф 1: Проявляйте здравый смысл при выборе контейнера
Параграф 2: Остерегайтесь иллюзий относительно контейнерно-независимого кода
Параграф 3: Делайте копирование объектов в контейнерах дешевым и корректным
Параграф 4: Вызывайте функцию empty вместо сравнения size() с нулем
Параграф 5: Предпочитайте функции, работающие с диапазонами, их одноэлементным аналогам
Параграф 6: Обращайте внимание на неприятные особенности синтаксического анализа в C++
Параграф 7: При использовании контейнеров, хранящих указатели, выделенные new, не забывайте вызвать delete перед уничтожением контейнера
Параграф 8: Никогда не помещайте объекты типа auto_ptr в контейнеры
Параграф 9: Тщательно выбирайте способ очистки
Параграф 10: Помните о соглашениях и ограничениях распределителей памяти
Параграф 11: О правильном применении специализированных распределителей памяти
Параграф 12: О реалистических ожиданиях относительно потоковой безопасности STL-контейнеров
Глава 2: vector и string
Параграф 13: Предпочитайте vector и string динамически выделенным массивам
Параграф 14: Используйте reserve для избежания ненужных операций перераспределения памяти
Параграф 15: Учитывайте различия в реализациях string
Параграф 16: Как передавать vector и string унаследованным программным интерфейсам
Параграф 17: Используйте «swap-трюк» для сокращения избыточной емкости
Параграф 18: Избегайте применять vector
Глава 3: Ассоциативные контейнеры
Параграф 19: Разберитесь, чем равенство отличается от эквивалентности
Параграф 20: Специфицируйте способ сравнения для ассоциативных контейнеров, содержащих указатели
Параграф 21: Позаботьтесь о том, чтобы функции сравнения возвращали false для равных значений
Параграф 22: Избегайте модификации ключей «по месту» в контейнерах set и multiset
Параграф 23: Рассмотрите замену ассоциативных контейнеров отсортированными векторами
Параграф 24: Тщательно выбирайте между map::operator[] и map::insert, когда важна эффективность.
Параграф 25: Ознакомьтесь с нестандартными кэшированными контейнерами
Глава 4: Итераторы
Параграф 26: Старайтесь использовать iterator вместо const_iterator, reverse_iterator и const_reverse_iterator
Параграф 27: Используйте distance и advance для преобразования const_iterator в iterator
Параграф 28: Научитесь использовать базовый iterator, соответствующий reverse_iterator
Параграф 29: Подумайте о применении istreambuf_iterator для посимвольного ввода
Глава 5: Алгоритмы
Параграф 30: Обеспечивайте достаточно большие целевые диапазоны при копировании
Параграф 31: Изучите различные варианты сортировки
Параграф 32: После вызова алгоритма remove или ему подобного не забывайте вызвать алгоритм erase, если действительно хотите что-то удалить
Параграф 33: Будьте осторожны при использовании алгоритма remove и ему подобных для контейнеров, содержащих указатели
Параграф 34: Не забывайте, что некоторые алгоритмы ожидают отсортированных диапазонов
Параграф 35: Реализуйте простое независимое от регистра сравнение строк с помощью алгоритмов mismatch или lexicographical_compare
Параграф 36: Разберитесь, как правильно реализовать алгоритм copy_if
Параграф 37: Используйте accumulate или for_each для суммирования диапазонов
Глава 6: Функторы, функторные классы, функции и т. п.
Параграф 38: Проектируйте классы-функторы для передачи по значению
Параграф 39: Делайте предикаты свободными функциями
Параграф 40: Делайте классы-функторы адаптируемыми
Параграф 41: Зачем нужны ptr_fun, mem_fun и mem_fun_ref
Параграф 42: Убедитесь, что less
Глава 7: Программирование с использованием STL
Параграф 43: Предпочитайте вызовы алгоритмов вручную написанным циклам
Параграф 44: Предпочитайте функции-члены алгоритмам с теми же именами
Параграф 45: О различиях между count, find, binary_search, lower_bound, upper_bound и equal_range.
Параграф 46: Рассмотрите применение функциональных объектов вместо функций в качестве параметров алгоритмов
Параграф 47: Избегайте создания кода «только для записи»
Параграф 48: Всегда включайте необходимые заголовочные файлы
Параграф 49: Научитесь понимать диагностические сообщения компилятора, касающиеся STL
Параграф 50: Посещайте Web-сайты, посвященные STL
Приложение В Соответствие правил во втором и третьем изданиях
Третье издание «Эффективного использования C++» во многом отличается от второго, так как содержит много новой информации. Однако большая часть материала из второго издания осталась и в третьем, хотя часто и в измененной форме или последовательности. В приведенной ниже таблице показано, в каких правилах третьего издания можно найти информацию из второго издания, и наоборот.
В таблице приведено соответствие
Второе издание в третьем
Третье издание во втором
Примечания
1
Имеется русский перевод: Саттер Герб. Решение сложных задач на C++. Издательский дом «Вильямс», 2002 (Прим. науч. ред.).
2
Имеется русский перевод: Паттерны проектирования. СПб.: Питер (Прим. науч. ред.).
3
Более подробную информацию о функции unexpected вы можете найти, воспользовавшись своим любимым поисковым сервисом или в полном руководстве по языку C++ (возможно, стоит поискать информацию о функции set_unexpected, которая специфицирует unexpected).
4
В начале 2005 года, когда писалась настоящая книга, этот документ еще не был завершен, и его URL может измениться. Поэтому я рекомендую узнавать о его текущем адресе на странице http://aristeia.com/EC3E/TR1_info.html. Этот URL не изменится.