分享

Incomplete type与Foreward declaration

 fisher60 2013-03-06

Incomplete type与Foreward declaration

 

有时我们在编程时会遇到一些与类型不完整有关的编译器报错,此时我们往往只是简单的把它改成相应的完整类型定义,也没空去想为什么会报错,还有没有其他更好的解决方法;还有,很多人会一上来不管三七二十一把所有可以包含的头文件都包含一遍,确保编译通过。而很多时候,使用一个自定义类型,是不需要包含它的头文件的。所以,今天写篇文章来对这些做个总结。

Incomplete Type

不完整类型,包括那些类型信息尚不完整的对象类型(incompletely-defined object type)以及空类型(void)。空类型大家都知道,主要用在函数返回值以及空指针上,这里不再赘述。前者才是今天的研究重点。它是指大小(size)、内存布局(layout)、对齐方式(alignment requirements)都还未知的模糊类型。

下面列举一些常见的不完整类型:

  1. // main.cpp  
  2.   
  3. // 变量定义,因为数组大小未知,无法通过编译  
  4. char a[];  
  5. // 变量定义,因为类型A未定义,无法通过编译  
  6. A b;  
  7. // 变量定义,虽然大小确定,但类型A未定义,无法通过编译  
  8. A c[10];  
  9.   
  10. int main()  
  11. {}  

这些全局对象在定义的时候仅仅提供了不完整类型,它们的大小等信息编译器都无法获知,因此无法通过编译。那不完整类型有何用处呢?下面是一个小例子。

  1. // A.cpp  
  2. char a[10] = "123456789";  
  3.   
  4. // main.cpp  
  5. #include <iostream>  
  6. using namespace std;  
  7.   
  8. // 变量声明,并且是不完整类型  
  9. extern char a[];  
  10.   
  11. int main(){  
  12.   
  13. // 编译成功,打印出1到9  
  14. for (int i = 0; a[i] != '\0'; ++i)  
  15.     cout << a[i] << endl;  
  16.   
  17. // 以下编译失败  
  18. // cout << sizeof(a) << endl;  
  19. }  

在这里,我们发现:不完整类型可以用在声明上,而不可以出现在定义式中。因为声明并不需要知道对象的大小、内存布局等信息。此外,我们还发现:虽然声明不完整类型的对象没有任何问题,但此后进行什么操作决定了是否可以编译通过。打印数组元素那段,因为元素类型(char)已知,也不需要知道其大小(因为我们是根据结尾的NUL字符来判定是否结束的),所以编译没有任何问题。但是,如果需要打印数组大小,那就会编译失败,因为此时的a 还是不完整类型。

上面这个例子仅仅说明不完整类型的一个用法,并不太实用。因为其他的数组(比如整型数组)并不以特殊字符(如NUL)结尾。如果我们连数组的大小都不知道,操作它又有什么意义呢?所以,在实际项目中更多见的是使用完整类型来声明,如:

  1. extern char a[10];  

Forward Declaration

真正的应用出现在类的向前声明上。

  1. // A.h  
  2. class A  
  3. { ... }  
  4.   
  5. // B.h  
  6. // 向前声明  
  7. class A;  
  8.   
  9. class B  
  10. {  
  11. private:  
  12.     // 以下A都是不完整类型  
  13.     A *m_a;  
  14.     int calculate(const A &a1, const A &a2);  
  15. }  

不知道大家有没有发现,B.h并没有包含类A的头文件,但它可以通过编译,为什么?因为它不需要,在头文件中它只用到了指向A的指针和引用,C++标准规定定义这两个变量是不需要类A的完整信息的。那什么时候需要呢?在通过指针或引用去调用A的成员函数或对象时。

  1. // B.cpp  
  2. // 哈哈,这下我有了A的完整定义了,可以通过"->", ".", "::"调用它的成员了  
  3. #include "A.h"  
  4. #include "B.h"  
  5.   
  6. int B::calculate(const A &a1, const A &a2)  
  7. {  
  8.     return (a1.GetValue() * a2.GetValue());  
  9. }  
这里要说的是,通过"->",”."或者“::"调用类的任何成员,或者B继承A,都需要类的完整信息。可能有同学会疑问:为什么要这么麻烦,直接包含A的头文件不就行了?呵呵,感兴趣的同学可以看看这篇文章,这里留个悬念。

从信息隐藏的一个需求看C++接口与实现的分离

分类: C++设计与模式776人阅读评论(7)收藏举报
让我们从stackoverflow上一个同学的问题来开始。问题的原型是这样的(原问题见:class member privacy and headers in C++):

Portaljacker:“有一个类A, 有一些共有成员函数和私有数据,如下所示。”

  1. class A
  2. {
  3. public:
  4. X getX();
  5. Y getY();
  6. Z getZ();
  7. ..
  8. private:
  9. X god;
  10. Y damn;
  11. Z it;
  12. };

“可是我不想让使用这个类的使用者看到我的私有数据,应该怎么做?可能是因为担心别人嘲笑我给变量起的名字太难听!哎,可是这关他们什么事呢!我试过把这三个成员变量放进另一个头文件中,就像下面那样,可是编译器报错- - 我该怎么办?!”

  1. // A.h
  2. class A
  3. {
  4. public:
  5. X getX();
  6. Y getY();
  7. Z getZ();
  8. };
  9. // A.cpp
  10. class A
  11. {
  12. private:
  13. X god;
  14. Y damn;
  15. Z it;
  16. };

刚看到这个问题的时候,觉得它很幼稚。首先,C++一个类的定义必须包含该类所有的成员函数和变量,而不像一个名字空间里的不同函数那样可以自由分布在不同的源文件中。其次,这些私有成员即使对调用者可见,又怎么样?反正它们是私有的,用户怎么也不可能直接访问它们。

然而,我错了。

Portaljacker的这个需要实际上是很合情合理的。试想,调用者一般是这样使用类A的。

  1. // main.cpp
  2. #include "A.h"
  3. int main()
  4. {
  5. A a;
  6. X x = a.getX();
  7. Y y = a.getY();
  8. Z z = a.getZ();
  9. ..
  10. return 0;
  11. }

通常情况下调用者必须要包含A的定义所在的头文件才能顺利通过编译,也就是说建立了一个编译依赖关系:main.cpp -> A.h。这样,任何A.h文件中的变化都将导致main.cpp重新编译,即使改变的只是类A中的私有变量(比如名称改变)。这非常糟糕,因为一个类的私有数据属于它的实现细节(implementation details),理想情况下应该隐藏起来,它的变化对于调用者不可见。哦,不知道你是否曾经遇到过这样一个工程,里面有成百上千的源文件。你只是改变了一个小小的头文件,结果发现项目中的大多数文件都重新编译,几分钟都没有编译完。

其实Portaljacker提出了一个很好的问题。问题的关键在于如何把实现的细节隐藏起来。这样,调用者既不会看到任何类内部的实现,也不会因为实现的任何改变而被迫重新编译。

在讨论问题的解决方法之前,有必要回过头来看看为什么Portaljacker同学的方法行不通。他是把同一个类的共有成员和私有成员风的定义分别放到了两个同名类的定义中(见上)。

我听到了,你说肯定不行。没错,为什么呢?”因为类的定义不能分割开。。“ 好吧,可是为什么呢?”C++就是这样的,常识!“ 资深一些的程序员甚至会翻到C++标准的某一页说,”喏,这就是标准“。我们中的很多人(包括我),学习一门语言的时候都是书上(或者老师)说什么就是什么,只要掌握了正确使用就行,很少有人会去想一下这规则背后的原因是什么。

回到正题。C++之所以不允许分割类定义的一大原因就是编译期需要确定对象的大小。考虑上面的main函数,在类定义分割开的情况下,这段代码将无法编译。因为编译器在编译”A a"的时候需要知道对象a有多大,而这个信息是通过查看A的定义得来的。而此时类的私有成员并不在其中,编译器将无法确定a的大小。注意,Java中并不存在这样的问题,因为Java所有的对象默认都是引用,类似于C++中的指针,编译期并不需要知道对象的大小。

接口与实现的分离

好了,现在让我们回到需要解决的问题上:

  1. 不希望使用者可以看到类内部的实现(比如有多少个私有数据,它们是什么类型,名字是什么等等)。
  2. 除了接口,任何类的改变不应引起调用者的重新编译。

解决这些问题的方法就是恰当地将实现隐藏起来。为了完整性,我们来看看几个常见的接口与实现分离的技术,它们对于信息隐藏的支持力度是不一样的,也不是都能解决以上所有的问题。

一、使用私有成员

类的接口作为共有,所有的实现细节作为私有。这也是C++面向对象思想的精髓。通过将所有实现封装成私有,这样当类发生改变时,调用者不需要改变任何代码,除非类的公共接口发生了变化。然而,这样的分离只是最初步的,因为它可能会导致调用者重新编译,即使共有接口没有发生变化。

  1. #include "X.h"
  2. #include "Y.h"
  3. #include "Z.h"
  4. class A
  5. {
  6. // 接口部分公有
  7. public:
  8. X getX();
  9. Y getY();
  10. Z getZ();
  11. ..
  12. // 实现部分私有
  13. private:
  14. X god;
  15. Y damn;
  16. Z it;
  17. };

二、依赖对象的声明(declaration)而非定义(definition)

在前一种方法中,类A与X,Y,Z之间是紧耦合的关系。如果类A使用指针而非对象的话,类A并不需要包含X,Y,Z的定义,简单的向前声明(forward declaration)就可以

  1. // A.h
  2. class X;
  3. class Y;
  4. class Z;
  5. class A
  6. {
  7. public:
  8. X getX();
  9. Y getY();
  10. Z getZ();
  11. ..
  12. private:
  13. X* god;
  14. Y* damn;
  15. Z* it;
  16. };
这样,当X,Y或者Z发生变化的时候,A的调用者(main.cpp)不需要重新编译,这样可以有效阻止级联依赖的发生。在前一种方法中,若X改变,包含A.h的所有源文件都需要重新编译。注意,在声明一个函数的时候,即使函数的参数或者返回值中有传值拷贝,也不需要对应类的定义(上例中,不需要包含X,Y,Z的头文件)。只有当函数实现的时候才需要。

三、Pimpl模式

一个更好的方法是把一个类所有的实现细节都“代理”给另一个类来完成,而自己只负责提供接口。接口的实现则是通过调用Impl类的对应函数来实现。Scott Meyers称这是“真正意义上接口与实现的分离”。

  1. // AImpl.h
  2. class AImpl
  3. {
  4. public:
  5. X getX();
  6. Y getY();
  7. Z getZ();
  8. ..
  9. private:
  10. X x;
  11. Y y;
  12. Z z;
  13. };
  14. // A.h
  15. class X;
  16. class Y;
  17. class Z;
  18. class AImpl;
  19. class A
  20. {
  21. public:
  22. // 可能的实现: X getX() { return pImpl->getX(); }
  23. X getX()
  24. Y getY()
  25. Z getZ();
  26. ..
  27. private:
  28. std::tr1::shared_ptr<AImpl> pImpl;
  29. };
我们来看一下,这种方法能否满足我们的两个要求。首先,因为任何实现细节都是封装在AImpl类中,所以对于调用端来说是不可见的。其次,只要A的接口没有变化,调用端都不需要重新编译。很好!当然,天下没有免费的午餐,这种方法也是需要付出代价的。代价就是多了一个AImpl类需要维护,并且每次调用A的接口都将导致对于AImpl相应接口的间接调用。所以,遇到这样的问题,想一想,效率和数据的封装,哪个对于你的代码更重要。

四、Interface类

另一个能够同时满足两个需求的方法是使用接口类,也就是不包含私有数据的抽象类。调用端首先获得一个AConcrete对象的指针,然后通过接口指针A*来进行操作。这种方法的代价是可能会多一个VPTR,指向虚表

  1. // A.h
  2. class A
  3. {
  4. public:
  5. virtual ~A();
  6. virtual X getX() = 0;
  7. virtual Y getY() = 0;
  8. virtual Z getZ() = 0;
  9. ..
  10. };
  11. class AConcrete: public A
  12. { ... };

小结:

  1. 尽量依赖对象的声明而不是定义,这样的松耦合可以有效降低编译时的依赖。
  2. 能够完全隐藏类的实现,并减少编译依赖的两种方法:Pimpl、Interface。

除了这种应用,还有一个用途:解决类之间的循环依赖。

  1. // A.h  
  2. class Fred  
  3. {  
  4. public:  
  5.   Barney* foo();  // Error: 未知符号 'Barney'  
  6. };  
  7.   
  8. class Barney  
  9. {  
  10. public:  
  11.   Fred* bar();  
  12. };  
这里,无论哪个类放在前,都会引起编译出错。解决方法就是在最开始加上向前声明。

  1. class Barney;  
  2.   
  3. class Fred  
  4. {...};  
  5.   
  6. class Barney  
  7. {...};  
C++ FAQ里面又对它进行解释,感兴趣的同学可以看看。

Use a forward declaration.

Sometimes you must create two classes that use each other. This is called a circular dependency. For example:

class Fred {
public:
  Barney* foo();  // Error: Unknown symbol 'Barney'
};

class Barney {
public:
  Fred* bar();
};

The Fred class has a member function that returns a Barney*, and the Barney class has a member function that returns a Fred*. You may inform the compiler about the existence of a class or structure by using a "forward declaration":

class Barney;

This line must appear before the declaration of class Fred. It simply informs the compiler that the name Barney is a class, and further it is a promise to the compiler that you will eventually supply a complete definition of that class.

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多