Разделяем интерфейс и реализацию в функциональном стиле на С++
В языке С++ для разделения объявлений структур данных (классов) используются заголовочные файлы. В них определяется полная структура класса, включая приватные поля.
Причины подобного поведения описаны в замечательной книге «Дизайн и эволюция 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-тест на любой метод, так как метод — это просто свободная функция.
Поделиться с друзьями
PkXwmpgN
Объект класса А занимает 4 байта (поле типа int), независимо от сложности предоставляемого интерфейса.
В вашей реализации объект класса А занимает:
16 байт * 2 (количество_методов в интерфейсе) +
4 байта для указателя на объект (unique_ptr) +
4 байта A_context +
8 байт для указателя на контекст (shader_ptr) +
8 байт объект bind +
8 байт для указатель на контект в bind'е +
8 байт для указатель на контект в лямбде.
А если в интерфейсе 10 методов?
Elsedar
Более того, теперь часть этого лежит в куче. Частое создание/удаление таких объектов скажется на производительности.
monah_tuk
А ещё есть соблазн присвоить другое значение хранителю функтора :)
RainM
Ну, всегда найдется возможность выстреллить в ногу :-)
monah_tuk
Но это не повод бросать оружие где попало, а то так ты себе только ногу, а тут и кто-то левый, и в голову, и не себе :)
RainM
Это конечно весомые цифры, но как часто Вам приходилось пересобирать весь (большой) проект после изменений в одном заголовочном файле.
PkXwmpgN
В вашей статье речь идет не об изменении заголовочного файла, а об изменение закрытых полей некоторого класса. Если в вашем проекте есть класс, в котором часто меняются приватные поля и это класс цепляется по всему большому проету (но он должен цепляться в явном виде, чтобы приводить к перекомпеляции) — то что-то в архитектуре этого проекта в принципе не так.
И потом, из стать несовсем понятно, чем ваш пример лучше pimpl? Вы привели единственный аргумент
Как заметели в коментариях ниже, такой необходимости нет.
Например, в качестве скрытой структуры может выступать A_context из вашего примера, тогда можно реализовать методы естественным путем, без дополнительного уровня косвенности.
По поводу памяти, можно также использовать unique_ptr для хранения указателя на реализацию. Плюс ко всему здесь есть улучшение под название The Fast Pimpl Idiom.
PkXwmpgN
Также, на мой взгляд, никаким улучшением времени компеляции, путь даже она будет происходит мгновенно при любом изменение, нельзя оправдать увеличение потребления памяти в рантайме с учетом того, что производительность останется таже. Вы же этот продукт в конечном итоге отдатите пользователям. Как минимум, все подобные манипуляции должы производиться только под дебагом. И здесь pimpl опять лучше, потому что не меняет интерфейс класса. Если я без скрытой реализации писал a.result_by_module(4), то и со скрытой реализацией я буду писать а.result_by_module(4).
PkXwmpgN
Еще ваша реализация полность убирает понятие константности из интерфейса.
RainM
да, не спорю. Но есть достоинство в явном и полном разделении интерфейса и реализации и сокращении времени пересборки
VasilyK
В вашем примере есть явное нарушение инкапсуляции, можно сделать так:
Хотя этого можно избежать.
Зачем? По умолчанию никакой необходимости в этом нет.
Думаю у такого подхода действительно есть много преимуществ, но не совсем тех, которые описаны в статье.
RainM
да, такие вещи можно делать (как можно сделать и другие опасные вещи).
Если не секрет, какие достоинства по Вашему у такого подхода?
TargetSan
Вариант любопытный. Но к сожалению такой же костыль для хождения по граблям как и все остальные. Он не решает гораздо более пакостную проблему — namespace pollution, когда вместе с безобидным заголовком вектора в пространство видимости попадает половина стандартной библиотеки.
Модули, которых нет. И не будет минимум до 20-го года. А то и дольше, учитывая скорость работы комитета.
RainM
Модули, даже если и появлятся, то явно не скоро. А проблема существует сейчас. И уже сейчас с этим хочется что-то сделать.
TargetSan
Проблема в том, что это решение реально получается толще pimpl и больше подвержено всяким фокусам вроде переназначения делегатов.
RainM
толще, но «тупого» кода меньше, ИМХО. В PIMPL в обертке получается достаточно много тривиальных методов, которые лишь делегируют управление.
Т.е., если нужно добавить метод, то при PIMPL нужно: 1. поправить объявление реализации, 2. поправить определение реализации 3. Поправить определение обертки 4. поправить определение обертки.
В описанном подходе нужно: 1. добавить поле-функтор, 2. добавить лямбду / бинд в «конструкторе», 3. (если использован бинд) сделать свободную функцию с реализацией.
TargetSan
Как-то многовато действий. Методы внешнего типа могут использовать данные из PImpl напрямую — и тогда накладные расходы будут в одном лишнем уровне индирекции.
Woodroof
Обычно это решается доступным снаружи интерфейсом и фабрикой, возвращающей shared_ptr.
qw1
Виртуальные вызовы. Пропадает возможность inline.
Впрочем, те же проблемы у pimpl, но для вашего решения меньше писать лишнего кода.
sliver2
Ну мысль понятна. Интересная мысль, спасибо. Но мне не понятно какую проблему это решает?
Да бывает такое, что лезешь менять незначительную вещь, а компилируется весь проект.
Но!
На каком этапе предлагается это городить? На этапе проектирования? То есть когда модуль еще не написан?
На этапе проектирования, и написания модуля таких проблем не стоит. Потому, что этот модуль мы пишем и тестируем до того как инклюдить его.
На этапе готового рабочего проекта когда нужно залезть в основополагающий класс?
Такие задачи возникают и да действительно долго компилировать проект. Но стоит ли оно того чтобы его переписывать так так вот?
Ну может быть пригодиться.
nickolaym
Это — приём (не хочу говорить «паттерн») NVI — невиртуальный интерфейс.
Если уж захотелось делать интерфейсы, то зачем громоздить, ведь что PIMPL, что NVI требуют изобретательного рукоделия. Тогда как штатный способ в ООП — на старых добрых виртуальных функциях.