Как использовать идиому Qt PIMPL?

PIMPL означает P вместо IMPL ementation. Реализация означает «детали реализации»: то, о чем пользователям класса не нужно беспокоиться.

Реализации собственных классов Qt четко отделяют интерфейсы от реализаций за счет использования идиомы PIMPL. Тем не менее, механизмы, предоставляемые Qt, недокументированы. Как их использовать?

Я бы хотел, чтобы это был канонический вопрос о том, «как я PIMPL» в Qt. Ответы должны быть мотивированы простым диалоговым интерфейсом ввода координат, показанным ниже.

Мотивация использования PIMPL становится очевидной, когда у нас есть что-нибудь с полусложной реализацией. Дополнительная мотивация приводится в этом вопросе. Даже довольно простой класс должен включать в свой интерфейс множество других заголовков.

снимок экрана диалога

Интерфейс на основе PIMPL довольно чистый и читаемый.

// CoordinateDialog.h
#include <QDialog>
#include <QVector3D>

class CoordinateDialogPrivate;
class CoordinateDialog : public QDialog
{
  Q_OBJECT
  Q_DECLARE_PRIVATE(CoordinateDialog)
#if QT_VERSION <= QT_VERSION_CHECK(5,0,0)
  Q_PRIVATE_SLOT(d_func(), void onAccepted())
#endif
  QScopedPointer<CoordinateDialogPrivate> const d_ptr;
public:
  CoordinateDialog(QWidget * parent = 0, Qt::WindowFlags flags = 0);
  ~CoordinateDialog();
  QVector3D coordinates() const;
  Q_SIGNAL void acceptedCoordinates(const QVector3D &);
};
Q_DECLARE_METATYPE(QVector3D)

Интерфейс на основе Qt 5, C ++ 11 не требует строки Q_PRIVATE_SLOT.

Сравните это с интерфейсом, отличным от PIMPL, который помещает детали реализации в частный раздел интерфейса. Обратите внимание, сколько еще кода нужно включить.

// CoordinateDialog.h
#include <QDialog>
#include <QVector3D>
#include <QFormLayout>
#include <QDoubleSpinBox>
#include <QDialogButtonBox>

class CoordinateDialog : public QDialog
{
  QFormLayout m_layout;
  QDoubleSpinBox m_x, m_y, m_z;
  QVector3D m_coordinates;
  QDialogButtonBox m_buttons;
  Q_SLOT void onAccepted();
public:
  CoordinateDialog(QWidget * parent = 0, Qt::WindowFlags flags = 0);
  QVector3D coordinates() const;
  Q_SIGNAL void acceptedCoordinates(const QVector3D &);
};
Q_DECLARE_METATYPE(QVector3D)

Эти два интерфейса в точности эквивалентны в том, что касается их общедоступного интерфейса. У них одинаковые сигналы, слоты и общедоступные методы.


person Kuba hasn't forgotten Monica    schedule 11.08.2014    source источник
comment
отличный FAQ. предотвратить необходимость чтения кода qt.   -  person UmNyobe    schedule 24.09.2015
comment
Здесь есть документация: wiki.qt.io/D-Pointer   -  person danadam    schedule 09.02.2016
comment
Этот пост сообщества хорош, но я не вижу практических преимуществ использования способа Qt для реализации Pimpl, поскольку классы QxxxPrivate не являются частью общедоступного API Qt, и мы не можем легко получить из них наши классы impl. Я действительно не понимаю, почему они сделали хороший пост вики Qt об использовании d_ptr и т. Д., Если мы действительно не должны использовать это в своих интересах. Может я чего то упускаю?   -  person renardesque    schedule 31.07.2020
comment
Что считать публичным API - решать вам. Очевидно, что проект Qt поддерживает этот код, он присутствует в каждом их классе, в значительной степени, поэтому не использовать его на том основании, что он не является общедоступным, - это то, что вы делаете, если действительно хотите предлог для изобретения велосипеда. В противном случае - просто пользуйтесь. У меня более 100 тысяч строк, в которых он используется. Какая разница, что это не публично. Исходный код Qt, насколько мне известно, является публичным API. Это не просто так: сделайте его модулем в своем репо, используйте его и настраивайте по своему усмотрению !!   -  person Kuba hasn't forgotten Monica    schedule 01.08.2020


Ответы (1)


Введение

PIMPL - это частный класс, который содержит все данные родительского класса, зависящие от реализации. Qt предоставляет структуру PIMPL и набор соглашений, которые необходимо соблюдать при использовании этой структуры. PIMPL Qt могут использоваться во всех классах, даже не производных от QObject.

PIMPL необходимо разместить в куче. В идиоматическом C ++ мы не должны управлять таким хранилищем вручную, а должны использовать умный указатель. Для этого работают либо QScopedPointer, либо std::unique_ptr. Таким образом, минимальный интерфейс на основе pimpl, не производный от QObject, может выглядеть так:

// Foo.h
#include <QScopedPointer>
class FooPrivate; ///< The PIMPL class for Foo
class Foo {
  QScopedPointer<FooPrivate> const d_ptr;
public:
  Foo();
  ~Foo();
};

Объявление деструктора необходимо, поскольку деструктор указателя с заданной областью действия должен разрушить экземпляр PIMPL. Деструктор должен быть сгенерирован в файле реализации, где находится класс FooPrivate:

// Foo.cpp
class FooPrivate { };
Foo::Foo() : d_ptr(new FooPrivate) {}
Foo::~Foo() {}

Смотрите также:

Интерфейс

Теперь мы объясним в этом вопросе CoordinateDialog интерфейс на основе PIMPL.

Qt предоставляет несколько макросов и помощников по реализации, которые уменьшают утомительную работу PIMPL. Реализация ожидает, что мы будем следовать этим правилам:

  • PIMPL для класса Foo называется FooPrivate.
  • PIMPL объявляется вперед вместе с объявлением класса Foo в интерфейсном (заголовочном) файле.

Макрос Q_DECLARE_PRIVATE

Макрос Q_DECLARE_PRIVATE должен быть помещен в раздел private объявления класса. В качестве параметра он принимает имя класса интерфейса. Он объявляет две встроенные реализации вспомогательного метода d_func(). Этот метод возвращает указатель PIMPL с правильной константой. При использовании в методах const он возвращает указатель на const PIMPL. В неконстантных методах он возвращает указатель на неконстантный PIMPL. Он также предоставляет pimpl правильного типа в производных классах. Отсюда следует, что весь доступ к pimpl изнутри реализации должен осуществляться с использованием d_func(), а ** не через d_ptr. Обычно мы использовали макрос Q_D, описанный в разделе «Реализация» ниже.

Макрос бывает двух видов:

Q_DECLARE_PRIVATE(Class)   // assumes that the PIMPL pointer is named d_ptr
Q_DECLARE_PRIVATE_D(Dptr, Class) // takes the PIMPL pointer name explicitly

В нашем случае Q_DECLARE_PRIVATE(CoordinateDialog) эквивалентно Q_DECLARE_PRIVATE_D(d_ptr, CoordinateDialog).

Макрос Q_PRIVATE_SLOT

Этот макрос необходим только для совместимости с Qt 4 или для компиляторов, отличных от C ++ 11. Для кода Qt 5, C ++ 11 в этом нет необходимости, поскольку мы можем подключать функторы к сигналам, и нет необходимости в явных частных слотах.

Иногда нам нужно, чтобы QObject имел частные слоты для внутреннего использования. Такие слоты загрязняют приватную секцию интерфейса. Поскольку информация о слотах относится только к генератору кода moc, вместо этого мы можем использовать макрос Q_PRIVATE_SLOT, чтобы сообщить moc, что данный слот должен быть вызван через указатель d_func(), а не через this.

Синтаксис, ожидаемый moc в Q_PRIVATE_SLOT:

Q_PRIVATE_SLOT(instance_pointer, method signature)

В нашем случае:

Q_PRIVATE_SLOT(d_func(), void onAccepted())

Это фактически объявляет слот onAccepted в классе CoordinateDialog. Moc генерирует следующий код для вызова слота:

d_func()->onAccepted()

Сам макрос имеет пустое расширение - он предоставляет информацию только для moc.

Таким образом, наш интерфейсный класс расширяется следующим образом:

class CoordinateDialog : public QDialog
{
  Q_OBJECT /* We don't expand it here as it's off-topic. */
  // Q_DECLARE_PRIVATE(CoordinateDialog)
  inline CoordinateDialogPrivate* d_func() { 
    return reinterpret_cast<CoordinateDialogPrivate *>(qGetPtrHelper(d_ptr));
  }
  inline const CoordinateDialogPrivate* d_func() const { 
    return reinterpret_cast<const CoordinateDialogPrivate *>(qGetPtrHelper(d_ptr));
  }
  friend class CoordinateDialogPrivate;
  // Q_PRIVATE_SLOT(d_func(), void onAccepted())
  // (empty)
  QScopedPointer<CoordinateDialogPrivate> const d_ptr;
public:
  [...]
};

При использовании этого макроса вы должны включить сгенерированный moc код в место, где полностью определен частный класс. В нашем случае это означает, что файл CoordinateDialog.cpp должен заканчиваться на:

#include "moc_CoordinateDialog.cpp"

Попался

  • Все макросы Q_, которые должны использоваться в объявлении класса, уже содержат точку с запятой. Явные точки с запятой после Q_ не требуются:

      // correct                       // verbose, has double semicolons
      class Foo : public QObject {     class Foo : public QObject {
        Q_OBJECT                         Q_OBJECT;
        Q_DECLARE_PRIVATE(...)           Q_DECLARE_PRIVATE(...);
        ...                              ...
      };                               };
    
  • PIMPL не должен быть частным классом внутри самого Foo:

      // correct                  // wrong
      class FooPrivate;           class Foo {
      class Foo {                   class FooPrivate;
        ...                         ...
      };                          };
    
  • Первый раздел после открывающей скобки в объявлении класса по умолчанию является закрытым. Таким образом, следующие варианты эквивалентны:

      // less wordy, preferred    // verbose
      class Foo {                 class Foo {              
        int privateMember;        private:
                                    int privateMember;
      };                          };
    
  • Q_DECLARE_PRIVATE ожидает имя класса интерфейса, а не имя PIMPL:

      // correct                  // wrong
      class Foo {                 class Foo {
        Q_DECLARE_PRIVATE(Foo)      Q_DECLARE_PRIVATE(FooPrivate)
        ...                         ...
      };                          };
    
  • Указатель PIMPL должен быть константным для не копируемых / не назначаемых классов, таких как QObject. Он может быть неконстантным при реализации копируемых классов.

  • Поскольку PIMPL является внутренней деталью реализации, его размер недоступен на сайте, где используется интерфейс. Следует противостоять соблазну использовать новое размещение и идиому Fast Pimpl, поскольку она не дает преимущества для чего угодно, кроме класса, который вообще не выделяет память.

Реализация

PIMPL должен быть определен в файле реализации. Если он большой, его также можно определить в частном заголовке, обычно называемом foo_p.h для класса, интерфейс которого находится в foo.h.

PIMPL, как минимум, является просто носителем данных основного класса. Ему нужен только конструктор и никакие другие методы. В нашем случае также необходимо сохранить указатель на основной класс, так как мы хотим испускать сигнал из основного класса. Таким образом:

// CordinateDialog.cpp
#include <QFormLayout>
#include <QDoubleSpinBox>
#include <QDialogButtonBox>

class CoordinateDialogPrivate {
  Q_DISABLE_COPY(CoordinateDialogPrivate)
  Q_DECLARE_PUBLIC(CoordinateDialog)
  CoordinateDialog * const q_ptr;
  QFormLayout layout;
  QDoubleSpinBox x, y, z;
  QDialogButtonBox buttons;
  QVector3D coordinates;
  void onAccepted();
  CoordinateDialogPrivate(CoordinateDialog*);
};

PIMPL не подлежит копированию. Поскольку мы используем не копируемые члены, любая попытка копирования или присвоения PIMPL будет перехвачена компилятором. Как правило, лучше явно отключить функцию копирования, используя Q_DISABLE_COPY.

Макрос Q_DECLARE_PUBLIC работает аналогично макросу Q_DECLARE_PRIVATE. Это описано далее в этом разделе.

Мы передаем указатель на диалог в конструктор, что позволяет нам инициализировать макет в диалоге. Мы также подключаем принятый сигнал QDialog к внутреннему слоту onAccepted.

CoordinateDialogPrivate::CoordinateDialogPrivate(CoordinateDialog * dialog) :
  q_ptr(dialog),
  layout(dialog),
  buttons(QDialogButtonBox::Ok | QDialogButtonBox::Cancel)
{
  layout.addRow("X", &x);
  layout.addRow("Y", &y);
  layout.addRow("Z", &z);
  layout.addRow(&buttons);
  dialog->connect(&buttons, SIGNAL(accepted()), SLOT(accept()));
  dialog->connect(&buttons, SIGNAL(rejected()), SLOT(reject()));
#if QT_VERSION <= QT_VERSION_CHECK(5,0,0)
  this->connect(dialog, SIGNAL(accepted()), SLOT(onAccepted()));
#else
  QObject::connect(dialog, &QDialog::accepted, [this]{ onAccepted(); });
#endif
}

Метод onAccepted() PIMPL должен быть представлен как слот в проектах Qt 4 / не-C ++ 11. Для Qt 5 и C ++ 11 в этом больше нет необходимости.

После принятия диалога мы фиксируем координаты и излучаем сигнал acceptedCoordinates. Вот почему нам нужен публичный указатель:

void CoordinateDialogPrivate::onAccepted() {
  Q_Q(CoordinateDialog);
  coordinates.setX(x.value());
  coordinates.setY(y.value());
  coordinates.setZ(z.value());
  emit q->acceptedCoordinates(coordinates);
}

Макрос Q_Q объявляет локальную переменную CoordinateDialog * const q. Это описано далее в этом разделе.

Открытая часть реализации конструирует PIMPL и раскрывает его свойства:

CoordinateDialog::CoordinateDialog(QWidget * parent, Qt::WindowFlags flags) :
  QDialog(parent, flags),
  d_ptr(new CoordinateDialogPrivate(this))
{}

QVector3D CoordinateDialog::coordinates() const {
  Q_D(const CoordinateDialog);
  return d->coordinates;
}

CoordinateDialog::~CoordinateDialog() {}

Макрос Q_D объявляет локальную переменную CoordinateDialogPrivate * const d. Это описано ниже.

Макрос Q_D

Чтобы получить доступ к PIMPL в методе interface, мы можем использовать макрос Q_D, передав ему имя класса интерфейса.

void Class::foo() /* non-const */ {
  Q_D(Class);    /* needs a semicolon! */
  // expands to
  ClassPrivate * const d = d_func();
  ...

Чтобы получить доступ к PIMPL в методе константного интерфейса, нам нужно добавить к имени класса ключевое слово const:

void Class::bar() const {
  Q_D(const Class);
  // expands to
  const ClassPrivate * const d = d_func();
  ...

Макрос Q_Q

Чтобы получить доступ к экземпляру интерфейса из неконстантного метода PIMPL, мы можем использовать макрос Q_Q, передав ему имя класса интерфейса.

void ClassPrivate::foo() /* non-const*/ {
  Q_Q(Class);   /* needs a semicolon! */
  // expands to
  Class * const q = q_func();
  ...

Чтобы получить доступ к экземпляру интерфейса в методе const PIMPL, мы добавляем к имени класса ключевое слово const, как мы это делали для макроса Q_D:

void ClassPrivate::foo() const {
  Q_Q(const Class);   /* needs a semicolon! */
  // expands to
  const Class * const q = q_func();
  ...

Макрос Q_DECLARE_PUBLIC

Этот макрос не является обязательным и используется для разрешения доступа к интерфейсу из PIMPL. Обычно он используется, если методы PIMPL должны управлять базовым классом интерфейса или испускать его сигналы. Эквивалентный макрос Q_DECLARE_PRIVATE использовался для разрешения доступа к PIMPL из интерфейса.

Макрос принимает в качестве параметра имя класса интерфейса. Он объявляет две встроенные реализации вспомогательного метода q_func(). Этот метод возвращает указатель интерфейса с правильной константой. При использовании в методах const он возвращает указатель на интерфейс const. В неконстантных методах он возвращает указатель на неконстантный интерфейс. Он также предоставляет интерфейс правильного типа в производных классах. Отсюда следует, что весь доступ к интерфейсу из PIMPL должен осуществляться с использованием q_func(), а ** не через q_ptr. Обычно мы использовали макрос Q_Q, описанный выше.

Макрос ожидает, что указатель на интерфейс будет иметь имя q_ptr. У этого макроса нет варианта с двумя аргументами, который позволил бы выбрать другое имя для указателя интерфейса (как в случае с Q_DECLARE_PRIVATE).

Макрос раскрывается следующим образом:

class CoordinateDialogPrivate {
  //Q_DECLARE_PUBLIC(CoordinateDialog)
  inline CoordinateDialog* q_func() {
    return static_cast<CoordinateDialog*>(q_ptr);
  }
  inline const CoordinateDialog* q_func() const {
    return static_cast<const CoordinateDialog*>(q_ptr);
  }
  friend class CoordinateDialog;
  //
  CoordinateDialog * const q_ptr;
  ...
};

Макрос Q_DISABLE_COPY

Этот макрос удаляет конструктор копирования и оператор присваивания. Он должен отображаться в частном разделе PIMPL.

Общие проблемы

  • Заголовок interface для данного класса должен быть первым заголовком, включенным в файл реализации. Это заставляет заголовок быть самодостаточным и не зависеть от объявлений, которые случайно включены в реализацию. Если это не так, реализация не будет скомпилирована, что позволит вам исправить интерфейс, чтобы сделать его самодостаточным.

      // correct                   // error prone
      // Foo.cpp                   // Foo.cpp
    
      #include "Foo.h"             #include <SomethingElse>
      #include <SomethingElse>     #include "Foo.h"
                                   // Now "Foo.h" can depend on SomethingElse without
                                   // us being aware of the fact.
    
  • Макрос Q_DISABLE_COPY должен появиться в закрытом разделе PIMPL.

      // correct                    // wrong
      // Foo.cpp                    // Foo.cpp
    
      class FooPrivate {            class FooPrivate {
        Q_DISABLE_COPY(FooPrivate)  public:
        ...                           Q_DISABLE_COPY(FooPrivate)
      };                               ...
                                    };
    

PIMPL и классы, не являющиеся копируемыми QObject

Идиома PIMPL позволяет реализовать копируемый, копируемый и перемещаемый, назначаемый объект. Назначение выполняется с помощью идиомы копирования и обмена, предотвращающей дублирование кода. Указатель PIMPL, конечно, не должен быть константным.

В C ++ 11 нам необходимо соблюдать Правило четырех и предоставлять все из следующие: конструктор копирования, конструктор перемещения, оператор присваивания и деструктор. И, конечно же, автономная функция swap для реализации всего этого †.

Проиллюстрируем это на довольно бесполезном, но тем не менее правильном примере.

Интерфейс

// Integer.h
#include <algorithm>
#include <QScopedPointer>

class IntegerPrivate;
class Integer {
   Q_DECLARE_PRIVATE(Integer)
   QScopedPointer<IntegerPrivate> d_ptr;
public:
   Integer();
   Integer(int);
   Integer(const Integer & other);
   Integer(Integer && other);
   operator int&();
   operator int() const;
   Integer & operator=(Integer other);
   friend void swap(Integer& first, Integer& second) /* nothrow */;
   ~Integer();
};

Для повышения производительности конструктор перемещения и оператор присваивания должны быть определены в файле интерфейса (заголовке). Им не нужен прямой доступ к PIMPL:

Integer::Integer(Integer && other) : Integer() {
   swap(*this, other);
}

Integer & Integer::operator=(Integer other) {
   swap(*this, other);
   return *this;
}

Все они используют swap автономную функцию, которую мы также должны определить в интерфейсе. Обратите внимание, что это

void swap(Integer& first, Integer& second) /* nothrow */ {
   using std::swap;
   swap(first.d_ptr, second.d_ptr);
}

Выполнение

Это довольно просто. Нам не нужен доступ к интерфейсу из PIMPL, поэтому Q_DECLARE_PUBLIC и q_ptr отсутствуют.

// Integer.cpp
#include "Integer.h"

class IntegerPrivate {
public:
   int value;
   IntegerPrivate(int i) : value(i) {}
};

Integer::Integer() : d_ptr(new IntegerPrivate(0)) {}
Integer::Integer(int i) : d_ptr(new IntegerPrivate(i)) {}
Integer::Integer(const Integer &other) :
   d_ptr(new IntegerPrivate(other.d_func()->value)) {}
Integer::operator int&() { return d_func()->value; }
Integer::operator int() const { return d_func()->value; }
Integer::~Integer() {}

† На этот отличный ответ: есть и другие утверждения, на которых мы должны специализироваться std::swap для нашего типа, предоставьте класс swap вместе со свободной функцией swap и т. Д. Но в этом нет необходимости: любое правильное использование swap будет осуществляться через неквалифицированный вызов, а наша функция будет найдена через ADL. Подойдет одна функция.

person Kuba hasn't forgotten Monica    schedule 11.08.2014
comment
Для всех, кто, как я, должен был искать PIMPL = P ointer в IMPL ementation - person Tom Myddeltyn; 04.06.2016
comment
В примере Integer я заметил, что конструктор копирования создает дубликат объекта praivate вместо того, чтобы просто указывать на существующий. Разве весь смысл не в том, чтобы делиться личными данными между интерфейсами? - person Lennart Rolland; 04.07.2016
comment
@LennartRolland PIMPL и общие данные - две разные проблемы. Вы можете использовать PIMPL для реализации класса с общими данными, но это не обязательно. Совместное использование личных данных чревато опасностями, так как Qt делает это самопроизвольно, это следствие еще до C ++ 11. - person Kuba hasn't forgotten Monica; 06.07.2016
comment
Я не понимаю, зачем вам объявлять деструктор. Деструктор QScopedPointer должен вызываться автоматически, а деструктор по умолчанию эквивалентен пустому, как вы написали. - person Peregring-lk; 07.12.2016
comment
@ Peregring-lk Дело не в объявлении деструктора, а в его определении. QScopedPointer должен разрушить PIMPL. Для этого необходимо, чтобы PIMPL был полностью определен. И это только в .cpp файле, а не в заголовке. Таким образом, вы должны определить деструктор в .cpp файле. Пользователям класса доступны только заголовки, и там компилятор не может сгенерировать код для интеллектуального указателя. Есть умные указатели, которые справляются с этим, захватывая деструктор, где существует полное определение. std::unique_ptr - один из таких примеров. - person Kuba hasn't forgotten Monica; 07.12.2016
comment
@KubaOber Я никогда не думал об этом, и это имеет смысл. Итак, если я вас понял (в документации Qt дополнительно говорится, что конструкторы или деструктор владельца PIMPL / QScopedPointer не должны быть inline для объявленных вперед классов), вы должны объявить деструктор, чтобы заставить компилятор связать деструктор вместо генерации код там. Таким образом, деструктор интеллектуального указателя никогда не вызывается из единицы пользовательского перевода, и, следовательно, требуемый код никогда не генерируется, а только в cpp. - person Peregring-lk; 07.12.2016