Поділяємо інтерфейс і реалізацію у функціональному стилі на С++

Поділяємо інтерфейс і реалізацію у функціональному стилі на С++


В мові С++ для поділу оголошень структур даних (класів) використовуються відмінності файли. У них визначається повна структура класу, включаючи приватні поля.
Причини подібної поведінки описані в чудовій книзі «Дизайн і еволюція C++» Б. Страуструпа.

Ми отримуємо зовні парадоксальну ситуацію: зміни в закритих приватних полях класу вимагає перекомпіляції всіх одиниць трансляції (.cpp файлів), що використовують лише зовнішній інтерфейс класу. Звичайно, причина цього криється в необхідності знати розмір об'єкта при инстанцировании, але знання причини проблеми не вирішує саму проблему.

Спробуємо використовувати міць сучасного С++, щоб подолати цей недолік. Заинтереснванных прошу під кат.

1. Введення
Для початку, проілюструємо озвучений вище тезу ще раз. Припустимо, у нас є:

— Заголовковий файл → interface1.h:

class A {
public:
void next_step();
int result_by_module(int m);
private:
int _counter;
};

— Реалізація інтерфейсу → implementation1.cpp:

#include "interface1.h"

int A::result_by_module(int m) {
return _counter % m;
}

void A::next_step() {
++_counter;
}

— cpp-файл з функцією main → main.cpp:

#include "interface1.h"

int main(int argc, char** argv) {
A a;
while (argc--) {
a.next_step();
}
return a.result_by_module(4);
}

У заголовочном файлі визначено клас А, який має приватне поле _counter. До даного приватного поля мають доступ тільки методи класу і ніхто більше (залишимо за рамками хакі, friend-ів та інші прийоми, що порушують інкапсуляцію).

Однак, якщо ми захочемо змінити тип поля, потрібно перекомпіляція обох одиниць трансляції: файлів implementation.cpp і main.cpp. У файлі implementation.cpp розташована функція-член, а в main.cpp об'єкт типу А створюється на стеку.

Дана ситуація зрозуміла, якщо розглядати З++ як пряме розширення мови С, тобто макро-асемблер: необхідно знати розмір створюваного на стеку об'єкта.

Але давайте спробуємо зробити крок вперед і спробуємо позбутися перекомпиляци всіх одиниць трансляції, використовують визначення класу.

2. Використовуємо PIMPL
Перше, що приходить в голову — це використовувати патерн PIMPL (Pointer to implementation).
Але у цього патерну є недолік: необхідність писати обгортку для всіх методів класу приблизно таким чином (опустимо додаткові складності по управлінню пам'яттю):

— interface2.h:

class A_impl;

class A {
public:
A();
~A();
void next_step();
int result_by_module(int);
private:
A_impl* _impl;
};

— implementation2.cpp:

#include "interface2.h"

class A_impl {
public:
A_impl(): _counter(0) {}
void next_step() {
++_counter;
}
int result_by_module(int m) {
return _counter % m;
}
private:
int _counter;
};

A: A(): _impl(new A_impl) {}
A:~A() { delete _impl; }

int A::result_by_module(int m) {
return _impl->result_by_module(m);
}

void A::next_step() {
_impl->next_step();
}

3. Робимо зовнішній інтерфейс std::function
Спробуємо зробити цей патерн «більш функціональним» і відв'язати внутрішнє устройст класу від його публічного інтерфейсу.

Для зовнішнього інтерфейсу будемо використовувати стуктуру з полями типу std::function, зберігають методи. Також визначимо «віртуальний конструктор» — вільну функцію, яка повертає новий об'єкт, обгорнутий в smart-pointer:

— interface3.h:

struct A {
std::function<int(int)> _result_by_module;
std::function<void()> _next_couter;
};

std::unique_ptr<A> create_A();

Ми отримали повністю, «гальванічно», відв'язав інтерфейс класу. Час подумати про реалізації.

Реалізації почнемо вільної функції — віртуального конструктора.

std::unique_ptr<A> create_A(int start_i) {
std::unique_ptr<A> result(new A());

result->result_by_module_ = ???
result->next_counter_ = ???

return result;
}

Як же нам зберігати внутрішній стан об'єкта A? Для цього створимо окремий клас, який буде описувати внутрішньо стан зовнішнього об'єкта, але не буде ніяк з ним пов'язаним.

struct A_context {
int counter_;
};

Таким чином, ми отримали тип об'єкта, який буде зберігати стан і цей тип ніяк не пов'язаний із зовнішнім інтерфейсом!

Також, створимо вільну статичну функцію __A_result_by_module, яка буде виконувати роль методу. Фунція першим аргументом буде пренимать об'єкт типу A_context (точніше smart-pointer; чи не правда, схоже на python?). Для звуження області видимостипо помістимо функцію в анонімне просторі імен:

namespace {
static int __A_result_by_module(std::shared_ptr<A_context> ctx, int m) {
return ctx->counter_ % m;
}
}

Повернемося до функції create_A. Скористаємося функцією std::bind для зв'язування об'єкта C_context і функції __A_result_by_module в єдине ціле.

Для розмаїття, реалізуємо метод next_counter без використання нової функції, а з допомогою лямбда-функції.

std::unique_ptr<A> create_A() {
std::unique_ptr<A> result(new A());
auto ctx = std::make_shared<A_context>();

// Ініціалізуємо поля - аналог списків ініціалізації
ctx->counter_ = 0;

// Визначаємо методи
result->_result_by_module = std::bind(
__A_result_by_module,
ctx,
std::placeholders::_1);

result->_next_step = [ctx] () -> void {
ctx->counter_++;
};

return result;
}

4. Підсумковий приклад
Разом код з початку статті тепер можна переписати таким чином:

— interface.h:

#include <functional>
#include <memory>

struct A {
std::function<int(int)> _result_by_module;
std::function<void()> _next_step;
};

std::unique_ptr<A> create_A();

— implementation.cpp:

#include "interface3.h"
#include <memory>

struct A_context {
int counter_;
};

namespace {
static int __A_result_by_module(std::shared_ptr<A_context> ctx, int i) {
return ctx->counter_ % i;
}
}

std::unique_ptr<A> create_A() {
std::unique_ptr<A> result(new A());
auto ctx = std::make_shared<A_context>();

ctx->counter_ = 0;

result->_result_by_module = std::bind(
__A_result_by_module,
ctx,
std::placeholders::_1);

result->_next_step = [ctx] () -> void {
ctx->counter_++;
};

return result;
}

— main.cpp:

#include "interface3.h"

int main(int argc, char** argv) {
auto a = create_A();
while (argc--) {
a->_next_step();
}
return a->_result_by_module(4);
}

4.1. Трохи про володінні та управлінні пам'яттю
Схема володіння об'єктів може бути описана наступним чином: об'єкт зовнішнього інтерфейсу володіє функторами «методів». Функтори «методів» спільно володіють 1 об'єктом внутрішнього стану.

Таким чином, час життя об'єкта зовнішнього інтерфейсу визначає час звільнення об'єктів внутрішнього стану і об'єктів-функторів. У момент звільнення об'єкта зовнішнього інтерфейсу, будуть звільнені об'єкти-функтори. Так як об'єктом внутрішнього стану володіють тільки об'єкти-функтори, то в момент звільнення останнього об'єкта-функтора буде звільнений і об'єкт внутрішнього стану.

5. Підсумки
Таким чином, нам вдалося розв'язати внутрішній стан об'єкта від його зовнішнього інтерфейсу. Явно розділене:

1. Зовнішній інтерфейс:
— Використаний інтерфейс, заснований на std::function, ніяк не залежить від внутрішнього стану

2. Механізм породження об'єктів:
— Використовується вільна функція. Це дозволяє простіше реалізовувати породжують патерни.

3. Внутрішній стан об'єкта
— Використаний окремий клас, що описує внутрішній стан об'єкта, область видимості якого знаходиться повністю в межах однієї одиниці трансляції (cpp файлу).

4. Зв'язування внутрішнього стану і зовнішнього інтерфейсу
— Використана лямбда-функції для невеликих методів/геттеров/сеттерів/…
— Використана функція std::bind і вільні функції для методів з нетривіальною логікою.

Крім того, тестування коду в рамках даного коду вище, так як тепер легше написати unit-тест на будь-який метод, так як метод — це просто вільна функція.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.