Эта глава содержит решения проблем, часто возникающих при работе с классами С++. Рецепты по большей части независимы, но разбиты на две части, каждая из которых занимает примерно по половине главы. Первая половина главы содержит решения проблем, которые могут возникнуть при создании объектов классов, таких как использование функции для создания объекта (которая часто называется шаблоном фабрики) или использование конструкторов и деструкторов для управления ресурсами. Вторая половина содержит решения проблем, возникающих после создания объектов, таких как определение типа объекта во время выполнения, а также некоторые методики реализации наподобие создания интерфейса с помощью абстрактного базового класса.
Конечно, классы — это главная особенность С++, которая обеспечивает возможность объектно-ориентированного программирования, и с ними можно выполнять очень много разных действий. Эта глава не содержит рецептов, объясняющих основы классов: виртуальные функции (полиморфизм), наследование и инкапсуляцию. Я полагаю, что вы уже знакомы с этими основными принципами объектно-ориентированного проектирования независимо от используемого языка программирования. Напротив, целью этой главы является описание принципов некоторых механических сложностей, с которыми можно столкнуться при реализации объектно-ориентированного дизайна на С++.
Объектно-ориентированное проектирование и связанные с ним шаблоны проектирования — это обширный вопрос, и имеется большое количество различной литературы на эту тему. В этой главе я упоминаю названия только некоторых шаблонов проектирования, и это шаблоны, для которых возможности C++ обеспечивают элегантное или, возможно, не совсем очевидное решение. Если вы не знакомы с концепцией шаблонов проектирования, я рекомендую прочесть книгу Design Patterns (Addison Wesley), поскольку это полезная вещь при разработке программного обеспечения. Однако для этой главы знание шаблонов проектирования не требуется.
Требуется инициализировать переменные-члены, которые имеют встроенные типы, являются указателями или ссылками.
Для установки начальных значений переменных членов используйте список инициализации. Пример 8.1 показывает, как это делается для встроенных типов, указателей и ссылок.
Пример 8.1. Инициализация членов класса
#include
using namespace std;
class Foo {
public:
Foo() : counter_(0), str_(NULL) {}
Foo(int c, string* p) : counter_(c), str_(p) {}
private:
int counter_;
string* str_;
};
int main() {
string s = "bar";
Foo(2, &s);
}
Переменные встроенных типов следует всегда инициализировать, особенно если они являются членами класса. С другой стороны, переменные класса должны иметь конструктор, который корректно инициализирует их состояние, так что самостоятельно инициализировать их не требуется. Сохранить неинициализированное состояние переменных встроенных типов, когда они содержат мусор, — значит напрашиваться на проблемы. Но в C++ есть несколько различных способов выполнить инициализацию, и они описываются в этом рецепте.
Простейшими объектами инициализации являются встроенные типы. Работать с
int
, char
и указателями очень просто. Рассмотрим простой класс и его конструктор по умолчанию.
class Foo {
public:
Foo() : counter_(0), str_(NULL) {}
Foo(int c, string* p) : counter_(c), str_(p) {}
private:
int counter_;
string* str_;
};
Для инициализации переменных-членов используется список инициализации, в результате чего тело конструктора освобождается от этой задачи. Тело конструктора может при этом содержать логику, выполняемую при создании объектов, а инициализацию переменных-членов становится легко найти. Это не столь значительное преимущество по сравнению с присвоением начальных значений в теле конструктора, но все его преимущества становятся очевидны при создании переменных-членов типа класса или ссылок или при попытке эффективного использования исключений.
Члены инициализируются в порядке их указания в объявлении класса, а не в порядке объявления их в списке инициализации.
Используя тот же класс
Foo
, как и в примере 8.1, рассмотрим переменную-член класса.
class Foo {
public:
Foo() : counter_(0), str_(NULL), cls_(0) {}
Foo(int с, string* p) :
counter_(c), str_(p), cls_(0) {}
private:
int counter_;
string* str_;
SomeClass cls_;
};
В конструкторе по умолчанию
Foo
инициализировать cls_
не требуется, так как будет вызван ее конструктор по умолчанию. Но если требуется создать Foo
с аргументами, то следует добавить аргумент в список инициализации, как это сделано выше, а не делать присвоение в теле конструктора. Используя список инициализации, вы избежите дополнительного шага создания cls_
(так как при присвоении cls_
значения в теле конструктора cls_
вначале создается с использованием конструктора по умолчанию, а затем с помощью оператора присвоения выполняется присвоение нового значения), а также получите автоматическую обработку исключений. Если объект создается в списке инициализации и этот объект в процессе его создания выбрасывает исключение, то среда выполнения удаляет все ранее созданные объекты списка и передает исключение в код, вызывавший конструктор. С другой стороны, при присвоении аргумента в теле конструктора такое исключение необходимо обрабатывать с помощью блока try/catch
.
Ссылки более сложны: инициализация переменной-ссылки (и
const
-членов) требует обязательного использования списка инициализации. В соответствии со стандартом ссылка всегда должна ссылаться на одну переменную и никогда не может измениться и ссылаться на другую переменную. Переменная-ссылка никогда не может не ссылаться на какой-либо объект. Следовательно, чтобы присвоить что-то осмысленное переменной-члену, являющейся ссылкой, это должно происходить при инициализации, т.е. в списке инициализации.
Следующая запись в C++ недопустима.
int& x;
Это значит, что невозможно объявить переменную-ссылку без ее инициализации. Вместо этого ее требуется инициализировать каким-либо объектом. Для переменных, не являющихся членами класса, инициализация может выглядеть вот так.
int а;
int& x = a;
Это все замечательно, но приводит к возникновению проблемы при создании классов. Предположим, вам требуется переменная-член класса, являющаяся ссылкой, как здесь.
class HasARef {
public:
int& ref;
};
Большинство компиляторов примет эту запись, но только до тех пор, пока вы не попытаетесь создать экземпляр этого класса, как здесь.
HasARef me;
В этот момент вы получите ошибку. Вот какую ошибку выдаст gcc.
error: structure 'me' with uninitialized reference members
(ошибка: структура 'me' с неинициализированными членами-ссылками)
Вместо этого следует использовать список инициализации.
class HasARef {
public:
int &ref;
HasARef(int &aref) : ref(aref) {}
};
Затем при создании экземпляра класса требуется указать переменную, на которую будет ссылаться переменная
ref
, как здесь.
int var;
HasARef me(var);
Именно так следует безопасно и эффективно инициализировать переменные-члены. В общем случае всегда, когда это возможно, используйте список инициализации и избегайте инициализации переменных-членов в теле конструктора. Даже если требуется выполнить какие-либо действия с переменными в теле конструктора, список инициализации можно использовать для установки начальных значений, а затем обновить их в теле конструктора.
Рецепт 9.2.
Вместо создания объекта в куче с помощью new вам требуется функция (член или самостоятельная), выполняющая создание объекта, тип которого определяется динамически. Такое поведение достигается с помощью шаблона проектирования Abstract Factory (абстрактная фабрика).
Здесь есть две возможности. Вы можете:
• создать функцию, которая создает экземпляр объекта в куче и возвращает указатель на этот объект (или обновляет переданный в нее указатель, записывая в него адрес нового объекта);
• создать функцию, которая создает и возвращает временный объект.
Пример 8.2 показывает оба этих способа. Класс
Session
в этом примере может быть любым классом, объекты которого должны не создаваться непосредственно в коде (т.е. с помощью new
), а их создание должно управляться каким-либо другим классом. В этом примере управляющий класс — это SessionFactory
.
Пример 8.2. Функции, создающие объекты
#include
class Session {};
class SessionFactory {
public:
Session Create();
Session* CreatePtr();
void Create(Session*& p);
// ...
};
// Возвращаем копию объекта в стеке
Session SessionFactory::Create() {
Session s;
return(s);
}
// Возвращаем указатель на объект в куче
Session* SessionFactory::CreatePtr() {
return(new Session());
}
// Обновляем переданный указатель, записывая адрес
// нового объекта
void SessionFactory::Create(Session*& p) {
p = new Session();
}
static SessionFactory f; // Единственный объект фабрики
int main() {
Session* p1;
Session* p2 = new Session();
*p2 = f.Create(); // Просто присваиваем объект, полученный из Create
p1 = f.CreatePtr(); // или полученный указатель на объект в куче
f.Create(p1); // или обновляем указатель новым адресом
}
Пример 8.2 показывает несколько различных способов написания функции, возвращающей объект. Сделать так вместо обращения к
new
может потребоваться, если создаваемый объект берется из пула, связан с оборудованием или удаление объектов должно управляться не вызывающим кодом. Существует множество причин использовать этот подход (и именно поэтому существует шаблон проектирования для него), я привел только некоторые. К счастью, реализация шаблона фабрики в C++ очень проста.
Наиболее часто используют возврат адреса нового объекта в куче или обновление адреса указателя, переданного как аргумент. Их реализация показана в примере 8.2, и она тривиальна и не требует дальнейших пояснений. Однако возврат из функции целого объекта используется реже — возможно, потому, что это требует больших накладных расходов.
При возврате временного объекта в стеке тела функции создается временный объект. При выходе из функции компилятор копирует данные из временного объекта в другой временный объект, который и возвращается из функции, Наконец, в вызывающей функции объекту с помощью оператора присвоения присваивается значение временного объекта. Это означает, что на самом деле создается два объекта: объект в функции фабрики и временный объект, который возвращается из функции, содержимое которого затем копируется в целевой объект. Здесь осуществляется большое количество копирований (хотя компилятор может оптимизировать временный объект), так что при работе с большими объектами или частыми вызовами этой функции фабрики внимательно следите за тем, что в ней происходит.
Также эта методика копирования временных объектов работает только для объектов, которые ведут себя как объекты значений, что означает, что когда он копируется, то новая версия будет эквивалентна оригинальной. Для большинства объектов это выполняется, но для некоторых — нет. Например, рассмотрим создание объекта класса, прослушивающего сетевой порт. При создании экземпляра объекта он может начинать прослушивать целевой порт, так что скопировать его в новый объект не получится, так как при этом появятся два объекта, пытающиеся слушать один и тот же порт. В этом случае следует возвращать адрес объекта в куче.
Если вы пишете функцию или метод, создающий объекты, то посмотрите также рецепт 8.12. Используя шаблоны, функций можно написать одну функцию, которая будет возвращать новый объект любого типа. Например:
template
T* createObject() {
return(new T());
}
MyClass* p1 = createObject();
MyOtherClass* p2 = createObject();
// ...
Этот подход удобен, если требуется единственная функция фабрики, которая сможет одинаковым образом создавать объекты любых классов (или группы связанных классов), что позволит избежать избыточного многократного кодирования функции фабрики.
Рецепт 8.12.
Для класса, представляющего некоторый ресурс, требуется использовать конструктор для получения этого ресурса и деструктор для его освобождения. Эта методика часто называется «получение ресурсов как инициализация» (resource acquisition is initialization — RAII).
Выделите или получите ресурс в конструкторе и освободите этот ресурс в деструкторе. Это снизит объем кода, который пользователь класса должен будет написать для обработки исключений. Простая иллюстрация этой методики показана в примере 8.3.
Пример 8.3. Использование конструкторов и деструкторов
#include
#include
using namespace std;
class Socket {
public:
Socket(const string& hostname) {}
};
class HttpRequest {
public:
HttpRequest(const string& hostname) :
sock_(new Socket(hostname)) {}
void send(string soapMsg) {sock << soapMsg;}
~HttpRequest() {delete sock_;}
private:
Socket* sock_;
};
void sendMyData(string soapMsg, string host) {
HttpRequest req(host);
req.send(soapMsg);
// Здесь делать ничего не требуется, так как когда req выходит
// за диапазон, все очищается.
}
int main() {
string s = "xml";
sendMyData(s, "www.oreilly.com");
}
Гарантии, даваемые конструкторами и деструкторами, представляют собой удобный способ заставить компьютер выполнить всю очистку за вас. Обычно инициализация объекта и выделение используемых ресурсов производится в конструкторе, а очистка — в деструкторе. Это нормально. Но программисты имеют склонность использовать последовательность событий «создание-открытие-использование-закрытие», когда пользователю класса требуется выполнять явные открытия и закрытия ресурсов. Класс файла является хорошим примером.
Примерно так звучит обычный аргумент в пользу RAII. Я легко мог бы создать в примере 8.3 свой класс
HttpRequest
, который заставил бы пользователя выполнить несколько больше работы. Например:
class HttpRequest {
public:
HttpRequest();
void open(const std::string& hostname);
void send(std::string soapMsg);
void close();
~HttpRequest();
private:
Socket* sock_;
};
При таком подходе соответствующая версия
sendMyData
может выглядеть так.
void sendMyData(std::string soapMsg, std::string host) {
HttpRequest req;
try {
req.open();
req.send(soapMsg);
req.close();
} catch (std::exception& e) {
req.close(); // Do something useful...
}
}
Здесь требуется выполнить больше работы без каких бы то ни было преимуществ. Этот дизайн заставляет пользователя писать больше кода и работать с исключениями, очищая ваш класс (при условии, что в деструкторе
close
не вызывается).
Подход RAII имеет широкое применение, особенно когда требуется гарантировать, что при выбрасывании исключения будет выполнен «откат» каких-либо действий, позволяя при этом не загромождать код бесконечными
try/catch
. Рассмотрим настольное приложение, которое в процессе выполнения какой-либо работы отображает в строке состояния или заголовка сообщение.
void MyWindow : thisTakesALongTime() {
StatusBarMessage("Copying files...");
// ...
}
Все, что класс
StatusBarMessage
должен сделать, — это использовать информацию о статусе для обновления соответствующего окна при создании и вернуть его первоначальное состояние при удалении. Вот ключевой момент: если функция завершает работу или выбрасывается исключение, StatusBarMessage
все равно выполнит работу. Компилятор гарантирует, что при выходе из области видимости стековой переменной для нее будет вызван ее деструктор. Без этого подхода автор thisTakesALongTime
должен был бы принять во внимание все пути передачи управления, чтобы неверное сообщение не осталось в окне при неудачном завершении операции, ее отмене пользователем и т.п. И снова повторю, что этот подход приводит к уменьшению кода и снижению числа ошибок автора вызывающего кода.
RAII не является панацеей, но если вы его еще не использовали, то вы, скорее всего, найдете немало возможностей для его применения. Еще одним хорошим примером является блокировка. При использовании RAII для управления блокировками ресурсов, таких как потоки, объекты пулов, сетевые соединения и т.п., этот подход позволяет создавать более надежный код меньшего размера. На самом деле именно так многопоточная библиотека Boost реализует блокировки, делая программирование пользовательской части более простым. За обсуждением библиотеки Boost Threads обратитесь к главе 12.
Требуется хранить все экземпляры класса в едином контейнере, не требуя от пользователей класса выполнения каких-либо специальных операций.
Включите в класс статический член, являющийся контейнером, таким как
list
, определенный в
. Добавьте в этот контейнер адрес объекта при его создании и удалите его при уничтожении. Пример 8.4 показывает, как это делается.
Пример 8.4. Отслеживание объектов
#include
#include
#include
using namespace std;
class MyClass {
protected:
int value_;
public:
static list instances_;
MyClass(int val);
~MyClass();
static void showList();
};
list MyClass::instances_;
MyClass::MyClass(int val) {
instances_.push_back(this);
value_ = val;
}
MyClass::~MyClass() {
list::iterator p =
find(instances_.begin(), instances_.end(), this);
if (p != instances_.end()) instances_.erase(p);
}
void MyClass::showList() {
for (list::iterator p = instances_.begin();
p != instances_.end(); ++p)
cout << (*p)->value_ << endl;
}
int main() {
MyClass a(1);
MyClass b(10);
MyClass с(100);
MyClass::showList();
}
Пример 8.4 создаст следующий вывод.
1
10
100
Подход в примере 8.4 очень прост: используйте для хранения указателей на объекты
static list
. При создании объекта его адрес добавляется в list
; при его уничтожении он удаляется. Здесь имеется пара важных моментов.
При использовании любых членов-данных типа
static
их требуется объявлять в заголовочном файле класса и определять в файле реализации. Пример 8.4 весь находится в одном файле, так что здесь это не применимо, но помните, что переменную типа static
требуется определять в файле реализации, а не в заголовочном файле. За объяснением причин обратитесь к рецепту 8.5.
Вы не обязаны использовать член
static
. Конечно, можно использовать глобальный объект, но тогда дизайн не будет таким «замкнутым». Более того, вам где-то еще придется выделять память для глобального объекта, передавать его в конструктор MyClass
и в общем случае выполнять еще целый ряд действий.
Помните, что совместное использование глобального контейнера, как в примере 8.4, не будет работать, если объекты класса
MyClass
создаются в нескольких потоках. В этом случае требуется сериализация доступа к общему объекту через мьютексы. Рецепты, относящиеся к этой и другим методикам многопоточности, приведены в главе 12.
Если требуется отслеживать все экземпляры класса, можно также использовать шаблон фабрики. В целом это будет означать, что для создания нового объекта клиентский код вместо вызова оператора new должен будет вызывать функцию. За подробностями о том, как это делается, обратитесь к рецепту 8.2.
Рецепт 8.2.
Имеется переменная-член, у которой должен быть только один экземпляр независимо от числа создаваемых экземпляров класса. Этот тип переменных-членов обычно называется статическими членами или переменными класса — в противоположность переменным экземпляра, свои копии которых создаются для каждого объекта класса.
Объявите переменную-член с ключевым словом
static
, затем инициализируйте ее в отдельном исходном файле (но не в заголовочном файле, где она объявлена), как показано в примере 8.5.
Пример 8.5. Использование статических переменных-членов
// Static.h
class OneStatic {
public:
int getCount() {return count;}
OneStatic();
protected:
static int count;
};
// Static.cpp
#include "Static.h"
int OneStatic::count = 0;
OneStatic::OneStatic() {
count++;
}
// StaticMain.cpp
#include
#include "static.h"
using namespace std;
int main() {
OneStatic a;
OneStatic b;
OneStatic c;
cout << a.getCount() << endl;
cout << b.getCount() << endl;
cout << c.getCount() << endl;
}
static
— это способ C++ разрешить создание только одной копии чего-либо. Если переменную-член объявить как static
, то будет создана только одна такая переменная вне зависимости от количества созданных объектов этого класса. Аналогично, если объявить как static
переменную функции, она будет создана только один раз и будет хранить свое значение от одного вызова функции к другому. Однако в случае с переменными-членами, чтобы убедиться, что переменная создана правильно, требуется проделать несколько больше работы. Именно по этой причине в примере 8.5 показано три файла.
Во-первых, при объявлении переменной требуется использовать ключевое слово
static
. Это достаточно просто: добавьте это ключевое слово в заголовок класса, находящийся в заголовочном файле Static.h.
protected:
static int count;
После этого требуется определить эту переменную в исходном файле. При этом для нее будет выделена память. Это делается с помощью указания полного имени переменной и присвоения ей значения, как здесь.
int OneStatic::count = 0;
В примере 8.5 я поместил это определение в файл Static.cpp. Именно так вы и должны делать — не помещайте определение в заголовочный файл. Если это сделать, память будет выделена в каждом файле реализации, включающем этот заголовочный файл, и либо возникнут ошибки компиляции, либо, что хуже, в памяти появятся несколько экземпляров этой переменной. Это не то, что требуется при использовании переменной-члена
static
.
В главном файле StaticMain.cpp вы можете видеть то, что происходит. Создается несколько экземпляров класса
OneStatic
, и каждый раз конструктор по умолчанию OneStatic
инкрементирует статическую переменную. В результате вывод main
из StaticMain.cpp имеет вид:
3
3
3
Каждый вызов
getCount
возвращает одно и то же целое значение, даже несмотря на то, что он делается для различных экземпляров класса.
Во время выполнения требуется динамически узнавать тип определенного класса.
Для запроса, на объект какого типа указывает адрес объекта, используйте идентификацию типов во время выполнения (обычно называемую просто RTTI — runtime type identification). Пример 8.6 показывает, как это делается.
Пример 8.6. Использование идентификации типов во время выполнения
#include
#include
using namespace std;
class Base {};
class Derived : public Base {};
int main() {
Base b, bb;
Derived d;
// Используем typeid для проверки равенства типов
if (typeid(b) == typeid(d)) { // No
cout << "b и d имеют один и тот же тип.\n";
}
if (typeid(b) == typeid(bb)) { // Yes
cout << "b и bb имеют один и тот же тип.\n";
}
it (typeid(a) == typeid(Derived)) { // Yes
cout << "d имеет тип Derived.\n";
}
}
Пример 8.6 показывает, как использовать оператор
typeid
для определения и сравнения типов объектов, typeid
принимает выражение или тип и возвращает ссылку на объект типа type_info
или его подкласс (что зависит от реализации). Возвращенное значение можно использовать для проверки на равенство или получить строковое представление имени типа. Например, сравнить типы двух объектов можно так.
if (typeid(b) == typeid(d)) {
Это выражение возвращает истину, если возвращаемые объекты
type_info
равны. Это работает благодаря тому, что typeid
возвращает ссылку на статический объект, так что при его вызове для двух объектов одного и того же типа будут получены две ссылки на один и тот же объект и сравнение вернет истину.
typeid
также можно использовать непосредственно с типом, как здесь.
if (typeid(d) == typeid(Derived)) {
Это позволяет явно проверять определенный тип.
Вероятно, наиболее часто
typeid
используется для отладки. Для записи имени типа используйте type_info::name
, как здесь.
std::cout << typeid(d).name() << std::endl;
При передаче объектов различных типов это может быть очень полезно. Строка, завершающаяся нулем, возвращаемая
name
, зависит от реализации, но вы можете ожидать (но не полагаться на это), что она будет равна имени типа. Это также работает и для встроенных типов.
Не злоупотребляйте этой методикой, основывая на информации о типе логику программы, если это не абсолютно необходимо. В общем случае наличие логики, которая выполняет что-то похожее на следующее, расценивается как плохой дизайн.
Если
obj
имеет тип X
, сделать что-то одно, а если obj
имеет тип Y
, сделать что-то другое.
Это плохой дизайн, потому что клиентский код теперь содержит избыточные зависимости от типов используемых объектов. Это также приводит к большой каше из if/then кода, который то и дело повторяется, если для объектов типов
X
или Y
требуется различное поведение. Объектно-ориентированное программирование и полиморфизм существуют в большой степени для того, чтобы избавить нас от написания подобного рода логики. Если для какого-либо семейства связанных классов требуется зависящее от типа поведение, то они все должны наследоваться от какого-то базового класса и использовать виртуальные функции, динамически вызывая различное поведение в зависимости от типа.
RTTI приводит к накладным расходам, так что компиляторы обычно по умолчанию его отключают. Скорее всего ваш компилятор имеет параметр командной строки для включения RTTI. Также это не единственный способ, которым можно получить информацию о типе. Другая методика приведена в рецепте 8.7.
Рецепт 8.7.
Имеется два объекта и требуется узнать, имеют ли их классы отношения на уровне базовый класс/производный класс, или они не связаны друг с другом.
Используйте оператор
dynamic_cast
, который пытается выполнить преобразование одного типа в другой. Результат скажет, имеется ли связь между классами. Пример 8.7 представляет код, который это делает.
Пример 8.7. Определение отношений классов
#include
#include
using namespace std;
class Base {
public:
virtual ~Base() {} // Делаем класс полиморфным
};
class Derived : public Base {
public:
virtual ~Derived() {}
};
int main() {
Derived d;
// Запрашиваем тип отношений
if (dynamic_cast (&d)) {
cout << "Derived является классом, производным от Base" << endl;
} else {
cout << "Derived HE является классом, производным от Base" << endl;
}
}
Для запроса отношений между двумя типами используйте оператор
dynamic_cast
. dynamic_cast
принимает указатель или ссылку на некий тип и пытается преобразовать его к указателю или ссылке на производный класс, т.е. выполняя преобразование типа вниз по иерархии классов. Если есть Base*
, который указывает на объект Derived
, то dynamic_cast (&d)
возвращает указатель типа Derived
только в том случае, если d
— это объект типа, производного от Base
. Если преобразование невозможно (из-за того, что Derived
не является подклассом — явным или косвенным — класса Base
), то преобразование завершается неудачей и, если в dynamic_cast
был передан указатель на производный класс, возвращается NULL
. Если в него была передана ссылка, то выбрасывается стандартное исключение bad_cast
. Также базовый класс должен наследоваться как public
и это наследование не должно быть двусмысленным. Результат говорит о том, является ли один класс наследником другого класса. Вот что я сделал в примере 8.7.
if (dynamic_cast (&d)) {
Здесь возвращается нe-
NULL
-указатель, так как d
— это объект класса, производного от Base
. Эту возможность можно использовать для определения отношения любых двух классов. Единственным требованием является то, что аргумент объекта должен быть полиморфным типом, что означает, что он должен иметь по крайней мере одну виртуальную функцию. Если это не будет соблюдено, то такой код не скомпилируется. Однако обычно это не вызывает особых проблем, так как иерархия классов без виртуальных функций встречается крайне редко.
Если этот синтаксис кажется вам слишком запутанным, используйте макрос, скрывающий некоторые подробности.
#define IS_DERIVED_FROM(BaseClass, x) (dynamic_cast(&(x)))
//...
if (IS_DERIVED_FROM(Base, l)){//...
Но помните, что такая информация о типах не бесплатна, так как
dynamic_cast
должен во время выполнения пройти по иерархии классов и определить, является ли один класс наследником другого, так что не злоупотребляйте этим способом. Кроме того, компиляторы не включают эту информацию по умолчанию, так как RTTI приводит к накладным расходам, и не все используют эту функцию, так что вам может потребоваться включить ее с помощью опции компилятора.
Рецепт 8.6.
Требуется, чтобы каждый объект класса имел уникальный идентификатор.
Для отслеживания следующего доступного для использования идентификатора используйте статическую переменную-член. В конструкторе присвойте текущему объекту очередное доступное значение, а затем инкрементируйте статическую переменную. Чтобы понять, как это работает, посмотрите на пример 8.8.
Пример 8.8. Присвоение уникальных идентификаторов
#include
class UniqueID {
protected:
static int nextID;
public:
int id;
UniqueID();
UniqueID(const UniqueID& orig);
UniqueID& operator=(const UniqueID& orig);
};
int UniqueID::nextID = 0;
UniqueID::UniqueID() {
id = ++nextID;
}
UniqueID::UniqueID(const UniqueID& orig) {
id = orig.id;
}
UniqueID& UniqueID::operator=(const UniqueID& orig) {
id = orig.id;
return(*this);
}
int main() {
UniqueID a;
std::cout << a.id << std::endl;
UniqueID b;
std::cout << b.id << std::endl;
UniqueID c;
std::cout << c.id << std::endl;
}
Для отслеживания следующего доступного для использования идентификатора используйте статическую переменную. В примере 8.8 используется
static int
, но вместо нее можно использовать все, что угодно, при условии, что имеется функция, которая может генерировать уникальные значения.
В данном случае идентификаторы не используются повторно до тех пор, пока не будет достигнуто максимально возможное для целого числа значение. При удалении объекта его уникальное значение пропадает либо до перезапуска программы, либо до переполнения значения идентификатора. Эта уникальность в программе может иметь несколько интересных преимуществ. Например, при работе с библиотекой управления памятью, которая перемещает блоки памяти и обновляет значения указателей, можно быть уверенным, что для каждого объекта будет сохранено его первоначальное уникальное значение. При использовании уникальных значений в сочетании с рецептом 8.4, но применении
map
вместо list
можно легко найти объект с заданным уникальным номером. Чтобы сделать это, просто отобразите уникальные ID на экземпляры объектов, как здесь.
static map instmap;
Таким образом любой код, который отслеживает идентификаторы объектов, всегда сможет найти его без необходимости хранить ссылку на него.
Но это еще не все. Рассмотрим случай, когда один из этих объектов требуется добавить в стандартный контейнер (
vector
, list
, set
и т.п.). Стандартные контейнеры хранят копии объектов, добавляемых в них, а не ссылки или указатели на эти объекты (конечно, при условии, что это не контейнер указателей). Таким образом, стандартные контейнеры ожидают, что объекты, которые в них содержатся, ведут себя как объекты значений, что означает, что при присвоении с помощью оператора присвоения или копировании с помощью конструктора копирования создается новая версия, полностью эквивалентная оригинальной версии.
Это означает, что требуется решить, как должны себя вести уникальные объекты. При создании объекта с уникальным идентификатором и добавлении его в контейнер у вас появятся два объекта с одним и тем же идентификатором при условии, что вы не переопределили оператор присвоения. В операторе присвоения и конструкторе копирования требуется выполнить те действия с уникальным значением, которые имеют смысл для вашего случая. Имеет ли смысл то, что объект в контейнере будет равен оригинальному объекту? Если да, то вполне подойдет стандартный конструктор копирования и оператор присвоения, но вы должны указать это явно, чтобы пользователи вашего класса знали, что вы делаете это намеренно, а не просто забыли, как работают контейнеры. Например, чтобы использовать одно и то же значение идентификатора, конструктор копирования и оператор присвоения должны выглядеть вот так.
UniqueID::UniqueID(const UniqueID& orig) {
id = orig.id;
}
UniqueID& UniqueID::operator=(const UniqueID& orig) {
id = orig.id;
return(*this);
}
Но может возникнуть ситуация, когда в контексте приложения будет иметь смысл создать для объекта в контейнере новое уникальное значение. В этом случае просто снова используйте статическую переменную, как это сделано в обычном конструкторе и показано здесь.
UniqueID::UniqueID(const UniqueID& orig) {
id = ++nextID;
}
UniqueID& UniqueID::operator=(const UniqueID& orig) {
id = ++nextID;
return(*this);
}
Однако трудности еще не закончились. Если
UniqueID
будет использоваться несколькими потоками, у вас снова возникнут проблемы, так как доступ к статическим переменным не синхронизирован. За дополнительной информацией о работе с ресурсами при наличии нескольких потоков обратитесь к главе 12.
Рецепт 8.3.
Имеется класс, который должен иметь только один экземпляр, и требуется предоставить способ доступа к этому классу из клиентского кода таким образом, чтобы каждый раз возвращался именно этот единственный объект. Часто это называется шаблоном singleton или singleton-классом.
Создайте статический член, который указывает на текущий класс, ограничьте использование конструкторов для создания класса, сделав их
private
, и создайте открытую статическую функцию-член, которая будет использоваться для доступа к единственному статическому экземпляру. Пример 8.9 демонстрирует, как это делается.
Пример 8.9. Создание singleton-класса
#include
using namespace std;
class Singleton {
public:
// С помощью этого клиенты получат доступ к единственному экземпляру
static Singleton* getInstance();
void setValue(int val) {value_ = val;}
int getValue() {return(value_);}
protected:
int value_;
private:
static Singleton* inst_; // Единственный экземпляр
Singleton() : value_(0) {} // частный конструктор
Singleton(const Singleton&);
Singleton& operator=(const Singleton&);
};
// Определяем указатель
static Singleton Singleton* Singleton::inst_ = NULL;
Singleton* Singleton::getInstance() {
if (inst_ == NULL) {
inst_ = new Singleton();
}
return(inst_);
}
int main() {
Singleton* p1 = Singleton::getInstance();
p1->setValue(10);
Singleton* p2 = Singleton::getInstance();
cout << "Value = " << p2->getValue() << '\n';
}
Существует множество ситуаций, когда требуется, чтобы у класса существовал только один экземпляр. Для этой цели служит шаблон
Singleton
. Выполнив несколько простых действий, можно реализовать singleton-класс в С++.
Когда принимается решение, что требуется только один экземпляр чего-либо, то на ум сразу должно приходить ключевое слово
static
. Как было сказано в рецепте 8.5, переменная-член static
— это такая, которая может иметь в памяти только один экземпляр. Для отслеживания единственного объекта singleton-класса используйте переменную-член static
, как сделано в примере 8.9.
private:
static Singleton* inst_;
Чтобы клиентский код ничего про нее не знал, сделайте ее
private
. Убедитесь, что в файле реализации она проинициализирована значением NULL
.
Singleton* Singleton::inst_ = NULL;
Чтобы запретить клиентам создавать экземпляры этого класса, сделайте конструкторы
private
, особенно конструктор по умолчанию.
private:
Singleton() {}
Таким образом, если кто-то попробует создать в куче или стеке новый singleton-класс, то он получит дружественную ошибку компилятора.
Теперь, когда статическая переменная для хранения единственного объекта
Singleton
создана, создание объектов Singleton
ограничено с помощью ограничения конструкторов; все, что осталось сделать, — это предоставить клиентам способ доступа к единственному экземпляру объекта Singleton
. Это делается с помощью статической функции-члена.
Singleton* Singleton::getInstance() {
if (inst_ == NULL) {
inst_ = new Singleton();
}
return(inst_);
}
Посмотрите, как это работает. Если указатель
static Singleton
равен NULL
, создается объект. Если он уже был создан, то возвращается его адрес. Клиенты могут получить доступ к экземпляру Singleton
, вызвав его статический метод.
Singleton* p1 = Singleton::getInstance();
И если вы не хотите, чтобы клиенты работали с указателями, то можно возвращать ссылку.
Singleton& Singleton::getInstance() {
if (inst_ == NULL) {
inst_ = new Singleton();
}
return(*inst_);
}
Важно здесь то, что в обоих случаях клиентам запрещено создавать экземпляры объекта
Singleton
, и создается единый интерфейс, который предоставляет доступ к единственному экземпляру.
Рецепт 8.3.
Требуется определить интерфейс, который будет реализовываться производными классами, но концепция этого интерфейса является абстракцией и не должна наследоваться сама по себе.
Создайте абстрактный класс, который определяет интерфейс, объявляя, по крайней мере, одну из своих функций как чисто виртуальную (
virtual
). Создайте классы, производные от этого абстрактного класса, которые будут использовать различные реализации, обеспечивая при этом один и тот же интерфейс. Пример 8.10 показывает, как можно определить абстрактный класс для чтения настроечного файла.
Пример 8.10. Использование абстрактного базового класса
#include
#include
#include
using namespace std;
class AbstractConfigFile {
public:
virtual ~AbstractConfigFile() {}
virtual void getKey(const string& header,
const string& key, strings val) const = 0;
virtual void exists(const string& header,
const string& key, strings val) const = 0;
};
class TXTConfigFile : public AbstractConfigFile {
public:
TXTConfigFile() : in_(NULL) {}
TXTConfigFile(istream& in) : in_(&in) {}
virtual ~TXTConfigFile() {}
virtual void getKey(const string& header,
const string& key, strings val) const {}
virtual void exists(const string& header,
const strings key, strings val) const {}
protected:
istream* in_;
};
class MyAppClass {
public:
MyAppClass() : config_(NULL) {}
~MyAppClass() {}
void setConfigObj(const AbstractConfigFile* p) {config_ = p;}
void myMethod();
private:
const AbstractConfigFile* config_;
};
void MyAppClass::myMethod() {
string val;
config_->getKey("Foo", "Bar", val);
// ...
}
int main() {
ifstream in("foo.txt");
TXTConfigFile cfg(in);
MyAppClass m;
m.setConfigObj(&cfg);
m.myMethod();
}
Абстрактный базовый класс (часто называемый ABC — abstract base class) — это класс, для которого невозможно создать экземпляры, и, таким образом, он выполняет роль исключительно интерфейса. Класс является абстрактным, если он объявляет, по крайней мере, одну чисто виртуальную функцию или наследует функцию без реализации. Таким образом, если требуется создать экземпляр подкласса ABC, то он должен реализовать все виртуальные функции, что означает, что он будет поддерживать интерфейс, объявленный в ABC.
Подкласс, который наследуется от ABC (и реализует все его чисто виртуальные методы), поддерживает контракт, определенный интерфейсом. Рассмотрим классы
MyAppClass
и TXTConfigFile
из примера 8.10. MyAppClass
содержит указатель, который указывает на объект типа AbstractConfigFile
.
const AbstractConfigFile* config_;
(Я сделал его
const
, потому что МуАррСlass
не должен изменять настроечный файл, а только читать из него.) Пользователи могут указать используемый в MyAppClass
настроечный файл с помощью функции установки значения setConfigObj
.
Когда приходит время использовать в
MyAppClass
настроечный файл, как это делает MyAppClass::myMethod
, можно вызвать любую из функций, объявленных в AbstractConfigFile
, независимо от реально используемого типа настроечного файла. Это может быть TXTConfigFile
, XMLConfigFile
или любой другой, который наследуется от AbstractConfigFile
.
Это полиморфное поведение является следствием наследования: если код ссылается на объект базового класса, вызов виртуальных функций для него приведет к их динамической переадресации и вызову правильных версий подкласса этого класса при условии, что реальный объект, на который ссылается код, является объектом этого подкласса. Но это происходит независимо от того, является ли базовый класс ABC или нет. Так в чем же разница?
Здесь имеется два различия. Чисто виртуальный класс (ABC, который не предоставляет никаких реализаций) служит только как контракт, которому должны подчиняться все подклассы, если требуется создавать их объекты. Часто это означает, что проверка на принадлежность подкласса к чисто интерфейсному классу может не сработать (что означает, что нельзя сказать, что объект подкласса является также и объектом базового класса), но что сработает проверка «ведет себя как». Это позволяет различать то, чем объект является, оттого, что он может сделать. Спасибо Супермену. Он человек, но он также и супергерой. Супергерои могут летать как птицы, но сказать, что супергерой — это птица, будет неверно. Иерархия классов для Супермена может выглядеть так, как это показано в примере 8.11.
Пример 8.11. Использование чистого интерфейса
class Person {
public:
virtual void eat() = 0;
virtual void sleep() = 0;
virtual void walk() = 0;
virtual void jump() = 0;
};
class IAirborne {
public:
virtual void fly() = 0;
virtual void up() = 0;
virtual void down() = 0;
};
class Superhero : public Person, // Супергерой «является» человеком
public IAirborne { // и летает
public:
virtual void eat();
virtual void sleep();
virtual void walk();
virtual void jump();
virtual void fly();
virtual void up();
virtual void down();
virtual ~Superhero();
};
void Superhero::fly() {
// ...
}
// Все виртуальные методы реализуем в родительских классах супергероя...
int main() {
Superhero superman;
superman.walk(); // Супермен может ходить как человек
superman.fly(); // или летать как птица
}
Однако летать может огромное количество объектов, так что не стоит называть этот интерфейс, например,
IBird
. IAirborne
указывает, что всё, что поддерживает этот интерфейс, может летать. Все, что он делает, — это позволяет клиентскому коду быть уверенным, что если он работает с объектом, наследуемым от IAirborne
, клиентский код может вызвать методы fly
, up
и down
.
Второе различие состоит в том, что ABC может определить абстрактную сущность, которая не имеет смысла как объект, так как она, по сути, является обобщением. В этом случае проверка на принадлежность при наследовании выполняется, но ABC — это абстракция, так как сам по себе он не содержит реализаций, которые могут наследоваться объектами. Рассмотрим класс
AbstractConfigFile
из примера 8.10. Имеет ли смысл создавать объект типа AbstractConfigFile
? Нет, имеет смысл только создавать различные виды настроечных файлов, которые имеют конкретное представление.
Вот краткий перечень правил, касающихся абстрактных классов и чисто виртуальных функций. Класс является абстрактным, если:
• он объявляет, по крайней мере, одну чисто виртуальную функцию;
• он наследует, но не реализует, по крайней мере, одну чисто виртуальную функцию.
Создавать объекты абстрактного класса нельзя. Однако абстрактный класс может:
• содержать данные-члены;
• содержать не-виртуальные методы;
• предоставлять реализации для чисто виртуальных функций;
• делать большую часть из того, что может делать обычный класс.
Другими словами, с ними можно делать почти все, что можно делать с обычными классами, кроме создания объектов этих классов.
Когда дело доходит до реализации, использование ABC в C++ требует осторожности. Используется ли ABC как чистый интерфейс или нет, зависит от вас. Например, предположим на мгновение, что в примере с супергероем я решил, что класс
Person
должен быть абстрактным, но так как все виды людей имеют имя и фамилию, я добавил в класс эти два члена и связал с ними методы их задания и получения, так что авторам подклассов этого делать уже не требуется.
class Person {
public:
virtual void eat() = 0;
virtual void sleep() = 0;
virtual void walk() = 0;
virtual void jump() = 0;
virtual void setFirstName(const string& s) {firstName_ = s;}
virtual void setLastName(const string& s) {lastName_ = s;}
virtual string getFirstName() {return(firstName_);}
virtual string getLastName() {return(lastName_);}
protected:
string firstName_;
string lastName_;
};
Теперь, если подкласс
Superhero
хочет переопределить одну из этих функций, то он может это сделать. Все, что он должен сделать, чтобы указать, какая версия должна вызываться, — это использовать имя базового класса. Например:
string Superhero::getLastName() {
return(Person::getLastName() + " (Superhero)");
}
Кстати, эти функции также можно сделать чисто виртуальными и предоставить реализацию по умолчанию. Для этого после объявления требуется использовать запись вида
=0
, а собственно определение поместить куда-либо еще, как здесь.
class Person {
// ...
virtual void setFirstName(const string& s) = 0;
// ...
Person::setFirstName(const string& s) {
firstName_ = s;
}
Сделав так, вы заставите подклассы переопределять этот метод, но они, если это требуется, по-прежнему могут вызвать версию по умолчанию, использовав для этого полное имя класса.
Наконец, если в базовом классе создать виртуальный деструктор (чистый или нет), то потребуется предоставить тело для него. Это требуется потому, что деструктор подкласса автоматически вызывается деструктором базового класса.
Имеется класс, чьи члены в различных ситуациях должны иметь разные типы, а использование обычного полиморфного поведения очень сложно или сильно избыточно. Другими словами, как разработчик класса, вы хотите, чтобы пользователь класса при создании объектов этого класса мог выбрать типы различных его частей, вместо того чтобы указывать их при первоначальном определении класса.
Для параметризации типов, которые используются при объявлении членов класса (и в других случаях), используйте шаблон класса. Это значит, что требуется написать класс с заполнителями типов, оставив, таким образом, выбор используемых типов на усмотрение пользователя класса. В примере 8.12 показан пример класса узла дерева, который может указывать на любой тип.
Пример 8.12. Написание шаблона класса
#include
#include
using namespace std;
template
class TreeNode {
public:
TreeNode (const T& val) : val_(val), left_(NULL), right_(NULL) {}
~TreeNode() {
delete left_;
delete right_;
}
const T& getVal() const {return(val_);}
void setVal(const T& val) {val_ = val;}
void addChild(TreeNode* p) {
const T& other = p->getVal();
if (other > val_)
if (rights)
right_->addChild(p);
else
right_ = p;
else
if (left_)
left_->addChild(p);
else
left_ = p;
}
const TreeNode* getLeft() {return(left_);}
const TreeNode* getRight() {return(right_);}
private:
T val_;
TreeNode* left_;
TreeNode* right_;
};
int main() {
TreeNode node1("frank");
TreeNode node2("larry");
TreeNode node3("bill");
node1.addChild(&node2);
node1.addChild(&node3);
}
Шаблоны классов предоставляют способ параметризации типов, используемых в классе, так что эти типы могут указываться пользователем класса при создании объектов. Однако шаблоны могут оказаться несколько запутанными, так что позвольте мне перед разбором их работы пояснить приведенный выше пример.
Рассмотрим объявление шаблона класса
TreeNode
из примера 8.12.
template class TreeNode {
//...
Часть
template
— это то, что делает этот класс шаблоном, а не обычным классом. Эта строка говорит, что T
— это имя типа, который будет указан при использовании класса, а не при его объявлении. После этого параметр T
может использоваться в объявлении и определении TreeNode
так, как будто это обычный тип — встроенный или определенный пользователем. Например, имеется частный член с именем val_
, который должен иметь тип T
. Тогда его объявление будет иметь вид:
T val_;
Здесь просто объявляется член класса с именем
val_
некоторого типа, который будет определен позднее. Это объявление выглядит так же, как и при использовании для val_
типов int
, float
, MyClass
или string
. В этом отношении его можно рассматривать как макрос (т.е. использование #define
), хотя сходство с макросом на этом и заканчивается.
Параметр типа может применяться любым способом, которым можно использовать обычный параметр: возвращаемые значения, указатели, параметры методов и т.д. Рассмотрим методы установки и получения
val_
.
const T& getVal() const (return(val_);}
void setVal(const T& val) {val_ = val;}
getVal
возвращает const
-ссылку на val_
, имеющий тип T
, a setVal
принимает ссылку на T
и записывает ее значение в val_
. Некоторые сложности появляются в отношении методов getLeft
и getRight
, так что далее я вернусь к этому вопросу. Подождите немного.
Теперь, когда
TreeNode
объявлен с помощью заполнителя типа, его должен использовать клиентский код. Вот как это делается.
TreeNode
— это простая реализация двоичного дерева. Чтобы создать дерево, которое хранит строковые значения, создайте узлы следующим образом.
ТreeNode node1("frank");
TreeNode node2("larry");
TreeNode node3("bill");
Тип между угловыми скобками — это то, что используется вместо
T
при создании экземпляра класса. Создание экземпляра шаблона — это процесс, выполняемый компилятором при создании версии TreeNode
при условии, что T
— это string
. Двоичное физическое представление TreeNode
создается тогда, когда создается его экземпляр (и только в этом случае). В результате в памяти получается структура, эквивалентная той, которая была бы, если TreeNode
был написан без ключевого слова template
и параметра типа, а вместо T
использовался бы string
.
Создание экземпляра шаблона для данного параметра типа аналогично созданию экземпляра объекта любого класса. Ключевое различие состоит в том, что создание экземпляра шаблона происходит в процессе компиляции, в то время как создание объекта класса происходит во время выполнения программы. Это означает, что если вместо
string
двоичное дерево должно хранить данные типа int
, его узлы должны быть объявлены вот так.
TreeNode intNode1(7);
TreeNode intNode2(11);
TreeNode intNode3(13);
Как и в случае с версией для
string
, создается двоичное представление шаблона класса TreeNode
с использованием внутреннего типа int
.
Некоторое время назад я сказал, что рассмотрю методы
getLeft
и getRight
. Теперь, когда вы знакомы с созданием экземпляра шаблона (если еще не были), объявление и определение getLeft
и getRight
должно стать более осмысленным.
const TreeNode* getLeft() {return(left_);}
const TreeNode* getRight() {return(right_);}
Здесь говорится, что каждый из этих методов возвращает указатель на экземпляр
TreeNode
для T
. Следовательно, когда создается экземпляр TreeNode
для, скажем, string
, экземпляры getLeft
и getRight
создаются следующим образом.
const TreeNode* getLeft() {return(left_);}
const TreeNode* getRight() {return(right_);}
При этом не существует ограничения одним параметром шаблона. Если требуется, можно использовать несколько таких параметров. Представьте, что вам требуется отслеживать число дочерних узлов данного узла, но пользователи вашего класса могут быть ограничены в использовании памяти и не захотят использовать
int
, если смогут обойтись short
. Аналогично они могут захотеть применять для подсчета использованных узлов что-то более сложное, чем простой встроенный тип (например, их собственный класс). В любом случае это можно разрешить сделать с помощью еще одного параметра шаблона.
template
class TreeNode {
// ...
N getNumChildren();
private:
TreeNode() {}
T val_;
N numChildren_;
// ...
Таким образом, человек, использующий ваш класс, может указать для отслеживания размера поддеревьев каждого узла
int
, short
или что-либо еще.
Для параметров шаблона также можно указать аргументы по умолчанию, как это сделано в моем примере, для чего используется такой же синтаксис, как и при объявлении параметров функций по умолчанию.
template
Как и в случае с параметрами функций по умолчанию, их можно использовать только для отдельных параметров при условии, что этот последний параметр или все параметры справа от него имеют аргументы по умолчанию.
В примере 8.12 определение шаблона дается в том же месте, что и его объявление. Обычно это делается для экономии места, занимаемого примером, не в данном случае есть и еще одна причина. Шаблоны (классов или функций — см. рецепт 8.12) компилируются в двоичную форму только тогда, когда создается их экземпляр. Таким образом, невозможно создать объявление шаблона в заголовочном файле, а его реализацию — в исходном файле (т.е. .cpp) Причина заключается в том, что в нем нечего компилировать! Из этого правила имеются исключения, но обычно при написании шаблона класса его реализация должна помешаться в заголовочном файле или встраиваемом файле, который подключается заголовочным.
В этом случае требуется использовать несколько необычный синтаксис. Методы и другие части класса объявляются как в обычном классе, но при определении методов требуется включить дополнительные лексемы, которые говорят компилятору, что это части шаблона класса. Например,
getVal
можно определить вот так (сравните с примером 8.12)
template
const T& TreeNode::getVal() const {
return(val_);
}
Тело функции выглядит точно так же.
Однако с шаблонами следует быть осторожными, так как если написать шаблон, который используется повсеместно, то можно получить раздувание кода, что случается, когда один и тот же шаблон с одними и теми же параметрами (например,
TreeNode
) компилируется в нескольких объектных файлах. По существу в нескольких файлах окажется одно и то же двоичное представление экземпляра шаблона, и это сделает библиотеку или исполняемый файл значительно больше по размеру, чем требуется.
Одним из способов избежать этого является использование явного создания экземпляров, что позволяет указать компилятору создать версию шаблона класса для определенного набора аргументов шаблона. Если сделать это в таком месте, которое компонуется вместе с остальными клиентскими частями, то раздувания кода не произойдет. Например, если известно, что в приложении будет использоваться
TreeNode
, то в общий исходный файл можно поместить такую строку.
// common.cpp
template class TreeNode;
Соберите динамическую библиотеку с этим файлом, и после этого код, использующий
TreeNode
, сможет применять эту библиотеку динамически, не содержа своей собственной скомпилированной версии шаблона. Другой код может включить заголовочный файл шаблона класса, затем скомпоноваться с этой библиотекой и. следовательно, избежать необходимости иметь свою копию. Однако этот подход требует проведения экспериментов, так как не все компиляторы имеют одинаковые проблемы с раздуванием кода, но это общий подход для его минимизации.
Шаблоны C++ (как классов, так и функций) — это очень обширная тема, и имеется огромное количество методик создания мощных, эффективных проектов на основе шаблонов. Великолепным примером шаблонов классов являются контейнеры из стандартной библиотеки, такие как
vector
, list
, set
и другие, которые описываются в главе 15. Большая часть интересных разработок, описанных в литературе по С++, связана с шаблонами. Если вы заинтересовались этим предметом, почитайте группы новостей comp.lang.std.c++ и comp.lang.c++. В них всегда можно найти интересные вопросы и ответы на них.
Рецепт 8.12.
Имеется один метод, который должен принимать параметр любого типа, и невозможно ограничиться каким-либо одним типом или категорией типов (используя указатель на базовый класс).
Используйте шаблон метода и объявите параметр шаблона для типа объекта. Небольшая иллюстрация приведена в примере 8.13.
Пример 8.13. Использование шаблона метода
class ObjectManager {
public:
template T* gimmeAnObject();
template
void gimmeAnObject(T*& p);
};
template
T* ObjectManager::gimmeAnObject() {
return(new T);
}
template
void ObjectManager::gimmeAnObject(T*& p) {
p = new T;
}
class X { /*...*/ };
class Y { /* ... */ };
int main() {
ObjectManager om;
X* p1 = om.gimmeAnObject(); // Требуется указать параметр
Y* p2 = om.gimmeAnObject(); // шаблона
om.gimmeAnObject(p1); // Однако не здесь, так как компилятор может
om.gimmeAnObject(p2); // догадаться о типе T по аргументам
}
При обсуждении шаблонов функций или классов слова «параметр» и «аргумент» становятся несколько двусмысленными. Имеется по два вида каждого: шаблона и функции. Параметры шаблона — это параметры в угловых скобках, например
T
в примере 8.13, а параметры функции — это параметры в обычном смысле.
Рассмотрим класс
ObjectManager
из примера 8.13. Это упрощенная версия шаблона фабрики, описанного в рецепте 8.2, так что мне потребовалось объявить метод gimmeAnObject
, который создает новые объекты и который клиентский код сможет использовать вместо непосредственного обращения к new
. Это можно сделать, либо возвращая указатель на новый объект, либо изменяя указатель, переданный в метод клиентским кодом. Давайте посмотрим на каждый из этих подходов.
Объявление шаблона метода требует, чтобы было использовано ключевое слово
template
и были указаны параметры шаблона.
template T* gimmeAnObject();
template void gimmeAnObject(T*& p);
Оба этих метода используют в качестве параметра шаблона
T
, но они не обязаны это делать. Каждый из них представляет параметр шаблона только для данного метода, так что их имена не связаны друг с другом. То же самое требуется сделать для определения этих шаблонов методов, т.е. использовать это же ключевое слово и перечень параметров шаблона. Вот как выглядят мои определения.
template
T* ObjectManager.:gimmeAnObject() {
return(new T);
}
template
void ObjectManager::gimmeAnObject(T*& p) {
p = new T;
}
Теперь есть пара способов вызвать эти шаблоны методов. Во-первых, их можно вызвать явно, используя параметры шаблона, как здесь.
X* p1 = om.gimmeAnObject();
X
— это имя некоего класса. Либо можно позволить компилятору догадаться об аргументах параметров шаблона, передав в методы аргументы типа (типов) параметров шаблона. Например, можно вызвать вторую форму gimmeAnObject
, не передавая ей ничего в угловых скобках.
om.gimmeAnObject(p1);
Это работает благодаря тому, что компилятор может догадаться о
T
, посмотрев на p1
и распознав, что он имеет тип X*
. Такое поведение работает только для шаблонов функций (методов или отдельных) и только тогда, когда параметры шаблона понятны из аргументов функции.
Шаблоны методов не имеют большой популярности при разработке на C++, но время от времени они оказываются очень полезны, так что следует знать, как создавать их. Я часто сталкиваюсь с необходимостью сдерживать себя, когда мне хочется использовать метод, который бы работал с типами, которые не связаны друг с другом механизмом наследования. Например, если есть метод
foo
, который должен принимать один аргумент, который всегда будет классом, наследуемым от некоторого базового класса, то шаблон не требуется: здесь можно просто сделать параметр типа базового класса или ссылки. После этого этот метод будет прекрасно работать с параметром, имеющим тип любого подкласса; это обеспечивается самим C++.
Но может потребоваться функция, которая работает с параметрами, которые не наследуются от одного и того же базового класса (или классов). В этом случае можно либо написать несколько раз один и тот же метод — по одному разу для каждого из типов, либо сделать его шаблоном метода. Использование шаблонов также позволяет использовать специализацию, предоставляющую возможность создавать реализации шаблонов для определенных аргументов шаблона. Но это выходит за рамки одного рецепта, так что сейчас я прекращаю обсуждение, но это мощная методика, поэтому при использовании программирования шаблонов не забудьте про такую возможность.
Рецепт 8.11.
Имеется класс, для которого имеют смысл операции инкремента и декремента, и требуется перегрузить
operator++
и operator--
, которые позволят легко и интуитивно выполнять инкремент и декремент объектов этого класса.
Чтобы это сделать, перегрузите префиксную и постфиксную формы
++
и --
. Пример 8.14 показывает обычную методику перегрузки операторов инкремента и декремента.
Пример 8.14. Перегрузка инкремента и декремента
#include
using namespace std;
class Score {
public:
Score() : score_(0) {}
Score(int i) : score_(i) {}
Score& operator++() {
// префикс
++score_;
return(*this);
}
const Score operator++(int) {
// постфикс
Score tmp(*this);
++(*this); // Использование префиксного оператора
return(tmp);
}
Score& operator--() {
--score_;
return(*this);
}
const Score operator--(int x) {
Score tmp(*this);
--(*this);
return(tmp);
}
int getScore() const {return(score_);}
private:
int score_;
};
int main() {
Score player1(50);
player1++;
++player1; // score = 52
cout << "Счет = " << player1.getScore() << '\n';
(--player1)--; // score_ = 50
cout << "Счет = " << player1.getScore() << '\n';
}
Операторы инкремента и декремента часто имеют смысл для классов, которые представляют некоторые разновидности целых значений. Если вы понимаете разницу между префиксной и постфиксной формами и следуете соглашениям о возвращаемых значениях, то их легко использовать.
Представьте себе инкремент целого числа. С помощью оператора
++
имеется два способа выполнить его для некоторого целого i
.
i++; // постфиксный
++i; // префиксный
Оба инкрементируют
i
: первая версия создает временную копию i
, инкрементирует i
и затем возвращает временное значение, а вторая инкрементирует i
и затем возвращает его. C++ позволяет выполнять перегрузку операторов, что означает, что вы можете заставить свой собственный тип (класс или enum
) вести себя так же, как и int
.
Чтобы добиться нужного эффекта, перегрузите
operator++
и operator--
. Пример 8.14 иллюстрирует, как перегружать префиксную и постфиксную версии.
Score& operator++() { // префиксный
++score_;
return(*this);
}
const Score operator++(int) { // постфиксный
Score tmp(*this);
++(*this);
return(tmp);
}
Префикс выглядит так, как и следует ожидать, но компилятор различает эти две версии, и в объявление постфиксной версии включается параметр
int
. Он не имеет семантического применения — он всегда передается как ноль, так что его можно игнорировать.
После этого класс
Score
можно использовать как int
.
Score player1(50);
player1++;
++player1; // score_ = 52
Вы, вероятно, заметили, что сигнатуры префиксной версии
operator++
возвращают ссылку на текущий класс. Именно так и следует делать (а не возвращать, к примеру, void
), чтобы инкрементируемый или декрементируемый объект мог использоваться в других выражениях. Рассмотрим такую строку из примера.
(--player1)--;
Да, это странно, но она иллюстрирует этот момент. Если бы префиксный
operator--
не возвращал чего-то осмысленного, то это выражение не скомпилировалось бы. Еще один пример показывает вызов функции.
foo(--player1);
Функция
foo
ожидает аргумент типа Score
, и для корректной компиляции именно это должно возвращаться из префиксного operator--
.
Перегрузка операторов — это мощная возможность, которая позволяет для типов, определяемых пользователем, использовать те же операторы, что и для встроенных типов. Сторонники других языков, которые не поддерживают перегрузку операторов, утверждают, что эта возможность сбивает с толку и очень сложна, и, следует признать, может быть перегружено очень много операторов, соответствующих любому поведению. Но когда дело касается простого инкремента и декремента, хорошо иметь возможность изменить поведение класса так, как этого хочется.
Рецепт 8.14.
Имеется класс, для которого имеют смысл некоторые из унарных или бинарных операторов С++, и требуется, чтобы пользователи класса могли использовать их при работе с объектами этого класса. Например, если есть класс с именем
Balance
, который содержит значение с плавающей точкой (например, баланс счета), будет удобно, если для объектов Balance
можно было бы использовать некоторые стандартные операторы С++, как здесь.
Balance checking(50.0);
savings(100.0);
checking += 12.0;
Balance total = checking + savings;
Перегрузите операторы, которые требуется использовать как методы и отдельные функции, указав аргументы различных типов, для которых данный оператор имеет смысл, как в примере 8.15.
Пример 8.15. Перегрузка унарных и бинарных операторов
#include
using namespace std;
class Balance {
// These have to see private data
friend const Balance operator+(const Balance& lhs, const Balance& rhs);
friend const Balance operator+(double lhs, const Balance& rhs);
friend const Balance operator+(const Balance& lhs, double rhs);
public:
Balance() : val_(0.0) {}
Balance(double val) : val_(val) {}
~Balance() {}
// Унарные операторы
Balance& operator+=(const Balance& other) {
val_ += other.val_;
return(*this);
}
Balance& operator+=(double other) {
val_ += other;
return(*this);
}
double getVal() const {return(val_);}
private:
double val_;
};
// Бинарные операторы
const Balance operator+(const Balance& lhs, const Balance& rhs) {
Balance tmp(lhs.val_ + rhs.val_);
return(tmp);
}
const Balance operator+(double lhs, const Balance& rhs) {
Balance tmp(lhs + rhs.val_);
return(tmp);
}
const Balance operator+(const Balance& lhs, double rhs) {
Balance tmp(lhs.val_ + rhs);
return(tmp);
}
int main() {
Balance checking(500.00);
savings(23.91);
checking += 50;
Balance total = checking + savings;
cout << "Платежный баланс: " << checking.getVal() << '\n';
cout << "Общий баланс: " << total.getVal() << '\n';
}
Наиболее часто используют перегрузку для арифметических операторов и операторов присвоения. Существует огромное количество различных классов, для которых имеют смысл арифметические операторы (сложение, умножение, остаток от деления, сдвиг битов вправо/влево) и операторы присвоения — вне зависимости от того, используются ли они для вычислений или для чего-то другого. Пример 8.15 показывает основные методики перегрузки этих операторов.
Давайте начнем с того, что, вероятно, является наиболее часто перегружаемым оператором, — оператора присвоения. Оператор присвоения используется при присвоении одного объекта другому, как в следующем выражении.
Balance x(0), у(32);
x = y;
Вторая строка — это краткая запись вызова
Balance::operator=(y)
. Оператор присвоения отличается от большинства других операторов тем, что если вы не создаете собственной его версии, то компилятором создается версия по умолчанию. Версия по умолчанию просто копирует в текущий объект каждый член целевого объекта, что, конечно, не всегда приемлемо, так что его можно перегрузить и обеспечить другое поведение или перегрузить и предоставить возможность присвоения объектов типов, отличных от текущего
Для класса
Balance
из примера 8.15 оператор присвоения можно определить вот так.
Balance& operator=(const Balance& other) {
val_ = other.val_;
return(*this);
}
Первое, на что вы должны обратить внимание, если не знакомы с перегрузкой операторов, — это синтаксис
operator=
. Именно так объявляются все операторы. Все операторы можно рассматривать как функции с именами operator[symbol]
, где symbol
— это перегружаемый оператор. Единственным различием между операторами и обычными функциями является синтаксис их вызова. На самом деле, если вы хотите ввести побольше кода и написать отвратительно выглядящий код, то операторы можно вызывать и с помощью такого синтаксиса.
x.operator=(y); // То же самое, что и x = y, но уродливее
Работа моей реализации оператора присвоения проста. Он обновляет член
val_
текущего объекта, записывая в него значение аргумента other
, а затем возвращает ссылку на текущий объект. Операторы присвоения возвращают текущий объект как ссылку, так что вызывающий код может использовать присвоение в выражениях:
Balance x, y, z;
// ...
x = (y = z);
Таким образом, возвращаемое из
(y = z)
значение — это измененный объект y
, который затем передается в оператор присвоения объекта x
. Такая запись для присвоения используется не так часто, как для арифметических операторов, но чтобы придерживаться общего соглашения, всегда следует возвращать ссылку на текущий объект (то, как это связано с арифметическими операторами, я рассказываю дальше).
Однако простое присвоение — это только начало. Скорее всего, вам потребуется использовать другие арифметические операторы, определяющие более интересное поведение. В табл. 8.1 показан перечень арифметических операторов и операторов присвоения.
Табл. 8.1. Арифметические операторы и присвоение
Оператор | Поведение |
---|---|
|
Присвоение (должен быть функцией-членом) |
|
Сложение |
|
Вычитание |
|
Умножение и разыменование |
|
Деление |
|
Остаток от деления |
|
Инкремент |
|
Декремент |
|
Битовое исключающее ИЛИ |
|
Битовое дополнение |
|
Битовое И |
|
Битовое ИЛИ |
|
Сдвиг влево |
|
Сдвиг вправо |
Для большинства операторов из табл. 8.1 существует две лексемы: первая — это версия оператора, используемая обычным образом, т.е.
1+2
, а вторая версия — это версия, которая также присваивает результат операции переменной, т. е. x+=5
. Заметьте, что операторы инкремента и декремент ++
и --
описываются в рецепте 8.13.
Реализация всех арифметических операторов и оператора присвоения очень похожа, за исключением оператора присвоения, который не может быть отдельной функцией (т.е. он должен быть методом).
Наиболее популярным при перегрузке является оператор сложения — благодаря тому что он может использоваться в отличных от математических контекстах, таких как объединение двух строк, так что давайте вначале рассмотрим именно его. Он складывает правый аргумент с левым и присваивает результирующее значение левому аргументу, как в выражении.
int i = 0;
i += 5;
После выполнения второй строки
int i
изменяется и содержит значение 5. Аналогично, если посмотреть на пример 8.15, следует ожидать такого же поведения от этих строк.
Balance checking(500.00), savings(23.91);
checking += 50;
Это означает, что следует ожидать, что после использования оператора
+=
значение checking
будет увеличено на 50. Именно это происходит при использовании реализации из примера 8.15. Посмотрите на определение функции для оператора +=
.
Balance& operator+=(double other) {
val_ += other;
return(*this);
}
Для оператора присвоения список параметров — это то, что будет указано в операторе в его правой части. В данном случае используется целое число. Тело этой функции тривиально: все, что здесь делается, — это добавление аргумента к частной переменной-члену. Когда эта работа сделана, возвращается
*this
. Возвращаемым значением из арифметических операторов и операторов присвоения должно быть *this
, что позволяет использовать их в выражениях, когда их результаты будут входом для чего-то еще. Например, представьте, что operator+= объявлен вот так.
void operator+=(double other) { // Не делайте так
val += other;
}
Затем кто-то захочет использовать этот оператор для экземпляра класса и передать результат в другую функцию.
Balance moneyMarket(1000.00);
// ...
updateGeneralLeager(moneyMarket += deposit); // He скомпилируется
Это приведет к проблеме, так как
Balance::operator+=
возвращает void
, а функция типа updateGeneralLedger
ожидает получить объект типа Balance. Если из арифметических операторов и оператора присвоения возвращать текущий объект, то этой проблемы не возникнет. Однако это верно не для всех операторов. Другие операторы, типа оператора элемента массива []
или оператора отношения возвращают объекты, отличные от *this
, так что это правило верно только для арифметических операторов и операторов присвоения.
Здесь обеспечивается работа операторов присвоения, выполняющих какие-то вычисления, но как насчет вычислений без присвоения? Еще один способ использовать арифметические операторы выглядит так.
int i = 0, j = 2;
i = j + 5;
В этом случае к значению
j
прибавляется 5, а затем результат присваивается i
(при этом, если бы i
был объектом класса, а не встроенного типа, использовался бы оператор присвоения этого класса), а значение j
остается без изменения. Если требуется, чтобы класс вел себя точно так же, то перегрузите оператор сложения как самостоятельную функцию. Например, имеется возможность сделать так, чтобы можно было записать следующее.
Balance checking(500.00), savings(100.00), total(0);
total = checking + savings;
Это делается в два этапа. Первый шаг — это создание функции, которая перегружает оператор
+
.
Balance operator+(const Balance& lhs, const Balance& rhs) {
Balance tmp(lhs.val_ + rhs.val_);
return(tmp);
}
Она принимает два объекта типа
const Balance
, складывает их частные члены, создает временный объект и возвращает его. Обратите внимание, что в отличие от оператора присвоения здесь возвращается объект, а не ссылка на него. Это сделано потому, что возвращаемый объект является временным, и возврат ссылки на него будет означать, что вызывающий код получит ссылку на удаленную из памяти переменную. Однако само по себе это работать не будет, так как здесь требуется доступ к закрытым (частным) членам аргументов оператора (если, конечно, вы не сделали данные класса открытыми). Чтобы обеспечить такой доступ, класс Balance
должен объявить эту функцию как friend
.
class Balance {
// Здесь требуется видеть частные данные
friend Balance operator+(const Balance& lhs, const Balance& rhs);
// ...
Все что объявляется, как
friend
, получает доступ ко всем членам класса, так что этот фокус сработает. Только не забудьте объявить параметры как const
, чтобы случайно не изменить их содержимое.Это почти все, что от вас требуется, но есть еще кое-что, что требуется сделать. Пользователи класса могут создать выражение, аналогичное такому.
total = savings + 500.00;
Для кода из примера 8.15 это выражение будет работать, так как компилятор увидит, что класс
Balance
содержит конструктор, который принимает число с плавающей точкой, и создаст временный объект Balance
, используя в конструкторе число 500.00. Однако здесь есть две проблемы: накладные расходы на создание временного объекта и отсутствие в классе Balance
конструктора для всех возможных аргументов, которые могут использоваться в операторе сложения. Скажем, имеется класс с именем Transaction
, который представляет сумму кредита или дебета. Пользователь Balance
может сделать что-то подобное этому.
Transaction tx(-20.00);
total = savings + tx;
Этот код не скомпилируется, так как не существует оператора, который бы складывал объекты
Ваlance
и Transaction
. Так что создайте такой.
Balance operator+(const Balance& lhs, const Transaction& rhs) {
Balance tmp(lhs.val_ + Transaction.amount_);
return(tmp);
}
Однако необходимо сделать еще кое-что. Этот оператор также требуется объявить как
friend
в классе Transaction
, а кроме того, нужно создать идентичную версию этого оператора, которая бы принимала аргументы в обратном порядке, что позволит использовать аргументы сложения в любом порядке и сделает эту операцию коммутативной, т.е. x+y == y+x
.
Balance operator+(const Transaction& lhs, const Balance& rhs) {
Balance tmp(lhs.amount_ + rhs.val_);
return(tmp);
}
По той же причине и чтобы избежать создания дополнительного временного объекта при автоматическом вызове конструктора, создайте собственные версии операторов для работы с любыми другими типами переменных.
Balance operator+(double lhs, const Balance& rhs) {
Balance tmp(lhs + rhs.val_);
return(tmp);
}
Balance operator+(const Balance& lhs, double rhs) {
Balance tmp(lhs.val_ + rhs);
return(tmp);
}
И снова требуется создать по две версии каждого, чтобы позволить запись, как здесь.
total = 500.00 + checking;
В этом случае создание временного объекта относительно недорого. Но временный объект — это временный объект, и в простых выражениях он не создаст заметных накладных расходов, но такие незначительные оптимизации всегда следует рассматривать в более широком контексте — что, если в результате инкремента каждого элемента
vector
будет создан миллион таких временных объектов? Лучше всего заранее узнать, как будет использоваться класс, и в случае сомнений провести измерительные тесты.
В этот момент уместно спросить, почему для этих операторов мы должны создавать отдельные функции и не можем использовать методы, как это делается для присвоения? На самом деле вы можете объявить эти операторы как методы класса, но это не позволит создавать коммутативные операторы. Чтобы сделать оператор коммутативным, его потребуется объявить как метод в обоих классах, которые будут участвовать в операции, и это сработает (хотя и только для классов, знающих о внутренних членах друг друга), но если нет доступных конструкторов, это не сработает для операторов, использующих встроенные типы, и даже если конструкторы есть, придется платить за создание временных объектов.
Перегрузка операторов — это мощная возможность С++, и аналогично множественному наследованию имеются как ее сторонники, так и противники. На самом деле большая часть популярных языков не поддерживает ее совсем. Однако при осторожном использовании она дает возможность писать качественный и компактный код, использующий классы.
Большая часть стандартных операторов имеет несколько значений, и в общем случае вы должны следовать общепринятым соглашениям. Например, оператор
<<
означает битовый сдвиг влево или, при работе с потоками, помещение чего-либо в поток, как здесь.
cout << "Это записывается в поток стандартного вывода.\n.";
Если вы решите перегрузить
<<
для одного из своих классов, он должен делать одно из этих действий или, по крайней мере, аналогичное им. Перегрузка оператора — это одно, а придание им другого семантического смысла — это совсем другое. Если вы не вводите новое соглашение, повсеместно используемое в вашем приложении или библиотеке (что все равно является плохой идеей), и оно не является интуитивно понятным кому-либо еще, кроме вас, следует строго придерживаться стандартных значений.
Чтобы эффективно перегрузить операторы, требуется проделать большое количество черновой работы. Но ее требуется проделать только один раз, и она будет окупаться каждый раз, когда ваш класс будет использоваться в простых выражениях. При умеренном и разумном использовании перегрузки операторов она может сделать код легким как для чтения, так и для написания.
Рецепт 8.13.
Требуется вызвать функцию родительского класса, но она переопределена в производном классе, так что обычный синтаксис
p->method()
не дает нужного результата.
Укажите полное имя вызываемого метода, включая имя родительского или базового класса (если есть только два класса, например). (См. пример 8.16.)
Пример 8.16. Вызов определенной версии виртуальной функции
#include
using namespace std;
class Base {
public:
virtual void foo() {cout << "Base::foo()" << endl;}
};
class Derived : public Base {
public:
virtual void foo() {cout << "Derived::foo()" << endl;}
};
int main() {
Derived* p = new Derived();
p->foo(); // Вызов версии производного класса
p->Base::foo(); // Вызов версии базового класса
}
Регулярное использование переопределения полиморфных возможностей C++ является плохой идеей, но иногда это требуется сделать. Как и в случае с большинством других методик С++, это по большей части вопрос синтаксиса. Когда требуется вызвать определенную версию виртуальной функции базового класса, просто укажите ее имя после имени этого класса, как это сделано в примере 8.16.
p->Base::foo();
Здесь будет вызвана версия
foo
, определенная в Base
, а не та, которая определена в каком-то из подклассов Base
, на который указывает p
.