最近很累,不是说面试累(没几次面试),而是时差混乱,到了第二天凌晨5,6点钟才有睡意,在新西兰,应该属于当地时间早上9,10点钟,我现在都不知道我是什么时区的作息了。真怀念新西兰的交通,虽然bus少了点,但走人行横道的时候我可以大胆的走过去,司机会主动停下来,还会朝你微笑,在深圳,我这么干,司机会鸣喇叭冲过来,然后白眼对我示意我居然不怕死,我终究还是怕死的人,人行横道,还是让给汽车吧。
以前在杂志上看,说为什么中国人这么没秩序,因为是人多。虽然新西兰人口很少,但在奥克兰city下班高峰期间,city的bus站的人数和北京中关村大街bus站人数一样多,但新西兰人上bus100%会排队,哪怕没座位。中国人对资源的争抢已经渗透到骨子里。
以上算是近期的牢骚,开始说正题。
最近遇到了一个话题,就是编译与不编译的问题。场景是这样的:现有A和B两个类,各分为h和cpp,B类包含了A的对象或者指针,那么以下情况,哪些时候B类是不用重新编译的?
1、 在A类的头文件空白处敲个空格;
2、 在A类的头文件增加一个非虚函数;
3、 在A类的头文件增加一个虚函数;
4、 在A类的头文件增加一个成员数据;
5、 在A类的头文件增加一个全局数据。
起初我一直觉得这个应该是一个成熟且对编译器行为了解的C++程序员都应该知道答案的话题,但不经意间发现,一些有多年C++经验的人都没有理解这个问题,可能也包括我也没理解。说实话,我们往往太过于依赖复杂工程项目的经验,想当然的觉得一些事情就是那样或这样,但复杂的工程往往把本质遮挡了,这个问题就是如此。
以下都是我的个人理解,其实《程序员自身修养》一书说得比我详细一万倍(甚至更多),有兴趣去看看。本人比较懒,就不翻那本书作为参考了,观点有错误就当是笑话(本人不是高手,出错在所难免,照惯例,不反驳但会学习反对者或嘲笑者的观点)。
尽管这不是什么好玩的算法或数据结构(本人爱好),但研究这个问题其实还是有点点用的,通过理解这个问题,我们会知道耦合度到底是怎么产生的,如何降低耦合度。
Ok,开始!
l 先说一些前置知识
要想知道B要不要重新编译,首先要知道编译器是做什么的?编译器其实就是做一件事情——生成执行二进制代码,二进制太难理解的话,可以想象成汇编代码。细分一下这件事情,编译器就是在做:
1、 给数据确定使用空间;
2、 确定具体的逻辑代码。
也就是说,一个类(注意是类,不是cpp文件,但一般一个cpp就是实现一个类)需要编译或者重新编译是因为编译器需要做这两件事情。
编译器每生成一份这样的二进制代码,就对应生成一个obj文件(Linux下是.a文件,以下统称obj文件,场景放在Windows,因为当时我们讨论的是Windows操作系统下的C++编程,顺便再说一下,以下场景是Visual C++ 2008测试的),然后连接各个obj文件,生成一份可以执行的PE文件(DLL,exe或者sys)。Lib文件或者DLL文件也可以用作连接(静态和动态链接),这个时候可以把它们看成是obj文件的集合。
但不管是何种生成PE文件的方式,编译器都是在做那两件事情,需不需要重新编译,就看这两件事情是不是需要重新做。
说到这,已经可以明确一个已经被很多C++程序员误解的事情了——如果文件被修改那么就必须重新编译。
真的如此吗?表面似乎如此,但本质是什么?我们先做一个实验:
A类如下:
A.h文件:
#pragma once
class CA
{
public:
CA(void);
~CA(void);
void f();
};
A.cpp文件:
#include "A.h"
#include <iostream>
CA::CA(void)
{
}
CA::~CA(void)
{
}
// 写复杂点,防止被优化成inline
void CA::f()
{
int iSum = 0;
for (int i = 0; i < 100; ++i)
{
iSum+= i;
}
std::cout << iSum;
}
编译之,然后在A.cpp文件随意敲个空格,需要重新编译吗?当然需要,接着,在A.h随意敲个空格?需要重新编译吗?需要。
尽管是一次很明显简单的测试,但却告诉我们:
编译器发现文件被修改后,会执行一次生成二进制代码的过程,也就是去重新编译(看过侯捷的《深入浅出MFC》都应该记得侯捷说编译器决定是否编译就是看文件最后修改时间)。不过,现在我们开始加入B类,有趣的现象开始发生:
B.h文件
#pragma once
#include "A.h"
class CB
{
CA a;
public:
CB(void);
~CB(void);
void f();
};
B.cpp文件:
#include "B.h"
CB::CB(void)
{
}
CB::~CB(void)
{
}
void CB::f()
{
a.f();
}
Ok,先编译一次,然后,在A类的头文件处敲个空格,结果是什么?B类没有重新编译,他被skip掉了。神奇的是,若在A类头文件敲个回车,B类却重新编译了,这一点,我觉得应该是编译器编译优化没有做很好的缘故,应该不会影响本文结论。
敲空格,B类没有重新编译的缘故,源至于A类的布局没有发生改变,也就是B类的布局也没发生改变。这个时候编译器一不用重新确定B类的使用空间,二不用重新确定的B类的逻辑代码,理所当然不用重新编译。
那么,给A增加一个新的非虚成员函数呢?按上面提到的原则猜一猜B类需要重新编译吗?嗯,增加一个非虚函数,A类布局改变了吗?没有,因为增加一个非虚的成员函数,其实就相当于增加一个全局函数,只是和非虚的成员函数相比,它多了一个A类指针作为函数参数而已,这并不影响A类布局。B包含了A类的对象,由于A类布局未发生改变,B类布局当然不会改变,那么我猜,B类不用重新编译,这时的A类代码如下:
A.h文件:
#pragma once
class CA
{
public:
CA(void);
~CA(void);
void f();
void ff();
};
A.cpp文件
#include "A.h"
#include <iostream>
CA::CA(void)
{
}
CA::~CA(void)
{
}
// 写复杂点,防止被优化成inline
void CA::f()
{
int iSum = 0;
for (int i = 0; i < 100; ++i)
{
iSum+= i;
}
std::cout << iSum;
}
// 写复杂点,防止被优化成inline
void CA::ff()
{
int iSum = 0;
for (int i = 0; i < 100; ++i)
{
iSum+= i;
}
std::cout << iSum;
}
其中的ff()就是增加的函数。Ok,验证一下,是否如此:
果然,B类的cpp文件被跳过了,看来编译器还是挺聪明的。B类重新编译是多余的。那么继续,如果给A类增加的是一个虚函数呢?hmm,这样A就多了一个虚函数表的指针,A类的布局发生改变,看来B是需要重新编译的,这是A类代码如下:
A.h文件:
#pragma once
class CA
{
public:
CA(void);
~CA(void);
void f();
virtual void ff();
};
A.cpp文件和之前展示的一样,不写了。Ok试试猜对了吗?
没错,B这回重新编译了。
更深入点,我再增加一个虚函数呢?重新编译吗?!hmm,原来已经有一个虚函数表指针了,那么再增加一个虚函数,A类布局没变,那么B类应该不用重新编译。这时A类代码如下:
A类头文件:
#pragma once
class CA
{
public:
CA(void);
~CA(void);
void f();
virtual void ff();
virtual void fff();
};
A类cpp文件:
#include "A.h"
#include <iostream>
CA::CA(void)
{
}
CA::~CA(void)
{
}
// 写复杂点,防止被优化成inline
void CA::f()
{
int iSum = 0;
for (int i = 0; i < 100; ++i)
{
iSum+= i;
}
std::cout << iSum;
}
// 写复杂点,防止被优化成inline
void CA::ff()
{
int iSum = 0;
for (int i = 0; i < 100; ++i)
{
iSum+= i;
}
std::cout << iSum;
}
// 写复杂点,防止被优化成inline
void CA::fff()
{
int iSum = 0;
for (int i = 0; i < 100; ++i)
{
iSum+= i;
}
std::cout << iSum;
}
编译,发现B果然没有被重新编译:
嗯,猜对了,不过可别高兴太早,我之所以拿这个做例子来说,就是因为我们很可能遗漏掉一个很重要的信息,这个重要的信息在这个例子中没有被体现出来,以至于我们可能想当然的认为A类布局没变,因此B不受影响,B不会重新编译。
这个逻辑是对的,但我们忽略了一个事情,就是B类没有调用A类的虚函数!!!!!!
B类调用A类的虚函数会怎么样?嗯,布局没变,虚函数表指针还是那个虚函数表指针,没问题呀,我们再重温编译器在生成二进制代码的时候做了哪两件事情(主要):
1、 给数据确定使用空间;
2、 确定具体的逻辑代码。
对于第一点,的确没变,第二点呢?变了!尝试修改B类cpp文件,让他调用一个A的虚函数,编译,然后再给A类增加第二个虚函数,B类仍然调用原来A类的虚函数,也就是B类cpp文件不修改,但B重新编译了!
这时的A类h文件是:
#pragma once
class CA
{
public:
CA(void);
~CA(void);
void f();
virtual void ff();
virtual void fff();
};
其cpp是:
#include "A.h"
#include <iostream>
CA::CA(void)
{
}
CA::~CA(void)
{
}
// 写复杂点,防止被优化成inline
void CA::f()
{
int iSum = 0;
for (int i = 0; i < 100; ++i)
{
iSum+= i;
}
std::cout << iSum;
}
// 写复杂点,防止被优化成inline
void CA::ff()
{
int iSum = 0;
for (int i = 0; i < 100; ++i)
{
iSum+= i;
}
std::cout << iSum;
}
// 写复杂点,防止被优化成inline
void CA::fff()
{
int iSum = 0;
for (int i = 0; i < 100; ++i)
{
iSum+= i;
}
std::cout << iSum;
}
而B类修改成调用A类的虚函数:
void CB::f()
{
a.ff();
}
为什么?对比给A类增加多一个虚函数前后代码中,虚函数表指针的值:
增加新的虚函数前:
增加后:
嗯,没错,虚函数表的指针的值被改变了。这个值是在编译器确定的,它是一个相对偏移地址,由于这个值是编译期间确定,因此,当A类增加新的虚函数的时候,尽管B类布局没有变化(还是包含A类,而A类还是只有一个虚函数表指针,只是这个指针的值被修改),但B类的逻辑代码被修改了,它需要重新编译。
说到这里,根据以上各种结论,有多少C++程序员是在简单的认为头文件被修改,谁包含这个头文件谁就必须被重新编译的?这么想的程序员,多半被复杂的工程所迷惑,把本质问题覆盖掉了,因为我们写函数的时候,多数写的或修改的地方是虚函数,天真的认为之所以其他类需要重新编译是因为它们包含了被修改的头文件。其实不然,修改头文件,未必会影响相关联的类,未必会使其他类需要重新编译,即使重新编译,也未必是因为头文件被修改,而可能是因为我们修改了虚函数表指针的值!
关于增加虚函数和非虚函数的内容,貌似我们讨论得已经够多了,那么增加一个成员数据呢?其实前面已经讨论过了,当我们向一个之前没有虚函数的类A增加一个虚函数时,就偷偷的改变了A类的布局,因此,这个时候B是需要重新编译的。
Ok,我们开始切换成B包含A类指针的情况,这个时候B变成:
B的头文件:
#pragma once
#include "A.h"
class CB
{
CA a;
CA aa;
public:
CB(void);
~CB(void);
void f();
};
B的cpp文件:
#include "B.h"
CB::CB(void)
{
}
CB::~CB(void)
{
}
void CB::f()
{
}
注意到这个时候B中没有调用任何A类函数,仅仅是包含了A类指针。Ok,用上面的想法猜一猜,我们进行各种修改A类的时候B类需要重新编译吗?
1、 不管A类如何修改,B类始终是包含了A类的指针,其布局不会改变;
2、 由于B类没有调用A类的任何函数,其逻辑代码不会改变;
嗯,那么我猜B类不会被重新编译,不管对A类做任何修改:
Ok,那么,如果我们在B类中初始化A类指针,让它指向A类对象呢?重复做以上测试,和原来相比有什么不同?首先在B构造函数中增加一个初始化A类指针的代码:
CB::CB(void)
{
a = new CA;
}
一下直接给出测试结果:
1、 增加一个函数——B不重新编译;
2、 增加一个虚函数——B重新编译;
3、 增加一个虚函数后再增加一个虚函数——B不调用虚函数时,B不重新编译,而B调用虚函数时B重新编译;
4、 增加一个成员数据——B重新编译;
这些结论,其实和前面B包含A类对象时是一样的,实际原理也一样。
趁热打铁,我们怎么做才可以让B类尽量少的重新编译呢?B越少重新编译,耦合度就越低,这正是我们写代码追求的。
再次重温B什么情况下重新编译:
1、 给数据确定使用空间;
2、 确定具体的逻辑代码。
只要我们不让编译器有机会重新做这两件事情,我们就可以避免重编B。在面向对象编程中,之所以使用接口和尽量避免修改接口,就是为了避免:
1、 使用了接口,类包含了指针,使得类的布局空间总是不变;
2、 尽量避免修改接口,虚函数表指针不会被重写,使得类的逻辑代码中调用虚函数的地方不需要重新更新。
因此,为了降低耦合,我们多数使用指针的方法,面向对象编程就是面向接口编程。但上面的例子看到,B类包含了A类指针,但其结果仍然和包含了A类对象一样,这是因为我们做得不够彻底,在B类的cpp文件中访问了A类,因此,我们才有类似于工厂模式等之类的模式,将这种生成代码放到一个集中修改的地方,从而降低A和B之间的耦合度。
但话说回来,抛开这些不谈,其实这种设计方式并不完美(但也算是一种解决方案吧,也许适合Java这种语言),最近我看了陈硕的《Linux多线程服务端编程》一书,也许里面的设计方案思想才是最好的,但本人还在研读当中,不敢说太多。
最后,若是在A类增加定义一个全局数据呢?B类需要重新编译吗?B类不需要重新编译(没必要再去解释了),但你会看到B.cpp的确被重新编译了,其实编译器编译的不是B类,而是给B.obj放一个全局数据,这个时候,就相当于A.obj和B.obj都有相同的全局数据,编译是编译了,只是连接失败而已(连接又是一个大话题啊,不知道会不会又倒一片C++程序员)。
就解释这么多了,我一直觉得,很多程序员对编译器这种行为的想法和我一样,但这几天通过交流,发现大家想法都不一样,太多程序员固守头文件被修改,相关联类就必须被重新编译这个错误的结论了。
说完,post上CSDN,然后看书,睡觉去,已经凌晨4点45分了我还很有精神……