【问题标题】:Cyclic include trick to hide implementation details in C++ header files循环包含技巧以隐藏 C++ 头文件中的实现细节
【发布时间】:2011-10-08 15:19:28
【问题描述】:

我正在尝试找到一种干净的方法来分离大型项目中 C++ 头文件中的实现细节,以实现更好的信息隐藏并减少构建时间。 C++ 的问题在于,每次更改私有成员声明时,都必须重新构建依赖类。

这是我想出的解决方案。好用吗?

基本思路是在头部有条件地包含cpp文件的一部分。这部分包含实现声明,仅当实现文件包含头文件时才包含。在外部类的情况下,此详细信息从标题中排除。所以客户端和实现看到两个不同版本的头文件。内部声明更改不会影响客户端(不编译依赖类),并且标头不会包含私有详细信息。

这里是实现:

标题

#pragma once

class Dependency
{
public:
    Dependency(void);
    ~Dependency(void);
    void Proc(void);

//PRIVATE Implementaion details stays private
#ifdef Dependency_PRIVATE_IMPELEMENTATION
    #define Dependency_PRIVATE_MODE 1   
        #include "Dependency.cpp"
    #undef Dependency_PRIVATE_MODE
#endif 
};

CPP

#define Dependency_PRIVATE_IMPELEMENTATION
#include "Dependency.h"
#undef Dependency_PRIVATE_IMPELEMENTATION

#ifdef Dependency_PRIVATE_MODE
private:
    int _privateData;
#else

#include <iostream>

Dependency::Dependency(void)
{
//This line causes a runtime exception, see client
    Dependency::_privateData = 0;
}

Dependency::~Dependency(void)
{
}

void Dependency::Proc(void)
{
    std::cout << "Shiny happy functions.";
}

#endif

客户

#include "stdafx.h"
#include "Dependency.h"

#pragma message("Test.Cpp Compiled")

int _tmain(int argc, _TCHAR* argv[])
{
    Dependency d;
    d.Proc();

    return 0;
//and how I have a run time check error #2, stack around d ?!!

}

【问题讨论】:

    标签: c++ include implementation


    【解决方案1】:

    这是一个非常有趣的问题,真的。管理依赖项对于大型项目很重要,因为构建时间的增加甚至会使最简单的更改令人生畏……当它发生时,人们会尝试破解它以避免死亡重建 (tm)。

    很遗憾,它不起作用。

    标准明确规定出现在不同翻译单元(大致为文件)中的类定义应遵守单一定义规则(参见§ 3.2 单一定义规则 [basic.def.odr] )。

    为什么?

    在某种程度上,问题是阻抗问题。类的定义包含关于类 ABI(应用程序二进制接口)的信息,最值得注意的是,此类类在内存中的布局方式。如果你在不同的翻译单元中对同一个类有不同的布局,那么放在一起是行不通的。就好像一个 TU 说德语而另一个说韩语。他们可能试图说同样的话,他们只是不会互相理解。

    那么?

    有几种方法可以管理依赖项。主要思想是你应该尽可能地努力提供“轻”的标题:

    • 包含尽可能少的内容。您可以转发声明:显示为参数或函数返回声明的类型,通过引用或指针传递但未使用的类型。
    • 隐藏实现细节

    嗯...这是什么意思:x?

    让我们举一个简单的例子,好吗?

    #include "project/a.hpp" // defines class A
    #include "project/b.hpp" // defines class B
    #include "project/c.hpp" // defines class C
    #include "project/d.hpp" // defines class D
    #include "project/e.hpp" // defines class E
    
    namespace project {
    
      class MyClass {
      public:
        explicit MyClass(D const& d): _a(d.a()), _b(d.b()), _c(d.c()) {}
        MyClass(A a, B& b, C* c): _a(a), _b(b), _c(c) {}
    
        E e() const;
    
      private:
        A _a;
        B& _b;
        C* _c;
      }; // class MyClass
    
    } // namespace project
    

    此标头包含 5 个其他标头,但实际上需要多少个标头?

    • a.hpp 是必需的,因为 _a 类型的 A 是类的属性
    • b.hpp 不是必须的,我们只有B 的引用
    • c.hpp 不是必须的,我们只有一个指向 C 的指针
    • d.hpp 是必要的,我们在 D 上调用方法
    • e.hpp 不是必须的,它只是作为返回出现

    好的,让我们清理一下吧!

    #include "project/a.hpp" // defines class A
    #include "project/d.hpp" // defines class D
    
    namespace project { class B; }
    namespace project { class C; }
    namespace project { class E; }
    
    namespace project {
    
      class MyClass {
      public:
        explicit MyClass(D const& d): _a(d.a()), _b(d.b()), _c(d.c()) {}
        MyClass(A a, B& b, C* c): _a(a), _b(b), _c(c) {}
    
        E e() const;
    
      private:
        A _a;
        B& _b;
        C* _c;
      }; // class MyClass
    
    } // namespace project
    

    我们可以做得更好吗?

    好吧,首先我们可以看到,我们只在类的构造函数中调用D上的方法,如果我们把D的定义移出头部,放到一个.cpp文件中,那么我们不再需要包含d.hpp

    // no need to illustrate right now ;)
    

    但是...A 呢?

    有可能“作弊”,即仅仅持有一个指针并不需要完整的定义。这被称为指向实现的成语(简称 pimpl)。它用运行时间来换取更轻的依赖关系,并为类增加了一些复杂性。这是一个演示:

    #include <memory> // don't really worry about std headers,
                      // they are pulled in at one time or another anyway
    
    namespace project { class A; }
    namespace project { class B; }
    namespace project { class C; }
    namespace project { class D; }
    namespace project { class E; }
    
    namespace project {
    
      class MyClass {
      public:
        explicit MyClass(D const& d);
        MyClass(A a, B& b, C* c);
        ~MyClass(); // required to be in the source file now
                    // because for deleting Impl,
                    // the std::unique_ptr needs its definition
    
        E e() const;
    
      private:
        struct Impl;
        std::unique_ptr<Impl> _impl;
      }; // class MyClass
    
    } // namespace project
    

    以及相应的源文件,因为发生了有趣的事情:

    #include "project/myClass.hpp" // good practice to have the header included first
                                   // as it asserts the header is free-standing
    
    #include "project/a.hpp"
    #include "project/b.hpp"
    #include "project/c.hpp"
    #include "project/d.hpp"
    #include "project/e.hpp"
    
    struct MyClass::Impl {
      Impl(A a, B& b, C* c): _a(a), _b(b), _c(c) {}
    
      A _a;
      B& _b;
      C* _c;
    };
    
    MyClass::MyClass(D const& d): _impl(new Impl(d.a(), d.b(), d.c())) {}
    MyClass::MyClass(A a, B& b, C* c): _impl(new Impl(a, b, c)) {}
    MyClass::~MyClass() {} // nothing to do here, it'll be automatic
    
    E MyClass::e() { /* ... */ }
    

    好的,这就是低调而坚韧的。延伸阅读:

    • Law of Demeter:避免顺序调用多个方法(a.b().c().d()),这意味着您有泄漏的抽象,并迫使您包含整个世界来做任何事情。相反,您应该致电 a.bcd(),它会向您隐藏详细信息。
    • 将您的代码分成多个模块,并为每个模块提供一个定义明确的接口,通常情况下,模块内的代码应该比其表面上的代码多得多(即暴露的标头)。

    有很多方法可以封装和隐藏信息,你的探索才刚刚开始!

    【讨论】:

      【解决方案2】:

      这不起作用。如果您在私有 .cpp 文件中向该类添加任何内容,该类的用户将看到与您的实现所认为的不同的类。

      这是不合法的,并且在很多情况下都不起作用。 KDE 有一篇很棒的文章,介绍了您可以在 C++ 中更改哪些内容以及不能更改哪些内容以保持 ABI 兼容性:Binary Compatibility Issues。如果你用你的“隐藏”实现破坏了其中的任何一个,你就会破坏用户。

      查看pimpl idiom,了解一种相当常见的方法来实现您想要实现的目标。

      【讨论】:

      • 您能否解释一下在什么情况下可能不起作用?什么是非法的?它编译了吗?:)
      • 我编辑了一个链接,其中包含相关信息。 “它编译”并不意味着它有效。如果您只将方法定义放在您的cpp 文件中,它将起作用,但它没有用(一个普通的 cpp 文件就是这样做的)。任何可以改变课堂布局的东西都是无效的。
      • 虽然 OP 的示例不起作用,但在 Dependency_PRIVATE_MODE 部分中应该起作用的是定义私有非虚拟方法、私有枚举和私有静态成员 (当然,只要这些都不被内联方法使用)。如果没有内联构造函数,我还认为您可以覆盖基类方法。一个有趣的快速原型设计技巧......
      【解决方案3】:

      这行不通。您可以很容易地看到它,因为实现和客户端的sizeof(Dependency) 是不同的。客户端基本上看到了不同的类,访问了内存中的不同位置,一切都搞砸了!

      不幸的是,如果您更改了一个类,您将无法阻止依赖文件的重建。但是,您可以像这样隐藏实现细节:

      标题:

      class privateData;
      
      class Dependency
      {
      private:
          privateData *pd;
      public:
          Dependency(void);
          ~Dependency(void);
          void Proc(void);
      };
      

      cpp 文件

      #include <Dependency.h>
      
      class privateData
      {
          /* your data here */
      };
      
      Dependency::Dependency()
      {
          pd = new privateData;
      }
      Dependency::~Dependency()
      {
          if (pd)
              delete pd;
      }
      void Dependency::Proc()
      {
          /* your code */
      }
      

      请注意,这不是供您复制粘贴的。这只是给你的想法。可能缺少此用法暗示的错误检查或代码。其中之一是防止浅拷贝的拷贝构造函数。

      【讨论】:

      • 您的 Pimpl 示例不正确。 1. 你真的应该使用智能指针(std::unique_ptr 最好,boost::scoped_ptr 如果你无法访问 C++11)。 . 2.问题是你违反了3的规则,你需要编写复制构造函数和赋值运算符。
      • 一旦我忘记发表免责声明:“这不是用于复制粘贴。这只是为了给你一个想法。可能会缺少错误检查或这种用法暗示的代码”,你就是再次用你的“为什么你的代码没有被错误检查和副作用凝结”来打击我。好的好的,我会编辑并放上去。
      • 接下来你知道,有人会过来说你的头文件为什么没有保护!
      • 这不是提供复制/粘贴示例的问题,只是如果您显示一个示例,您还不如将它完整显示,否则,有什么意义?
      • @MatthieuM。关键是,如果你给出大纲,他们必须思考、理解和学习。如果他们复制,他们就会出错,必须调试,并且首先要学会不要从互联网上复制粘贴代码并真正了解发生了什么。如果你给他们复制粘贴的代码,每一个细节都解决了,他们就不会学习。想出一个完美的解决方案可以炫耀一下,这只是表明他们在教学方面是多么缺乏经验。
      【解决方案4】:

      看看Opaque_pointer pattern(又名pImpl)

      该模式通常用于当类想要隐藏内部实现时,但也有这样的好处,即对内部和私有结构的更改不会创建重新编译,因为二进制调用兼容性得到了维护。

      任何其他方式的问题在于,当您更改类定义中的任何内容时,可能无法保持二进制兼容性,因此所有软件都必须重新编译。

      看起来您的解决方案试图做到这一点,但是您应该使用 (void*) 而不是 int 以确保软件在不同平台上的 32 位和 64 位编译器上正确编译——并且只需使用不透明指针的食谱示例即可。

      【讨论】:

      • 如果cpp文件被修改,客户端类将不会在我的测试中编译。
      • 头文件在所有情况下都需要以相同的方式编译,否则最终会出现二进制兼容性问题。也可以在 stackover flow 上搜索“pimpl”和“Opaque pointer”,你会发现很多关于这个主题的讨论。
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2013-08-09
      • 1970-01-01
      • 2010-10-10
      相关资源
      最近更新 更多