Pull to refresh

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

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


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

Articles