Действительно ли идиома pImpl используется на практике?
Я читаю книгу "Исключительный C++" Херба Саттера, и в этой книге я узнал об идиоме pImpl. По сути, идея состоит в том, чтобы создать структуру для private
объекты class
и динамически распределять их, чтобы уменьшить время компиляции (а также лучше скрыть частные реализации).
Например:
class X
{
private:
C c;
D d;
} ;
может быть изменено на:
class X
{
private:
struct XImpl;
XImpl* pImpl;
};
и в CPP определение:
struct X::XImpl
{
C c;
D d;
};
Это кажется довольно интересным, но я никогда не видел такого подхода раньше, ни в компаниях, где я работал, ни в проектах с открытым исходным кодом, где я видел исходный код. Итак, мне интересно, эта техника действительно используется на практике?
Должен ли я использовать его везде или с осторожностью? И рекомендуется ли использовать эту технику во встроенных системах (где производительность очень важна)?
12 ответов
Итак, мне интересно, эта техника действительно используется на практике? Должен ли я использовать его везде или с осторожностью?
Конечно, он используется, и в моем проекте, почти в каждом классе, по нескольким причинам, которые вы упомянули:
- сокрытие данных
- время перекомпиляции действительно уменьшается, так как нужно перестраивать только исходный файл, но не заголовок и каждый файл, который его включает
- двоичная совместимость Поскольку объявление класса не изменяется, безопасно просто обновить библиотеку (при условии, что вы создаете библиотеку)
этот метод рекомендуется использовать во встроенных системах (где производительность очень важна)?
Это зависит от того, насколько сильна ваша цель. Однако единственный ответ на этот вопрос: измерьте и оцените, что вы получаете и теряете.
Похоже, что многие библиотеки используют его для стабильности API, по крайней мере, для некоторых версий.
Но что касается всех вещей, вы никогда не должны использовать что-либо везде без осторожности. Всегда думайте, прежде чем использовать его. Оцените, какие преимущества это дает вам, и стоят ли они той цены, которую вы платите.
Преимущества, которые он может дать вам:
- помогает в сохранении двоичной совместимости разделяемых библиотек
- скрытие определенных внутренних деталей
- уменьшение циклов перекомпиляции
Это может или не может быть реальным преимуществом для вас. Как и для меня, меня не волнует несколько минут перекомпиляции. Конечные пользователи обычно тоже этого не делают, так как всегда компилируют его один раз и с самого начала.
Возможные недостатки (также здесь, в зависимости от реализации и являются ли они реальными недостатками для вас):
- Увеличение использования памяти из-за большего количества выделений, чем с наивным вариантом
- увеличенное обслуживание (вы должны написать хотя бы функции пересылки)
- потеря производительности (возможно, компилятор не сможет встроить что-либо, как в случае с наивной реализацией вашего класса)
Поэтому тщательно оцените все и оцените это сами. Для меня почти всегда оказывается, что использование идиомы pimpl не стоит усилий. Есть только один случай, когда я лично использую его (или хотя бы что-то подобное):
Моя оболочка C++ для Linux stat
вызов. Здесь структура из заголовка C может отличаться в зависимости от того, что #defines
установлены. И так как мой заголовок оболочки не может контролировать их все, я только #include <sys/stat.h>
в моем .cxx
файл и избежать этих проблем.
Согласитесь со всеми остальными о товарах, но позвольте мне представить ограничение: не очень хорошо работает с шаблонами.
Причина в том, что создание экземпляра шаблона требует полного объявления, доступного там, где оно было выполнено. (И это главная причина, по которой вы не видите методы шаблона, определенные в файлах CPP)
Вы по-прежнему можете ссылаться на шаблонные подклассы, но, поскольку вы должны включить их все, все преимущества "развязки реализации" при компиляции (исключение включения всего кода, специфичного для платформ, везде, сокращение компиляции) теряются.
Является хорошей парадигмой для классического ООП (на основе наследования), но не для общего программирования (на основе специализации).
Другие люди уже предоставили технические плюсы / минусы, но я думаю, стоит отметить следующее:
Прежде всего, не будьте догматичными. Если pImpl подходит для вашей ситуации, используйте его - не используйте его только потому, что "он лучше ОО, так как он действительно скрывает реализацию" и т. Д. Цитирование часто задаваемых вопросов по C++:
инкапсуляция для кода, а не людей ( источник)
Просто чтобы дать вам пример программного обеспечения с открытым исходным кодом, где оно используется и почему: OpenThreads, библиотека потоков, используемая OpenSceneGraph. Основная идея состоит в том, чтобы удалить из шапки (например, <Thread.h>
) весь специфичный для платформы код, потому что внутренние переменные состояния (например, дескрипторы потоков) отличаются от платформы к платформе. Таким образом, вы можете скомпилировать код для вашей библиотеки без знания особенностей других платформ, потому что все скрыто.
Я бы в основном рассмотрел PIMPL для классов, которые могут использоваться в качестве API другими модулями. Это имеет много преимуществ, поскольку перекомпиляция изменений, внесенных в реализацию PIMPL, не влияет на остальную часть проекта. Кроме того, для классов API они обеспечивают двоичную совместимость (изменения в реализации модуля не влияют на клиентов этих модулей, их не нужно перекомпилировать, поскольку новая реализация имеет тот же двоичный интерфейс - интерфейс, предоставляемый PIMPL).
Что касается использования PIMPL для каждого класса, я бы подумал об осторожности, потому что все эти преимущества обходятся дорого: для доступа к методам реализации необходим дополнительный уровень косвенности.
Я думаю, что это один из самых фундаментальных инструментов для развязки.
Я использовал pimpl (и многие другие идиомы из Exceptional C++) во встроенном проекте (SetTopBox).
Особая цель этого idoim в нашем проекте состояла в том, чтобы скрыть типы, используемые классом XImpl. В частности, мы использовали его, чтобы скрыть подробности реализаций для разных аппаратных средств, в которые будут загружаться разные заголовки. У нас были разные реализации классов XImpl для одной платформы и разные для другой. Планировка класса X осталась прежней независимо от платформы.
Раньше я часто использовал эту технику, но потом отошел от нее.
Конечно, это хорошая идея, чтобы скрыть детали реализации от пользователей вашего класса. Однако вы также можете сделать это, заставив пользователей класса использовать абстрактный интерфейс, а детали реализации должны быть конкретным классом.
Преимущества pImpl:
Предполагая, что есть только одна реализация этого интерфейса, это более ясно, не используя абстрактный класс / конкретную реализацию
Если у вас есть набор классов (модуль), такой, что несколько классов обращаются к одному и тому же "impl", но пользователи модуля будут использовать только "открытые" классы.
Нет V-таблицы, если предполагается, что это плохо.
Недостатки, которые я обнаружил в pImpl (где абстрактный интерфейс работает лучше)
Хотя у вас может быть только одна "производственная" реализация, используя абстрактный интерфейс, вы также можете создать "ложную" реализацию, которая работает в модульном тестировании.
(Самая большая проблема). До дней с unique_ptr и переездом у вас был ограниченный выбор того, как хранить pImpl. Необработанный указатель, и у вас возникли проблемы с невозможностью копирования вашего класса. Старый auto_ptr не будет работать с заранее объявленным классом (во всяком случае, не со всеми компиляторами). Таким образом, люди начали использовать shared_ptr, что было неплохо в том, чтобы сделать ваш класс копируемым, но, конечно, обе копии имели один и тот же базовый shared_ptr, чего вы не ожидали (измените один, и оба будут изменены). Поэтому решение часто заключалось в том, чтобы использовать необработанный указатель для внутреннего и сделать класс не подлежащим копированию, и вместо этого вернуть к нему shared_ptr. Итак, два звонка на новый. (На самом деле 3 с учетом старого shared_ptr дали вам второй).
Технически не совсем константно-корректно, так как константность не распространяется на указатель на член.
В общем, поэтому я отошел в годы от pImpl и вместо этого стал использовать абстрактный интерфейс (и фабричные методы для создания экземпляров).
Вот фактический сценарий, с которым я столкнулся, где эта идиома очень помогла. Недавно я решил поддержать DirectX 11, а также мою существующую поддержку DirectX 9 в игровом движке. Движок уже обернул большинство функций DX, поэтому ни один из интерфейсов DX не использовался напрямую; они были определены в заголовках как частные члены. Движок использует библиотеки DLL в качестве расширений, добавляя поддержку клавиатуры, мыши, джойстика и сценариев, как и многие другие расширения. Хотя большинство из этих DLL не использовали DX напрямую, им требовались знания и связь с DX просто потому, что они использовали заголовки, раскрывающие DX. При добавлении DX 11, эта сложность должна была резко возрасти, но без необходимости. Перемещение членов DX в Pimpl, определенный только в источнике, устранило это наложение. Помимо сокращения библиотечных зависимостей, мои открытые интерфейсы стали чище, так как частные функции-члены перемещались в Pimpl, открывая только внешние интерфейсы.
Как говорили многие другие, идиома Pimpl позволяет достичь полной независимости от сокрытия информации и компиляции, к сожалению, за счет потери производительности (дополнительного обращения к указателю) и необходимости в дополнительной памяти (сам указатель на элемент). Дополнительные затраты могут иметь решающее значение при разработке встроенного программного обеспечения, особенно в тех сценариях, когда память должна быть максимально экономной. Использование абстрактных классов C++ в качестве интерфейсов приведет к тем же преимуществам при той же цене. На самом деле это показывает большой недостаток C++, где, не возвращаясь к C-подобным интерфейсам (глобальные методы с непрозрачным указателем в качестве параметра), невозможно получить истинную независимость от скрытия информации и компиляции без дополнительных недостатков ресурса: это главным образом потому, что Объявление класса, которое должно быть включено его пользователями, экспортирует не только интерфейс класса (публичные методы), который нужен пользователям, но также и его внутренние компоненты (частные члены), которые не нужны пользователям.
Одно преимущество, которое я вижу, состоит в том, что он позволяет программисту выполнять некоторые операции довольно быстро:
X( X && move_semantics_are_cool ) : pImpl(NULL) {
this->swap(move_semantics_are_cool);
}
X& swap( X& rhs ) {
std::swap( pImpl, rhs.pImpl );
return *this;
}
X& operator=( X && move_semantics_are_cool ) {
return this->swap(move_semantics_are_cool);
}
X& operator=( const X& rhs ) {
X temporary_copy(rhs);
return this->swap(temporary_copy);
}
PS: Надеюсь, я не неправильно понял семантику ходов.
Он используется на практике во многих проектах. Его полезность сильно зависит от типа проекта. Одним из наиболее известных проектов, использующих это, является Qt, где основная идея заключается в том, чтобы скрыть код реализации или платформу от пользователя (других разработчиков, использующих Qt).
Это благородная идея, но есть реальный недостаток: отладка. Пока код, скрытый в частных реализациях, имеет превосходное качество, это все хорошо, но если в нем есть ошибки, то у пользователя / разработчика есть проблема, потому что это просто тупой указатель на скрытую реализацию, даже если он имеет исходный код реализации.
Так как почти во всех дизайнерских решениях есть плюсы и минусы.
Я думал, что добавлю ответ, потому что, хотя некоторые авторы намекали на это, я не думал, что этот вопрос был достаточно ясен.
Основная цель PIMPL — решить проблему N*M. Эта проблема может иметь другие названия в другой литературе, однако краткое изложение таково.
У вас есть какая-то иерархия наследования, где, если бы вы добавили новый подкласс в свою иерархию, вам потребовалось бы реализовать N или M новых методов.
Это лишь приблизительное объяснение, поскольку я только недавно узнал об этом и поэтому, по собственному признанию, еще не являюсь экспертом в этом.
Обсуждение существующих моментов
Однако я столкнулся с этим вопросом и подобными вопросами несколько лет назад, и меня смутили типичные ответы, которые даны. (Предположительно, я впервые узнал о PIMPL несколько лет назад и нашел этот вопрос и другие, похожие на него.)
- Включает бинарную совместимость (при написании библиотек)
- Сокращает время компиляции
- Скрывает данные
Принимая во внимание вышеупомянутые «преимущества», ни одно из них, на мой взгляд, не является особенно веской причиной для использования PIMPL. Следовательно, я никогда не использовал его, и в результате пострадали мои проекты программ, потому что я отказался от полезности PIMPL и того, для чего он действительно может быть использован.
Позвольте мне прокомментировать каждый, чтобы объяснить:
1.
Двоичная совместимость имеет значение только при написании библиотек. Если вы компилируете конечную исполняемую программу, то это не имеет значения, если только вы не используете чужие (бинарные) библиотеки. (Другими словами, у вас нет оригинального исходного кода.)
Это означает, что это преимущество имеет ограниченный масштаб и полезность. Это представляет интерес только для людей, которые пишут библиотеки, поставляемые в проприетарной форме.
2.
Я лично не считаю, что это имеет какое-либо значение в наши дни, когда редко приходится работать над проектами, где время компиляции имеет решающее значение. Возможно, это важно для разработчиков Google Chrome. Связанные с этим недостатки, которые, вероятно, значительно увеличивают время разработки, вероятно, более чем компенсируют это преимущество. Я могу ошибаться в этом, но я нахожу это маловероятным, особенно учитывая скорость современных компиляторов и компьютеров.
3.
Я не сразу вижу преимущество, которое приносит здесь PIMPL. Тот же результат может быть достигнут путем отправки файла заголовка и двоичного объектного файла. Без конкретного примера передо мной трудно понять, почему PIMPL здесь актуален. Соответствующая «вещь» — это отправка двоичных объектных файлов, а не исходного кода.
Что на самом деле делает PIMPL:
Вам придется простить мой слегка взмахивающий рукой ответ. Хотя я не являюсь полным экспертом в этой конкретной области разработки программного обеспечения, я могу по крайней мере кое-что рассказать вам об этом. Эта информация в основном повторяется из Design Patterns. Авторы называют его "Bridge Pattern", он же Handle, он же Body.
В этой книге приведен пример написания оконного менеджера. Ключевым моментом здесь является то, что оконный менеджер может реализовывать различные типы окон, а также различные типы платформ.
Например, у человека может быть
- Окно
- Окно значка
- Полноэкранное окно с 3D-ускорением
- Какое-то другое причудливое окно
- Это типы окон, которые можно отображать
так же как
- Реализация Microsoft Windows
- Реализация платформы OS X
- Диспетчер окон Linux X
- Линукс Вэйланд
- Это разные типы движков рендеринга, с разными вызовами ОС и, возможно, с принципиально разными функциями.
Приведенный выше список аналогичен приведенному в другом ответе, где другой пользователь описал программное обеспечение для написания, которое должно работать с различными типами оборудования для чего-то вроде DVD-плеера. (Я точно забыл, что это был за пример.)
Я привожу здесь немного другие примеры по сравнению с тем, что написано в книге Design Patterns.
Дело в том, что есть два отдельных типа вещей, которые должны быть реализованы с использованием иерархии наследования, однако использования одной иерархии наследования здесь недостаточно. (Проблема N*M, сложность масштабируется как квадрат количества вещей в каждом списке маркеров, что разработчик не может реализовать.)
Следовательно, используя PIMPL, можно выделить типы окон и предоставить указатель на экземпляр класса реализации.
Итак, ПИМПЛ:
- Решает проблему N*M
- Разделяет две принципиально разные вещи, которые моделируются с использованием наследования, так что существует 2 или более иерархий, а не только один монолит.
- Разрешает обмен во время выполнения точным поведением реализации (путем изменения указателя). Это может быть выгодно в некоторых ситуациях, тогда как один монолит обеспечивает выбор статического (во время компиляции) поведения, а не выбор поведения во время выполнения.
Могут быть и другие способы реализовать это, например, с множественным наследованием, но обычно это более сложный и сложный подход, по крайней мере, по моему опыту.