解析C语⾔与C++的编译模型
⾸先简要介绍⼀下C的编译模型:
限于当时的硬件条件,C编译器不能够在内存⾥⼀次性地装载所有程序代码,⽽需要将代码分为多个源⽂件,并且分别编译。并且由于内存限制,编译器本⾝也不能太⼤,因此需要分为多个可执⾏⽂件,进⾏分阶段的编译。在早期⼀共包括7个可执⾏⽂件:cc(调⽤其它可执⾏⽂件),cpp(预处理器),c0(⽣成中间⽂件),c1(⽣成汇编⽂件),c2(优化,可选),as(汇编器,⽣成⽬标⽂件),ld(链接器)。
1. 隐式函数声明
为了在减少内存使⽤的情况下实现分离编译,C语⾔还⽀持”隐式函数声明”,即代码在使⽤前⽂未定义的函数时,编译器不会检查函数原型,编译器假定该函数存在并且被正确调⽤,还假定该函数返回int,并且为该函数⽣成汇编代码。此时唯⼀不确定的,只是该函数的函数地址。这由链接器来完成。如:
int main()
{
printf("ok\n");
return 0;
}
在gcc上会给出隐式函数声明的警告,但能编译运⾏通过。因为在链接时,链接器在libc中到了printf符号的定义,并将其地址填到编译阶段留下的空⽩中。PS:⽤g++编译则会⽣成错误:use of undeclared identifier 'printf'。⽽如果使⽤的是未经定义的函数,如上⾯的printf函数改为print,得到的将是链接错误,⽽不是编译错误。
2. 头⽂件
有了隐式函数声明,编译器在编译时应该就不需要头⽂件了,编译器可以按函数调⽤时的代码⽣成汇编代码,并且假定函数返回int。⽽C头⽂件的最初⽬的是⽤于⽅便⽂件之间共享数据结构定义,外部变量,常量宏。早期的头⽂件⾥,也只包含这三样东西。注意,没有提到函数声明。
⽽如今在引⼊将函数声明放⼊头⽂件这⼀做法后,带来了哪些便利和缺陷:
优点:
项⽬不同的⽂件之间共享接⼝。
头⽂件为第三⽅库提供了接⼝说明。
缺点:
效率性:为了使⽤⼀个简单的库函数,编译器可能要parse成千上万⾏预处理之后的头⽂件源码。
传递性:头⽂件具有传递性。在头⽂件传递链中任⼀头⽂件变动,都将导致包含该头⽂件的所有源⽂件重新编译。哪怕改动⽆关紧要(没有源⽂件使⽤被改动的接⼝)。
差异性:头⽂件在编译时使⽤,动态库在运⾏时使⽤,⼆者有可能因为版本不⼀致造成⼆进制兼容问题。
printf函数是什么意思
⼀致性:头⽂件函数声明和源⽂件函数实现的参数名⽆需⼀致。这将可能导致函数声明的意思,和函数具体实现不⼀致。如声明为 void draw(int height, int width) 实现为 void draw(int width, int height)。
3. 单遍编译( One Pass )
由于当时的编译器并不能将整个源⽂件的语法树保存在内存中,因此编译器实际上是”单遍编译”。即编译器从头到尾地编译源⽂件,⼀边解析,⼀边即刻⽣成⽬标代码,在单遍编译时,编译器只能看到已经解析过的部分。意味着:
C语⾔结构体需要先定义,才能访问。因为编译器需要知道结构体定义,才知道结构体成员类型和偏移量,并⽣成⽬标代码。局部变量必须先定义,再使⽤。编译器需要知道局部变量的类型和在栈中的位置。
外部变量(全局变量),编译器只需要知道它的类型和名字,不需要知道它的地址,就能⽣成⽬标代码。⽽外部变量的地址将留给连接器去填。
对于函数,根据隐式函数声明,编译器可以⽴即⽣成⽬标代码,并假定函数返回int,留下空⽩函数地址交给连接器去填。
C语⾔早期的头⽂件就是⽤来提供结构体定义和外部变量声明的,⽽外部符号(函数或外部变量)的决议则交给链接器去做。
单遍编译结合隐式函数声明,将引出⼀个有趣的例⼦:
void bar()
{
foo('a');
}
int foo(char a)
{
printf("foobar\n");
return 0;
}
int main()
{
bar();
return 0;
}
test.c:16:6: error: conflicting types for 'foo'
void foo(char a)
^
test.c:12:2: note: previous implicit declaration is here
foo('a');
这是因为当编译器在bar()中遇到foo调⽤时,编译器并不能看到后⾯近在咫尺的foo函数定义。它只能根据隐式函数声明,⽣成int foo(int)的函数调⽤代码,注意隐式⽣成的函数参数为int⽽不是char,这应该是编译器做的⼀个向上转换,向int靠齐。在编译器解析到更为适合的int foo(char)时,它可不会认错,它会认为foo定义和编译器隐式⽣成的foo声明不⼀致,得到编译错误。将上⾯的foo函数替换为 void foo(int a)也会得到类似的编译错误,C语⾔严格要求⼀个符号只能有⼀种定义,包括函数返回值也要⼀致。
⽽将foo定义放于bar之前,就编译运⾏OK了。
C++ 编译模型到⽬前为⽌,我们提到的3点关于C编译模型的特性,对C语⾔来说,都是利多于弊的,因为C语⾔⾜够简单。⽽当C++试图兼容这些特性时(C++没有隐式函数声明),加之C++本⾝独有的重载,类,模板等特性,使得C++更加难以理解。
1. 单遍编译
C++没有隐式函数声明,但它仍然遵循单遍编译,⾄少看起来是这样,单遍编译语义给C++带来的影响主要是重载决议和名字解析。
1.1 重载决议
#include<stdio.h>
void foo(int a)
{
printf("foo(int)\n");
}
void bar()
{
foo('a');
}
void foo(char a)
{
printf("foo(char)\n");
}
int main()
{
bar();
return 0;
}
以上代码通过g++编译运⾏结果为:foo(int)。尽管后⾯有更合适的函数原型,但C++在解析bar()时,只
看到了void foo(int)。这是C++重载结合单遍编译造成的困惑之⼀,即使现在C++并⾮真的单遍编译(想⼀下前向声明),但它要和C兼容语义,因此不得不”装傻”。对于C++类是个例外,编译器会先扫描类的定义,再解析成员函数,因此类中所有同名函数都能参加重载决议。
关于重载还有⼀点就是C的隐式类型转换也给重载带来了⿇烦:
// Case 1
void f(int){}
void f(unsigned int){}
void test() { f(5); } // call f(int)
// Case 2
void f(int){}
void f(long){}
void test() { f(5); } // call f(int)
// Case 3
void f(unsigned int){}
void f(long){}
void test() { f(5); } // error. 编译器也不知道你要⼲啥
// Case 4
void f(unsigned int){}
void test{ f(5); } // call f(unsigned int)...
void f(long){}
再加上C++⼦类到⽗类的隐式转换,转换运算符的重载… 你必须费劲⼼思,才能确保编译器按你预想的去做。
单遍编译给C++造成的另⼀个影响是名字查,C++只能通过源码来了解名字的含义,⽐如 AA BB(CC),这句话即可以是声明函数,也可以是定义变量。编译器需要结合它解析过的所有源代码,来判
断这句话的确切含义。当结合了C++ template之后,这种难度⼏何攀升。因此不经意地改动头⽂件,或修改头⽂件包含顺序,都可能改变语句语义和代码的含义。
2. 头⽂件
在初学C++时,函数声明放在.h⽂件,函数实现放在.cpp⽂件,似乎已经成了共识。C++没有C的隐式函数声明,也没有其它⾼级语⾔的包机制,因此,同⼀个项⽬中,头⽂件已经成了模块与模块之间,类与类之间,共享接⼝的主要⽅式。
C中的效率性,传递性,差异性,⼀致性,C++都⼀个不落地继承了。除此之外,C++头⽂件还带来如下⿇烦:
2.1 顺序性
由于C++头⽂件包含更多的内容:template, typedef, #define, #pragma, class,等等,不同的头⽂件包含顺序,将可能导致完全不同的语义。或者直接导致编译错误。
2.2 ⼜见重载
由于C++⽀持重载,因此如果头⽂件中的函数声明和源⽂件中函数实现不⼀致(如参数个数,const属性
等),将可能构成重载,这个时候”聪明”的C++编译器不错报错,它将该函数的调⽤地址交给链接器去填,⽽源⽂件中写错了的实现将被认定为⼀个全新的重载。从⽽到链接阶段才报错。这⼀点在C中会得到编译错误,因为C没有重载,也就没有名字改编(name mangling),将会在编译时得到符号冲突。
2.3 重复包含
由于头⽂件的传递性,有可能造成某上层头⽂件的重复包含。重复包含的头⽂件在展开后,将可能导致符号重定义,如:
// common.h
class Common
{
// ...
};
// h1.h
#include "common.h"
// h2.h
#include "common.h"
// test.cpp
#include "h1.h"
#include "h2.h"
int main()
{
return 0;
}
如果common.h中,有函数定义,结构体定义,类声明,外部变量定义等等。test.cpp中将展开两份com
mon.h,编译时得到符号重定义的错误。⽽如果common.h中只有外部函数声明,则OK,因为函数可在多处声明,但只能在⼀处定义。关于类声明,C++类保持了C结构体语义,因此叫做”类定义”更为适合。始终记得,头⽂件只是⼀个公共代码的整合,这些代码会在预编译期替换到源⽂件中。
为了解决重复包含,C++头⽂件常⽤ #ifndef #define #endif或#pragma once来保证头⽂件不被重复包含。
2.4 交叉包含
C++中的类出现相互引⽤时,就会出现交叉包含的情况。如Parent包含⼀个Child对象,⽽Child类包含Parent的引⽤。因此相互包含对⽅的头⽂件,编译器展开Child.h需要展开Parent.h,展开Parent.h⼜要展开Child.h,如此⽆限循环,最终g++给出:error: #include nested too deeply的编译错误。
解决这个问题的⽅案是前向声明,在Child类定义前⾯加上 class Parent; 声明Parent类,⽽⽆需包含其头⽂件。前向声明不⽌可以⽤于类,还可以⽤于函数(即显式的函数声明)。前向声明应该被⼤量使⽤,它可以解决头⽂件带来的绝⼤多数问题,如效率性,传递性,重复包含,交叉包含等等。这⼀点有点像包(package)机制,需要什么,就声明(导⼊)什么。前向声明也有局限:仅当编译器⽆需知道⽬标类完整定义时。如下情形,类A可使⽤ class B;:
类A中使⽤B声明引⽤或指针;
类A使⽤B作为函数参数类型或返回类型,⽽不使⽤该对象,即⽆需知道其构造函数和析构函数或成员函数;
2.5 如何使⽤头⽂件
关于头⽂件使⽤的建议:
降低将⽂件间的编译依赖(如使⽤前向声明);
将头⽂件归类,按照特定顺序包含,如C语⾔系统头⽂件,C++系统头⽂件,项⽬基础头⽂件,项⽬头⽂件;
防⽌头⽂件重复编译(#ifndef or #pragma);
确保头⽂件和源⽂件的⼀致;
3.总结
C语⾔本⾝⼀些⽐较简单的特性,放在C++中却引起了很多⿇烦,主要是因为C++复杂的语⾔特性:类,模板,各种宏… 举个例⼦来说,对于⼀个类A,它有⼀个私有函数,需要⽤到类B,⽽这个私有函
数必须出现在类定义即头⽂件中,因此就增加了A头⽂件对B的不必要引⽤。这是因为C++类遵循C结构体的语义,所有类成员都必须出现在类定义中,”属于这个类的⼀部分”。这不仅在定义上造成不便,也在容易在语义上造成误解,事实上,C++类的成员函数不属于对象,它更像普通函数(虚函
⽽在C中,没有”类的捆绑”,实现起来就要简单多了,将该函数放在A.c中,函数不在A.h中声明。由A.c包含B.h,解除了A.h和B.h之间的关联,这也是C将数据和操作分离的优势之⼀。
最后,看看其它语⾔是如何避免这些”坑”的:
对于解释型语⾔,import的时候直接将对应模块的源⽂件解析⼀遍,⽽不是将⽂件包含进来;
对于编译型语⾔,编译后的⽬标⽂件中包含了⾜够的元数据,不需要读取源⽂件(也就没有头⽂件⼀说了);
它们都避免了定义和声明不⼀致的问题,并且在这些语⾔⾥⾯,定义和声明是⼀体的。import机制可以确保只到处必要的名字符号,不会有多余的符号加进来。