【问题标题】:Pimpl idiom without using dynamic memory allocation不使用动态内存分配的 Pimpl 习惯用法
【发布时间】:2011-06-22 18:11:01
【问题描述】:

我们想在项目的某些部分使用 pimpl idiom。项目的这些部分也恰好是禁止动态内存分配的部分,这个决定不在我们的控制范围内。

所以我要问的是,有没有一种干净又好的方法来实现 pimpl idiom 而无需动态内存分配?

编辑
以下是一些其他限制:嵌入式平台、标准 C++98、无外部库、无模板。

【问题讨论】:

  • 没有动态分配的 pimpl 有什么意义? pimpl 的主要用途是使动态对象的生命周期可管理。如果您没有生命周期管理问题,那么只需将引用直接传递给静态/堆栈范围的对象。
  • 我认为 pimpl 的主要用途是隐藏实现细节,因此得名“指向实现成语的指针”。
  • @Chris:我们不需要 pimpl 来管理对象的生命周期。只需使用智能指针(或首先编写对象以遵循 RAII 习语)。 pimpl 是关于隐藏类的内部。
  • 23k 代表的人怎么会如此严重地误解基本成语
  • @FantasticMrFox 对于不知道它是什么的人来说是完全公平的。但是他们不应该发布关于它的用途的虚假断言。

标签: c++ embedded dynamic-memory-allocation pimpl-idiom


【解决方案1】:

我使用的一种技术是非拥有 pImpl 包装器。这是一个非常小众的选择,不如传统的 pimpl 安全,但如果性能是一个问题,它会有所帮助。它可能需要一些重新架构才能像 api 一样具有更多功能。

您可以创建一个非拥有的 pimpl 类,只要您可以(在某种程度上)保证堆栈 pimpl 对象的寿命会超过包装器。

例如

/* header */
struct MyClassPimpl;
struct MyClass {
    MyClass(MyClassPimpl& stack_object); // Initialize wrapper with stack object.

private:
    MyClassPimpl* mImpl; // You could use a ref too.
};


/* in your implementation code somewhere */

void func(const std::function<void()>& callback) {
    MyClassPimpl p; // Initialize pimpl on stack.

    MyClass obj(p); // Create wrapper.

    callback(obj); // Call user code with MyClass obj.
}

与大多数包装器一样,这里的危险是用户将包装器存储在一个范围内,该范围将超过堆栈分配。使用风险自负。

【讨论】:

    【解决方案2】:

    警告:这里的代码只展示了存储方面,它是一个骨架,没有考虑动态方面(构造、复制、移动、销毁)。

    我建议使用 C++0x 新类 aligned_storage 的方法,这正是用于原始存储的方法。

    // header
    class Foo
    {
    public:
    private:
      struct Impl;
    
      Impl& impl() { return reinterpret_cast<Impl&>(_storage); }
      Impl const& impl() const { return reinterpret_cast<Impl const&>(_storage); }
    
      static const size_t StorageSize = XXX;
      static const size_t StorageAlign = YYY;
    
      std::aligned_storage<StorageSize, StorageAlign>::type _storage;
    };
    

    然后在源代码中实施检查:

    struct Foo::Impl { ... };
    
    Foo::Foo()
    {
      // 10% tolerance margin
      static_assert(sizeof(Impl) <= StorageSize && StorageSize <= sizeof(Impl) * 1.1,
                    "Foo::StorageSize need be changed");
      static_assert(StorageAlign == alignof(Impl),
                    "Foo::StorageAlign need be changed");
      /// anything
    }
    

    这样,虽然您必须立即更改对齐方式(如有必要),但只有在对象更改过多时大小才会更改。

    很明显,由于检查是在编译时进行的,所以你不能错过它:)

    如果您无权访问 C++0x 功能,则 TR1 命名空间中有 aligned_storagealignof 的等效项,并且有 static_assert 的宏实现。

    【讨论】:

    • @Gart:Foo 大小的任何变化都会导致二进制不兼容,这是我们在此试图阻止的。因此,您需要 StorageSize 优于 sizeof(Impl) 并且 稳定,因此您可能会稍微加大它的大小,以便稍后能够向 Impl 添加字段。但是,您可能会过冲太多,最终得到一个非常大的对象...什么也没有,所以我建议使用这 10% 的余量检查您是否最终得到一个过大的对象。
    • 我需要在构造函数中调用new( &amp;_storage )Impl(); 来让Pimpl 成员正确初始化。
    • 我还需要在析构函数中调用reinterpret_cast&lt; Impl* &gt;( &amp;_storage )-&gt;~Impl(); 以避免内存泄漏。
    • 反驳 Sutter 的“为什么尝试 #3 是可悲的”gotw.ca/gotw/028.htm(我认为这是 C++11 之前的版本):1. 我处理了对齐问题(使用 @987654337 可以做得更好@ 允许值在缓冲区中偏移) 2. 脆弱性:现在很容易使其静态安全。 3. 维护成本:在某些情况下,尺寸不会改变,但所需的标头价格昂贵。 4.浪费空间:有时我不在乎。 5. 我不回答。我的观点是,我确实有一些我想要作为词汇类型成员的课程,但它们会引入巨大的标题。这可以解决这个问题;模块也可以。
    • @Ben:确实,模块应该淘汰 PIMPL 的“编译防火墙”方面,因此 InlinePimpl ......虽然它们还没有,所以我认为你的实现可以很好地为你服务同时:)
    【解决方案3】:

    使用 pimpl 的目的是隐藏对象的实现。这包括真正的实现对象的size。然而,这也使得避免动态分配变得很尴尬——为了为对象保留足够的堆栈空间,您需要知道对象有多大。

    典型的解决方案确实是使用动态分配,并将分配足够空间的责任交给(隐藏的)实现。但是,这在您的情况下是不可能的,因此我们需要另一种选择。

    其中一个选项是使用alloca()。这个鲜为人知的函数在栈上分配内存;当函数退出其作用域时,内存将被自动释放。 这不是可移植的 C++,但是许多 C++ 实现都支持它(或者这个想法的变体)。

    请注意,您必须使用宏分配 pimpl 对象;必须调用alloca() 才能直接从拥有函数中获取必要的内存。示例:

    // Foo.h
    class Foo {
        void *pImpl;
    public:
        void bar();
        static const size_t implsz_;
        Foo(void *);
        ~Foo();
    };
    
    #define DECLARE_FOO(name) \
        Foo name(alloca(Foo::implsz_));
    
    // Foo.cpp
    class FooImpl {
        void bar() {
            std::cout << "Bar!\n";
        }
    };
    
    Foo::Foo(void *pImpl) {
        this->pImpl = pImpl;
        new(this->pImpl) FooImpl;
    }
    
    Foo::~Foo() {
        ((FooImpl*)pImpl)->~FooImpl();
    }
    
    void Foo::Bar() {
        ((FooImpl*)pImpl)->Bar();
    }
    
    // Baz.cpp
    void callFoo() {
        DECLARE_FOO(x);
        x.bar();
    }
    

    如您所见,这使得语法相当尴尬,但它确实完成了一个 pimpl 类似物。

    如果您可以在标头中硬编码对象的大小,还可以选择使用 char 数组:

    class Foo {
    private:
        enum { IMPL_SIZE = 123; };
        union {
            char implbuf[IMPL_SIZE];
            double aligndummy; // make this the type with strictest alignment on your platform
        } impl;
    // ...
    }
    

    这不如上述方法纯粹,因为每当实现大小发生变化时,您都必须更改标头。但是,它允许您使用普通语法进行初始化。

    您还可以实现影子堆栈 - 即与普通 C++ 堆栈分开的辅助堆栈,专门用于保存 pImpl 的对象。这需要非常仔细的管理,但是,如果包装得当,它应该可以工作。这种类型处于动态分配和静态分配之间的灰色地带。

    // One instance per thread; TLS is left as an exercise for the reader
    class ShadowStack {
        char stack[4096];
        ssize_t ptr;
    public:
        ShadowStack() {
            ptr = sizeof(stack);
        }
    
        ~ShadowStack() {
            assert(ptr == sizeof(stack));
        }
    
        void *alloc(size_t sz) {
            if (sz % 8) // replace 8 with max alignment for your platform
                sz += 8 - (sz % 8);
            if (ptr < sz) return NULL;
            ptr -= sz;
            return &stack[ptr];
        }
    
        void free(void *p, size_t sz) {
            assert(p == stack[ptr]);
            ptr += sz;
            assert(ptr < sizeof(stack));
        }
    };
    ShadowStack theStack;
    
    Foo::Foo(ShadowStack *ss = NULL) {
        this->ss = ss;
        if (ss)
            pImpl = ss->alloc(sizeof(FooImpl));
        else
            pImpl = new FooImpl();
    }
    
    Foo::~Foo() {
        if (ss)
            ss->free(pImpl, sizeof(FooImpl));
        else
            delete ss;
    }
    
    void callFoo() {
        Foo x(&theStack);
        x.Foo();
    }
    

    使用这种方法,确保您不会将影子堆栈用于包装对象位于堆上的对象,这一点至关重要;这将违反对象总是以相反的创建顺序销毁的假设。

    【讨论】:

      【解决方案4】:

      如果您可以使用 boost,请考虑 boost::optional&lt;&gt;。这避免了动态分配的成本,但同时,除非您认为有必要,否则不会构造您的对象。

      【讨论】:

      • 抱歉,我们不能使用 boost 或任何其他外部库 :(
      • 你为什么要道歉,你不能帮助人为约束? :) 无论如何,如果您愿意,从 boost::optional 中删除代码非常简单,代码中最聪明的部分是 aligned_storage 结构,它声明了一个考虑对齐的字符数组,那么它很简单新建的位置。
      【解决方案5】:

      请参阅The Fast Pimpl IdiomThe Joy of Pimpls,了解如何将固定分配器与 pimpl 习语一起使用。

      【讨论】:

      • 我认为编写一个固定分配器错过了“不使用动态内存”的全部意义。它可能不需要动态内存分配,但它需要动态内存管理,我认为这与全局覆盖 new 和 delete 没有什么不同。
      【解决方案6】:

      一种方法是在你的类中有一个 char[] 数组。使其足够大以适合您的 Impl,并在您的构造函数中,在您的数组中的适当位置实例化您的 Impl,并放置一个新位置:new (&amp;array[0]) Impl(...)

      您还应该确保您没有任何对齐问题,可能通过让您的 char[] 数组成为联合的成员。这个:

      union { char array[xxx]; int i; double d; char *p; };

      例如,将确保 array[0] 的对齐方式适用于 int、double 或指针。

      【讨论】:

      • +1:正在写一篇较长的文章,但基本上就是这样。您可以编写第二个项目,将 impl 类和工具的大小放入包含的类中,因此您无需手动跟踪每个更改。
      • 不确定工会的成员是否足以保证对齐
      • 这种方法要求我们在实现更改时保持 char 数组的大小(并且在某些地方可能会频繁更改)。此外,由于内存稀缺,我们无法为未来做大。
      • @erelender:虽然它可以作为一个简单的预处理任务来完成。在返回其大小的小型测试程序中编译定义“内部”类的文件,然后将该大小写入 pimpl 类定义。或者,@Matthieu M. 建议的静态断言可用于提醒您“预测的大小太小,因此除非选择有效大小,否则代码将无法编译。
      • union 技巧现在没有必要了,因为 std::aligned_storage 存在(它可能在内部使用它,但是呃,无论如何)。但这里一个更根本的问题是您如何说“将适用于 int、double 或指针”。对于指针,您的示例只能保证针对char* 指针 进行适当对齐。请记住,指向不同类型的指针不需要具有相同的大小(或表示等)
      【解决方案7】:

      pimpl 基于指针,您可以将它们设置到分配对象的任何位置。这也可以是在 cpp 文件中声明的对象的静态表。 pimpl 的主要目的是保持接口稳定并隐藏实现(及其使用的类型)。

      【讨论】:

      • 我认为这是我们案例的最佳方法,但我认为它不会像标准 pimpl 那样干净整洁。
      • 恕我直言,这种方法的唯一缺点是您必须提前/在编译时就该类型对象的最大数量达成一致。对于我能想到的所有其他方面,pimpl 的目标都达到了。
      • 必须提前决定对象的最大数量不是错误,而是一个特性。这是禁止动态内存分配的规则背后的主要理由之一。这样做,你永远不会耗尽内存。而且您永远不必担心碎片堆。
      • 好点 sbass 强调这一点,我的表述在这方面有点消极。 +1
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2017-03-29
      • 2019-04-28
      • 2018-01-13
      • 1970-01-01
      • 1970-01-01
      • 2013-11-20
      • 1970-01-01
      相关资源
      最近更新 更多