Pimpl (pointer to implementation, указатель на имплементацию) — полезная идиома, распространенная в языке C++. У этой идиомы есть несколько положительных сторон, однако, в этой статье она рассматривается только как средство уменьшения зависимостей времени компиляции. Более подробно о самой идиоме можно посмотреть, например, здесь, здесь и здесь. Эта статья посвящена тому какой умный указатель использовать при работе с Pimpl и зачем он нужен.


Рассмотрим различные варианты реализации Pimpl:


Голый указатель


Самый простой способ, который, наверняка, многие видели — использование голого указателя.


Пример использования:


// widget.h
class Widget {
public:
    Widget();
    ~Widget();
//...
private:
    struct Impl;
    Impl* d_;
};
// widget.cpp
struct Widget::Impl { /*...*/ };
Widget::Widget(): d_(new Impl) {}
Widget::~Widget() { delete d_; }

Плюсы:


  • не нужно никаких дополнительных сущностей

Минусы:


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

Использование std::auto_ptr


Сразу стоит отметить, что auto_ptr уже запрещен и его не стоит использовать. Однако важно отметить его преимущества перед голым указателем, а также проблемы, связанные с Pimpl.


Пример использования:


// widget.h
class Widget {
// ... как раньше
    struct Impl;
    std::auto_ptr<Impl> d_;
};
// widget.cpp
struct Widget::Impl { /*...*/ };
Widget::Widget(): d_(new Impl) {}
Widget::~Widget() {}

auto_ptr, как и другие умные указатели из стандартной библиотеки, берет на себя ответственность за управление временем жизни указателя. С помощью идиомы RAII auto_ptr позволяет работать с Pimpl безопасно относительно исключений, так как при возникновении исключения вызывется его деструктор, который освобождает память.


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


// widget.h
class Widget {
public:
    Widget();
//... отсутствует деструктор
private:
    struct Impl;
    std::auto_ptr<Impl> d_;
};
// widget.cpp
struct Widget::Impl { /*...*/ };
Widget::Widget(): d_(new Impl) {}

Это связано с тем, что auto_ptr будет удалять неполный класс. Более подробно с данной проблемой можно ознакомиться здесь. Так как эта проблема относится не только к auto_ptr настоятельно рекомендуется ознакомиться и разобраться с этим вопросом. Краткое решение проблемы в этой ситуации — явное объявление и определение деструктора.


Плюсы:


  • безопасен относительно исключений

Минусы:


  • запрещен
  • возможна утечка памяти при удалении неполного класса


Использование std::unique_ptr


В C++11 появилась семантика перемещения (move semantic), которая позволила заменить auto_ptr на умный указатель с ожидаемым поведением unique_ptr.


Пример использования:


// widget.h
class Widget {
// ... как раньше
    struct Impl;
    std::unique_ptr<Impl> d_;
};
// widget.cpp
struct Widget::Impl { /*...*/ };
Widget::Widget(): d_(std::make_unique<Impl>()) {}
Widget::~Widget() {}

unique_ptr решает проблему удаления неполного класса при проверке на полноту на этапе компиляции. Теперь молча удалять неполный класс не получится.


Однако для решения поставленной задачи unique_ptr все еще имеет недостаток, заключающийся в том, что он имеет семантику обычного указателя. Рассмотрим пример:


// widget.h
class Widget {
public:
// ... как раньше
    void foo() const; // <- константный метод
private:
    struct Impl;
    std::unique_ptr<Impl> d_;
};
// widget.cpp
struct Widget::Impl { int i = 0; };
Widget::Widget(): d_(std::make_unique<Impl>()) {}
Widget::~Widget() {}

void Widget::foo() const {
    d_->i = 42; // <- изменение данных внутри константного метода
}

В подавляющем большинстве случаев компиляция такого кода нежелательна.


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


Плюсы:


  • защита от утечек памяти

Минусы:


  • нарушение логической константности


Использование std::unique_ptr с propagate_const


В экспериментальной библиотеке есть обертка для указателей propagate_const, которая позволяет исправить логическую константность.


Пример использования:


// widget.h
class Widget {
// ... как раньше
    struct Impl;
    std::experimental::propagate_const<std::unique_ptr<Impl>> d_;
};
// widget.cpp
struct Widget::Impl { /*...*/ };
Widget::Widget(): d_(std::make_unique<Impl>()) {}
Widget::~Widget() {}

Теперь код из предыдущего примера будет вызывать ошибки компиляции.


Кажется это близко к полноценному решению проблемы, однако, есть еще один небольшой момент.
При написании конструктора надо всегда явно создавать Impl. Это не кажется большой проблемой, так как, скорее всего, ошибка проявится при первом обращении к классу во время выполнения.


Плюсы:


  • соблюдение логической константности

Минусы:


  • возможность забыть создать Impl в конструкторе
  • propagate_const пока не является частью стандарта


Использование PimplPtr


Учитывая все вышеописанные минусы и плюсы для полноценного решения необходимо предоставить умный указатель, который соответствует следующим требованиям:


  • безопасность относительно исключений
  • защита от удаления неполного класса
  • соблюдение логической константности
  • защита от несозданного Impl

Первые два пункта можно реализовать с помощью unique_ptr:


template<class T>
class PimplPtr {
public:   
   using ElementType = typename std::unique_ptr<T>::element_type;
 // ...
private:
   std::unique_ptr<T> p_; // <- Должен быть неконстантный для семантики перемещения
};

Третий пункт можно было бы реализовать с помощью propagate_const, но, так как его пока нет в стандарте, можно легко реализовать методы доступа к указателю самостоятельно:


    const ElementType* get() const noexcept { return p_.get(); }
    const ElementType* operator->() const noexcept { return get(); }
    const ElementType& operator*() const noexcept { return *get(); }
    explicit operator const ElementType*() const noexcept { return get(); }

    ElementType* get() noexcept { return p_.get(); }
    ElementType* operator->() noexcept { return get(); }
    ElementType& operator*() noexcept { return *get(); }
    explicit operator ElementType*() noexcept { return get(); }

Для выполнения четвертого пункта нужно реализовать конструктор по умолчанию, который будет создавать Impl:


   PimplPtr(): p_(std::make_unique<T>()) {}

Если у Impl нет конструктора по умолчанию, то компилятор скажет об этом, и пользователю потребуется другой конструктор:


    explicit PimplPtr(std::unique_ptr<T>&& p) noexcept: p_(std::move(p)) {}

Для большей ясности, возможно, стоит добавить статические проверки в конструкторе и деструкторе:


   PimplPtr(): p_(std::make_unique<T>()) {
       static_assert(sizeof(T) > 0, "Probably, you forgot to declare constructor explicitly");
   }

   ~PimplPtr() {
       static_assert(sizeof(T) > 0, "Probably, you forgot to declare destructor explicitly");
   }

И, чтобы сохранить семантику перемещения, надо добавить соответствующие конструктор и оператор:


   PimplPtr(PimplPtr&&) noexcept = default;
   PimplPtr& operator =(PimplPtr&&) noexcept = default;

Весь код целиком:


namespace utils {

template<class T>
class PimplPtr {
public:   
   using ElementType = typename std::unique_ptr<T>::element_type;

   PimplPtr(): p_(std::make_unique<T>()) {
       static_assert(sizeof(T) > 0, "Probably, you forgot to declare constructor explicitly");
   }
   explicit PimplPtr(std::unique_ptr<T>&& p): p_(std::move(p)) {}

   PimplPtr(PimplPtr&&) noexcept = default;
   PimplPtr& operator =(PimplPtr&&) noexcept = default;

   ~PimplPtr() {
       static_assert(sizeof(T) > 0, "Probably, you forgot to declare destructor explicitly");
   }

    const ElementType* get() const noexcept { return p_.get(); }
    const ElementType* operator->() const noexcept { return get(); }
    const ElementType& operator*() const noexcept { return *get(); }
    explicit operator const ElementType*() const noexcept { return get(); }

    ElementType* get() noexcept { return p_.get(); }
    ElementType* operator->() noexcept { return get(); }
    ElementType& operator*() noexcept { return *get(); }
    explicit operator ElementType*() noexcept { return get(); }

private:
   std::unique_ptr<T> p_;
};

} // namespace utils

Пример использования:


// widget.h
class Widget {
// ... как раньше
    struct Impl;
    utils::PimplPtr<Impl> d_;
};
// widget.cpp
struct Widget::Impl { /*...*/ };
Widget::Widget() {}
Widget::~Widget() {}

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


» Исходный код

Поделиться с друзьями
-->

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


  1. Door
    26.09.2016 17:07
    +6

    Я бы всё-же не приводил "не очень хороший" код в статье для новичков: в первом примере, раз вы явно говорите об деструкторе, то можно бы и явно прописать реализацию конструкторов/операторов присваивания, следуя хорошему тону — The rule of three/five/zero. auto_ptr я бы выбросил вообще — всё по тому же поводу — deprecated — можно просто ссылочку дать — так для истории и действительно интересующихся. Про unique_ptr, возможно, нужно было бы рассказать, почему деструктор нужно реализовывать в файле-реализации (а что с move-семантикой на этот счёт ?) — но это отклонение от темы — зачем нужна своя реализация PimplPtr-а?


    Если убрать из статью вводную, то причина у вас, получается одна — "нарушение логической константности" (а точнее 2ве: "propagate_const пока не является частью стандарта"). Хорошо, но — код из статьи — не компилируется.


    По поводу реализации:


    • static_assert — не нужен — это сделает за вас вызов конструктора в make_unique.
    • constexpr — зачем? Он сдесь только мешает код читать и нет случая, когда он нужен был бы.
    • 2й конструктор, который explicit (кстати, а почему? почему и не первый ?), наверное, должен вызывать первый (Delegating constructor) — хотя бы для того, чтобы логика в конструкторах совпадала
    • зачем в деструкторе делать тот-же statis_assert, когда в конструкторе — она уже есть ?
    • почему здесь = default используется, а в примерах выше, в файле-реализации — нет ?: Widget::~Widget() = default
    • хорошим тоном является использование одной и той же функции — основной — для реализации других ф-й, которые дублируют код для удобства, т.е. — реализация операторов должна использовать эталонный get()

    Спасибо за статью. Извините за, возможно, резкий тон. Хорошего вам дня ^_^


    1. VasilyK
      26.09.2016 18:34

      По поводу "не очень хорошего" кода, я с вами согласен, так как это имеет прямое отношение к рассматриваемому вопросу. Можно было показать в чем заключается проблема, и то, что unique_ptr по умолчанию запрещает попасть в неприятную ситуацию. И тут же описать использование move семантики.


      Рассказать, почему деструктор нужно реализовывать в файле-реализации я думаю не стоит, так как это не совсем относиться к рассматриваемой теме, одна ссылка для ознакомления приведена в статье.


      “не компилируется” — поправил.


      “то причина у вас, получается одна” — еще как минимум одна причина — конструктор по умолчанию, я часто забывал написать явное создание Impl, после чего несколько минут искал ошибку.


      По поводу реализации:


      • static_assert действительно можно опустить, но я его использую для более приятного сообщения об ошибке.
      • constexpr — пожалуй, вы правы.
      • Если вызывать конструктор из конструкторы, то можно было бы из первого вызывать второй (но не наоборот), но не думаю что это было бы лучше. ?? explicit
      • static_assert нужен как в конструкторе так и деструкторе — сообщения об ошибке отличаются, они указывают предполагаемую причину ошибку, если забыли конструктор, ты будет первое сообщение, если забыли деструктор — второе.
      • возможно "= default" было бы лучше
      • Согласен с вами, исправил исходный код.

      Спасибо за комментарий.


      1. Door
        26.09.2016 20:59

        Спасибо за ответ.


        если забыли конструктор, ты будет первое сообщение, если забыли деструктор — второе.

        Я немного не понял вас. Вот моя логика: sizeof(T) — приведёт к ошибке компиляции, если компилятор не видет определения типа T, т.е., T — неполный тип. sizeof(T) никак не зависит от того определён для user defined типа конструктор или деструктор. Это означает, что sizeof(T) > 0 всегда — для нашого случая — нужен только для того, чтобы выдать пользователю ошибку во время компиляции о том, что он забыл определить указанный класс (тип) до места его использования (кстати, поскольку sizeof(T) никогда не может быть 0м — то можно просто писать sizeof(T)).
        Дальше: у вас, по сути, таких места 2: конструктор и деструктор. Ставим вопрос — может ли пользователь написать такое использование PimplPtr<Impl>, чтобы Impl был, например, определён до вызова конструктора PimplPtr<Impl> и, одновременно, не определён при вызове деструктора PimplPtr<Impl>? (или наоборот). Ответ — да, может:


        Случай первый: в конструкторе тип - неопределён, в деструкторе - уже определён
            template<typename T>
            struct PimplPtr
            {
                // 
            };
        
            // Header
            // 
            struct UserType
            {
                struct Impl;
                PimplPtr<Impl> _impl;
        
                UserType()
                    // Используем неполный тип `Impl`
                    : _impl{}
                {
                }
        
                ~UserType();
            };
        
            // Source
            // 
        
            struct UserType::Impl
            {
            };
        
            // Используем уже определённый тип `Impl`
            UserType::~UserType() = default;
        


        1. VasilyK
          26.09.2016 21:34

          "Но текст сообщения, всё же, немного неправильный" — что вы имеете ввиду? Я рассматриваю это сообщение как небольшую подсказку, чтобы быстрее вспомнить что может быть не так. Если человек не понимает о чем речь, то ему наверняка придется идти на какой-то ресурс и искать более подробную информацию.


          "Т.е. не хватает проверки времени выполнения инварианта указатель не nullptr" — полностью согласен


          "operator ElementType() как explicit..." — скорее всего это правильно, но в данном случае возможно это не важно


  1. VasilyK
    26.09.2016 18:32
    -1

    del


  1. VitaminPSG
    26.09.2016 21:16

    1) Не стоит объявлять пустые деструктор в классах. Т.к. вы вводите запрет на создание конструкторов и операторов присваивания по умолчанию. А в. будущем и на копирования.
    2) Константные методы можно реализовать в интерфейсе.


    1. VasilyK
      26.09.2016 21:27

      Не совсем понял про деструкторы. Если вы говорите про класс widget, то деструктор необходим для нормальной работы Pimpl, если же говорите про класс PimplPtr, то, я думаю, нет никаких проблем явно написать конструкторы и операторы присваивания.


      Второе совсем не понял, константные методы где? В классе widget? Зачем их реализовывать через интерфейс?


  1. Adamantium
    27.09.2016 08:49

    Спасибо, интересно изложено, и местами стало понятнее. Ещё и по ссылкам про внутренности Qt, стало ещё понятнее.
    А может подскажете, в каком направлении искать/читать, как принято реализовывать модульность, несколько подобную этой идиоме pimpl, но в случае, когда в рантайме необходимо выбирать, какая конкретно из разных реализаций одного интерфейса выбирается. Различные реализации для различных входных данных. Каждая реализация в своей dll/so с одинаковыми названиями функций. Я правильно понимаю, что подобная задача возникает в медиаплеерах, когда открывается файл, и в зависимости от его формата подключаются разные декодеры потока? Но сформулировать правильный вопрос гуглу не смог, к сожалению.


    1. VasilyK
      27.09.2016 08:49

      Если вы хотите выбирать реализацию в рантайме, то, скорее всего, вам нужна некоторая фабрика и, возможно, шаблон проектирования мост.


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


      “Я правильно понимаю, что подобная задача возникает в медиаплеерах” — возможно, не сталкивался.