c++ - 使用pimpl习语设计类的实例化和销毁

标签 c++ architecture pimpl-idiom

注意:我已经重写了问题,以指定我的意图更清晰,并使其更短。

我正在设计一个库的一部分,它有一些要求:

  • 所有实现细节都不能从公共(public)头中看到。
  • 内存必须由库管理。
  • 客户端通过句柄引用访问其所需的信息。

  • 为了实现这一点,我使用了pimpl习惯用法。

    我正在创建一种实例化条目树的方法,并且用户可以在实例化树后为每个实体添加其他行为。稍后,库的其他部分将使用该树执行某些操作。树中的条目不必在内存中复制或移动,分配后,即使更改了树中的父级,它们的内存地址仍保持不变。

    由于其他部分需要访问该实现,因此需要某种方式来访问它,同时最好将其限制为客户端代码。

    我在最初的问题中描述了多种方法,但是现在我将介绍已实现的方法,我认为这可能是实现此目标的最佳方法之一。

    目前的方法
  • 公共(public)构造函数使用一个(拥有的)指向实现类的指针。 (1)
  • 公共(public)析构函数。 (2)
  • 与实现类的友谊。 (3)
  • 实现类提供了一种静态方法,该方法可以从对原始类的引用中访问实现类。 (4)

  • Entry.h
    // 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,而无需更改EntryEntryImpl的文件。 (6)
  • std::unique_ptr<Entry>一起使用,不需要特殊的解除分配器。

  • 缺点
  • 公共(public)析构函数允许客户端代码释放Entry的内存,从而几乎立即崩溃。
  • 友谊吗?尽管与友谊有关的大多数问题在这里并不突出。


  • 我的问题仅涉及软件设计。有没有其他替代方法可能对我的情况更好?或者只是我忽略的方法。

    最佳答案

    现在,这几乎是一个代码审查问题,因此您可能需要考虑将此问题发布在CodeReview.SE上。另外,它可能不适合StackOverflow的特定问题,没有特定答案的哲学。尽管如此,我将尝试提出一个替代方案。

    对《任择议定书》方法的(细节)进行分析和批评

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

    正如OP已经指出的那样,库用户不应调用这些函数。例如,如果EntryImpl具有非平凡的析构函数,则析构函数调用Undefined Behavior。

    在我看来,阻止用户构造新的Entry对象没有太大的好处。在OP的先前方法之一中,Entry的构造函数都是私有(private)的。使用OP的当前解决方案,库用户可以编写:
    Entry e(0);
    

    这会创建无法合理使用的对象e。请注意,Entry应该不可复制,因为它拥有数据成员指针指向的对象。

    但是,不管类Entry的定义如何,库用户始终可以使用指针创建引用任何Entry对象的对象。 (这是反对从树中返回Entry&的原始实现的参数。)

    据我了解OP的意图,Entry对象使用指针将其自身的存储“扩展”到堆上的某些固定内存中:
    class Entry final
    {
    private:
        EntryImpl* const m_Impl;
    

    由于它是const,因此无法重置该指针。 Entry对象和EntryImpl对象之间也存在一对一的关系。但是,库接口(interface)必须处理EntryImpl 指针。这些实际上是从库实现传递给库用户的。 Entry类本身似乎仅用于在EntryEntryImpl对象之间建立一对一关系。

    对我来说,尚不清楚EntryTree之间的关系是什么。似乎每个Entry必须属于一个Tree,这意味着Tree对象应该拥有从其创建的所有条目。反过来,这意味着无论库用户从Tree::AddEntry获得什么,都应该是该树所拥有条目(即指针)的 View 。因此,您应考虑以下解决方案。

    一种使用多态的方法

    仅当您可以在库实现和库用户之间共享vtable时,此方法才有效。如果不是这种情况,则可以使用不透明指针而不是带有虚函数的接口(interface)来实现类似的方法。这甚至允许将库的接口(interface)定义为C API(请参阅Hourglass interfaces for C++ APIs)。

    让我们看一下满足需求的经典解决方案:
    // 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*,库可以与条目的更多私有(private)部分进行通信。该库甚至还有第二个接口(interface),用于将EntryImplTree分开。据我所知,这种方法不需要类之间的友谊。

    请注意,稍微更好的解决方案可能是让EntryImpl类实现概念而不是接口(interface),然后将EntryImpl对象包装到实现虚拟功能的适配器中。这允许将EntryImpl类重用于其他接口(interface)。

    通过上述解决方案,库用户可以处理指针:
    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中。即使这些解决方案对性能的影响不大,但从概念上讲,我还是认为它们很奇怪。

    因此,我认为对于该接口(interface),您应该坚持最通用的生命周期管理方法(据我所知),类似于手动“new”和“delete”调用的组合。我们不能直接使用这些语言功能,因为它们也处理内存。

    从其树中删除条目需要具备以下两项知识:条目和树。也就是说,您既可以提供销毁功能,也可以在每个条目中存储一个树指针。另一种查看方式是:如果您已经需要TreeImpl*中的EntryImpl,则可以免费获得它。另一方面,库用户可能已经具有每个条目的Tree*
    class Tree
    {
        // [implementation]
    public:
        non_owning_pointer<IEntry> AddEntry();
        void RemoveEntry(non_owning_pointer<IEntry>);
    
        void DoSomething();
    };
    

    (写完之后,这让我想起了迭代器;尽管它们也允许进入下一个条目。)

    使用此接口(interface),您可以轻松编写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可以被很好地封装。

    关于c++ - 使用pimpl习语设计类的实例化和销毁,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/27695315/

    相关文章:

    c++ - 一个程序有很多系统(类)。使系统可以通过类名调用其他人?

    ASP.Net MVC 架构 - ViewModel 的位置

    c++ - 针对 pimpl 的最终用户,pimpl 中的全局和私有(private)前向声明之间的区别

    c++ - 为什么要使用 "PIMPL"成语?

    c++ - 比较 pimpl idiom 与 Microsoft COM

    c++ - if 条件为一个数的倍数

    c++ - 检查 4 个点是否构成一个正方形

    c++ - 如何将 child 添加到 BST

    c++ - 在 C++ 中验证 double

    database - Elasticsearch - 多日志设计