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() {}
Использование разработанного указателя помогает избежать некоторых глупых ошибок и сосредоточиться на написании полезного кода.
Door
Я бы всё-же не приводил "не очень хороший" код в статье для новичков: в первом примере, раз вы явно говорите об деструкторе, то можно бы и явно прописать реализацию конструкторов/операторов присваивания, следуя хорошему тону — The rule of three/five/zero.
auto_ptr
я бы выбросил вообще — всё по тому же поводу — deprecated — можно просто ссылочку дать — так для истории и действительно интересующихся. Проunique_ptr
, возможно, нужно было бы рассказать, почему деструктор нужно реализовывать в файле-реализации (а что с move-семантикой на этот счёт ?) — но это отклонение от темы — зачем нужна своя реализацияPimplPtr
-а?Если убрать из статью вводную, то причина у вас, получается одна — "нарушение логической константности" (а точнее 2ве: "
propagate_const
пока не является частью стандарта"). Хорошо, но — код из статьи — не компилируется.По поводу реализации:
static_assert
— не нужен — это сделает за вас вызов конструктора вmake_unique
.constexpr
— зачем? Он сдесь только мешает код читать и нет случая, когда он нужен был бы.explicit
(кстати, а почему? почему и не первый ?), наверное, должен вызывать первый (Delegating constructor) — хотя бы для того, чтобы логика в конструкторах совпадалаstatis_assert
, когда в конструкторе — она уже есть ?= default
используется, а в примерах выше, в файле-реализации — нет ?:Widget::~Widget() = default
get()
Спасибо за статью. Извините за, возможно, резкий тон. Хорошего вам дня ^_^
VasilyK
По поводу "не очень хорошего" кода, я с вами согласен, так как это имеет прямое отношение к рассматриваемому вопросу. Можно было показать в чем заключается проблема, и то, что unique_ptr по умолчанию запрещает попасть в неприятную ситуацию. И тут же описать использование move семантики.
Рассказать, почему деструктор нужно реализовывать в файле-реализации я думаю не стоит, так как это не совсем относиться к рассматриваемой теме, одна ссылка для ознакомления приведена в статье.
“не компилируется” — поправил.
“то причина у вас, получается одна” — еще как минимум одна причина — конструктор по умолчанию, я часто забывал написать явное создание Impl, после чего несколько минут искал ошибку.
По поводу реализации:
Спасибо за комментарий.
Door
Спасибо за ответ.
Я немного не понял вас. Вот моя логика:
sizeof(T)
— приведёт к ошибке компиляции, если компилятор не видет определения типаT
, т.е.,T
— неполный тип.sizeof(T)
никак не зависит от того определён для user defined типа конструктор или деструктор. Это означает, чтоsizeof(T) > 0
всегда — для нашого случая — нужен только для того, чтобы выдать пользователю ошибку во время компиляции о том, что он забыл определить указанный класс (тип) до места его использования (кстати, посколькуsizeof(T)
никогда не может быть 0м — то можно просто писатьsizeof(T)
).Дальше: у вас, по сути, таких места 2: конструктор и деструктор. Ставим вопрос — может ли пользователь написать такое использование
PimplPtr<Impl>
, чтобыImpl
был, например, определён до вызова конструктораPimplPtr<Impl>
и, одновременно, не определён при вызове деструктораPimplPtr<Impl>
? (или наоборот). Ответ — да, может:VasilyK
"Но текст сообщения, всё же, немного неправильный" — что вы имеете ввиду? Я рассматриваю это сообщение как небольшую подсказку, чтобы быстрее вспомнить что может быть не так. Если человек не понимает о чем речь, то ему наверняка придется идти на какой-то ресурс и искать более подробную информацию.
"Т.е. не хватает проверки времени выполнения инварианта указатель не nullptr" — полностью согласен
"operator ElementType() как explicit..." — скорее всего это правильно, но в данном случае возможно это не важно
VasilyK
del
VitaminPSG
1) Не стоит объявлять пустые деструктор в классах. Т.к. вы вводите запрет на создание конструкторов и операторов присваивания по умолчанию. А в. будущем и на копирования.
2) Константные методы можно реализовать в интерфейсе.
VasilyK
Не совсем понял про деструкторы. Если вы говорите про класс widget, то деструктор необходим для нормальной работы Pimpl, если же говорите про класс PimplPtr, то, я думаю, нет никаких проблем явно написать конструкторы и операторы присваивания.
Второе совсем не понял, константные методы где? В классе widget? Зачем их реализовывать через интерфейс?
Adamantium
Спасибо, интересно изложено, и местами стало понятнее. Ещё и по ссылкам про внутренности Qt, стало ещё понятнее.
А может подскажете, в каком направлении искать/читать, как принято реализовывать модульность, несколько подобную этой идиоме pimpl, но в случае, когда в рантайме необходимо выбирать, какая конкретно из разных реализаций одного интерфейса выбирается. Различные реализации для различных входных данных. Каждая реализация в своей dll/so с одинаковыми названиями функций. Я правильно понимаю, что подобная задача возникает в медиаплеерах, когда открывается файл, и в зависимости от его формата подключаются разные декодеры потока? Но сформулировать правильный вопрос гуглу не смог, к сожалению.
VasilyK
Если вы хотите выбирать реализацию в рантайме, то, скорее всего, вам нужна некоторая фабрика и, возможно, шаблон проектирования мост.
Класть каждую реализацию в отдельную динамической библиотеку мне не приходилось, но, как я понимаю, система плагинов QtCreator устроена подобным образом, так что можете посмотреть в эту сторону.
“Я правильно понимаю, что подобная задача возникает в медиаплеерах” — возможно, не сталкивался.