解析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成千上万行预处理之后的头文件源码。
传递性:头文件具有传递性。在头文件传递链中任一头文件变动,都将导致包含该头文件的所有源文件重新编译。哪怕改动无关紧要(没有源文件使用被改动的接口)。
差异性:头文件在编译时使用,动态库在运行时使用,二者有可能因为版本不一致造成二进制兼容问题。
一致性:头文件函数声明和源文件函数实现的参数名无需一致。这将可能导致函数声明的意思,和函数具体实现不一致。如声明为 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;
}

gcc编译上面的代码,得到如下错误:

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++子类到父类的隐式转换,转换运算符的重载… 你必须费劲心思,才能确保编译器按你预想的去做。
1.2 名字查找
单遍编译给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中将展开两份common.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机制可以确保只到处必要的名字符号,不会有多余的符号加进来。

(0)

相关推荐

  • 浅谈c++的编译和运行

    命令行编译: g++ xx.cpp 运行: ./a.out 以上这篇浅谈c++的编译和运行就是小编分享给大家的全部内容了,希望能给大家一个参考,也希望大家多多支持我们.

  • C/C++程序编译流程详解

    程序的基本流程如图: 1.预处理 预处理相当于根据预处理指令组装新的C/C++程序.经过预处理,会产生一个没有宏定义,没有条件编译指令,没有特殊符号的输出文件,这个文件的含义同原本的文件无异,只是内容上有所不同. 读取C/C++源程序,对其中的伪指令(以#开头的指令)进行处理 ①将所有的"#define"删除,并且展开所有的宏定义 ②处理所有的条件编译指令,如:"#if"."#ifdef"."#elif"."#els

  • 解析C语言与C++的编译模型

    首先简要介绍一下C的编译模型: 限于当时的硬件条件,C编译器不能够在内存里一次性地装载所有程序代码,而需要将代码分为多个源文件,并且分别编译.并且由于内存限制,编译器本身也不能太大,因此需要分为多个可执行文件,进行分阶段的编译.在早期一共包括7个可执行文件:cc(调用其它可执行文件),cpp(预处理器),c0(生成中间文件),c1(生成汇编文件),c2(优化,可选),as(汇编器,生成目标文件),ld(链接器). 1. 隐式函数声明 为了在减少内存使用的情况下实现分离编译,C语言还支持"隐式函数

  • 解析go语言调用约定多返回值实现原理

    目录 go简单代码反汇编 go语言调用约定分析 1.C/C++调用约定类别 2.go语言调用约定 go语言如何实现多返回值的 总结 go简单代码反汇编 用简单的代码用以分析go的调用约定及多返回值的返回方式. package main func vals(c, d int) (a int, b int) { e := 1 f := 2 a = c + d + e + f b = d * 2 return } func testMutil() { i, j := vals(1, 2) i = i

  • go语言静态库的编译和使用方法

    本文主要介绍go语言静态库的编译和使用方法,以windows平台为例,linux平台步骤一样,具体环境如下: >echo %GOPATH% E:\share\git\go_practice\ >echo %GOROOT% C:\Go\ >tree /F %GOPATH%\src 卷 work 的文件夹 PATH 列表 卷序列号为 0009-D8C8 E:\SHARE\GIT\GO_PRACTICE\SRC │ main.go │ └─demo demo.go 在%GOPATH%\src目

  • 深度解析C语言中的变量作用域、链接和存储期的含义

    在c中变量有三种性质: 1.存储期限:变量的存储期限决定了变量占用的内存空间什么时候会被释放,具有动态存储期限的变量会在所属的程序块被执行时获得内存空间,在结束时释放内存空间.具有静态存储期限的变量在程序运行的整个期间都会占用内存空间. 2.作用域:变量有块作用域也有文件作用域,结合序章第一张图可以明白块作用域是在某些程序块内起作用,文件作用域是在整个c文件之内起作用. 3.链接:链接是各个文件之间的关系,具有内部链接的变量只在本文件内起作用,具有外部链接的变量可以在不同文件内起作用.具有无链接

  • Go语言非main包编译为静态库并使用的示例代码

    Go语言项目中基本上都是以源码的形式提供包,对公司或者企业而言,如果想要隐藏关键代码怎么办? 我们可以将Go的非main包编译为静态库提供给用户使用.下面以Windows为例,介绍一下如何将Go的非main包编译为静态库,用户又将如何使用. 一.环境 笔者使用的VSCode,在终端中查看Go的环境变量,主要看GOROOT以及GOPATH,笔者的GOROOT在C:\Program Files\Go. 二.常规工程 在任意地方新建一个目录,假设为test,然后使用VSCode打开.我们创建如下3个工

  • C语言简明讲解预编译的使用

    目录 小复习 1.内置符号 2.自定义符号 3.自定义宏 4.条件编译 小复习 预处理,预编译是编译的第一步. 会有三件基本的事情发生: 引入#include 去除注释 修改#define 1.内置符号 这些符号都可以直接使用: __FILE__            点c文件全名__LINE__            当前行号__DATE__            编译日期__TIME__            编译时间 举例: #include<stdio.h> int main() {

  • 深度解析C语言中数据的存储

    目录 前言 数据类型介绍 类型的基本归类 整型家族 浮点数家族 构造类型 指针类型 空类型 前言 在VS编译器里有release和debug两种形式,debug包含调试信息,release不包含调试信息,并会对程序进行优化 int main() { int i = 0; int arr[10] = { 1,2,3,4,5,6,7,8,9,10 }; for (i = 0; i <= 12; i++) { arr[i] = 0; printf("hehe\n"); } return

  • 解析C语言基于UDP协议进行Socket编程的要点

    两种协议 TCP 和 UDP 前者可以理解为有保证的连接,后者是追求快速的连接. 当然最后一点有些 太过绝对 ,但是现在不需熬考虑太多,因为初入套接字编程,一切从简. 稍微试想便能够大致理解, TCP 追求的是可靠的传输数据, UDP 追求的则是快速的传输数据. 前者有繁琐的连接过程,后者则是根本不建立可靠连接(不是绝对),只是将数据发送而不考虑是否到达. 以下例子以 *nix 平台的便准为例,因为 Windows平台需要考虑额外的加载问题,稍作添加就能在 Windows 平台上运行UDP. U

  • 深入解析Swift语言编程中的可选链

    查询,调用属性,下标和方法上的一个可选可能 'nil' 的过程被定义为可选的链.可选链返回两个值 如果可选包含一个值,然后调用其相关属性,方法和下标返回值 如果可选包含一个"nil"值,所有的相关属性,方法和下标返回nil 由于多种查询方法,属性和下标故障组合在一起,以一种链将影响到整个链,并导致产生 'nil' 的值. 可选链作为一种替代强制解包裹 可选链与可选值后指定"?"调用一个属性,方法或下标当可选的值返回一些值. 程序用于可选链 '!' 复制代码 代码如下

  • 使用ASP控制指定站点解析脚本语言函数

    ============================================================= '       感谢您使用ASP001工作室开发的实用函数程序 '               HTTP://WWW.ASP001.NET '=============================================================      'ASP001工作室为您提供订制程序开发.企业互联网拓展服务 'QQ:1974229 'E-Mail

随机推荐