Разделяем интерфейс и реализацию в функциональном стиле на С++
В языке С++ для разделения объявлений структур данных (классов) используются заголовочные файлы. В них определяется полная структура класса, включая приватные поля.
Причины подобного поведения описаны в замечательной книге «Дизайн и эволюция C++» Б.Страуструпа.
Мы получаем внешне парадоксальную ситуацию: изменения в закрытых приватных полях класса требует перекомпиляции всех единиц трансляции (.cpp файлов), использующих лишь внешний интерфейс класса. Конечно, причина этого кроется в необходимости знать размер объекта при инстанцировании, но знание причины проблемы не решает саму проблему.
Попытаемся использовать мощь современного С++, чтобы побороть этот недостаток. Заинтереснванных прошу под кат.
Для начала, проиллюстрируем озвученный выше тезис еще раз. Допустим, у нас есть:
— Заголовочный файл → interface1.h:
— Реализация интерфейса → implementation1.cpp:
— cpp-файл с функцией main → main.cpp:
В заголовочном файле определен класс А, имеющий приватное поле _counter. До данного приватного поля имеют доступ только методы класса и никто более (оставим за рамками хаки, friend-ов и другие приемы, нарушающие инкапсуляцию).
Однако, если мы захотим изменить тип данного поля, потребуется перекомпиляция обоих единиц трансляции: файлов implementation.cpp и main.cpp. В файле implementation.cpp расположена функция-член, а в main.cpp объект типа А создается на стеке.
Данная ситуация понятна, если рассматривать С++ как прямое расширение языка С, т.е. макро-ассемблер: необходимо знать размер создаваемого на стеке объекта.
Но давайте попробуем сделать шаг вперед и попробуем избавиться перекомпиляци всех единиц трансляции, использующих определение класса.
Первое, что приходит в голову — это использовать паттерн PIMPL (Pointer to implementation).
Но у этого паттерна есть недостаток: необходимость писать обертку для всех методов класса примерно таким образом (опустим дополнительные сложности по управлению памятью):
— interface2.h:
— implementation2.cpp:
Попробуем сделать этот паттерн «более функциональным» и отвязать внутреннее устройсто класса от его публичного интерфейса.
Для внешнего интерфейса будем использовать стуктуру с полями типа std::function, хранящими методы. Также определим «виртуальный конструктор» — свободную функцию, которая возвращает новый объект, обернутый в smart-pointer:
— interface3.h:
Мы получили полностью, «гальванически», отвязанный интерфейс класса. Время подумать о реализации.
Реализации начнем в свободной функции — виртуального конструктора.
Как же нам хранить внутреннее состояние объекта A? Для этого создадим отдельный класс, который будет описывать внутренне состояние внешнего объекта, но не будет являться никак с ним связанным.
Таким образом, мы получили тип объекта, который будет хранить состояние и этот тип никак не связан с внешним интерфейсом!
Также, создадим свободную статическую функцию __A_result_by_module, которая будет будет выполнять роль метода. Фунция первым аргументом будет пренимать объект типа A_context (точнее smart-pointer; не правда ли, похоже на python?). Для сужения области видимостипо поместим функцию в анонимное пространстве имен:
Вернемся к функции create_A. Воспользуемся функцией std::bind для связывания объекта C_context и функции __A_result_by_module в единое целое.
Для разноообразия, реализуем метод next_counter без использования новой функции, а с помощью лямбда-функции.
Итого, код из начала статьи теперь можно переписать таким образов:
— interface.h:
— implementation.cpp:
— main.cpp:
Схема владения объектов может быть описана следующим образом: объект внешнего интерфейса владеет функторами «методов». Функторы «методов» совместно владеют 1 объектом внутреннего состояния.
Таким образом, время жизни объекта внешнего интерфейса определяет время освобождения объектов внутреннего состояния и объектов-функторов. В момент освобождения объекта внешнего интерфейса, будут освобождены объекты-функторы. Так как объектом внутреннего состояния владеют только объекты-функторы, то в момент освобождения последнего объекта-функтора будет освобожден и объект внутреннего состояния.
Таким образом, нам удалось развязать внутреннее состояние объекта от его внешнего интерфейса. Явно разделено:
1. Внешний интерфейс:
— Использован интерфейс, основанный на std::function, никак не зависящий от внутреннего состояния
2. Механизм порождения объектов:
— Используется свободная функция. Это позволяет проще реализовывать порождающие паттерны.
3. Внутреннее состояние объекта
— Использован отдельный класс, описывающий внутреннее состояние объекта, область видимости которого находится полностью внутри одной единицы трансляции (cpp файла).
4. Связывание внутреннего состояния и внешнего интерфейса
— Использована лямбда-функции для небольших методов/геттеров/сеттеров/…
— Использована функция std::bind и свободные функции для методов с нетривиальной логикой.
Кроме того, тестируемость кода в рамках данного кода выше, так как теперь легче написать unit-тест на любой метод, так как метод — это просто свободная функция.
В языке С++ для разделения объявлений структур данных (классов) используются заголовочные файлы. В них определяется полная структура класса, включая приватные поля.
Причины подобного поведения описаны в замечательной книге «Дизайн и эволюция 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-тест на любой метод, так как метод — это просто свободная функция.