Избавляемся от недостатков классического ООП и пишем на С++ в модульном стиле.
Волею судеб мне пришлось поддерживать и развивать проект средней сложности написанный на С++. Проект написан в классическом ООП стиле и неплохо структурирован по модулям и классам. Нужно сказать, что до этого я провел немало времени разрабатывая проект на Java и Apache Tapestry 5. В частности очень хорошо разобрался с идеологией его IOC контейнера. Поэтому часть идей скопировано оттуда.
Итак проект структурирован, но любое незначительное изменение практически в любом заголовочном файле приводит к перекомпиляции половины проекта. Я не отличаюсь особо большой внимательностью с синтаксическим деталям при написании кода (забывание включения заголовков, пространств имен и т.п. для меня норма), поэтому бывает что приходится исправлять ошибки и перекомпилировать заново 2-3 раза и это отнимает очень много времени. Поэтому я решил внедрить в проект ряд практик по снижению компонентной связности кода о чем и хочу поделится. Сразу хочу сделать предупреждение. Проект требует совместимости с С++ 98, поэтому все что выходит за его рамки реализовано с помощью Boost.
Время жизни переменных
Один из базовых принципов ООП — инкапсуляция. К нему относится правило, что переменная должна быть доступна только там, где она используется. Доступность почти эквивалентна времени жизни автоматических переменных. Поэтому если переменная типа MyStack
является частным членом класса A, то все пользователи класса вынуждены импортировать также заголовок MyStack.h. Если же эта переменная используется только одной функцией и не содержит состояние, то ее нужно сделать вообще статической переменной. Кроме этого не стоит забывать что автоматические переменные живут до конца блока и пользоваться этим для уничтожения более ненужных переменных добавлением скобок блока кода.
PImpl
Проблему сокрытия реализации частной части класса частично решает указатель на реализацию (Pimpl). Я не хотел бы заново пересказывать в подробностях что-такое Pimpl, так как статей на эту тему можно найти достаточно. Вот, например, у Герба Саттера:
- GotW #24: Compilation Firewalls
- GotW #28: The Fast Pimpl Idiom
- GotW #100: Compilation Firewalls
- GotW #101: Compilation Firewalls, Part 2
Я сделаю только свои замечания и приведу мою реализацию идиомы.
- Идиома не скрывает публичные конструкторы, принимающие параметры для реализации. Эту проблему можно решить комбинируя интерфейсы и фабрики объектов.
- Не забываем перемещать все лишние для публичной части include в модуль с реализацией.
- Что-бы скрыть от глаз лишний код я реализовал модуль PImpl совместимый с С++ 98
Код
#ifndef PIMPL_H #define PIMPL_H ///idea from GotW #101: Compilation Firewalls, Part 2s http://herbsutter.com/gotw/_101/ #include <boost/scoped_ptr.hpp> template<typename T> class PImpl { private: boost::scoped_ptr<T> m; public: PImpl() : m(new T) { } template<typename A1> PImpl(A1& a1) : m(new T(a1)) { } //тут объявления оберток от 2 до 9 параметров …. template<typename A1, typename A2, typename A3, typename A4, typename A5, typename A6 , typename A7, typename A8, typename A9, typename A10> PImpl(A1& a1, A2& a2, A3& a3 , A4& a4, A5& a5, A6& a6, A7& a7, A8& a8, A9& a9, A10& a10) : m(new T(a1, a2, a3, a4, a5 , a6, a7, a8, a9, a10)) { } PImpl(const PImpl& orig) : m(new T(*orig)) { } T* operator->() const { return m.get(); } T& operator*() const { return *m.get(); } PImpl& operator=(const PImpl& orig) { m.reset(new T(*orig)); return *this; } }; #endif /* PIMPL_H */
- Во всех классах объявление реализации выглядит как
class Impl; PImpl<Impl> me;
me
позаимствовано из VBA - Если требуется указатель на публичную часть (для вызова публичых методов), то в конструктор
Impl
первым параметром передается публичныйthis
и сохраняется в полеppub
- Реализация при полном объявлении всегда объявляется как
struct
так как имеет область видимости только в текущем модуле. - Реализация обычно должна иметь конструкторы и перегруженные операторы полностью повторяющие публичные. Для конструкторов копирования и
operator=
не забываем правильно устанавливатьme
иppub
. - Объявления функций Impl в стиле Java. Как известно функции объявленные и определенные сразу в классе являются inline функциями. Не следует забывать, что inline это только совет компилятору, который он может и не учесть, поэтому, скорее всего, большие функции не будут inline, но будет меньше boilerplate на объявления и определения функций.
- О модульном тестировании. Как известно при модульном тестировании часто требуется иметь заглушки вместо реализаций от которых зависит тестируемый модуль. Если объект от которого зависит наш код реализован с
PImpl
, то мы можем очень просто заменить настоящую реализацию заглушкой с помощью компоновщика. Тестирование же сокрытой реализации возможно включением в тестовый модуль с помощью директивы #include кода реализации.Комплексный пример сказанного выше------- Hasher.h ------ #include <PImpl.h> class Hasher { class Impl; //Предварительное объявление реализации class или struct не имеет значения PImpl<Impl> me; //Указатель на реализацию public: Hasher(); void execute(); int getResults(); }; ------- Hasher.cpp ------ #include “Hasher.h” #include <HashLib.h> #include “SecTokens.h” //Объявление реализации. struct для уменьшения лишних модификаторов доступа struct Hasher::Impl { Hasher* ppub; //Указатель на публичную часть HashContext cnt; int hash; Impl(Hasher* ppub): ppub(ppub) { } void prepare() { HashLib::createContext(cnt); hash = 0; } void update(int val) { HashLib::updateHash(cnt, hash, val); } void finalize() { HashLib::releaseContext(cnt); } }; Hasher::Hasher(): me(this) { //Инициализация указателя на публичную часть } void Hasher::execute() { me->prepare(); me->update(SecTokens::one); me->update(SecTokens::two); me->finalize(); } int Hasher::getResults(){ return me->hash; } ------- Cryptor.h ------ #include <string> #include <PImpl.h> class Cryptor { class Impl; PImpl<Impl> me; public: Cryptor(std::string salt); std::string crypt(std::string plain); }; ------- Cryptor.cpp ------ #include <CryptoLib.h> #include “Cryptor.h” struct Cryptor::Impl { std::string salt; CryptoContext cnt; Impl(std::string salt): me(salt) { } void prepare() { CryptoLib::createContext(cnt); } void update(std::string plain) { CryptoLib::updateHash(cnt, plain); } std::string finalize() { return CryptoLib::releaseContext(cnt); } }; Cryptor::Cryptor(std::string salt): me(salt) { } std::string Cryptor::crypt(std::string plain) { me->prepare(); me->update(plain); return me->finalize(); } ------- MockHasher.cpp ------ #include “Hasher.h” struct Hasher::Impl { }; void Hasher::execute() { } int Hasher::getResults(){ return 4; } ------- TestCryptor.cpp ------ #include “Cryptor.cpp” int main(int argc, char** argv) { Cryptor::Impl impl(“salt”); impl.prepare(); //тут проверяем состояние impl после prepare impl.update(“text”); //тут проверяем состояние impl после update std::string crypto=impl.finalize(); //тут проверяем правильность значения crypto }
Итак есть класс
Cryptor
(обертка для некогоCryptoLib
), для которого нужно написать тест и классHasher
(обертка для некогоHashLib
) от которого зависитCryptor
. ноCryptor
еще зависит от модулейHashLib
иSecTokens
, а это нам совершенно не нужно для тестаCryptor
. Вместо него подготавливаем MockHasher.cpp.
Код Cryptor.cpp включен в TestCryptor.cpp, поэтому для сборки теста компилируем и компонуем только TestCryptor.cpp и MockHasher.cpp. Я не привожу примеров на базе библиотек модульного тестирования так как это не относится к теме данной статьи.
Пересмотр включения заголовочных файлов
Тут просто. Заголовок нужно включать как можно позже по ходу разбора кода, но желательно в начале файла. Т.е. если только реализация класса использует сторонний заголовок, то переносим его в модуль реализации класса из заголовка класса.
Callbacks и функторы вместо публичных функций
В проекте есть модуль в который я выношу все платформозависимые функции. Называется он Platform
. Получается модуль с несвязанными между собой функциями, которые я просто объявил в одном пространстве имен platform
. В дальнейшем я собираюсь заменять модуль с реализацией в зависимости от платформ. Но вот беда. Одна из функций должна заполнять пары <ключ, значение> класса (это std::map
, но со специфическим компаратором) объявленного вообще в частной части другого публичного класса Settings
.
Можно вынести частный класс в публичную видимость и разбить заголовок Platform на несколько заголовков. Тогда функция заполнения не будет включена в классы не имеющие отношения к этому заполнению и они не приобретут зависимость от этого std::map
. Я не сторонник плодить заголовочные файлы, кроме этого изменение области видимости шаблонного компаратора с частной на более общую приведет к увеличению компонентной связности. При любом изменении в нем будет перекомпиляция всего, что зависит от платформозависимого заполнителя.
Другой путь это использовать boost::bind
и callback функции. Функция-заполнитель будет принимать указатель на функцию
void fillDefaults(boost::function<void(std::string, std::string) > setDefault);
вместо
void fillDefaults(std::map<std::string, std::string, ci_less>& defaults);
Создаем callback в частной части Settings
:
void setDefault(std::string key, std::string value) {
defaults[key] = value;
}
void fillDefaults() {
platform::fillDefaults(boost::bind(&SettingsManager::Impl::setDefault, this, _1, _2));
}
вместо
void fillDefaults() {
platform::fillDefaults(defaults);
}
Используя pimpl иногда удобнее публичную функцию сделать в виде обертки для одноименной частной. Используя пример выше функцию
void Hasher::execute() {
me->prepare();
me->update(SecTokens::one);
me->update(SecTokens::two);
me->finalize();
}
можно представить как
void Hasher::Impl::execute() {
prepare();
update(SecTokens::one);
update(SecTokens::two);
finalize();
}
void Hasher::execute() {
me->execute();
}
но можно это сделать и с помощью bind функтора
------- Hasher.h ------
#include <boost/functions.hpp>
#include <PImpl.h>
class Hasher {
class Impl; //Предварительное объявление реализации class или struct не имеет значения
PImpl<Impl> me; //Указатель на реализацию
public:
Hasher();
boost::function<void()> execute;
int getResults();
};
------- Hasher.cpp ------
//……...
Hasher::Hasher(): me(this), execute(boost::bind(&Hasher::Impl::execute, &*me)) {
}
int Hasher::getResults(){
return me->hash;
}
Мы избавились от определения функции
Теперь execute может вызываться как и раньше
void f(Hasher& h) {
h.execute();
}
и, например, отправлен на исполнение в отдельный исполнитель
void f(Hasher& h, boost::asio::io_service& executor) {
executor.post(h.execute);
}
вместо
void f(Hasher& h, boost::asio::io_service& executor) {
executor.post(boost::bind(&Hasher::execute, &h));
}
boilerplate объявления функции-обертки трансформировался в boilerplate объявления boost функтора и остался только в конструкторе.
Нужно заметить, что есть и обратная сторона медали. execute
теперь публичное поле класса и к нему может быть случайно присвоено новое значение во время исполнения, чего не может произойти с функцией. Также теперь недоступно обычное переопределение виртуального метода, хотя эта проблема решается просто.
Таким образом получаем прелести функций высшего порядка как в JavaScript.
Еще пару слов о функторах за рамками основной темы. Пусть мы создали функтор и хотим сделать на базе него еще один функтор с меньшим числом аргументов
void myFunction(int, int);
int main(int argc, char** argv) {
boost::function<void(int, int)> functor1(boost::bind(myFunction, _1, _2));
boost::function<void(int)> functor2(boost::bind(functor1, 4, _1));
}
Вот этот вызов boost::bind(functor1, 4, _1) режет глаз. Почему-бы не объединить function pointer и bind, ведь они редко когда используются по отдельности. Тогда код выше приобретет вид:
int main(int argc, char** argv) {
Bindable<void(int, int)> functor1(boost::bind(myFunction, _1, _2));
Bindable<void(int)> functor2(functor1.bind(4, _1));
}
#ifndef BINDABLE_H
#define BINDABLE_H
#include <boost/bind.hpp>
#include <boost/function.hpp>
template<typename Signature>
struct Bindable : public boost::function<Signature> {
Bindable() {
}
template<typename T>
Bindable(const T& fn)
: boost::function<Signature>(fn) {
}
template<typename NewSignature, typename A1>
Bindable<NewSignature> bind(const A1& a1) {
return boost::bind(this, a1);
}
//тут объявления оберток от 2 до 9 параметров
template<typename NewSignature, typename A1, typename A2, typename A3, typename A4, typename A5, typename A6, typename A7, typename A8, typename A9, typename A10>
Bindable<NewSignature> bind(const A1& a1, const A2& a2, const A3& a3, const A4& a4, const A5& a5, const A6& a6, const A7& a7, const A8& a8, const A9& a9, const A10& a10) {
return boost::bind(*this, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10);
}
};
#endif /* BINDABLE_H */
Сокрытие параметров конструктора
Для начала нужно определиться какие виды параметров конструктора могут быть:
- конфигурационные параметры для конкретного применения экземпляра. Обычно это параметры простого типа — флаги, строки, метрики. Как бы то ни было эти параметры скрыть нет возможности;
- объекты, получаемые из глобальной области видимости для работы реализации. Вот их и будем скрывать.
Может возникнуть вопрос: “а зачем передавать глобально доступные объекты в конструктор, если можно обратиться к ним в любой момент?”. Да это так. Но есть ряд причин, из-за которых так лучше не делать:
- извлечение глобального объекта может быть ресурсоемкой операцией, тогда его лучше кешировать в поле класса
- извлечение глобального объекта может иметь сложный синтаксис, например
globalStorage.getProfiles().getProfile(“Default”)
. Что-бы не повторять такое выражение, объект или ссылку на него также лучше сохранить в поле класса - может потребоваться изменять копию глобального объекта. Тогда копия тоже должна быть в поле класса
- может потребоваться заменить используемый объект в отладочных целях. Тогда изменяется только один вызов извлечения и присвоения к полю класса.
Наследование. Фабрики и интерфейсы
Используя абсолютно абстрактные классы как интерфейсы (достаточно заголовочного файла) и создав наследника с нужными параметрами конструктора можно избежать публикования параметров. Для создания экземпляра в этом случае используется фабрика. Это может быть фабричный метод, объявленный в интерфейсе и определенный в модуле реализации, а может быть и самостоятельный класс, объект которого возвращает новый объект или указатель на новый объект.
Я давно склоняюсь к тому, что при возможности выбора использовать наследование или композицию я выбираю композицию. Дополнительно убедился в правильности этого подхода получив ошибку Pure Virtual Function Called
Композиция
Если в класс внедрена идиома pimpl, то при создании частной реализации можно передать ей в конструктор не параметры конструктора публичной части, а объекты из глобальной области видимости. т.е. в публичном конструкторе не остается параметров глобального значения, только флаги и т.п. параметры которые действительно нужно знать и задавать в участке кода, создающего экземпляр.
Структурирование файлов, модульность и ленивая инициализация
Проект содержит около 50 файлов “.cpp” плюс заголовочные файлы. Файлы логически разнесены по каталогам — подсистемам. В коде присутствует ряд глобальных переменных простых типов и объект доступа к разделяемым объектам пользовательских типов. Получение доступа к объектам может выглядеть так
globalStorage.getHoster()->invoke();
или так:
Profile pr=globalStorage.getProfiles()->getProfile(“Default”);
Аналогично рассмотренному выше Platform
все кто использует globalStorage
вынуждены знать что экспортирует интерфейс GlobalStorage
со всеми внешними типами. Но GlobalStorage
должен действительно вернуть объект заданного типа (или реализующего заданный интерфейс) и нет возможности решить проблему как в Platform
.
Итак следующая цель — преобразовать подсистемы во что-то похожее на IOC модули Apache Tapestry 5, упростить доступ к глобальным объектам (в дальнейшем сервисы — по аналоги с сервисовами Tapestry) и вынести конфигурирование сервисов в самостоятельный файл в IOC модуле. В итоге мы получим самые настоящие компоненты (см. Компонентно-ориентированное программирование)
Сразу хочу сказать что о полноценном IOC контейнере речь не идет. Описанный пример это только генерализация шаблона Синглтон сервиса и Фабрика. Используя этот подход можно также реализовать Теневые сервисы (поле сервиса представляем как самостоятельный сервис) и другие источники сервисов.
Конфигурация сервисов IOC модуля
Создаем
#include "InjectPtr.h"
///Helper interface class. Only for visual marking of needed methods.
///We can't do virtual template members
namespace ioc {
///methods like http://tapestry.apache.org/defining-tapestry-ioc-services.html#DefiningTapestryIOCServices-ServiceBuilderMethods
///Like public @InjectService or @Inject annotation
///ServiceId Case http://tapestry.apache.org/defining-tapestry-ioc-services.html#DefiningTapestryIOCServices-ServiceIds
template<typename T, size_t ID>
InjectPtr<T> resolve();
///Singleton or factory case
template<typename T>
InjectPtr<T> resolve();
};
теперь вместо
boost::shared_ptr<Hoster> hoster = globalStorage.getHoster();
вызов будет выглядеть
InjectPtr<Hoster> hoster = ioc::resolve<Hoster>();
Как видим эта конструкция не импортирует ничего лишнего. Если в коде нужно получить Hoster
, то следует самостоятельно позаботится о импорте его заголовка. Второй параметр шаблона метода resolve
это идентификатор сервиса. Используется в случае если есть несколько сервисов с одним интерфейсом.
InjectPtr
это умный указатель на объект с отложенной (ленивой) инициализацией. Внутри хранит boost::shared_ptr
на boost::shared_ptr
на хранимый объект. Последний инициализируется при первом разыменовании InjectPtr
. Для создания экземпляра хранимого объекта InjectPtr
получает функтор-фабрику.
#ifndef INJECT_PTR_H
#define INJECT_PTR_H
#include <cassert>
#include <cstddef>
#include <boost/shared_ptr.hpp>
#include <boost/scoped_ptr.hpp>
#include <boost/make_shared.hpp>
#include <boost/function.hpp>
#include <boost/thread/mutex.hpp>
///Pointer to lazy instantiative object
template<typename T> class InjectPtr {
private:
typedef boost::function<T*() > Factory;
boost::shared_ptr< boost::shared_ptr<T> > px;
boost::shared_ptr< boost::scoped_ptr<boost::mutex> > instantiateMutex;
Factory factory;
public:
///Main constructor. Take factory for future instantiate object
InjectPtr(Factory factory)
: px(boost::make_shared<boost::shared_ptr<T> >())
, instantiateMutex(boost::make_shared<boost::scoped_ptr<boost::mutex> >(new boost::mutex))
, factory(factory) {
}
InjectPtr()
: px(boost::make_shared<boost::shared_ptr<T> >())
, instantiateMutex(boost::make_shared<boost::scoped_ptr<boost::mutex> >()) {
}
InjectPtr(boost::shared_ptr<T> pObject)
: px(boost::make_shared<boost::shared_ptr<T> >(pObject)) {
assert(*px != 0);
}
InjectPtr(InjectPtr const &orig)
: px(orig.px)
, instantiateMutex(orig.instantiateMutex)
, factory(orig.factory) {
}
InjectPtr & operator=(InjectPtr const & orig) {
px = orig.px;
instantiateMutex = orig.instantiateMutex;
factory = orig.factory;
return *this;
}
virtual ~InjectPtr() {
}
T & operator*() {
instantiate();
return **px;
}
T * operator->() {
instantiate();
return &**px;
}
bool operator!() const {
return !*px;
}
void operator==(InjectPtr const& that) const {
return *px == that->px;
}
void operator!=(InjectPtr const& that) const {
return *px != that->px;
}
boost::shared_ptr<T> sharedPtr() {
instantiate();
return *px;
}
void instantiate() {
if (!*px && factory) {
{
boost::mutex::scoped_lock lock(**instantiateMutex);
if (!*px) {
px->reset(factory());
}
}
instantiateMutex->reset();
}
}
Factory getFactory() const {
return factory;
}
void setFactory(Factory factory) {
if(!*px && !this->factory){
if(!*instantiateMutex) instantiateMutex->reset(new boost::mutex);
this->factory = factory;
}
}
};
template<class T, class U> InjectPtr<T> static_pointer_cast(InjectPtr<U> r) {
return InjectPtr<T>(boost::static_pointer_cast<T>(r.sharedPtr()));
}
#endif /* INJECT_PTR_H */
InjectPtr
потокобезопасный. Во время создания объекта операция блокируется мутексом.
Переходим к файлу конфигурации IOC. Делаем полные специализации шаблонного метода ioc::resolve
------- IOCModule.h ------
//Этот файл один на все модули
#ifndef IOCMODULE_H
#define IOCMODULE_H
#include <boost/functional/factory.hpp>
#include <boost/bind.hpp>
#include <IOC.h>
#endif /* IOCMODULE_H */
------- IOCModule.cpp ------
#include "Hoster.h"
#include "SomeService.h"
#include "InjectPtr.h"
#include <IOCModule.h>
#include <IOC.h>
//Module like http://tapestry.apache.org/tapestry-ioc-modules.html
//Now only for: - To provide explicit code for building a service
using namespace ioc;
///methods like http://tapestry.apache.org/defining-tapestry-ioc-services.html#DefiningTapestryIOCServices-ServiceBuilderMethods
template<> InjectPtr<SomeService> resolve<SomeService>() {
static InjectPtr<Hoster> result(boost::bind(boost::factory<SomeService*>()));
return result;
}
///Hoster takes SomeService in constructor
template<> InjectPtr<Hoster> resolve<Hoster>() {
static InjectPtr<Hoster> result(boost::bind(boost::factory<Hoster*>(), resolve<SomeService>()));
return result;
}
GCC гарантирует блокировку и при создании static локальной переменной функции. Но стандарт этого не гарантирует. Пришлось изменить код и вынести хранителя InjectPtr
в глобальную статическую переменную, которая наверняка инициализируется еще до запуска кода программы. Можно, конечно и в отдельные переменные, но тогда придется изобретать имя для каждой. Тут CoreStorage
это хранитель для IOC модуля Core:
#include "Hoster.h"
#include "SomeService.h"
#include "InjectPtr.h"
#include <IOCModule.h>
#include <IOC.h>
//Module like http://tapestry.apache.org/tapestry-ioc-modules.html
//Now only for: - To provide explicit code for building a service
using namespace ioc;
struct CoreStorage {
InjectPtr<SomeService> someService;
InjectPtr<Hoster> hoster;
};
static CoreStorage storage;
///methods like http://tapestry.apache.org/defining-tapestry-ioc-services.html#DefiningTapestryIOCServices-ServiceBuilderMethods
template<> InjectPtr<SomeService> resolve<SomeService>() {
if(!storage.someService.getFactory()) {
storage.someService.setFactory(boost::bind(boost::factory<SomeService*>()));
}
return storage.someService;
}
///Hoster takes SomeService in constructor
template<> InjectPtr<Hoster> resolve<Hoster>() {
if(!storage.hoster.getFactory()) {
storage.hoster.setFactory(boost::bind(boost::factory<Hoster*>(), resolve<SomeService>()));
}
return storage.hoster;
}
Заголовочные файлы IOC модуля
Этот пункт немного увеличивает компонентную связность внутри IOC модуля, но снижает ее при межмодульном взаимодействии.
Для взаимодействия IOC модулей удобно создать интерфейсный заголовок IOC модуля одноименный с самим модулем. Он должен содержать:
- включения публичных на уровне IOC модуля интерфейсов классов;
- полные декларации публичных на уровне IOC модуля перечислений и простых структур;
- публичные на уровне IOC модуля определения препроцессора.
Так же удобно иметь частный заголовок модуля, который импортирует публичный и делает:
- предварительные объявления всех классов проекта;
- полные декларации внутренних для IOC модуля перечислений и простых структур;
- внутренние для IOC модуля определения препроцессора.
Автор: slonm