Как идиома pimpl уменьшает количество зависимостей?

Рассмотрим следующее:

PImpl.hpp

class Impl;

class PImpl
{
    Impl* pimpl;
    PImpl() : pimpl(new Impl) { }
    ~PImpl() { delete pimpl; }
    void DoSomething();
};

PImpl.cpp

#include "PImpl.hpp"
#include "Impl.hpp"

void PImpl::DoSomething() { pimpl->DoSomething(); }

Импл.HPP

class Impl
{
    int data;
public:
    void DoSomething() {}
}

клиент.cpp

#include "Pimpl.hpp"

int main()
{
    PImpl unitUnderTest;
    unitUnderTest.DoSomething();
}

Идея этого шаблона заключается в том, что интерфейс Impl может измениться, но клиенты не должны перекомпилироваться. Тем не менее, я не понимаю, как это может быть на самом деле. Допустим, я хотел добавить метод к этому классу — клиентам все равно пришлось бы перекомпилировать.

По сути, единственные виды изменений, которые, как я вижу, когда-либо требуют изменения файла заголовка для класса, — это вещи, для которых изменяется интерфейс класса. И когда это происходит, pimpl или не pimpl, клиентам приходится перекомпилировать.

Какие виды редактирования здесь дают нам преимущества с точки зрения отсутствия перекомпиляции клиентского кода?


person Billy ONeal    schedule 30.08.2010    source источник


Ответы (7)


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

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

Изменить: пример

SomeClass.h

struct SomeClassImpl;

class SomeClass {
    SomeClassImpl * pImpl;
public:
    SomeClass();
    ~SomeClass();
    int DoSomething();
};

SomeClass.cpp

#include "SomeClass.h"
#include "OtherClass.h"
#include <vector>

struct SomeClassImpl {
    int foo;
    std::vector<OtherClass> otherClassVec;   //users of SomeClass don't need to know anything about OtherClass, or include its header.
};

SomeClass::SomeClass() { pImpl = new SomeClassImpl; }
SomeClass::~SomeClass() { delete pImpl; }

int SomeClass::DoSomething() {
    pImpl->otherClassVec.push_back(0);
    return pImpl->otherClassVec.size();
}
person Alan    schedule 30.08.2010
comment
Ваш пример вызывает неопределенное поведение: вы забыли печально известное правило трех ›› Всякий раз, когда вы определяете один из конструктора копирования, оператора присваивания копирования или деструктора, определяйте два других. - person Matthieu M.; 30.08.2010
comment
Не уверен насчет неопределенного поведения, сгенерированный компилятором конструктор копирования четко определен, но при вызове приведет к возможному двойному освобождению. Или эффект двойного бесплатного был тем, что вы имели в виду под UB? - person Ben Voigt; 30.08.2010
comment
@Matthieu M.: Да, класс может использовать конструктор копирования и оператор =. Но это не имеет отношения к вопросу ОП и просто загромождает и без того слишком многословный пример. - person Alan; 30.08.2010
comment
Я не согласен, к сожалению, многие новички просто скопируют/вставят и адаптируют ваш пример, и в итоге у них в руках будет больной беспорядок. Кроме того, они могут быть нетривиальными из-за обработки исключений (в зависимости от выбранной вами реализации). @Ben: двойное бесплатное было тем, что я имел в виду под UB. - person Matthieu M.; 31.08.2010

Было несколько ответов... но до сих пор нет правильной реализации. Я несколько опечален тем, что примеры неверны, поскольку люди, вероятно, будут их использовать...

Идиома «Pimpl» является сокращением от «Указатель на реализацию» и также упоминается как «Брандмауэр компиляции». А теперь давайте погрузимся.

<сильный>1. Когда необходимо включение?

Когда вы используете класс, вам нужно его полное определение, только если:

  • вам нужен его размер (атрибут вашего класса)
  • вам нужно получить доступ к одному из его методов

Если вы только ссылаетесь на него или имеете указатель на него, то, поскольку размер ссылки или указателя не зависит от типа, на который ссылается/указывается, вам нужно только объявить идентификатор (форвардное объявление).

Пример:

#include "a.h"
#include "b.h"
#include "c.h"
#include "d.h"
#include "e.h"
#include "f.h"

struct Foo
{
  Foo();

  A a;
  B* b;
  C& c;
  static D d;
  friend class E;
  void bar(F f);
};

В приведенном выше примере, который включает «удобство», его можно удалить, не влияя на правильность? Самое удивительное: все, кроме "a.h".

<сильный>2. Внедрение Pimpl

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

  • тем самым изолируя клиента от зависимостей
  • тем самым предотвращая волновой эффект компиляции

Дополнительное преимущество: сохраняется ABI библиотеки.

Для простоты использования идиому Pimpl можно использовать со стилем управления «умный указатель»:

// From Ben Voigt's remark
// information at:
// http://en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Checked_delete
template<class T> 
inline void checked_delete(T * x)
{
    typedef char type_must_be_complete[ sizeof(T)? 1: -1 ];
    (void) sizeof(type_must_be_complete);
    delete x;
}


template <typename T>
class pimpl
{
public:
  pimpl(): m(new T()) {}
  pimpl(T* t): m(t) { assert(t && "Null Pointer Unauthorized"); }

  pimpl(pimpl const& rhs): m(new T(*rhs.m)) {}

  pimpl& operator=(pimpl const& rhs)
  {
    std::auto_ptr<T> tmp(new T(*rhs.m)); // copy may throw: Strong Guarantee
    checked_delete(m);
    m = tmp.release();
    return *this;
  }

  ~pimpl() { checked_delete(m); }

  void swap(pimpl& rhs) { std::swap(m, rhs.m); }

  T* operator->() { return m; }
  T const* operator->() const { return m; }

  T& operator*() { return *m; }
  T const& operator*() const { return *m; }

  T* get() { return m; }
  T const* get() const { return m; }

private:
  T* m;
};

template <typename T> class pimpl<T*> {};
template <typename T> class pimpl<T&> {};

template <typename T>
void swap(pimpl<T>& lhs, pimpl<T>& rhs) { lhs.swap(rhs); }

Что в нем есть такого, чего не было в других?

  • Он просто подчиняется правилу трех: определение конструктора копирования, оператора присваивания копирования и деструктора.
  • Он реализует Strong Guarantee: если во время присваивания копия выдает исключение, объект остается неизменным. Обратите внимание, что деструктор T не должен бросать... но это очень распространенное требование;)

Опираясь на это, теперь мы можем довольно легко определить классы Pimpl:

class Foo
{
public:

private:
  struct Impl;
  pimpl<Impl> mImpl;
}; // class Foo

Примечание: компилятор не может сгенерировать здесь корректный конструктор, скопировать оператор присваивания или деструктор, поскольку для этого потребуется доступ к Impl определению. Поэтому, несмотря на хелпер pimpl, вам нужно будет определить эти 4 вручную. Однако благодаря хелперу pimpl компиляция завершится ошибкой, а не затянет вас в страну неопределенного поведения.

<сильный>3. Идем дальше

Следует отметить, что наличие virtual функций часто рассматривается как деталь реализации. Одно из преимуществ Pimpl заключается в том, что у нас есть правильная структура для использования возможностей шаблона стратегии.

Для этого необходимо изменить «копию» pimpl:

// pimpl.h
template <typename T>
pimpl<T>::pimpl(pimpl<T> const& rhs): m(rhs.m->clone()) {}

template <typename T>
pimpl<T>& pimpl<T>::operator=(pimpl<T> const& rhs)
{
  std::auto_ptr<T> tmp(rhs.m->clone()); // copy may throw: Strong Guarantee
  checked_delete(m);
  m = tmp.release();
  return *this;
}

И тогда мы можем определить наш Foo вот так

// foo.h
#include "pimpl.h"

namespace detail { class FooBase; }

class Foo
{
public:
  enum Mode {
    Easy,
    Normal,
    Hard,
    God
  };

  Foo(Mode mode);

  // Others

private:
  pimpl<detail::FooBase> mImpl;
};

// Foo.cpp
#include "foo.h"

#include "detail/fooEasy.h"
#include "detail/fooNormal.h"
#include "detail/fooHard.h"
#include "detail/fooGod.h"

Foo::Foo(Mode m): mImpl(FooFactory::Get(m)) {}

Обратите внимание, что ABI Foo совершенно не касается различных изменений, которые могут произойти:

  • в Foo нет виртуального метода
  • размер mImpl равен размеру простого указателя, на что бы он ни указывал

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

person Matthieu M.    schedule 30.08.2010
comment
+1 -- В каких обстоятельствах вы бы когда-либо оказались, когда хотели бы получить доступ к объектам pimpl, но не хотели бы иметь доступ к объектам impl? И в таких случаях, чем это отличается от прямого объявления impl и сохранения его в классе интеллектуальных указателей? - person Billy ONeal; 30.08.2010
comment
@Billy: не все понимаю ... основное отличие от классического интеллектуального указателя заключается в том, что pimpl реализует семантику Deep Copy в строгой гарантии, освобождая пользователя от этого бремени. В остальном он очень похож на scoped_ptr. - person Matthieu M.; 30.08.2010
comment
Поскольку ваш класс pimpl<T> интенсивно использует T, он повторно вводит зависимости, которые p-impl призван сломать. Я думаю, что это то, о чем ваш комментарий о ручном определении конструктора по умолчанию, конструктора копирования, оператора присваивания и деструктора, но вам нужно удалить встроенные версии. - person Ben Voigt; 30.08.2010
comment
@Ben: На самом деле мне не нужно их удалять. Как вы заметили, необходимость ручного переопределения конструкторов исходит из того факта, что они могут быть созданы только в файле .cpp. У меня есть гораздо более продвинутый дизайн, основанный на трюке shared_ptr для удаления неизвестного типа, который я фактически использую в своем собственном программном обеспечении, чтобы избежать этой ручной нагрузки; но он работает как таковой, поскольку отсутствие заголовка препятствует фактическому созданию экземпляров методов. - person Matthieu M.; 30.08.2010
comment
Я предполагаю, что ваши ручные определения на самом деле являются специализациями, иначе у вас будет нарушение ODR. Но специализации должны быть объявлены перед использованием. Таким образом, либо ваши специализации находятся в заголовке, где они могут быть видны клиентам Foo, либо компилятор будет использовать встроенное определение при компиляции клиентов Foo, потому что это наиболее видимое определение. Вам нужно удалить определения и оставить только объявления, тогда компилятор будет генерировать вызовы, которые разрешаются статически во время компоновки. - person Ben Voigt; 30.08.2010
comment
Также было бы целесообразно добавить некоторые интеллектуальные указатели, такие как std::auto_ptr, в ваш первый фрагмент, чтобы подчеркнуть, что завершенные типы необходимы для использования интеллектуальных указателей. - person Ben Voigt; 30.08.2010
comment
В частности, вы выступаете за размещение std::auto_ptr‹T› в pimpl.h, для правильной работы требуется, чтобы было видно полное определение T. Если вы переместите это в pimpl-internals.h, который включен в foo.cpp, и оставите только объявления в pimpl.h, все будет хорошо. К сожалению, вы не можете полагаться на то, что компилятор будет генерировать ошибки, потому что удаление неполного типа, по-видимому, выдает предупреждение только на некоторых компиляторах. - person Ben Voigt; 30.08.2010
comment
@Ben: нет необходимости в специализациях. Я говорил о переопределении конструктора для Foo, автоматически сгенерированные компилятором версии кода шаблона в порядке, но требуется доступ к определению T, которое доступно только в файле .cpp, поэтому определение конструкторов и всего прочего имеют должен быть расположен в файле .cpp, чтобы компилятор имел полное определение T при создании экземпляров методов. Я полагаюсь на то, что эти экземпляры откладываются в файле .cpp. - person Matthieu M.; 30.08.2010
comment
Проблема в том, что кто-то забывает заменить сгенерированный компилятором встроенный деструктор для Foo (например, он отсутствует в вашем примере), а затем компилятор генерирует его, создавая экземпляр pimpl<FooBase>::~pimpl, в то время как FooBase неполный, что вызывает неопределенное поведение, если FooBase имеет деструктор . Но ошибки компиляции нет. См., например. en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Checked_delete - person Ben Voigt; 30.08.2010
comment
@Ben: спасибо за это :) Однако он генерирует предупреждение о gcc 3.4.2, думаю, любой хороший компилятор тоже. В любом случае, реальное решение, как я уже сказал, состоит в том, чтобы использовать shared_ptr идею размещения средства удаления рядом с объектом во время создания, тем самым отказываясь от необходимости иметь полное определение для создания копии/назначения/уничтожения копии, но это немного более продвинуто. ... хотя это форма, которую я лично использую :) - person Matthieu M.; 31.08.2010
comment
О, эй, согласно C++0x FCD, проверяемый модуль удаления может быть записан как delete &m[0];... см. стр. 93. - person Ben Voigt; 01.09.2010

При использовании идиомы PIMPL, если изменяются детали внутренней реализации класса IMPL, клиенты не должны перестраиваться. Любое изменение интерфейса класса IMPL (и, следовательно, файла заголовка), очевидно, потребует изменения класса PIMPL.

Кстати, в показанном коде существует сильная связь между IMPL и PIMPL. Таким образом, любое изменение в реализации класса IMPL также вызовет необходимость перестроения.

person Chubsdad    schedule 30.08.2010
comment
Эээ... разве это уже не относится к шаблонам .cpp и .hpp? Если реализация в .cpp изменится, .hpp не изменится. Следовательно, никакой другой код не должен перекомпилироваться.... - person Billy ONeal; 30.08.2010
comment
Реализация включает элементы данных, такие как data, и закрытые методы. Они изменили бы Impl.h (если бы он существовал), но не изменили бы PImpl.h. - person Beta; 30.08.2010
comment
@Bill ONeal: Кроме того, поскольку PIMPL является непрозрачным указателем, его можно заставить указывать на любой конкретный производный класс из абстракции PIMPL, тем самым получая преимущества шаблона проектирования стратегии. Вы больше не зависите от конкретных конкретных стратегий, а зависите от интерфейса. На мой взгляд, эта идиома в основном сродни двум принципам OOAD: а) Программа для интерфейса, а не для реализации б) Предпочтение агрегации наследованию Верно ли мое понимание? - person Chubsdad; 30.08.2010
comment
@chusbad: я тоже так думаю, действительно, мы регулярно используем pimpl для базового класса. Я добавил ответ с реализацией, ориентированной на это. - person Matthieu M.; 30.08.2010
comment
@chubsdad: я не понимаю, как абстрактный базовый класс не обеспечивает точно такое же разделение интерфейса. - person Billy ONeal; 30.08.2010

Подумайте о чем-то более реалистичном, и преимущества станут более заметными. В большинстве случаев, когда я использовал это для брандмауэра компилятора и сокрытия реализации, я определял класс реализации в той же единице компиляции, в которой находится видимый класс. В вашем примере у меня не было бы Impl.h или Impl.cpp, а Pimpl.cpp выглядело бы примерно так :

#include <iostream>
#include <boost/thread.hpp>

class Impl {
public:
  Impl(): data(0) {}
  void setData(int d) {
    boost::lock_guard l(lock);
    data = d;
  }
  int getData() {
    boost::lock_guard l(lock);
    return data;
  }
  void doSomething() {
    int d = getData();
    std::cout << getData() << std::endl;
  }
private:
  int data;
  boost::mutex lock;
};

Pimpl::Pimpl(): pimpl(new Impl) {
}

void Pimpl::doSomething() {
  pimpl->doSomething();
}

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

person D.Shawley    schedule 30.08.2010

В вашем примере вы можете изменить реализацию data без перекомпиляции клиентов. Этого не было бы без посредника PImpl. Точно так же вы можете изменить подпись или имя Imlp::DoSomething (до определенного предела), и клиенты не должны будут об этом знать.

В общем, все, что можно объявить private (по умолчанию) или protected в Impl, можно изменить без перекомпиляции клиентов.

person Beta    schedule 30.08.2010

В заголовках классов не Pimpl файл .hpp определяет общедоступные и частные компоненты вашего класса в одном большом сегменте.

Частные тесно связаны с вашей реализацией, так что это означает, что ваш файл .hpp действительно может многое рассказать о вашей внутренней реализации.

Рассмотрим что-то вроде библиотеки потоков, которую вы выбрали для частного использования внутри класса. Без использования Pimpl классы и типы потоков могут встречаться как частные члены или параметры в частных методах. Хорошо, библиотека потоков может быть плохим примером, но вы поняли: частные части определения вашего класса должны быть скрыты от тех, кто включает ваш заголовок.

Вот тут-то и появляется Pimpl. Поскольку заголовок общедоступного класса больше не определяет «приватные части», а вместо этого имеет указатель на реализацию, ваш приватный мир остается скрытым от логики, которая «#include» является вашим общедоступным классом. заголовок.

Когда вы меняете свои приватные методы (реализация), вы меняете вещи, скрытые под Pimpl, и поэтому клиентам вашего класса не нужно перекомпилировать, потому что с их точки зрения ничего не изменилось: они больше не видят частные члены реализации.

http://www.gotw.ca/gotw/028.htm

person Allbite    schedule 30.08.2010

Не все классы выигрывают от p-impl. Ваш пример имеет только примитивные типы во внутреннем состоянии, что объясняет, почему нет очевидной выгоды.

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

person Ben Voigt    schedule 30.08.2010
comment
Я не согласен, даже если в реализации используются только примитивные типы, использование Pimpl позволяет вам изменять их без каких-либо последствий для ваших клиентов (сохраняется ABI, перекомпиляция не требуется). - person Matthieu M.; 30.08.2010