13.1 概念
随着程序越来越复杂,我们希望把程序的各个部分分别存储在不同文件中。在一般的程序中,不可能只有单个文件的。 我们在写代码的时候,通常会对各种代码的结构进行组织,从而使之后的调试修改等工作更加方便。
C++语言支持所谓的分离式编译(separate compilation)。 分离式编译允许我们把程序分割到几个文件中去,每个文件独立编译,最后再链接到一起。
一般来说,我们可以将实体的定义与其声明分离,使它们的定义与其声明分属不同的文件中,从而方便管理组织程序的代码。
13.2 代码的翻译过程
我们的程序代码通常是由多个代码文件组成的,其中包含头文件和源文件(对于Windows系统来说,C++语言的头文件通常以.h
为后缀,源文件通常以.cpp
为后缀)。 从程序代码到程序运行,我们需要进行程序代码的翻译工作,翻译工作都是由c++的编译器驱动程序进行的,编译器驱动程序会对程序所有的代码进行一些操作,这些操作也就是我们之前所讲的C++代码翻译的四大阶段。
我们再回顾以下这四个阶段,其中有需要注意的几个地方:
13.21 预处理阶段
- 在预处理阶段,预处理器会对程序代码中的每个代码文件
.cpp/.h
独立地执行其所有的预处理指令,并将其中的每行注释替换成一行空行。每个文件处理完后的所有代码保存在一个预处理文本文件.i
中。#include
也是预处理指令之一,它的作用就是将指定的文件的所有内容包含在指令出现的位置,该指令会自动展开包含文件的代码到该使用位置,如果该包含文件也有预处理指令,则也执行该指令。 所以为了防止包含文件时出现重复定义,我们需要在被包含文件中使用宏和条件编译(这几个操作的综合也就叫做头文件保护符(header guard))来防止多次包含该文件的内容。
// 在windows系统上使用GNU编译器套件进行C++的代码翻译
// 以下是test.cpp源文件中的代码
#include "te.h"
#define MA(a,b) a * b;
int main()
{
int ins = MA(23,13)
double dou = 17;
// 这是一行注释,会被替换成一行空行。
/* 这是两行注释,会被替换成四行空行。
这是两行注释,会被替换成四行空行。*/
return 0;
}
// te.h头文件与test.cpp源文件为同一路径。
// te.h头文件使用了头文件保护符操作。
// 以下是te.h头文件中的代码
#ifndef TE_H
#define TE_H
extern int ins;
extern double dou;
#endif
/* 在命令行中的这两个文件所在路径下输入以下命令进行test.cpp文件的预处理操作:
g++ -E test.cpp -o t.i
-o指令是指定生成文件的文件名(可包含路径信息),
因为GNU预处理指令-E默认在命令行输出预处理文本文件(.i),
而不保存下来,所以需要显式指定文件名。
预处理后生成的文件为同路径下的t.i
*/
// 以下都是t.i文件中的代码
# 1 "test.cpp"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "test.cpp"
# 1 "te.h" 1
extern int ins;
extern double dou;
# 2 "test.cpp" 2
int main()
{
int ins = 23 * 13;
double dou = 17;
return 0;
}
13.22 编译阶段
- 在编译阶段,编译器会独立地检查所有从程序代码转换而成的预处理文本文件
.i
,也就是只检查该文件中的代码,而不会考虑其他文件对其的影响。当某个预处理文本文件通过了编译检查时,编译器会将该文件里的所有代码翻译成一种能被汇编语言程序所识别的代码,所有翻译后的代码保存在一个汇编文本文件.s
中。 其中,有以下几点需要注意:- 编译器在进行编译检查时,如果我们使用了某个对象(包括普通函数、类的非类类型成员、类的非成员模板成员和模板实例)时,编译器会检查这些对象的声明是否在该作用域中存在:
- 对于声明存在但定义不存在的对象,编译器会在汇编文本文件
.s
中做一个标记,告诉之后的链接器在链接的时候去找该对象的定义,把问题移交给链接器,此时不会触发编译出错。 - 如果该对象的声明不存在,或者声明存在但与其定义不匹配,或者该对象有多个不匹配的声明或定义时,就会编译出错。
- 对于声明存在但定义不存在的对象,编译器会在汇编文本文件
- 但是当我们在不符合不完全类型的使用情形时使用了某类型(包括类类型和类模板),则编译器不仅会检查其声明,还会检查是否存在对应的定义,否则编译出错。
- 对于模板来说,模板的实例化只会在编译阶段进行,生成的实例(也就是实例定义)包含以下部分:
- 对于类模板来说: 编译器生成的实例包含该类的定义和其所有成员的声明(不包含其成员的定义)。
- 对于函数模板来说: 编译器生成的实例包含该函数的定义。
- 编译器实例化模板的时机:
- 当遇到模板或者模板部分特例化的声明以及定义语句时,编译器不进行实例化。
- 当遇到使用非类模板的模板语句(隐式实例化),且该语句不是显式实例化定义或者全部特例化时,编译器还是按常规进行声明检查:
- 如果满足以下任意一项,则编译器不会生成任何实例,否则编译器就会生成对应的实例定义:
- 该模板只有声明而不存在定义。
- 同作用域中已存在该处所使用的对应的实例声明。
- 同作用域中已存在该处所使用的对应的实例定义(由其他隐式或显式实例化所产生的)。
- 如果满足以下任意一项,则编译器不会生成任何实例,否则编译器就会生成对应的实例定义:
- 当遇到全部特例化的声明或定义语句时: 如果满足以下任意一项,则会编译出错,否则编译器就会生成对应特殊实例的声明或定义:
- 原始模板的声明不存在时。
- 该特例化语句是定义语句,且该文件中已存在该特例化对应的实例定义(由其他隐式或显式实例化所产生的)。
对于全部特例化声明所生成的特殊实例声明,后续会在链接阶段与该模板的其他同声明的实例定义链接上。
- 当遇到显式实例化的定义语句时:
- 如果满足以下任意一项,则会编译出错:
- 同作用域中不存在该模板的定义。
- 同作用域中已存在同与其相同的显式实例化定义。
- 如果满足以下任意一项,则编译器不会生成对应的实例:
- 同作用域中已存在该处所使用的对应的实例定义(由其他隐式实例化所产生的)。
否则,编译器就会生成对应的实例定义。
- 如果满足以下任意一项,则会编译出错:
所以为了生成一个模板的实例,编译器可能既需要模板自身的定义,也需要模板成员的定义。因此与其他对象不同的是,模板通常将其声明和定义放在同一个文件中。
- 编译器在进行编译检查时,如果我们使用了某个对象(包括普通函数、类的非类类型成员、类的非成员模板成员和模板实例)时,编译器会检查这些对象的声明是否在该作用域中存在:
// 在windows系统上使用GNU编译器套件进行C++的代码翻译
// 以下是test.cpp源文件中的代码
#include "te.h"
extern template int t_ret(int);
int main()
{
int var = ins;
int var2 = ret();
ret();
int var3 = t_ret<int>(15);
t_ret(t_ret(152));
return 0;
}
// te.h头文件与test.cpp源文件为同一路径。
// te.h头文件使用了头文件保护符操作。
// 以下是te.h头文件中的代码
#ifndef TE_H
#define TE_H
extern int ins;
int ret();
template <typename ty>
ty t_ret(ty val);
#endif
/* 在命令行中的这两个文件所在路径下输入以下命令进行test.cpp文件的预处理以及编译操作:
g++ -S test.cpp
因为GNU预处理加编译的合成操作-S(该操作也可接受预处理文本文件,
从而只对该文件进行编译操作)默认在同路径下创建一个同名的汇编文本文件(.s),
所以可以不用-o显式指定文件名。
编译成功,在同路径下生成文件test.s
*/
// 以下都是test.s文件中的代码
.file "test.cpp"
.text
.def __main; .scl 2; .type 32; .endef
.globl main
.def main; .scl 2; .type 32; .endef
.seh_proc main
main:
.LFB0:
pushq %rbp
.seh_pushreg %rbp
movq %rsp, %rbp
.seh_setframe %rbp, 0
subq $48, %rsp
.seh_stackalloc 48
.seh_endprologue
call __main
movq .refptr.ins(%rip), %rax
movl (%rax), %eax
movl %eax, -4(%rbp)
call _Z3retv
movl %eax, -8(%rbp)
call _Z3retv
movl $15, %ecx
call _Z5t_retIiET_S0_
movl %eax, -12(%rbp)
movl $152, %ecx
call _Z5t_retIiET_S0_
movl %eax, %ecx
call _Z5t_retIiET_S0_
movl $0, %eax
addq $48, %rsp
popq %rbp
ret
.seh_endproc
.ident "GCC: (x86_64-win32-seh-rev0, Built by MinGW-W64 project) 8.1.0"
.def _Z3retv; .scl 2; .type 32; .endef
.def _Z5t_retIiET_S0_; .scl 2; .type 32; .endef
.section .rdata$.refptr.ins, "dr"
.globl .refptr.ins
.linkonce discard
.refptr.ins:
.quad ins
// 在windows系统上使用GNU编译器套件进行C++的代码翻译
// 以下是test.cpp源文件中的代码
#include "te.h"
// 函数模板t_ret的显式实例化定义
// 编译会出错,
// 因为te.h中的函数模板t_ret只有声明没有定义
template int t_ret(int);
int main()
{
// 类类型Cls对象的定义
// 编译会出错,
// 因为te.h中的类类型Cls只有声明没有定义
Cls obj;
// 类模板T_cls对象的定义
// 编译会出错,
// 因为te.h中的类模板T_cls只有声明没有定义
T_cls<int, 48> t_obj;
return 0;
}
// te.h头文件与test.cpp源文件为同一路径。
// te.h头文件使用了头文件保护符操作。
// 以下是te.h头文件中的代码
#ifndef TE_H
#define TE_H
template <typename ty>
ty t_ret(ty val);
struct Cls;
template <typename ty, int val>
struct T_cls;
#endif
/* 在命令行中的这两个文件所在路径下输入以下命令进行test.cpp文件的预处理以及编译操作:
g++ -S test.cpp
因为GNU预处理加编译的合成操作-S(该操作也可接受预处理文本文件,
从而只对该文件进行编译操作)默认在同路径下创建一个同名的汇编文本文件(.s),
所以可以不用-o显式指定文件名。
编译失败,没有生成任何文件。
*/
// 在windows系统上使用GNU编译器套件进行C++的代码翻译
// 以下是test.cpp源文件中的代码
#include "te.h"
// 函数模板t_ret的显式实例化定义
// 编译会通过,
// 因为te.h中,函数模板t_ret的全部特例化声明会生成一个对应的实例声明
template int t_ret(int);
// 类模板T_cls的显式实例化定义
// 编译会通过,
// 因为te.h中,类模板T_cls的全部特例化定义会生成一个对应的实例定义,其中包含其成员prints的声明。
template struct T_cls<int, 48>;
int main()
{
int var3 = t_ret<int>(15);
t_ret(t_ret(152));
// 类类型Cls对象的定义
// 编译会通过,
// 因为te.h中有类类型Cls的定义。
Cls obj;
T_cls<int, 48> t_obj;
// 编译会通过,
// 因为te.h中有函数prints的声明。
t_obj.prints();
return 0;
}
// te.h头文件与test.cpp源文件为同一路径。
// te.h头文件使用了头文件保护符操作。
// 以下是te.h头文件中的代码
#ifndef TE_H
#define TE_H
template <typename ty>
ty t_ret(ty val);
template<> int t_ret(int);
struct Cls {};
template <typename ty, int val>
struct T_cls;
template<> struct T_cls<int, 48> { void prints(); };
#endif
/* 在命令行中的这两个文件所在路径下输入以下命令进行test.cpp文件的预处理以及编译操作:
g++ -S test.cpp
因为GNU预处理加编译的合成操作-S(该操作也可接受预处理文本文件,
从而只对该文件进行编译操作)默认在同路径下创建一个同名的汇编文本文件(.s),
所以可以不用-o显式指定文件名。
编译成功,在同路径下生成文件test.s。
*/
13.23 汇编阶段
- 在汇编阶段期间,汇编器会独立地将所有从程序代码转换而成的汇编文本文件
.s
翻译成机器代码,也叫做机器语言指令(也就是由二进制值组成的代码,计算器能够识别的代码),并将这些机器语言指令打包成一种叫做可重定位目标程序(relocate object program)的格式,最后将每个文件的打包结果保存在一个为其创建的可重定位目标文件.o
中。
// 在windows系统上使用GNU编译器套件进行C++的代码翻译
// 以下是test.cpp源文件中的代码
#include "te.h"
template int t_ret(int);
template struct T_cls<int, 48>;
int main()
{
int var3 = t_ret<int>(15);
t_ret(t_ret(152));
Cls obj;
T_cls<int, 48> t_obj;
t_obj.prints();
return 0;
}
// te.h头文件与test.cpp源文件为同一路径。
// te.h头文件使用了头文件保护符操作。
// 以下是te.h头文件中的代码
#ifndef TE_H
#define TE_H
template <typename ty>
ty t_ret(ty val);
template<> int t_ret(int);
struct Cls {};
template <typename ty, int val>
struct T_cls;
template<> struct T_cls<int, 48> { void prints(); };
#endif
/* 在命令行中的这两个文件所在路径下输入以下命令进行test.cpp文件的预处理、编译以及汇编的合成操作:
g++ -c test.cpp
因为GNU预处理加编译加汇编的合成操作-c(该操作也可接受预处理文本文件或者汇编文本文件,
从而进行对应文件的合成操作)默认在同路径下创建一个同名的目标文件(.o),
所以可以不用-o显式指定文件名。
汇编成功,在同路径下生成文件test.o。
*/
13.24 链接阶段
- 在链接阶段,链接器会把所有从程序代码转换而成的目标文件
.o
链接到一起,并进行检查合并: 链接器会将所有文件中的对象的声明和定义链接在一起,如果链接器在链接时发现有对象进行了重复定义,或者是该对象的声明和定义不匹配,又或者是某句使用了未定义的对象(此时,使用了未实例化的实例的地方就会被认为是使用了未定义对象),则会链接出错。 当所有地方都链接无误后,链接器就会形成一个可执行文件.exe/.out
,此时程序才真正可以在操作系统中运行。
// 在windows系统上使用GNU编译器套件进行C++的代码翻译
// 以下是test.cpp源文件中的代码
#include "te.h"
template struct T_cls<int, 48>;
template int t_ret(int);
int main()
{
int var = ins;
int var2 = ret();
int var3 = t_ret<int>(15);
// 输出74 60 169
cout << var << " " << var2 << " " << var3 << "\n";
Cls obj;
T_cls<int, 48> t_obj;
// 输出spec
t_obj.prints();
return 0;
}
// te.h头文件与test.cpp源文件为同一路径。
// te.h头文件使用了头文件保护符操作。
// 以下是te.h头文件中的代码
#ifndef TE_H
#define TE_H
#include <iostream>
using namespace std;
extern int ins;
int ret();
template <typename ty>
ty t_ret(ty val);
template<> int t_ret(int);
struct Cls {};
template <typename ty, int val>
struct T_cls { void prints(); };
template<> void T_cls<int, 48>::prints();
#endif
// te.cpp源文件与te.h头文件为同一路径。
// 以下是te.cpp源文件中的代码
#include "te.h"
int ins = 74;
int ret() { return 60; }
template <typename ty>
ty t_ret(ty val) { return val + 25; }
template<> int t_ret(int val) { return val + 154; }
template <typename ty, int val>
void T_cls<ty, val>::prints() { cout << "original\n"; }
template<> void T_cls<int, 48>::prints() { cout << "spec\n"; }
/* 在命令行中的这几个文件所在路径下输入以下命令进行test.cpp、te.cpp文件的预处理、编译、汇编以及链接的合成操作:
g++ test.cpp te.cpp
GNU的默认操作就是预处理加编译加汇编加链接的合成操作(该操作也可接受预处理文本文件、汇编文本文件或者目标文件,从而进行对应文件的合成操作)。
GNU的默认操作默认在同路径下创建一个文件名为a的可执行文件(.exe),所以可以不用-o显式指定文件名。
链接成功,在同路径下生成文件a.exe。
*/
13.3 代码翻译过程的注意事项
综上来说,我们建议:
- 所有头文件应使用头文件保护符来防止重复定义。
- 各种变量、函数,类的非类类型或者非成员模板成员应该在头文件中独立声明,而它们的定义部分可以放在其它的源文件中,且这些源文件应该把包含其对应声明的头文件包含进来,这样就可以使编译器来验证其定义和声明是否匹配。
- 对于模板来说,要么提前实例化模板的所有所需实例(一般不推荐,因为需要提前实例化多个,很麻烦),要么就将模板的声明和定义(不包括类模板的非类类型或者非成员模板成员的定义)放在同一个头文件中。
- 当我们的某些文件需要使用这些实体时,只需要用包含预编译指令将包含该实体声明的头文件包含进来就行了,而不需要将这些实体的定义文件包含进来。
- 链接时不要忘记链接这些实体的定义文件。
/*
* 综合示例
*/
// 在windows系统上使用GNU编译器套件进行C++的代码翻译
// 以下是test.cpp源文件中的代码
#include "test.h"
std::string ret() { return std::string("linker"); }
// test.h头文件与test.cpp源文件为同一路径。
// test.h头文件使用了头文件保护符操作。
// 以下是test.h头文件中的代码
#ifndef TEST_H_
#define TEST_H_
#include <string>
std::string ret();
std::string sprints() { return ret(); }
#endif
// main.cpp源文件与test.h头文件为同一路径。
// 以下是main.cpp源文件中的代码
#include <iostream>
#include "test.h"
int main()
{
std::cout << sprints() << std::endl;
return 0;
}
/*
在命令行中的这几个文件所在路径下输入以下命令进行test.cpp、main.cpp文件的预处理、编译、汇编以及链接的合成操作:
g++ test.cpp main.cpp
此时会链接出错,提示sprints函数重定义。
造成重定义的原因为:
test.cpp和main.cpp在经过预处理阶段后,这两个文件都包含了sprints的定义,
编译时编译器只单独检查每个文件,因此这两个文件都通过了编译,
但在链接阶段,链接器整合这两个文件的所有对象进行链接时就发现了sprints的定义重复的现象,于是报错。
正确做法是将sprints的定义移到test.cpp,test.h只包含sprints的声明,也就是:
// 以下是修改后的test.cpp源文件中的代码
#include "test.h"
std::string ret() { return std::string("linker"); }
std::string sprints() { return ret(); }
// 以下是修改后的test.h头文件中的代码
#ifndef TEST_H_
#define TEST_H_
#include <string>
std::string ret();
std::string sprints();
#endif
这样修改后重新运行以下操作:
g++ test.cpp main.cpp
此时就会链接成功,在同路径下生成文件a.exe。
*/