4. ООП


4.1. Разработка объекта

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

Правило. Обычный объект должен иметь минимальные функциональные возможности, достаточные для эффективного доступа к его данным.
Пояснение. Имеем объект - связанный список. Если предоставить объекту только выборку элемента оператором [] и не предоставить способа итерации по списку, то производительность резко уменьшится. Другой пример, пусть имеется объект Image - картинка. Теперь если сделать объекту методы loadFromFile, flipX, и подобные, надо будет всегда включать в модули, использующие класс Image полное описание последнего, т.е. заголовок. Такой подход порождает сильно запутанные зависимости между заголовками, поскольку объект Image может зависеть еще от ряда других объектов. Если же вынести функции не имеющие непосредственного отношения к данным объектов в отдельный класс "руководитель" (Manager), которому на уровне файла-заголовка надо знать о Image только имя типа, то тогда проблема со сложными зависимостями решится автоматически.
Мотивация. Это правило позволяет значительно уменьшить зависимости между заголовками и помогает создавать эффективные объекты. Также небольшой однородный объект легче документировать, а значит и легче понять его функцию другим программистам.

class Image;
class ImageMan
{
public:
    static bool loadFromFile(Image &im, const char *filename);
    static bool rotate90(Image &im);
};

Правило. Manager class должен работать только с объектами одного вида.
Пояснение. Класс ImageMan работает только с объектами Image.
Мотивация. Можно легко определить при помощи каких классов следует работать с объектом.

Правило. Всегда описывайте дополнительный класс - итератор для любого объекта, позволяющего работать с данными итеративно.
Пояснение. Для контейнерных типов это традиционно понятно, как сделать, однако такой подход возможен и для того же класса Image. Полезно разработать ImageIter, который позволяет итеративно обходить весь буфер с изображением. Алгоритм разложения отрезка в растр будет значительно эффективнее работать с таким объектом, нежели с функцией setPixel(x,y,color).
Мотивация. Итераторы позволяют повысить производительность программы. Повышение производительности происходит за счет сохранения состояния, полученного из предыдущих итераций. Выполнение увеличения итератора требует значительно меньше времени, чем расчет этого значения с исходного положения итератора. Также класс-итератор отлично защищает от ошибок связанных с переполнением или переходом за границы данных.

Правило. Используйте const везде где это возможно.
Пояснение.

class X
{
    int d_st;
public:
    X() : d_st(0) { }
    int getSt() const { return d_st; }
    void setSt(int st) { d_st = st; }
    void setStByK(const K &k) { d_st = k.getSt(); }
};
Неиспользование const в случае getSt наложит запрет на полноценную работу с типом "const X &". Аналогично не объявив аргумент setStByK const'ом сужается множество типов, которое может быть обработано setStByK корректно. Компилятор выдаст ошибку, при передаче setStByK параметра типа "const K &".
Мотивация. Использование const позволяет повысить надежность и улучшить различение между входными и выходными данными функций. Неиспользование const-методов полностью ломают гибкость использования объекта в C++.

Правило. Используйте static везде где это возможно.
Пояснение. Статические методы не имеют доступа к данным класса. На уровне ассемблера это значит, что функции передается на один указатель меньше (нет this). Соответственно повышается эффективность функции. Статические данные расположены в секции глобальных данных исполняемого файла, если данные можно объявить таковыми - объявите.
Мотивация. Повышение производительности.

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

class Vector3D
{
    float d_x, d_y, d_z;
    mutable float d_len;
public:
    ....
    void doCalcLength() const;
    float getLength() const {if (d_len<0) doCalcLength(); return d_len;}
};
.... в модуле:
void Vector3D::doCalcLength() const
{
    d_len = sqrt(d_x*d_x+d_y*d_y+d_z*d_z);
}

Мотивация. Использование const_cast требуется только тогда, когда конструкция объектов не позволяет работать с ними, как с константами, при хорошей разработки проекта этого произойти не должно, mutable позволяет хранить промежуточные данные, используется mutable в основном для повышения производительности в уже разработанных классах.

Правило. Если используете виртуальные функции, обязательно объявите виртуальный деструктор.
Мотивация. Если этого не делать, то появляется опасность не уничтожить объект корректно. Нарушается целостность объекта.

Правило. Всегда объявляйте конструктор по умолчанию, конструктор копирования и оператор присваивания.
Пояснение. Если в объекте копирование является некорректной операцией, то объявите конструктор и оператор копирование в private области. Компилятор сообщит ошибку, если кто-нибудь попытается использовать класс некорректно. Также можно поместить единственный конструктор по умолчанию в private, чтобы быть уверенным, что никто не будет использовать копию этого класса.
Мотивация. Строго определяет начальное состояние каждой копии класса.

4.2. Наследование и композиция

Для построения более сложных объектов из более простых в C++ можно использовать две методики: наследование и композиция. Вопрос поставим так: "Я разрабатываю объект X, мне кажется, что можно использовать объект Y, как базу для X, что предпочесть - наследование или композицию?". Ответ прост - если фразы "X был Y" или "X это Y" не являются бессмыслицей в человеческом понимании, то надо наследовать. Иначе объявлять копию внутри определения класса.

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

  1. Font и BitmappedFont; BitmappedFont это Font ? Да. Наследование.
    class BitmappedFont : Font { ... };
    
  2. Engine и Car; Car это Engine ? Нет. Car был Engine ? Нет. Композиция.
    class Car { Engine d_engine; ... };
    

4.3. Инкапсуляция

ОПРЕДЕЛЕНИЕ. Инкапсуляция это сокрытие внутреннего строения объекта от пользователя.
При использовании инкапсуляции, все что необходимо сделать - описать гибкий и эффективный интерфейс для работы с объектом. Первое основное правило для достижения частичной инкапсуляции - все данные должны быть с доступом private. Но это еще не является полной инкапсуляцией. Полная инкапсуляция достигается лишь тогда, когда в файле-заголовке нет никакой информации о внутренностях класса. Для большинства случаев достаточно частичной инкапсуляции и сокрытия всех данных в private-область.

Непрозрачные указатели. Используются для развязывания зависимостей между объектами и достижения полной инкапсуляции. Думаю пример является исчерпывающимся:

// ------ a.h (без непрозрачных указателей):
#ifndef I_A_H
#define I_A_H

#ifndef I_B_H
#include "b.h"
#endif

class A
{
public:
    B d_b;
    ....
};
#endif

// ------ a.h (с непрозрачными указателями):
#ifndef I_A_H
#define I_A_H

class B;

class A
{
public:
    B *d_b_p; // или в некоторых случаях "B &d_p;"
    ....
};
#endif

Эта методика может быть использована для достижения полной инкапсуляции:

....
class ImageInternals;
class Image
{
    ImageInternals *i_i; // внутренности
public:
    Image();
    ~Image();
    
    // описание методов
    unsigned char *getBuffer();
    ....
};
...
Полная инкапсуляция всегда требует накладных расходов, ее можно использовать только для достаточно сложных объектов. Для объекта Point это будет слишком сильная потеря производительности.

4.4. Компоненты

ОПРЕДЕЛЕНИЕ. Компонента это группа объектов, полностью определенных в группе модулей. В одну компоненту может входить несколько файлов-заголовков. Граф зависимости между компонентами в программе является деревом.

Имена файлов. Общепринято называть файлы с общим началом имени, например для компоненты image: image.h imageman.h image.cpp imageman_loadgif.cpp imageman_rasterops.cpp...

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

Разрешение циклических зависимостей между компонентами

Создание новой компоненты. Необходимо выделить связывающие части из обоих компонент и поместить их в отдельную компоненту. Получится так, что эта компонента будет зависеть от двух ранее циклически связанных компонент (escalation). Или наоборот две ранее циклически связанные компоненты будут зависеть от новой компоненты (термин demotion). Обычно новая компонента представляет из себя класс, аналогичный Manager классу. Пример:

// ------ компонента rectangle:
class Rectange
{
    ....
public:
    Rectangle(const Window &x) { copy data from x to this }
    operator Window() { return Window(*this); }
    ....
};
// ------ компонента window:
class Window
{
    ....
public:
    Window(const Rectangle &x) { copy data from x to this }
    operator Rectangle() { return Rectangle(*this); }
    ....
};
Можно конечно не делать эти функции инлайновыми, но это будет большая потеря производительности. Правильно сделать в данном случае так:
// ------ новая компонента boxutil:
class BoxUtil
{
public:
    static Window toWindow(const Rectangle &x) { ... }
    static Rectangle toRectangle(const Window &x) { ... }
};
Теперь новая компонента, зависящая от window и rectangle позволяет выполнять конверсию одного объекта в другой. Этот пример - не лучший, но позволяет объяснить, что значит escalation. Пример demotion:
// ------ компонента systema:
class SystemA
{
    ....
    public:
	enum ErrorsA { ... };
    ....
};
// ------ компонента systemb:
class SystemB
{
    ....
    public:
	enum ErrorsB { ... };
    ....
};
Объект SystemB использует SystemA::ErrorsA, а объект SystemA использует SystemB::ErrorsB. Создадим новый объект и компоненту:
// ------ компонента systemerrors:
class SystemErrors
{
    public:
	enum ErrorsA { ... };
	enum ErrorsB { ... };
};
Технику demotion можно использовать для уменьшения перекрестных зависимостей не только в циклически-зависимых компонентах.

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

Непрозрачные указатели. Ранее уже было описано, как их использовать.

4.5. Использование "friend"

Ключевое слово friend позволяет разрешить доступ объекту или функции к private области класса. friend можно использовать только в пределах одной компоненты, чтобы сохранялась инкапсуляция в масштабе этой компоненты. Сделать friend'ом объект из другой компоненты, значит признать неэффективность интерфейса. Правильно в данной ситуации будет исправить/дополнить интерфейс, а не ставить "заплаты" путем объявления некоторых классов friend'ами. Естественно делать friend'ами классы-менеджеры и классы-итераторы, которые могут быть сильно привязаны к внутренней структуры объекта для повышения эффективности.

4.6. Что такое Wrapper ?

Объект, который не содержит данных, а является лишь удобным пользовательским интерфейсом к одному или нескольким объектам программы. Использование wrapper'ов позволяет отвязать зависимость пользователей wrapper'а от всех компонент связанных с wrapper'ом. Wrapper можно представить как Manager class, управляющий не одним объектом, а набором компонент. Wrapper'ы используются на высоких уровнях, поэтому два дополнительных вызова не отразятся на общей производительности программы.

4.7. Что такое Template-Wrapper ?

Template-wrapper это несколько иное, они используются для "безопасного" преобразования типов. Смотрите list.h, list.cpp, listdemo.cpp. Эта методика позволяет сильно сократить размеры генерируемого кода, не потеряв удобств и гибкости C++ кода. Пользователь любой компоненты не должен делать принудительное преобразования типов, полученных в результате использования объектов какой-либо компоненты. Если все-же пользователь вынужден преобразовывать типы, то объект имеет неполный интерфейс и подлежит пересмотру.

4.8. Протокольные классы

ОПРЕДЕЛЕНИЕ. Абстрактный класс является протокольным классом, если:

  1. Он не содержит и не наследует данные, не виртуальные функции и private или protected члены класса.
  2. Имеет не-инлайновый виртуальный деструктор, определенный с пусты телом.
  3. Все остальные методы объявлены pure virtual и не определены.

Протокольные классы используют для достижения полной инкапсуляции и развязывания зависимостей между файлам-заголовками.


Валерий Щедрин <valery@forthpick.kiev.ua>

1