Проектирование создания и уничтожения класса с использованием идиомы pimpl

Примечание. Я переформулировал вопрос, чтобы более четко указать свои намерения и сделать его короче.

Я разрабатываю часть библиотеки, которая имеет несколько требований:

  • Ни одна из деталей реализации не должна быть видна из общедоступных заголовков.
  • Память должна управляться библиотекой.
  • Клиент получает доступ к необходимой ему информации через ссылку на дескриптор.

Для этого я использую идиому pimpl.

Я создаю способ создания экземпляра дерева записей, и пользователь может после создания экземпляра дерева добавить дополнительное поведение к каждому объекту. Позже это дерево используется другими частями библиотеки для выполнения некоторых действий. Записи в дереве не нужно копировать или перемещать в память, после выделения их адрес памяти остается фиксированным, даже если родитель в дереве меняется.

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

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


Текущий подход

  • Открытый конструктор принимает указатель (владелец) на класс реализации. (1)
  • Публичный деструктор. (2)
  • Дружба с классом реализации. (3)
  • Класс реализации предоставляет статический метод для получения доступа к классу реализации из ссылки на исходный класс. (4)

Запись.ч

// Public header
#pragma once
class EntryImpl;
class Entry final
{
private:
    // 3. Friendship with the implementation class
    friend class EntryImpl;
    EntryImpl* const m_Impl;

public:
    // 1. Constructor takes owning pointer to EntryImpl
    Entry(EntryImpl* impl) : m_Impl(impl) { }
    // 2. Public destructor
    ~Entry() { delete m_Impl; }

    // Public APIs here...
};

EntryImpl.h

// Private header
#pragma once
class EntryImpl final
{
public:
    EntryImpl() { }
    ~EntryImpl() { }

    // 4. Provides the library's internals access to the implementation.
    static EntryImpl& Get(Entry& entry) { return *entry.m_Impl; }

    // As an example function
    void DoSomething() { }
    // Other stuff the implementation does here...
};

Дерево.ч

// Public header
#pragma once
class Entry;
class TreeImpl;
class Tree final
{
private:
    TreeImpl* const m_Impl;

public:
    Tree();
    ~Tree();

    // Public API
    Entry& CreateEntry();

    void DoSomething();
};

Дерево.cpp

// Implementation of Tree
#include "Tree.h"
#include "Entry.h"
#include "EntryImpl.h"
#include <vector>
#include <memory>

// Implement the forward-declared class
class TreeImpl
{
public:
    TreeImpl() { }
    ~TreeImpl() { }

    std::vector<std::unique_ptr<Entry>> m_Entries;
};

Tree::Tree() : m_Impl(new TreeImpl()) { }
Tree::~Tree() { delete m_Impl; }

Entry& Tree::CreateEntry()
{
    // 5. Any constructor parameters can be passed to the private EntryImpl
    //    class and is therefore hidden from the client.
    auto entry = std::make_unique<Entry>(new EntryImpl(/* construction params */));
    Entry& entryRef = *entry;
    // Move it into our own collection
    m_Impl->m_Entries.push_back(std::move(entry));
    return entryRef;
}

void Tree::DoSomething()
{
    for (const auto& entryPtr : m_Impl->m_Entries)
    {
        // 6. Can access the implementation from any implementation
        //    code without modifying the Entry or EntryImpl class.
        EntryImpl& entry = EntryImpl::Get(*entryPtr);
        entry.DoSomething();
    }
}

Преимущества

  • Параметры построения Entry скрыты в конструкторе EntryImpl. (5)
  • Любой исходный файл в коде библиотеки может получить доступ к EntryImpl без изменения файлов Entry или EntryImpl. (6)
  • Работает с std::unique_ptr<Entry>, не требуя специального делокатора.

Недостатки

  • Открытый деструктор позволяет клиентскому коду освобождать память Entry, вызывая почти немедленный сбой.
  • Дружба? Хотя большинство проблем, связанных с дружбой, здесь не так заметны.

Мой вопрос касается исключительно дизайна программного обеспечения. Существуют ли какие-либо альтернативные подходы, которые могут быть лучше для моего сценария? Или просто методы, которые я пропускаю.


person Aidiakapi    schedule 29.12.2014    source источник
comment
К сожалению, я не совсем понимаю ваш дизайн/ограничения. Например: Какова цель std::vector<std::unique_ptr<Entry>> (или, скорее, какова цель EntryManager)? Почему Entry::EntryImpl константа? Является ли EntryImpl копируемым, перемещаемым, ..?   -  person dyp    schedule 30.12.2014
comment
Спасибо за ваш ответ. EntryImpl не константа, указатель. Это означает, что базовое хранилище реализации не может быть изменено после создания экземпляра. В настоящее время ни один из классов не может быть скопирован или перемещен. Они будут доступны только потребителю библиотеки в качестве ссылок. --- В основном я работаю с ограничениями, из-за которых никакие детали реализации не могут быть доступны потребителю библиотеки. Кроме того, вся память управляется библиотекой, а все данные, предоставляемые клиенту, представлены в виде дескрипторов, а не реальных объектов.   -  person Aidiakapi    schedule 30.12.2014
comment
Ой, я имел в виду Entry::m_Impl, то есть элемент данных. Члены данных const довольно неприятны, поэтому используются только для специальных целей. -- Я не совсем уверен, что понимаю связь между всей памятью управляет библиотека и они будут отображаться только как ссылки. Если EntryImpl можно копировать, то вы можете сделать Entry тоже копируемым, и, например, не допускать утечки деталей реализации. Кроме того, почему базовое хранилище реализации нельзя изменить после создания экземпляра? Какова связь между объектом Entry и объектом EntryImpl, на который он ссылается?   -  person dyp    schedule 30.12.2014
comment
Я говорил о Entry::m_Impl тоже. Я пометил указатель как const, потому что не собираюсь менять его после создания Entry. Я мог бы изменить хранилище класса реализации (изменить Entry::m_Impl), пометив его как неконстантный, но в моем случае использования это не имело бы смысла. Они не автономные сущности, и нет смысла перемещать или копировать память. По сути, они являются точкой входа для пользователей, чтобы подключить свой собственный код настройки, но пользователи не могут стать владельцами этих классов Entry.   -  person Aidiakapi    schedule 30.12.2014
comment
Кажется, я начинаю понимать ваш замысел. Тем не менее, цель EntryManagerImpl::m_Entries мне неясна. Вам нужно просто хранить экземпляры Entry/EntryImpl где-то в памяти, или библиотеке нужен список всех (текущих) экземпляров EntryImpl? Могут ли быть разные объекты типа EntryManager? Если да, то (чем) отличается их поведение? Библиотека может запросить новый Entry через EntryManager::CreateEntry. Может ли пользователь запросить уничтожение одного экземпляра Entry? (Имеет ли это смысл?)   -  person dyp    schedule 30.12.2014
comment
Да, библиотеке нужно как отслеживать память (чтобы потом ее правильно очистить), так и хранить список/другую структуру данных для ее перебора. В примере я просто храню его в std::vector, но в реальном сценарии базовое хранилище адаптируется к хранимому содержимому (когда набор данных становится больше, он переключается на более эффективно модифицируемые коллекции). --- Пользователь может запросить (отложенное) удаление, а также изменить, к какому EntryManager он принадлежит, но все это делается путем перемещения std::unique_ptr<Entry>, а не путем перераспределения/копирования/перемещения фактического Entry.   -  person Aidiakapi    schedule 30.12.2014
comment
Я не уверен, что смогу дать хороший ответ на ваши проблемы. Например, вы не показали никаких операций для Entrys (ни функций-членов, ни свободных функций, принимающих Entry&s). Использование Entry определяет, какая форма (представление) подходит для него. Насколько я понимаю, вы можете просто вернуть (не принадлежащий) указатель из EntryManager::CreateEntry, удаляя большую часть цели класса Entry.   -  person dyp    schedule 30.12.2014
comment
@dyp Я переписал вопрос, надеясь прояснить все вопросы, которые у вас есть по этому поводу. И счастливого Нового года :).   -  person Aidiakapi    schedule 01.01.2015


Ответы (1)


Теперь это почти вопрос проверки кода, поэтому вы можете рассмотреть возможность размещения его на CodeReview.SE. Кроме того, это может не соответствовать философии StackOverflow: конкретные вопросы с конкретными ответами, без обсуждения. Тем не менее попробую представить альтернативу.


Анализ и критика (подробностей) подхода ОП

Entry(EntryImpl* impl) : m_Impl(impl) { }
// 2. Public destructor
~Entry() { delete m_Impl; }

Как уже говорилось в ОП, ни одна из этих функций не должна вызываться пользователем библиотеки. Деструктор вызывает Undefined Behavior, например, если EntryImpl имеет нетривиальный деструктор.

На мой взгляд, нет особой пользы в том, чтобы запретить пользователям создавать новые объекты Entry. В одном из предыдущих подходов OP все конструкторы Entry были закрытыми. С текущим решением OP пользователь библиотеки может написать:

Entry e(0);

Что создает объект e, который нельзя разумно использовать. Обратите внимание, что Entry не должен быть копируемым, так как он владеет объектом, на который указывает указатель члена данных.

Однако, независимо от определения класса Entry, пользователь библиотеки всегда может создать объект, который ссылается на любой объект Entry, используя указатель. (Это аргумент против оригинальной реализации, которая возвращала Entry& из дерева.)


Насколько я понимаю намерения OP, объект Entry использует указатель для «расширения» своего собственного хранилища до некоторой фиксированной памяти в куче:

class Entry final
{
private:
    EntryImpl* const m_Impl;

Поскольку это const, вы не можете переустановить указатель. Между Entry объектами и EntryImpl объектами существует связь 1-к-1. Однако интерфейс библиотеки обязательно имеет дело с EntryImpl указателями. Это то, что по существу передается от реализации библиотеки пользователю библиотеки. Сам класс Entry, по-видимому, служит только для установления связи 1-к-1 между объектами Entry и EntryImpl.

Мне до сих пор не совсем ясно, какова связь между Entrys и Trees. Кажется, что каждый Entry должен принадлежать Tree, что означает, что объект Tree должен владеть всеми созданными из него записями. Это, в свою очередь, подразумевает, что все, что пользователь библиотеки получает от Tree::AddEntry, должно быть представлением записи, принадлежащей дереву, то есть указателем. В этом свете вы должны рассмотреть решение ниже.


Подход с использованием полиморфизма

Этот подход работает (только), если вы можете совместно использовать виртуальную таблицу между реализацией библиотеки и пользователем библиотеки. Если это не так, вы можете реализовать аналогичный подход, используя непрозрачный указатель вместо интерфейса с виртуальными функциями. Это даже позволяет определить интерфейс библиотеки как C API (см. интерфейсы песочных часов для C++ API).

Рассмотрим классическое решение требований:

// interface headers:

class IEntry // replacement for `Entry`
{
public:
    // public API as virtual functions
};

class Tree
{
    // [implementation]
public:
    IEntry* AddEntry();
    void DoSomething();
};


// implementation headers:

class EntryImpl : public IEntry
{
    // implementation
};

// implementation of `Tree::AddEntry` returns an `EntryImpl*`

Это решение полезно, если дескриптор записи (IEntry*) не владеет записью, на которую он ссылается. Путем приведения от IEntry* к EntryImpl* библиотека может взаимодействовать с более закрытыми частями записи. Может быть даже второй интерфейс для библиотеки, который отделяет EntryImpl от Tree. Насколько я понимаю, для такого подхода не требуется никакой дружбы между классами.

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

В приведенном выше решении пользователь библиотеки имеет дело с указателем:

Tree myTree;
auto myEntry = myTree.AddEntry();
myEntry->SomeFunction();

Чтобы задокументировать, что этот указатель не владеет объектом, на который он указывает, вы можете использовать то, что было названо «самым тупым умным указателем в мире». По сути, это легкая оболочка необработанного указателя, который как тип выражает, что он не владеет объектом, на который указывает:

class Tree
{
    // [implementation]
public:
    non_owning_pointer<IEntry> AddEntry();
    void DoSomething();
};

Если вы хотите разрешить пользователю удалять записи, вы должны удалить их из своего дерева. В противном случае вам придется иметь дело с уничтоженными записями явно, например. в TreeImpl::DoSomething. На данный момент мы начинаем перестраивать систему управления ресурсами для записей; первым шагом которого обычно является разрушение. Однако у пользователя библиотеки могут быть различные требования к сроку жизни своих записей. Если вы просто возвращаете shared_ptr, это может быть ненужным накладным расходом; если вы вернете unique_ptr, пользователю библиотеки, возможно, придется обернуть это unique_ptr в shared_ptr. Даже если эти решения не сильно влияют на производительность, я бы посчитал их странными с концептуальной точки зрения.

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

Удаление записи из ее дерева требует знания как записи, так и дерева. То есть либо вы предоставляете обе функции для уничтожения, либо сохраняете указатель дерева в каждой записи. Другой способ взглянуть на это так: если вам уже нужен TreeImpl* в EntryImpl, вы получите его бесплатно. С другой стороны, пользователь библиотеки может уже иметь Tree* каждой записи.

class Tree
{
    // [implementation]
public:
    non_owning_pointer<IEntry> AddEntry();
    void RemoveEntry(non_owning_pointer<IEntry>);

    void DoSomething();
};

(После того, как я это написал, это напомнило мне об итераторах, хотя они также позволяют перейти к следующей записи.)

С помощью этого интерфейса вы можете легко написать unique_ptr<IEntry, ..> и shared_ptr<IEntry>. Например:

namespace detail
{
    class UnqiueEntryPtr_deleter {
        non_owning_pointer<Tree> owner;
    public:
        UnqiueEntryPtr_deleter(Tree* t) : owner{t} ()
        void operator()(IEntry* p) { owner->RemoveEntry(p); }
    };
}

using unique_entry_ptr = std::unique_ptr<IEntry, UniqueEntryPtr_deleter>;

auto AddEntry(Tree& t) // convenience function
{ return unique_entry_ptr{ t.AddEntry(), &t }; }

Точно так же вы можете создать объект, который содержит unique_ptr для записи и shared_ptr для ее владельца Tree. Это предотвращает проблемы со сроком службы Entry*, которые относятся к мертвым деревьям.


Поднятие абстракции в подходах PIMPL

Конечно, использование полиморфизма позволяет легко перейти от IEntry* к EntryImpl* внутри библиотеки. Можем ли мы решить проблему также для подхода PIMPL? Да, либо через дружбу (как в OP), либо через функцию, которая извлекает (копию) PIMPL:

class EntryImpl;
class Entry
{
    EntryImpl* pimpl;
public:
    EntryImpl const* get_pimpl() const;
    EntryImpl* get_pimpl();
};

Это выглядит не очень красиво, но необходимо, чтобы части библиотеки, скомпилированные пользователем, извлекали этот указатель (например, пользовательский компилятор может выбрать другую структуру памяти для объектов Entry). Пока EntryImpl является непрозрачным указателем, можно утверждать, что инкапсуляция Entry не нарушается. Фактически, EntryImpl может быть хорошо инкапсулирован.

person Community    schedule 02.01.2015
comment
Основная проблема, которая требует отмены абстракции от Entry, по-видимому, заключается в том, что они есть как у Tree, так и у Entry. В функции-члене любого из них вы можете (без дружбы) получить доступ только к своему собственному pimpl. У меня нет хорошего решения этой проблемы. - person dyp; 02.01.2015
comment
Вопросы, касающиеся дизайна программного обеспечения, как правило, являются мягкими вопросами, я не знаю, действительно ли сайт обзора кода будет уместным, поскольку я публикую не фактический код, а упрощенный пример ситуации. --- В любом случае, спасибо за ответ. Несколько вещей, которые я пропустил в примере, но проверяются в коде. 1. Конструктор выдает исключение при передаче nullptr. 2. Присваивание и конструктор копирования фактически удаляются. --- Класс Entry и EntryImpl действительно имеют отношение 1 к 1. Entry предоставляет клиенту некоторые функции EntryImpl. - person Aidiakapi; 03.01.2015
comment
Но EntryImpl содержит больше информации. Отношения между Tree и Entry действительно являются отношениями собственности. Дерево создает, владеет и уничтожает Entry по запросу пользователя. Это заботится о нескольких других системах (события, кэширование, пространственное индексирование и т. д.), которые обрабатываются «под капотом». Клиент никогда не должен беспокоиться о том, как это делает библиотека, и библиотека должна иметь возможность свободно изменять то, как она это делает (лучшие алгоритмы или другие оптимизации), не влияя на код клиента. Указатель EntryImpl* const m_Impl; является непрозрачным указателем. - person Aidiakapi; 03.01.2015
comment
Пример с использованием полиморфизма — действительно классическое решение, возвращающее интерфейс. Положительная сторона этого подхода является недостатком для моего сценария. С интерфейсом любой может реализовать интерфейс, и у клиента может сложиться впечатление, что его класс, соответствующий интерфейсу, на самом деле является допустимым параметром. Я не вижу преимущества использования non_owning_pointer<T>, поскольку именно для этого я использую T&. Будучи ссылкой, он одновременно указывает, что значение не может быть nullptr, и что он не владеет памятью, он просто ссылается на нее. - person Aidiakapi; 03.01.2015
comment
Тем не менее, то, как вы его используете, - это именно то, что у меня есть. У меня есть метод CreateEntry и DestroyEntry. Хотя уничтожение отложено/поставлено в очередь, чтобы другие Entry, которые также ссылаются на этот Entry, могли должным образом очиститься перед освобождением памяти. Во время этой очистки также запускается несколько событий для самого Entry. Вот почему я исключил его из своего примера кода, так как это усложнило бы пример. --- unique_entry_ptr - хорошая концепция, хотя я не знаю, смогу ли я ее использовать. Удалителю потребуется доступ к EntryImpl, который содержит обратный указатель на Tree... - person Aidiakapi; 03.01.2015
comment
...или, точнее, он содержит обратный указатель на объект, владеющий Tree, который может быть разных типов (либо другой Entry, либо «корень»). Пункт остается, Entry знает, как удалить себя. Однако я не думаю, что смогу реализовать это, не нарушив первое «правило» проекта. Я не знаю, могу ли я использовать нереализованный функтор в качестве аргумента шаблона для unique_ptr. Но это определенно стоит попробовать. Я надеюсь, что я доберусь до тестирования, что завтра. --- Большое спасибо за приложенные усилия, чтобы ответить на мой вопрос, я собираюсь проверить все подходы, которые вы мне показали! - person Aidiakapi; 03.01.2015