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

В языке С++ для разделения объявлений структур данных (классов) используются заголовочные файлы. В них определяется полная структура класса, включая приватные поля.
Причины подобного поведения описаны в замечательной книге «Дизайн и эволюция 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-тест на любой метод, так как метод — это просто свободная функция.
