4

我正在寻找一种简单的方法来减少 C++ 项目中的标头耦合,这主要是由于(过度使用)类组合,这当然需要完整的类型。例如:

// header A
class A
{
  B b; // requires header B
};

我也考虑过接口和 pimpl,但两者都暗示了一些我不想手动编写/支持的样板代码(或者有没有办法让它自动化?)。

所以我考虑用指针和前向替换 member class B* pB;,但这需要处理对象的创建和删除。好的,我可以使用智能指针进行删除(auto_ptr虽然不是因为它在创建时需要完整的类型,所以说类似shared_ptr<class B> pB;),但是现在如何创建对象呢?

我可以在A的构造函数中创建对象,就像pB = new B;但这又是手动的,更糟糕​​的是,可能有几个构造函数......所以我正在寻找一种自动执行此操作的方法,它的工作原理很简单就像更改B b;autoobjptr<class B> pB;inA的定义而不必为pB实例化而烦恼。

我很确定这不是一个新想法,所以也许你可以给我一个通用解决方案或讨论的参考?

更新:为了澄清,我不是试图打破 and 之间的依赖关系AB但我想避免B在一个包含 ' 的标题时包含A' 。在实践中,B用于实现A,因此典型的解决方案是创建一个接口或 pimpl ,A但我目前正在寻找更容易的东西。

UPDATE2:我突然意识到,当结合虚拟析构函数(允许不完整的类型)时,像这里提出的惰性指针可以解决问题(太糟糕了,说 boost 中没有标准实现)。我仍然不明白为什么没有标准解决方案,并且感觉就像重新发明轮子......

UPDATE3:突然,谢尔盖·塔切诺夫提出了一个非常简单的解决方案(接受的答案),虽然我花了半个小时才明白为什么它真的有效......如果你删除 A() 构造函数或在头文件中内联定义它,魔法不再起作用(编译错误)。我猜当你定义一个显式的非内联构造函数时,成员的构造(甚至是隐式的)是在类型完整的同一个编译单元(A.cpp)内B完成的。另一方面,如果您的A构造函数是内联的,则成员的创建必须在其他编译单元内进行,并且不会因为B那里不完整而工作。嗯,这是合乎逻辑的,但现在我很好奇——这种行为是由 C++ 标准定义的吗?

UPDATE4:希望是最终更新。有关上述问题的讨论,请参阅已接受的答案和评论。

4

5 回答 5

3

起初我对这个问题很感兴趣,因为它看起来非常棘手,而且所有关于模板、依赖项和包含的评论都是有意义的。但后来我尝试实际实现这一点,发现它非常容易。所以要么我误解了这个问题,要么这个问题有一些特殊的属性,看起来比它实际上更难。无论如何,这是我的代码。

这是美化的 autoptr.h:

#ifndef TESTPQ_AUTOPTR_H
#define TESTPQ_AUTOPTR_H

template<class T> class AutoPtr {
  private:
    T *p;
  public:
    AutoPtr() {p = new T();}
    ~AutoPtr() {delete p;}
    T *operator->() {return p;}
};

#endif // TESTPQ_AUTOPTR_H

看起来很简单,我想知道它是否真的有效,所以我为它做了一个测试用例。这是我的 bh:

#ifndef TESTPQ_B_H
#define TESTPQ_B_H

class B {
  public:
    B();
    ~B();
    void doSomething();
};

#endif // TESTPQ_B_H

和 b.cpp:

#include <stdio.h>
#include "b.h"

B::B()
{
  printf("B::B()\n");
}

B::~B()
{
  printf("B::~B()\n");
}

void B::doSomething()
{
  printf("B does something!\n");
}

现在对于实际使用它的 A 类。这里啊:

#ifndef TESTPQ_A_H
#define TESTPQ_A_H

#include "autoptr.h"

class B;

class A {
  private:
    AutoPtr<B> b;
  public:
    A();
    ~A();
    void doB();
};

#endif // TESTPQ_A_H

和 a.cpp:

#include <stdio.h>
#include "a.h"
#include "b.h"

A::A()
{
  printf("A::A()\n");
}

A::~A()
{
  printf("A::~A()\n");
}

void A::doB()
{
  b->doSomething();
}

好的,最后是使用 A 但不包含“bh”的 main.cpp:

#include "a.h"

int main()
{
  A a;
  a.doB();
}

现在它实际上编译时没有任何错误或警告并且可以正常工作:

d:\alqualos\pr\testpq>g++ -c -W -Wall b.cpp
d:\alqualos\pr\testpq>g++ -c -W -Wall a.cpp
d:\alqualos\pr\testpq>g++ -c -W -Wall main.cpp
d:\alqualos\pr\testpq>g++ -o a a.o b.o main.o
d:\alqualos\pr\testpq>a
B::B()
A::A()
B does something!
A::~A()
B::~B()

这能解决你的问题还是我在做一些完全不同的事情?

编辑1:它是标准的还是不标准的?

好吧,这似乎是正确的,但现在它引导我们提出其他有趣的问题。这是我们在下面的评论中讨论的结果。

在上面的例子中会发生什么?ah 文件不需要 bh 文件,因为它实际上并没有对 做任何事情b,它只是声明它,并且它知道它的大小,因为 AutoPtr 类中的指针总是相同的大小。autoptr.h 中唯一需要定义 B 的部分是构造函数和析构函数,但它们没有在 ah 中使用,因此 ah 不需要包含 bh

但是为什么 ah 不使用 B 的构造函数呢?每当我们创建 A 的实例时,B 的字段不是都初始化了吗?如果是这样,编译器可能会尝试在 A 的每个实例化时内联此代码,但随后它将失败。在上面的示例中,看起来B::B()调用是放在A::A()a.cpp 单元中已编译构造函数的开头,但标准是否需要它?

起初,似乎没有什么能阻止编译器在创建瞬间时内联字段初始化代码,所以A a;变成了这个伪代码(当然不是真正的 C++):

A a;
a.b->B();
a.A();

这样的编译器可以按照标准存在吗?答案是否定的,他们不能,标准与此无关。当编译器编译“main.cpp”单元时,它不知道 A::A() 构造函数做了什么。它可能会为 调用一些特殊的构造函数,因此在使用不同的构造函数进行两次初始化b之前内联默认构造函数!b并且编译器无法检查它,因为A::A()定义的“a.cpp”单元是单独编译的。

好的,现在你可能会想,如果一个智能编译器想要查看 B 的定义,并且除了默认的构造函数之外没有其他构造函数,那么它不会B::B()在构造函数中放置任何调用,A::A()而是在调用时内联它A::A()。好吧,这也不会发生,因为编译器无法保证即使 B 现在没有任何其他构造函数,将来也不会有任何其他构造函数。假设我们将这个添加到 B 类定义中的 bh 中:

B(int b);

然后我们把它的定义放到b.cpp中,并相应地修改a.cpp:

A::A():
  b(17) // magic number
{
  printf("A::A()\n");
}

现在当我们重新编译 a.cpp 和 b.cpp 时,即使我们不重新编译 main.cpp,它也会按预期工作。这就是所谓的二进制兼容性,编译器不应该破坏它。但如果它内联B::B()调用,我们最终会得到调用两个B构造函数的 main.cpp。但是由于添加构造函数和非虚拟方法不应该破坏二进制兼容性,任何合理的编译器都不应该被允许这样做。

此类编译器不存在的最后一个原因是它实际上没有任何意义。即使成员初始化是内联的,它只会增加代码大小并且绝对不会提高性能,因为仍然需要一个方法调用,A::A()那么为什么不让这个方法在一个地方完成所有工作呢?

编辑 2:好的,A 的内联和自动生成的构造函数呢?

A:A()出现的另一个问题是,如果我们同时从 ah 和 a.cpp中删除会发生什么?这是发生的事情:

d:\alqualos\pr\testpq>g++ -c -W -Wall a.cpp
d:\alqualos\pr\testpq>g++ -c -W -Wall main.cpp
In file included from a.h:4:0,
                 from main.cpp:1:
autoptr.h: In constructor 'AutoPtr<T>::AutoPtr() [with T = B]':
a.h:8:9:   instantiated from here
autoptr.h:8:16: error: invalid use of incomplete type 'struct B'
a.h:6:7: error: forward declaration of 'struct B'
autoptr.h: In destructor 'AutoPtr<T>::~AutoPtr() [with T = B]':
a.h:8:9:   instantiated from here
autoptr.h:9:17: warning: possible problem detected in invocation of delete 
operator:
autoptr.h:9:17: warning: invalid use of incomplete type 'struct B'
a.h:6:7: warning: forward declaration of 'struct B'
autoptr.h:9:17: note: neither the destructor nor the class-specific operator 
delete will be called, even if they are declared when the class is defined.

唯一相关的错误消息是“无效使用不完整类型'struct B'”。基本上这意味着 main.cpp 现在需要包含 bh,但为什么呢?因为在我们实例化的时候自动生成的构造函数是内联的a在 main.cpp 中。好的,但这总是必须发生还是取决于编译器?答案是它不能依赖于编译器。没有编译器可以使自动生成的构造函数非内联。原因是它不知道将代码放在哪里。从程序员的角度来看,答案很明显:构造函数应该放在定义类的所有其他方法的单元中,但编译器不知道是哪个单元。此外,类方法可以分布在多个单元中,有时甚至是有意义的(例如,如果类的一部分是由某些工具自动生成的)。

当然,如果我们A::A()通过使用 inline 关键字或将其定义放在 A 类声明中来显式地内联,则会发生相同的编译错误,可能会不那么神秘。

结论

将上述技术用于自动实例化指针似乎完全没问题。我唯一不确定的是AutoPtr<B> b;ah 里面的东西可以与任何编译器一起使用。我的意思是,我们可以在声明指针和引用时使用前向删除类,但是将它用作模板实例化参数总是正确的吗?我认为这没有错,但编译器可能会不这么认为。谷歌搜索也没有产生任何有用的结果。

于 2010-12-15T18:21:35.070 回答
1

我很确定这可以以与实现相同的方式unique_ptr实现。不同之处在于allocated_unique_ptr构造函数会默认分配 B 对象。

但是请注意,如果您想要自动构造 B 对象,它将使用默认构造函数进行实例化。

于 2010-12-15T15:41:03.003 回答
0

好吧,您自己给出了最好的解决方案,使用指针,并new在构造函数中使用它们......如果有多个构造函数,请在此处重复该代码。您可以创建一个为您执行此操作的基类,但这只会使实现变得神秘......

你有没有想过一个模板class B?这也可以解决您的标头交叉依赖关系,但很可能会增加您的编译时间......这让我们想到了您试图避免这些#includes 的原因。你测量过编译时间吗?是不是很麻烦?这是问题吗?

更新:模板方式的示例:

// A.h
template<class T>
class A
{
public:
    A(): p_t( new T ) {}
    virtual ~A() { delete p_t }
private:
    T* p_t;
};

同样,这很可能不会增加编译时间(B.h需要拉入以创建模板实例A<B>),但它确实允许您删除 A 头文件和源文件中的包含。

于 2010-12-15T15:33:07.160 回答
0

你可以编写一个自动的pimpl_ptr<T>,它会自动新建、删除和复制包含的 T。

于 2010-12-15T15:36:37.620 回答
0

您的方法的一个问题是,虽然您可以避免以这种方式包含 B 的头文件,但它并没有真正减少依赖关系。

减少依赖关系的更好方法是让 B 从在单独的头文件中声明的基类派生,并在 A 中使用指向该基类的指针。您仍然需要在 A 的构造函数中手动创建正确的后代 (B) , 当然。

A 和 B 之间的依赖关系也很可能是真实的,在这种情况下,通过人为地避免包含 B 的头文件,您没有任何改进。

于 2010-12-15T15:37:25.487 回答