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


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

Комментарии (21)


  1. PkXwmpgN
    21.10.2016 12:21
    +7

    Объект класса А занимает 4 байта (поле типа int), независимо от сложности предоставляемого интерфейса.


    В вашей реализации объект класса А занимает:
    16 байт * 2 (количество_методов в интерфейсе) +
    4 байта для указателя на объект (unique_ptr) +
    4 байта A_context +
    8 байт для указателя на контекст (shader_ptr) +
    8 байт объект bind +
    8 байт для указатель на контект в bind'е +
    8 байт для указатель на контект в лямбде.


    А если в интерфейсе 10 методов?


    1. Elsedar
      21.10.2016 12:40
      +5

      Более того, теперь часть этого лежит в куче. Частое создание/удаление таких объектов скажется на производительности.


      1. monah_tuk
        21.10.2016 14:18
        +4

        А ещё есть соблазн присвоить другое значение хранителю функтора :)


        1. RainM
          21.10.2016 15:43
          -1

          Ну, всегда найдется возможность выстреллить в ногу :-)


          1. monah_tuk
            22.10.2016 05:53
            +4

            Но это не повод бросать оружие где попало, а то так ты себе только ногу, а тут и кто-то левый, и в голову, и не себе :)


    1. RainM
      21.10.2016 15:08

      Это конечно весомые цифры, но как часто Вам приходилось пересобирать весь (большой) проект после изменений в одном заголовочном файле.


      1. PkXwmpgN
        21.10.2016 15:44
        +3

        В вашей статье речь идет не об изменении заголовочного файла, а об изменение закрытых полей некоторого класса. Если в вашем проекте есть класс, в котором часто меняются приватные поля и это класс цепляется по всему большому проету (но он должен цепляться в явном виде, чтобы приводить к перекомпеляции) — то что-то в архитектуре этого проекта в принципе не так.


        И потом, из стать несовсем понятно, чем ваш пример лучше pimpl? Вы привели единственный аргумент


        необходимость писать обертку для всех методов класса примерно таким образом (опустим дополнительные сложности по управлению памятью):

        Как заметели в коментариях ниже, такой необходимости нет.
        Например, в качестве скрытой структуры может выступать A_context из вашего примера, тогда можно реализовать методы естественным путем, без дополнительного уровня косвенности.


        По поводу памяти, можно также использовать unique_ptr для хранения указателя на реализацию. Плюс ко всему здесь есть улучшение под название The Fast Pimpl Idiom.


        Пример
        // A.h
        
        class A 
        {
        public:
            A();
            ~A();
        
            void next_step();
            int result_by_module(int);
        
        private:
            struct impl;
            std::unique_ptr<impl> _impl;
        };
        
        // A.cpp
        
        struct A::impl
        {
            int counter_ = 0;
        };
        
        A::A()
            : _impl(std::make_unique<impl>())
        {}
        
        A::~A() = default;
        
        void A::next_step() 
        {
            ++_impl->_counter;
        }
        
        int A::result_by_module(int m) 
        {
            return _impl->_counter % m;
        }


      1. PkXwmpgN
        21.10.2016 16:41
        +2

        Также, на мой взгляд, никаким улучшением времени компеляции, путь даже она будет происходит мгновенно при любом изменение, нельзя оправдать увеличение потребления памяти в рантайме с учетом того, что производительность останется таже. Вы же этот продукт в конечном итоге отдатите пользователям. Как минимум, все подобные манипуляции должы производиться только под дебагом. И здесь pimpl опять лучше, потому что не меняет интерфейс класса. Если я без скрытой реализации писал a.result_by_module(4), то и со скрытой реализацией я буду писать а.result_by_module(4).


  1. PkXwmpgN
    21.10.2016 13:18
    +2

    Еще ваша реализация полность убирает понятие константности из интерфейса.


    1. RainM
      21.10.2016 15:09
      -1

      да, не спорю. Но есть достоинство в явном и полном разделении интерфейса и реализации и сокращении времени пересборки


  1. VasilyK
    21.10.2016 14:04
    +2

    В вашем примере есть явное нарушение инкапсуляции, можно сделать так:


    a->_result_by_module = std::function<int(int)>();

    Хотя этого можно избежать.


    есть недостаток: необходимость писать обертку для всех методов класса

    Зачем? По умолчанию никакой необходимости в этом нет.


    Думаю у такого подхода действительно есть много преимуществ, но не совсем тех, которые описаны в статье.


    1. RainM
      21.10.2016 15:10

      да, такие вещи можно делать (как можно сделать и другие опасные вещи).
      Если не секрет, какие достоинства по Вашему у такого подхода?


  1. TargetSan
    21.10.2016 15:09
    +1

    Вариант любопытный. Но к сожалению такой же костыль для хождения по граблям как и все остальные. Он не решает гораздо более пакостную проблему — namespace pollution, когда вместе с безобидным заголовком вектора в пространство видимости попадает половина стандартной библиотеки.
    Модули, которых нет. И не будет минимум до 20-го года. А то и дольше, учитывая скорость работы комитета.


    1. RainM
      21.10.2016 15:12

      Модули, даже если и появлятся, то явно не скоро. А проблема существует сейчас. И уже сейчас с этим хочется что-то сделать.


      1. TargetSan
        21.10.2016 15:20
        +1

        Проблема в том, что это решение реально получается толще pimpl и больше подвержено всяким фокусам вроде переназначения делегатов.


        1. RainM
          21.10.2016 15:41
          -1

          толще, но «тупого» кода меньше, ИМХО. В PIMPL в обертке получается достаточно много тривиальных методов, которые лишь делегируют управление.
          Т.е., если нужно добавить метод, то при PIMPL нужно: 1. поправить объявление реализации, 2. поправить определение реализации 3. Поправить определение обертки 4. поправить определение обертки.
          В описанном подходе нужно: 1. добавить поле-функтор, 2. добавить лямбду / бинд в «конструкторе», 3. (если использован бинд) сделать свободную функцию с реализацией.


          1. TargetSan
            21.10.2016 15:56

            Как-то многовато действий. Методы внешнего типа могут использовать данные из PImpl напрямую — и тогда накладные расходы будут в одном лишнем уровне индирекции.


  1. Woodroof
    22.10.2016 07:55
    +2

    Обычно это решается доступным снаружи интерфейсом и фабрикой, возвращающей shared_ptr.


    1. qw1
      24.10.2016 01:04

      Виртуальные вызовы. Пропадает возможность inline.
      Впрочем, те же проблемы у pimpl, но для вашего решения меньше писать лишнего кода.


  1. sliver2
    22.10.2016 15:10
    +1

    Ну мысль понятна. Интересная мысль, спасибо. Но мне не понятно какую проблему это решает?

    Да бывает такое, что лезешь менять незначительную вещь, а компилируется весь проект.
    Но!

    На каком этапе предлагается это городить? На этапе проектирования? То есть когда модуль еще не написан?
    На этапе проектирования, и написания модуля таких проблем не стоит. Потому, что этот модуль мы пишем и тестируем до того как инклюдить его.

    На этапе готового рабочего проекта когда нужно залезть в основополагающий класс?
    Такие задачи возникают и да действительно долго компилировать проект. Но стоит ли оно того чтобы его переписывать так так вот?
    Ну может быть пригодиться.


  1. nickolaym
    25.10.2016 05:00

    Это — приём (не хочу говорить «паттерн») NVI — невиртуальный интерфейс.
    Если уж захотелось делать интерфейсы, то зачем громоздить, ведь что PIMPL, что NVI требуют изобретательного рукоделия. Тогда как штатный способ в ООП — на старых добрых виртуальных функциях.