Pull to refresh

О компонентах и интерфейсах

Reading time 6 min
Views 12K
Вступление

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

Здесь и далее имеются в виду компоненты в смысле частей одного процесса. Компоненты, являющиеся отдельными процессами или сервисами, работа с которыми идет через RPC или еще как-то, здесь не рассматриваются. Хотя в большой мере все нижесказанное относится и к ним.

Пример первый:
Мне нужен компонент, давно разработанный и протестированный в другом отделе. Подхожу к разработчику. Завязывается диалог:
— Вот мне нужна вот эта штука. Как мне ее использовать в своем проекте?
— Да, вот нужно залить проект из CVS вот по этой метке. Скомпилить. Получится либа, и вот с ней нужно линковаться.
— Ок, спасибо.
Выкачиваешь проект. Компилишь. Вылезает куча ошибок, не хватает каких-то инклудов. Начинаешь выяснять. Оказывается для сборки проекта надо выкачать из CVS еще кучу проектов, их тоже собрать. Некоторые собираются стандартно студией, некоторые с бубном, вроде autoconf, make и иже с ними. Все. Собралось. Начинаются проблемы с линковкой. Не линкуется одно, второе, третье. Сторонних библиотек не хватает. В итоге — куча потерянного времени на непроизводительный труд и вникание в использованные библиотеки, сторонние компоненты и технологии.


Пример второй:
— Вот мне нужна вот эта штука. Как мне ее использовать в своем проекте?
— Понимаешь, этой штуки отдельно нет. Вот надо скачать проект и взять из него вот этот файлик, вот этот класс, вот это заинклудить, вот эти проекты взять, вот так положить…
— А-а-а!

Пример третий:
— Вот мне нужна вот эта штука. Как мне ее использовать в своем проекте?
— Да, вот либа, и вот с ней нужно линковаться.
— Ок, спасибо.
Подключаешь либу, а она не линкуется, не хватает символов. Опять начинается поиск и сборка компонентов, от которых она зависит.

Возникающие проблемы, навскидку:
1. Собрал не то, что протестировали. Да и в общем-то без разницы, протестированный компонент или нет, после пересборки его все равно надо тестировать.
2. Собрал не с теми версиями библиотек.
3. Собрал не с теми версиями сторонних компонентов.
4. Собрал не с теми опциями.
5. Просто собрал не то, что хотел.
А нужен был всего-то один метод одного класса… быстрее сам бы написал.

Откуда зло

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

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

Давайте рассмотрим правильное, с моей точки зрения, оформление компонентов.

Примеры будут на C++.

Как должен выглядеть компонент

По моему скромному мнению, компонент должен быть динамической библиотекой с заголовочным файлом в комплекте, описывающим интерфейсы, реализованные компонентом (предчувствуя, что полетят камни, сообщу, что знаю про dll-hell, но в правильно оформленной и установленной системе его не будет). Можно, в зависимости от ситуации, добавить сюда .def и .lib файлы, но в правильно оформленном компоненте достаточно .dll (.so) и .h. Желательно еще, чтобы наша динамическая библиотека была статически слинкована с runtime-библиотеками. Это избавит нас от проблемы различных Redistributable Packаges под Windows.

А вот статические библиотеки вообще нельзя считать компонентами. В статические библиотеки лучше складывать различные общие части реализации для разных компонентов, сильно завязанные на сторонние контейнеры, типа STL или boost.
В итоге, готовый компонент должен как бы фиксировать реализованный и протестированный функционал, предоставляя удобный интерфейс для его использования.

Интерфейсы

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

#include <string>

#ifdef EXPORTS
#define API __declspec( dllexport )
#else
#define API __declspec( dllimport )
#endif

class API Component
{
public:
    const std::string& GetString() const;

private:
    std::string m_sString;
};


Что тут не так? Ну, во-первых, реализация не скрыта. Мы видим член класса, мы видим сторонний контейнер, в данном случае std::string. Т.е. видим часть реализации, а это плохо. Кто-то возмутится и скажет, какой же он сторонний, если это стандартный контейнер? А сторонний потому, что в компоненте может быть использована реализация Microsoft STL, а мы хотим STLPort. И никогда тогда такой компонент использовать напрямую не сможем. Во-вторых: интерфейс не кроссплатформенный. Инструкции __declspec есть далеко не во всех компиляторах. В третьих: использование явной линковки для компонента с таким интерфейсом, мягко говоря, затруднительно.

Для решения первой проблемы подойдет PIMPL идиома и отказ от внешних контейнеров с заменой их на встроенные типы. Для решения второй расширим директивы define.

#ifdef WIN32
#ifdef EXPORTS
#define API __declspec( dllexport )
#else
#define API __declspec( dllimport )
#endif
#else
#define API
#endif

class ComponentImpl;

class API Component
{
public:
    const char* GetString() const;

private:
    ComponentImpl* m_pImpl;
};


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

Как быть с простотой явной линковки?

Для этого нужно использовать абстрактные классы и фабричную функцию.

#ifdef WIN32
#ifdef EXPORTS
#define API __declspec( dllexport )
#else
#define API __declspec( dllimport )
#endif
#else
#define API
#endif

class Component;

extern "C" API Container* GetComponent();

class Component
{
public:
    virtual ~Component() {}

    virtual const char* GetString() const = 0;
};


В данном случае мы имеем одну единственную функцию, имя которой известно и не меняется. Загрузить и слинковть такой компонент очень просто. Достаточно после загрузки библиотеки получить по имени и вызвать функцию GetComponent. Далее нам становится доступно все множество методов интерфейса Component.

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

#ifdef WIN32
#ifdef EXPORTS
#define API __declspec( dllexport )
#else
#define API __declspec( dllimport )
#endif
#else
#define API
#endif

class Factory;

extern "C" API Factory* GetFactory();

class Component;
class Component1;

class Factory
{
public:
    virtual ~Factory() {}

    virtual Component* GetComponent() = 0;

    virtual Component1* GetComponent1() = 0;
};

class Component
{
public:
    virtual ~Component() {}

    virtual const char* GetString() const = 0;
};

class Component1
{
public:
    virtual ~Component1() {}

    virtual const char* GetString() const = 0;
};


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

#ifdef WIN32
#ifdef EXPORTS
#define API __declspec( dllexport )
#else
#define API __declspec( dllimport )
#endif
#else
#define API
#endif

class Factory;

extern "C" API Factory* GetFactory();

class Base
{
public:
    virtual ~Base() {}

    virtual void QueryInterface( const char* id, void** ppInterface ) = 0;

    virtual void Release() = 0;
};

class ConnectionPoint
    : public Base
{
public:
    virtual void Bind( const char* id, void* pEvents ) = 0;

    virtual void Unbind( const char* id, void* pEvents ) = 0;
};

class Factory
    : public Base
{
};

static const char* COMPONENT_ID = "Component";

class Component
    : public Base
{
public:
    virtual const char* GetString() const = 0;
};

static const char* COMPONENT1_ID = "Component1";

class Component1
    : public Base
{
public:
    virtual const char* GetString() const = 0;
};


В данном случае мы можем добавлять интерфейсы в компонент, сохраняя обратную совместимость. Мы можем расширять сами интерфейсы наследованием, или использовать их как фабрики. Мы можем реализовывать в интерфейсах ConnectionPoint-ы и неограниченно расширять возможности по использованию обработчиков событий. Управление памятью в примере сильно упрощено, но можно, по аналогии с COM, использовать подсчет ссылок и смарт-указатели.

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

Конечно использование чистого COM-подхода почти всегда излишне, лучше комбинировать его с простой работой с абстрактными классами, как в предыдущем примере.

Часто и вовсе достаточно абстрактной фабрики и фабричной функции, когда понятно, что расширения возможностей компонента в будущем не потребуется.

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

В заключение

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

P.S.

Правильно оформлять компоненты и интерфейсы необходимо и в Java. Но об этом в следующий раз.
Tags:
Hubs:
+11
Comments 27
Comments Comments 27

Articles