跳转至

编程环境

从单个 C++ 代码文件到多个 C++ 代码文件

在讲解面向对象这个编程概念之前,我们认为有必要简单介绍我们是如何从 C++ 代码文件得到可执行文件的,以及如何编译多个 C++ 代码文件。简单了解这些知识有助于读者后续理解。

源程序的结构与预编译

源程序的结构

一个经典的 C++ 代码文件的结构为:

1
2
3
4
5
6
7
8
9
#include <iostream>
using namespace std; // 头文件与编译指令

int add(int a, int b) { return a + b; } // 辅助函数定义

int main() { // 主函数定义
    cout << add(3, 4);
    return 0;
}

预编译指令

定义

上文代码中提及的“头文件与编译指令”中的编译指令实际上是预编译指令的意思,而预编译与预处理是同一概念。

C/C++ 编译系统编译程序的过程为预编译、编译、链接。预处理器在程序源文件被编译之前根据预处理指令对程序源文件进行处理。

而预处理器指令以 # 号开头标识,并不是一句指令,末尾不包含分号。预处理命令不是 C/C++ 语言本身的组成部分,不能直接对它们进行编译和链接。C/C++ 语言的一个重要功能是可以使用预处理指令和具有预处理的功能。二者提供的预处理功能主要有文件包含、宏替换、条件编译等。

文件包含

预处理指令 #include 将被包含的文件代码,直接复制到当前文件,一般被用于包含头文件(实际也能包含任意代码)。有两种形式:#include <xxx.h> 以及 #include "xxx.h"。尖括号形式表示被包含的文件在系统目录中。如果被包含的文件不一定在系统目录中,应该用双引号形式(注意到是应该用,不是最好用)。

在双引号形式中可以指出文件路径和文件名。如果在双引号中没有给出绝对路径,则默认为用户当前目录中的文件,此时系统首先在用户当前目录中寻找要包含的文件,若找不到再在系统目录中查找。对于用户自己编写的头文件,应用双引号形式。对于系统提供的头文件,既可以用尖括号形式,也可以用双引号形式,都能找到被包含的文件,但使用用尖括号形式更直截了当,效率更高。

宏替换

  • 意义

#define 是 C++ 语言中的一个预编译指令,它用来将一个标识符定义为一个字符串,该标识符被称为宏名,被定义的字符串称为替换文本。在程序被编译前,先将宏名用被定义的字符串替换,这称为宏替换,替换后才进行编译,宏替换是简单的替换。

  • 宏定义

宏定义包括无参数宏定义和带参数宏定义两类。宏名和宏参数所代表的代码序列可以是任何意义的内容,如类型、常量、变量、操作符、表达式、语句、函数、代码块等。但是宏名和宏参数必须是合法的标识符,其所代表的内容及意义在宏展开前后必须一直是独立且保持不变的,不能分开解释和执行。

  • 无参数宏

用一个用户指定的称为宏名的标识符来代表一个代码序列,这种定义的一般形式为 #define 标识符 代码序列。其中 #define 之后的标识符称为宏定义名(简称宏名),在宏定义 #define 之前可以有若干个空格、制表符,但不允许有其它字符,宏名与代码序列之间用空格符分隔。例如:

1
#define PI 3.1415926535

在 C++ 中,这种替换一般被 const 取代,进而能保证类型的正确性。

1
const double PI = 3.1415926535
  • 带参数宏

带参数宏定义进一步扩充了无参数宏定义的能力,这时的宏展开既进行宏名的替换又进行宏参数的替换。带参数的宏定义的一般形式为 #define 标识符(参数表) 代码序列,其中参数表中的参数之间用逗号分隔,在代码序列中必须要包含参数表中的的参数。在定义带参数的宏时,宏名与左圆括号之间不允许有空白符,应紧接在一起,否则变成了无参数的宏定义。带参数宏调用提供的实在参数个数必须与宏定义中的形式参数个数相同。例如:

1
2
#define sqr(x) ((x) * (x))
sqr(3 + 2); // ((3 + 2) * (3 + 2));

在 C++ 中,这种替代一般被内联函数取代,进而能保证类型的正确性。内联函数我们会在后续章节中讲解。

1
inline double sqr(double x) { return x * x; }
  • 宏作用域

宏定义的有效范围称为宏名的作用域,宏名的作用域从宏定义的结束处开始到其所在的源代码文件末尾。宏名的作用域不受分程序结构的影响。如果需要终止宏名的作用域,可以用预处理指令 #undef 加上宏名。

  • 宏展开

预处理器在处理宏定义时,会对宏进行展开(即宏替换)。宏替换首先将源文件中在宏定义随后所有出现的宏名均用其所代表的代码序列替换之,如果是带参数宏则接着将代码序列中的宏形参名替换为宏实参名。

宏替换只作代码字符序列的替换工作,不作任何语法的检查,也不作任何的中间计算,一切其它操作都要在替换完后才能进行。如果宏定义不当,错误要到预处理之后的编译阶段才能发现。 源代码中的宏名和宏定义代码序列中的宏形参名必须是标识符才会被替换,即只替换标识符,不替换别的东西,像注释、字符串常量以及标识符内出现的宏名或宏形参名则不会被替换。例如下列宏:

1
#define NAME vrmozart

宏替换不会替换掉 // NAME/* NAME */"NAME"my_NAME_blog 中的 NAME

标识符的概念在后续章节中阐述。

  • 宏的独立性

前文提及,宏名和宏形参名所代表的内容及意义在宏展开前后必须一直是独立且保持不变的,不能分开解释和执行。然而,在宏调用时,预处理器用宏定义的代码序列替换宏名,用宏实参名替换宏形参名。替换后,宏定义的代码序列就与源文件中相邻的代码自然连接,宏实参名也与代码序列中相邻的代码自然连接,宏定义的代码序列和宏实参名的独立性就不一定依旧存在。例如,我们希望使用下列宏实现平方操作:

1
#define SQR(x) x * x

对于宏调用 p = SQR(y),能得到希望的宏展开 p = y * y。但对于宏调用 q = SQR(u + v),得到的宏展开是 q = u + v * u + v。显然,后者的展开结果不是程序设计者所希望的。为能保持宏实参名替换后的独立性,应在宏定义中给形式参数加上括号。进一步,为了保证宏名调用的独立性,作为算式的宏定义代码序列也应加括号。SQR 宏定义改写成 #define SQR(x) ((x) * (x)) 才是正确的宏定义。即便如此,这样的宏定义依然很危险。

  • 宏调用与函数调用

函数调用在程序运行时实行,而宏展开是在编译的预处理阶段进行。函数调用占用程序运行时间,宏调用只占编译时间。函数调用对实参有类型要求,而宏调用实在参数与宏定义形式参数之间没有类型的概念,只有字符序列的对应关系。函数调用可返回一个值,宏调用获得希望的代码序列。

  • 其他性质

宏名一般用大写字母,以便与变量名区别。如有必要,宏名可被重复定义,被重复定义后,宏名原先的意义被新意义所代替。

宏定义代码序列中可以引用已经定义的宏名,即宏定义可以嵌套。

条件编译指令

  • 定义

一般情况下,在进行编译时对源程序中的每一行都要编译,但是有时希望程序中某一部分内容只在满足一定条件时才进行编译,如果不满足这个条件,就不编译这部分内容,这就是条件编译。条件编译主要是进行编译时进行有选择的挑选,注释掉一些指定的代码,以达到多个版本控制、防止对文件重复包含。#if#ifndef#ifdef#else#elif#endif 是比较常见条件编译预处理指令,可根据表达式的值或某个特定宏是否被定义来确定编译条件。

  • 指令含义

#if 表示表达式非零就对代码进行编译。#ifdef 表示如果宏被定义就进行编译。#ifndef 表示如果宏未被定义就进行编译。#else 作为其它预处理的剩余选项。#elif 是一种 #else#if 的组合选项。#endif 结束编译块的控制。

  • 防止头文件重复包含

我们先前提到过 #include 指令的效果是把源文件直接复制到源代码中,那么考虑这样的多文件。头文件 A.h 中包含了头文件 B.h

1
2
3
// A.h
#include "B.h"
/* A.h CODE */

而主文件同时包含 A.hB.h

1
2
3
4
5
// main.cpp
#include "A.h"
#include "B.h"

int main() { }

预处理器处理到主文件的 #include "A.h" 的时候直接将 A.h 的内容复制过来:

1
2
3
4
5
6
// main.cpp
#include "B.h"
/* A.h CODE */
#include "B.h"

int main() {  }

会发现这里出现了重复包含,如果 B.h 中有函数定义,则有可能导致冲突,从而无法编译成功。那么为了防止这样的头文件重复包含,我们可以给每一个头文件按照这样的格式编写,如 B.h

1
2
3
4
5
6
7
// B.h
#ifndef B_H
#define B_H

/* B.h CODE */

#endif // B_H

这样即使出现重复包含,由于第一次包含的时候 B_H 宏就已经定义过了,所以后面的 #ifndef 就会保证 B.h 的代码不会再次包含进来。

事实上现在更常用的防止重复包含的预编译指令为 #pragma once

  • 预处理器表达式

预处理器表达式包括的操作符主要涉及到单个数的操作(+-~<<>>)、多个数的运算(*/%+-&^|)、关系比较(<<=>>===!=)、宏定义判断(defined)、逻辑操作(!&&||),其优先级和行为方式与 C++ 表达式操作符相同。预处理器表达式在编译器预处理器上执行,在编译前进行。

例如 #ifndef#if !defined 意义相同,#ifdef#if defined 意义相同。

其它预处理指令

除了上面讨论的常用预处理指令外,还有三个不太常见的预处理指令:#line#error#pragma,下面分别介绍。

  • #line

#line 指令用于重新设定当前由 __FILE____LINE__ 宏指定的源文件名字和行号。#line 一般形式为 #line number "filename",其中行号 number 为任何正整数,文件名 filename 可选。#line 主要用于调试及其它特殊应用,注意在 #line 后面指定的行号数字是表示从下一行开始的行号。

  • #error

#error 指令使预处理器发出一条错误消息,然后停止执行预处理。一般形式为 #error info,如 #error MFC requires C++ compilation

  • #pragma

#pragma 指令可能是最复杂的预处理指令(也最方便),它的作用是设定编译器的状态或指示编译器完成一些特定的动作。一般形式为 #pragma para,其中 para 为参数,下面介绍一些常用的参数。

1
2
3
4
- `#pragma once`,只要在头文件的最开始加入这条指令就能够保证头文件被编译一次。
- `#pragma message("info")`,在编译信息输出窗口中输出相应的信息,例如 `#pragma message("Hello")`。
- `#pragma warning`,设置编译器处理编译警告信息的方式,例如 `#pragma warning(disable: 4507 34; once: 4385; error: 164)` 表示不显示 4507 和 34 号警告信息、4385 号警告信息仅报告一次、把 164 号警告信息作为一个错误。
- `#pragma comment(...)`,设置一个注释记录到对象文件或者可执行文件中。常用  `lib` 注释类型,用来将一个库文件链接到目标文件中,一般形式为 `#pragma comment(lib, "*.lib")`,其作用与在项目属性链接器“附加依赖项”中输入库文件的效果相同。

标识符

定义

标识符即为编程的时候使用的“名字”, 给类、接口、方法、变量、常量名等起名字的字符序列。

组成

英文大小写字母、数字、下划线(_)和美元符号($)。可以使用汉字或其他合法字符命名,但是不推荐。

定义规则

不能以数字开头,不能是关键字,严格区分大小写。可以是汉字或其他合法字符命名,但不推荐。

命名规范(非强制)

  • 类和接口。首个字母大写,如果有多个单词,每个单词首字母大写。如 HelloWorldStudent

  • 变量和方法。首字母小写,如果有多个单词,从第二个单词开始首字母大写。如 getNamestudyJava

  • 常量名(自定义常量)。所有字母都大写,多个单词用下划线隔开。如 MAX_VALUE

编译与链接

过程

  • 预处理。预处理器在程序源文件被编译之前根据预处理指令对程序源文件进行处理,C/C++ 主要的预处理功能有文件包含、宏替换、条件编译等。

  • 编译。编译阶段是检查语法,生成汇编。第一遍执行语法分析和静态类型检查,将源代码解析为语法分析树的结构。第二遍由代码生成器遍历语法分析树,把树的每个节点转换为汇编语言或机器代码,生成目标模块(.o.obj 文件)

  • 汇编。汇编代码转换机器码。非底层的程序员不需要考虑这一阶段,编译器也不会出错。汇编与 C/C++开发者无关,但是我们可以利用反汇编来调试代码,学习汇编语言依然是必备的。

  • 链接。把一组目标模块链接为可执行程序,使得操作系统可以执行它。处理目标模块中的函数或变量引用,必要时搜索库文件处理所有的引用。

编译指令

这里我们使用 g++ 编译器讲解编译指令。

  • g++ -c 表示只编译不链接
  • g++ -o ex1.out ex1.o 表示将 ex1.o 链接为可执行文件 ex1.out
  • g++ ex5_main.cpp func.cpp -o 直接编译生成可执行文件(`g++ 帮我们省略了一些步骤)

链接

将各个目标文件中的各段代码进行地址定位,生成与特定平台相关的可执行文件。外部函数的声明(一般声明在头文件中)只是令程序顺利通过编译,此时并不需要搜索到外部函数的实现(或定义)。在链接过程中,外部函数的实现(或定义)才会被寻找和添加进程序,一旦没有找到函数实现,就无法成功链接。

头文件

意义

有时辅助函数(如全局函数)会在多个源文件中被使用。将辅助函数编入头文件中,从而避免反复编写同一段声明,也能够统一辅助函数的声明,避免错误。

例子

https://s2.loli.net/2022/01/09/X6xR8KscDMovIh2.png

程序在连接时,搜索了编译命令里的所有文件,本题中即为两个 C++ 文件。因为 main.cpp 里面写了 #include "func.h",所以能找到 func.h。如果把 main.cpp 里的 #include 也去掉,就无法编译了。

函数的声明与定义

概念

函数声明的语法为:

1
2
int add(int a, int b);
int add(int, int); // 变量名可省略,例如后缀运算符重载中的哑元

函数定义(即实现)的语法为:

1
int add(int a, int b) { return a + b; }

同一个函数可以有多次声明,但只能有一次实现,多次实现会导致链接错误。注意这和重载的区别,重载是同名函数但是参数不同,重载我们后续介绍。

变量的声明与定义

TODO

关于变量的声明与定义更加详细的讨论在L5-创建与销毁·二1.3.0节,此处不再赘述。

extern 关键字

TODO

关于 extern 修饰符更加详细的讨论在L5-创建与销毁·二1.2.5节,此处不再赘述。

一些编程建议

对于头文件,尽量只声明函数而不实现函数。尽量只声明全局变量而不定义全局变量。

评论

作者: Ashitemaru