Pull to refresh
0
Parallels
Мировой лидер на рынке межплатформенных решений

Инкапсуляция интерфейсов. Делаем API в C++ удобным и понятным

Reading time 9 min
Views 22K
В свое время я написал для журнала «Хакер» цикл статей для рубрики «Академия С++», в котором описывал интересные возможности использования C++. Цикл давно завершён, но меня до сих пор часто спрашивают, как именно работает эмуляция динамической типизации из первой статьи. Дело в том, что когда я начинал цикл, не знал точно, что нужно, а что нет, и упустил в описании ряд нужных фактов. Зря! В обучающем материале не бывает ничего лишнего. Сегодня я в деталях изложу, как именно получается красивый высокоуровневый API в терминах самого обычного C++: просто классы, методы и данные.

Для чего это нужно


Как правило, на C++ пишется что-то быстрое, но не всегда удобное в использовании. В процессе разработки любого продукта выделяется общий функционал с худо-бедно оформленным интерфейсом работы с сущностями продукта. Язык C++ всячески поощряет указатели и ссылки на базовые классы, которые множатся и усложняют код, заворачиваются во всевозможные «умные» указатели и порождают километровые строки при любом обращении к подобной конструкции!
Согласитесь, вряд ли удобно использовать такое:
	std::unordered_map<std::string, std::vector< std::shared_ptr<base_class>>>

Особенно если для каждого элемента вектора нужна операция класса-наследника, то есть метод не входит в вышеупомянутый base_class. Что, не можете найти base_class в конструкции чуть выше? А я о чём говорил!
Для удобства использования работы с базовым классом проще всего выделить сущность работы с ним и инкапсулировать в неё интерфейс как простой указатель на данные класса.

Интерфейс базового класса


Чтобы максимально упростить повествование, будет много примеров, и мы не будем отходить далеко от кода. Сам код будет прилагаться к статье, и здесь его нигде и никто уже не потеряет. Итак, базовый класс предлагаю оформить как объект данных, давайте максимально его упростим:

class object
{
public:
    object(); // по умолчанию создание без данных, аналог null
    virtual ~object(); // для корректной генерации unique_ptr
    // копирование
    object(const object& another);
    object& operator = (const object& another);
    // проверка на null
    bool is_null() const;
    // объявление типа спрятанного в реализации
    class data;
    // для работы с потомками
    const data* get_data() const;
    // это понадобится для тестирования
    const char* data_class() const;
protected:
    // инициализация в потомках
    object(data* new_data);
    void reset(data* new_data);
    // это нужно для работы с данными
    void assert_not_null(const char* file, int line) const;
private:
    // для простоты изложения
    std::unique_ptr<data> m_data;
};


То, что мы ранее использовали в качестве интерфейса на базовый класс, превращается у нас в object::data — важнейший класс, который теперь не виден нигде снаружи.
На самом деле, в object, как и в object::data, должны присутствовать базовые операции, для которых и был заведён тот самый base_class. Но нам они в описании не понадобятся, и без того будет много интересного.
В минимальном виде класс данных объекта выглядит проще некуда:

class object::data
{
public:
    // самый важный метод класса данных
    virtual data* clone() const = 0;
    // это понадобится для тестирования
    virtual const char* class_name() const = 0;
};


Единственный метод, который нам действительно понадобится в базовом классе — это клонирование данных соответствующего наследника. Причём, как можно было заметить, интерфейсный класс прекрасно обходится без метода clone(), сам object и все его наследники пользуются обычными конструкторами копирования. Вот здесь мы и подходим к самому главному — наследованию от инкапсулированного базового класса.

Двойное наследование


Для наследников нам нужно выбрать пару сущностей. Давайте будем разрабатывать компьютерную игру, где у нас будут космические корабли и астероиды. Соответственно, нам нужны две пары классов для работы: asteroid и spaceship.
Давайте добавим по уникальному методу классам наследникам: пусть астероиды различаются по целочисленному идентификатору, а космические корабли идентифицируются уникальным именем:

class asteroid : public object
{
public:
    // пусть астероидов без идентификатора не бывает
    asteroid(int identifier);
    // копируем астероид
    asteroid(const asteroid& another);
    asteroid& operator = (const asteroid& another);
    // понадобится для приведения типа "наверх"
    asteroid(const object& another);
    asteroid& operator = (const object& another);
    // уникальный метод класса-наследника
    int get_identifier() const;
    // собственный класс данных
    class data;
private:
    // ссылка на интерфейс своего (!) класса данных
    data* m_data;
};

class spaceship : public object
{
public:
    // да не будет безымянных кораблей
    spaceship(const char* name);
    // копируем данные корабля
    spaceship(const spaceship& another);
    spaceship& operator = (const spaceship& another);
    // понадобится для приведения типа "наверх"
    spaceship(const object& another);
    spaceship& operator = (const object& another);
    // уникальный метод класса "получить имя"
    const char* get_name() const;
    // свой класс данных
    class data;
private:
    // ссылка на свои (!) методы и свойства
    data* m_data;
};


Обратите внимание, что несмотря на то, что роль контейнера выполняет предок object, в наследниках есть ссылка на содержимое object, но уже нужного типа. Наследование основных классов также должно быть продублировано для классов данных (ниже я покажу, для чего это нужно):

class asteroid::data : public object::data
{
public:
    // данные астероида создаются только с идентификатором
    data(int identifier);
    // получение идентификатора доступно только для астероида
    int get_identifier() const;
    // вот эта перегрузка крайне важна!
    virtual object::data* clone() const override;
    // эта перегрузка понадобится только для теста
    virtual const char* class_name() const override;
private:
    // данные класса asteroid известны только в реализации
    int m_identifier;
};

class spaceship::data : public object::data
{
public:
    // имя обязательно, без него звездолёт с данными не создать
    data(const char* name);
    // запросить имя можно только через интерфейс spaceship::data
    const char* get_name() const;
    // очень важно перегрузить этот метод!
    virtual object::data* clone() const override;
    // понадобится для тестирования и наглядности
    virtual const char* class_name() const override;
private:
    // только в реализации нам и понадобится #include <string>
    std::string m_name;
};


Теперь чуточку подробнее пройдём по реализации, и всё сразу встанет на свои места.

Реализация методов


Создание экземпляра непосредственно типа object конструктором по умолчанию будет означать создание объекта с null-значением.

object::object()
{
}

object::~object()
{
}

object::object(object::data* new_data)
    : m_data(new_data)
{
}

object::object(const object& another)
    : m_data(another.is_null() ? nullptr : another.m_data->clone())
{
}

object& object::operator = (const object& another)
{
    m_data.reset(another.is_null() ? nullptr : another.m_data->clone());
    return *this;
}

bool object::is_null() const
{
    return !m_data;
}

const object::data* object::get_data() const
{
    return m_data.get();
}

const char* object::data_class() const
{
    return is_null() ? "null" : m_data->class_name();
}

void object::reset(object::data* new_data)
{
    m_data.reset(new_data);
}

void object::assert_not_null(const char* file, int line) const
{
    if (is_null())
    {
        std::stringstream output;
        output << "Assert 'object is not null' failed at file: '" << file << "' line: " << line;
        throw std::runtime_error(output.str());
    }
}


Теперь самое главное, как же инициализируются экземпляры классов-наследников:

asteroid::asteroid(int identifier)
	: object(m_data = new asteroid::data(identifier))
{
}

spaceship::spaceship(const char* name)
	: object(m_data = new spaceship::data(name))
{
}


Как видно из этих нескольких строк, мы убиваем сразу стадо зайцев одни залпом фазового бластера:
  1. мы получаем создание наследников с сохранением ссылки на данные в специальный класс-контейнер обычным конструктором;
  2. класс-контейнер является также и базовым классом для всех прочих, вся основная работе по хранению интерфейса делается в базовом классе;
  3. класс-наследник имеет интерфейс для работы с классом данных соответствующего класса в m_data;
  4. работаем мы с самыми обычными классами, не по ссылке, получая все плюшки автоматизации C++ работы с экземплярами классов.


Разумеется при обращении к данным соответствующий класс будет использовать свой интерфейс-наследник, при этом проверяя данные на null:

int asteroid::get_identifier() const
{
    assert_not_null(__FILE__, __LINE__);
    return m_data->get_identifier();
}

const char* spaceship::get_name() const
{
    assert_not_null(__FILE__, __LINE__);
    return m_data->get_name();
}


Простой пример, который будет работать как часы:

	asteroid aster(12345);
	spaceship ship("Alfa-Romeo");
	object obj;
	object obj_aster = asteroid(67890);
	object obj_ship = spaceship("Omega-Juliette");


Проверяем:
Test for null:
aster.is_null(): false
ship.is_null(): false
obj.is_null(): true
obj_aster.is_null(): false
obj_ship.is_null(): false

Test for data class:
aster.data_class(): asteroid
ship.data_class(): spaceship
obj.data_class(): null
obj_aster.data_class(): asteroid
obj_ship.data_class(): spaceship

Test identification:
aster.get_identifier(): 12345
ship.get_name(): Alfa-Romeo


Не правда ли, напоминает высокоуровневые языки: C#, Java, Python и т.п.? Единственную сложность составит получение обратно интерфейса наследников, запакованных в object. Сейчас мы научимся извлекать в экземпляры asteroid и spaceship то, что ранее было запаковано в object.

Путь наверх


Всё, что нам нужно, это перегрузить конструктор классов-наследников, правда сама инициализация при этом получится не очень:

asteroid::asteroid(const asteroid& another)
    : object(m_data = another.is_null() ? nullptr : static_cast<asteroid::data*>(another.get_data()->clone()))
{
}

asteroid& asteroid::operator = (const asteroid& another)
{
    reset(m_data = another.is_null() ? nullptr : static_cast<asteroid::data*>(another.get_data()->clone()));
    return *this;
}

asteroid::asteroid(const object& another)
    : object(m_data = (dynamic_cast<const asteroid::data*>(another.get_data()) ?
                       dynamic_cast<asteroid::data*>(another.get_data()->clone()) : nullptr))
{
}

asteroid& asteroid::operator = (const object& another)
{
    reset(m_data = (dynamic_cast<const asteroid::data*>(another.get_data()) ?
                    dynamic_cast<asteroid::data*>(another.get_data()->clone()) : nullptr));
    return *this;
}


spaceship::spaceship(const spaceship& another)
    : object(m_data = another.is_null() ? nullptr : static_cast<spaceship::data*>(another.get_data()->clone()))
{
}

spaceship& spaceship::operator = (const spaceship& another)
{
    reset(m_data = another.is_null() ? nullptr : static_cast<spaceship::data*>(another.get_data()->clone()));
    return *this;
}

spaceship::spaceship(const object& another)
    : object(m_data = (dynamic_cast<const spaceship::data*>(another.get_data()) ?
                       dynamic_cast<spaceship::data*>(another.get_data()->clone()) : nullptr))
{
}

spaceship& spaceship::operator = (const object& another)
{
    reset(m_data = (dynamic_cast<const spaceship::data*>(another.get_data()) ?
                    dynamic_cast<spaceship::data*>(another.get_data()->clone()) : nullptr));
    return *this;
}


Как видно, здесь придётся использовать dynamic_cast, просто потому что приходится идти вверх по иерархии классов данных. Выглядит массивно, но результат того стоит:

	object obj_aster = asteroid(67890);
	object obj_ship = spaceship("Omega-Juliette");
	asteroid aster_obj = obj_aster;
	spaceship ship_obj = obj_ship;


Проверяем:
Test for null:
aster_obj.is_null(): false
ship_obj.is_null(): false

Test for data class:
aster_obj.data_class(): asteroid
ship_obj.data_class(): spaceship

Test identification:
aster_obj.get_identifier(): 67890
ship_obj.get_name(): Omega-Juliette


Туда и обратно. Как у Толкиена, только значительно короче.
Не забываем протестировать также и операторы присвоения:

    aster = asteroid(335577);
    ship = spaceship("Ramambahara");
    obj = object();
    obj_aster = asteroid(446688);
    obj_ship = spaceship("Mamburu");
    aster_obj = obj_aster;
    ship_obj = obj_ship;


И снова проверяем:
Test for null:
aster.is_null(): false
ship.is_null(): false
obj.is_null(): true
obj_aster.is_null(): false
obj_ship.is_null(): false
aster_obj.is_null(): false
ship_obj.is_null(): false

Test for data class:
aster.data_class(): asteroid
ship.data_class(): spaceship
obj.data_class(): null
obj_aster.data_class(): asteroid
obj_ship.data_class(): spaceship
aster_obj.data_class(): asteroid
ship_obj.data_class(): spaceship

Test identification:
aster.get_identifier(): 335577
ship.get_name(): Ramambahara
aster_obj.get_identifier(): 446688
ship_obj.get_name(): Mamburu


Всё работает как надо! Ниже идёт ссылка на GitHub с исходниками.

PROFIT!


Что мы имеем? Это не Pimpl, для Pimpl здесь слишком много полиморфизма, да и название «указатель на реализацию» не самое удачное. В C++ реализация и так находится отдельно от объявления класса, в .cpp файлах, Pimpl позволяет убрать данные в реализацию. Здесь данные не просто прячутся в реализацию, они составляют дерево иерархии, при этом зеркально отражая иерархию интерфейсных классов. Вдобавок мы получаем инкапсуляцию null-значений и можем встраивать логику допустимости null-значений в классы-наследники. Все классы легко жонглируют данными — как своими, так и всей цепочкой предков и наследников, при этом сам синтаксис будет прост и лаконичен.
Хотите сделать просто в API своей библиотеки? Теперь вам ничего не мешает. Что до реплик о том, что C++ очень сложен и на нём нельзя сделать высокоуровневую логику — пожалуйста, можно комбинировать массивы таких объектов, не хуже C# или Java, при этом преобразования будут даже проще. Вы можете сделать ваши классы простыми в использовании, при этом не понадобится хранить указатели на базовый класс, возиться с фабриками, в общем, всячески эмулировать обычные конструкторы и операторы присвоения больше не придётся.

Полезные ссылки


Со статьёй идут исходники, выложенные на GitHub.
Исходники дополнены парой методов, которые упрощают тестирование и позволяют быстрее понять, как работает передача данных между объектами.
Также оставлю ссылку на цикл статей «Академия C++» для журнала «Хакер».
Tags:
Hubs:
+11
Comments 22
Comments Comments 22

Articles

Information

Website
www.parallels.com
Registered
Founded
Employees
201–500 employees
Location
США