版本修订记录

这本关于 C++ 的小书主要是为 C++的初学者和使用者讲解 C++中各种语法的使用,便于快速上手编写 C++应用。本书争取使用最简练的一锤定音式的描述和解释方法,方便读者确定 C++语言各项语法的使用。

作者首次发布最近更新修订版本适用版本
徐涛2018 年 06 月2024 年 02 月3C++11

修订版本 3:

  • 整书使用 mdbook 重排,并转为线上书籍。

修订版本 2:

  • 修订一些出现的错误内容。

修订版本 1:

  • 整书全新创建。

前言

本指南并非打算对 C++进行全面的描述,仅旨在能够让读者可以以最快的速度熟悉完整 C++项目的编写。故对 C++中的一些知识点会直接略过,并侧重于代码的书写和常用操作的介绍。

本文旨在向有编程基础的人提供一份关于 C++的速查手册。如果您是首次接触 C++的初学者,也可以看着本文然后带着各种疑问去其他教材中寻找答案,并预祝您早日练成 C++大法。

项目组织结构

C++是一个不走寻常路的语言,这是要首先被牢记的一个前提。很多人在学习了 C++之后,往往对如何建立一个多于一个文件的能够完成完整功能的项目感到疑惑,从而减慢了前进的脚步。但是没有任何一个语言在完成完整功能时是仅由一个文件来完成的。所以要上手使用 C++开发项目,首先要了解 C++项目的组织结构。

各种文件功能

C++项目中一般会出现以下几种文件,大部分可以通过文件名后缀来识别。

  • .h或者.hpp,头文件,用于放置各种声明,包括类型声明、函数声明、类声明等,但是最好不要将实现代码放在这里。
  • .c或者.cpp,源代码文件,用于放置同名.hpp文件声明内容的定义,需要使用#include将同名头文件包含进来从而与其建立关联。如果使用了其他头文件中声明的功能,只需要将保存有相应功能声明的头文件包含进来即可。
  • makefile,项目构建配置文件,用于定义项目中全部源码的编译过程。
  • CMakeLists.txt,跨平台的编译配置,比makefile更加方便易用,可以使用cmake命令将其转化为makefile文件,使编译配置工作简化。
  • .dll.so,动态链接库文件,作为程序的外部功能支持库使用。其中.dll用于 Windows 系统,.so用于 Unix 系统。
  • .o.obj,编译目标文件,由源码直接编译得来,未经过组合的原始二进制文件。分别用于 Unix 和 Windows 系统。
  • .a.lib,静态链接库文件,由.o.obj组合而来。分别用于 Unix 和 Windows 系统。

代码文件的组织

一个 C++项目中推荐使用以下这些目录来对相应的文件和库进行归集。

  • build/,项目编译目录,用来存放编译的临时文件和最终目标文件,其中可以继续建立debug/release/目录来分别存放调试用可执行文件和发布用可执行文件。
  • dist/,项目发布目录,用来存放最终可以发布的可执行文件和全部支持文件。
  • doc/,项目文档目录。
  • include/,公共头文件目录,可以按照模块来划分组织各个模块的头文件。
  • lib/,外部依赖库目录。
  • res/,资源目录。
  • samples/,示例代码目录。
  • src/,项目源代码目录。
  • tools/,项目支持工具目录。
  • makefile,项目构建配置文件。
  • CMakeLists.txt,CMake 配置文件。

项目的源码可以直接在项目根目录中放置也可以选择放置在src/目录中,头文件可以放在根目录中,也可以放在include/目录中,这完全由个人和团队的喜好决定。

Tip

常常可以见到在src/目录中还有其他的目录,在C++中,这些子目录通常会独立编译为一个库。但是由于C++对于内容的组织是十分自由的,所以对于代码的划分和存储也并没有什么定式。

后文中将要提到的命名空间,与项目目录结构毫无关联,请勿将其混淆。但#include预处理指令与项目目录结构关联较深,需要注意。

每个 C++应用都是从main()函数开始执行的,不论main()函数位置在什么地方,应用中应该有且仅有一个main()函数。C++允许main()函数带有或者不带参数列表,但main()函数返回值始终是整型。main()函数的声明一般是以下固定形式:int main(int argc, char *argv[]),其中argc表示程序接收到的参数的数量,argv[]用于接收传递来的参数,参数是以字符串数组的方式传递来的。具体main()函数的定义会在后文中具体说明。

源码文件结构

要使用一种编程语言其实并不复杂,语言的基本语法都是固定格式的,难点在于将它们组合在一起来完成完整功能。本指南中的语言要点将只陈列各项语法结构,以及基本使用方法和常见技巧,对于其中原理将不再叙述,有需要的读者可以选择向更加详细的 C++教程求索。

C++语言是面向对象与面向过程的集合体,其书写自由程度较大,可以根据需要选择不同的实现方式。但 C++源码中的整体代码结构是有一定规律的。

首先,C++的源码是分为两部分的,第一部分为声明部分,第二部分为定义部分。

声明部分主要用于将变量、命名空间、函数、类等名称引入到程序中,并且制定了类型信息以及正在声明的对象的其他特征。C++中的元素(包括变量、命名空间、函数、类等)都必须先完成声明后才可以使用。一般情况下,声明都放置在.h或者.hpp头文件中,但变量不可在头文件中声明。

前向声明

对于一些特殊的情况,例如两个类的声明中产生了交叉使用,即类A使用了类B,但类B是在类A之后定义的,这在C++中是不允许的,因为类A在使用类B的内容时,类B还未声明,此时可以将类B的声明进包含类B名称的部分放置在类A的声明前面,来使类A可以使用类B的内容,并在类A之后再定义类B的详细内容,这种声明方式称为前向声明。具体可见以下示例。

// 前向声明类B
class B;

// 声明类A,其中返回了类B的一个实例作为返回值
class A {
    public:
        B calculate();
}

// 这里才是类B的完整声明
class B {
    public:
        int numbers();
}

Tip

前向声明是一个不完整的声明,仅可用于定义指针、引用以及函数形参等,但不可定义对象。

定义部分主要用于将声明部分所声明的内容具现化。每一个给定的元素在程序中只能包含唯一的定义,但声明与定义间可以是多对一的关系。定义一般放置在.c或者.cpp文件中。

其次,不管在放置声明的头文件中,还是在放置定义的源码文件中,所有内容的排布均是有规律的。一般会按照以下顺序依次书写。

  1. #include引用。
  2. 预处理指令。
  3. 命名空间定义或引用。
  4. 自定义类型声明。
  5. 声明,包括类声明、函数声明等。
  6. 声明内容的定义,包括类成员、函数等。
  7. main()主函数。

书写时需要注意,在每一部分的内容中都不要违反先声明后使用的原则。

预处理指令

预处理指令在大部分 C++教程中都被放到了较为靠后的位置介绍。但在绝大多数阅读示例源码的时候,一打开文件首先是满眼的预处理指令,所以本指南将从预处理指令开始描述。

C++中的预处理指令都是以#开头,用来指示编译器在实际编译前先完成预处理指令定义的处理过程。常用的预处理指令有#include#define#if等。

#include

#include指令用于将一个文件包含进来并在当前位置插入。#include指令有两种语法形式,其用法和区别如下:

  • #include <文件名>,使用尖括号的包含指令,编译器会在标准库目录中寻找指定的文件,如果标准库中不存在指定文件,将会报错。
  • #include "文件名",使用双引号的包含指令,编译器将会在指定的文件目录中搜索指定文件,这里的文件名一般会采用相对于当前文件所在的相对路径。

包含指令主要用于将包含声明的头文件包含进来以供后续代码使用。注意.cpp文件只需要包含需要的头文件即可,你所编写的代码只需要关心要用的功能是如何声明的,不必关心它们是如何定义的。

#define

#define指令用于创建符号常量,该符号常量通常都被称为。使用#define定义的宏在编译期将直接进行文字的替换,并不做任何计算、转换等工作。宏的定义格式如下:

// 仅用于替换的宏
#define 宏名 用于替代的内容
// 可以携带参数的宏
#define 宏名(参数) (表达式)

例如有宏#define PI 3.14,则在编译时,代码中出现PI的位置都会被替换为3.14;而可以带参数的宏#define MAX(a, b) (a > b ? a : b)则会在编译时将类似于MAX(5, 6)的宏调用,替换为(5 > 6 ? 5 : 6)。需要注意的是,带参数的宏定义不能替代函数。

重要#define创建的符号常量仅在定义符号常量的文件中起效。

使用#define定义的符号常量,可以使用#undef删除。

在定义符号常量时,可以使用#来将用于替代的内容转换为使用引号括起来的字符串,例如:#define STR(x) #x在执行语句STR(234),得到的结果是"234"。操作符##则可以用来连接两个参数内容,也同样是将其连接为字符串,例如:#define CC(x, y) x##y在执行语句CC(a, b)时,得到的结果是变量ab的值。

条件编译

条件编译是由一组指令组成的,用于在编译之前根据一些条件有选择的对部分源代码进行编译。条件编译结构与if分支语句结构非常像。条件编译指令有以下两种形式。

// 如果指定符号常量已经定义,则执行后续的编译
#ifdef 第一个符号常量
    // 待编译的代码
#elif 第二个符号常量
    // 当第一个符号常量未定义,第二个符号常量定义了需要编译的代码
#else
    // 未定义全部符号常量时编译的代码
#endif
// 如果指定符号常量未定义,则执行后续的编译
#ifndef 符号常量
    // 待编译的代码
#endif

#ifndef常常用来判断指定符号常量是否已经定义,如果指定符号常量尚未定义,那么就可以结合#define来进行定义,例如:

#ifndef NONE
    #define NONE 0
#endif

这种符号常量的定义方式,避免了在多个位置使用#define造成的宏的覆盖。

一般头文件还会使用以下格式,来确保自身定义的内容只会被声明一次,防止重复声明产生意料之外的效果。

// 文件class_a.h
#ifndef CLASS_A_H_ // 如果这个常量已经定义,那么后面就不再继续了
#define CLASS_A_H_ // 联合上一句,如果这个常量没有定义过,那就定义一下

class A {}

#endif

每个头文件都应该这样定义,但是需要注意的是头文件中选择的常量时唯一的,没有重复的。

命名空间

C++提供了定义一个声明区域来创建命名的名称空间功能,在一个命名空间中的名称不会与另外一命名空间中的相同名称发生冲突。命名空间可以是全局或者嵌套在另一个命名空间中,但是不能位于代码块中。以下定义了一个命名空间。

namespace Galaxy {
    int stars;
    void travel();
}

namespace Orion {
    int stars;
    void travel();
}

上面这两个命名空间都具有相同的内容声明,但是它们之间并不会发生冲突。

要访问一个命名空间中的名称,需要使用作用域解析操作符::,例如Galaxy::travel()

此外还可以使用using来简化命名空间的使用。using既可以将指定名称加入全局空间,也可以将整个命名空间加入全局空间。可见以下示例。

// 仅引入指定名称
using Galaxy::travel();
// 在后续程序中可以直接调用travel
travel();

// 引入整个命名空间
using namespace Galaxy;
// 在后续程序中将可以不使用作用域解析操作符而直接调用被引入的命名空间中的全部内容
travel();

Tip

使用using引入指定内容或者全部命名空间内容之后,全局中的同名内容将会被覆盖。

内置数据类型

程序代码说到底,就是对各种类型的数据进行操作的过程。在这个过程中不可缺少的就是数据的类型。C++中的数据类型分为内置类型和自定义类型两种,从内容形式上,可以分为简单类型和复合类型。但是无论什么类型的数据,都需要变量这个载体。

变量定义

用最简单的话说,变量就是一个固定内存区域在程序中的代号,这个内存区域的大小是由变量的数据类型决定的。C++中的变量在使用前必须先声明,声明格式为数据类型 变量名,例如int a。C++声明变量时可以同时声明多个变量,变量间只需要使用逗号隔开。

C++中的变量在声明时不一定需要赋予一个初始值,即进行初始化操作。但没有被赋予初始值的变量在直接进行访问操作时可能会出现内存泄漏的情况,所以在访问变量的值时,最好先确认变量已被赋值。

左值与右值

在很多教材中经常会看到左值和右值的称呼。左值一般是指向内存位置的表达式,例如变量名,左值可以出现在赋值等号的任何一边。右值是指存储在内存中某个地址的确实的值。右值不能被赋值,只能出现在赋值等号的右边。

作用域

变量一般可以在三个位置进行声明:

  1. 在函数或者代码块内部,称为局部变量
  2. 在函数定义中声明的变量,称为形式参数
  3. 在函数外声明的变量,称为全局变量

其中,局部变量只能在声明它们的函数或者代码块内部使用。全局变量通常在程序头部定义,在整个程序的生命周期中起效。在程序中,局部变量可以和全局变量名称相同,在局部变量起效的函数或者代码块内,局部变量会遮蔽全局变量的值;但是在函数或者代码块内可以在没有声明局部变量的情况下改变全局变量的值。

局部变量在声明时,系统不会对其进行初始化,而全局变量在声明时,系统将自动使用下表中的默认值对其进行初始化。

数据类型默认值
int0
char\0
float0
double0
指针NULL

变量修饰符

变量修饰符用于定义变量的可见范围和生命周期以及行为特性,修饰符放在变量声明的类型之前。C++中可以使用的变量修饰符有以下这些:

  • auto,根据初始化表达式的值自动推断变量的类型。C++11 标准中已经删除了自动变量的用法,目前仅用于自动推断。
  • register,指示将变量存储于寄存器中。C++11 中此关键字已经弃用。
  • static,指示变量在整个程序的生命周期内存在。
    • 用于修饰局部变量时,可以在不同的作用域间维持局部变量的值。
    • 用于修饰全局变量时,会将全局变量的作用域限制在声明全局变量的文件中。
    • 用于类的数据成员时,会使该成员在所有类实例中共享。
  • extern,用于提供一个全局变量的引用。
    • 全局变量对于所有的程序文件都是可见的,当使用extern时,会将被修饰的变量指向之前已经定义过的同名变量的位置。
    • 当有多个文件并且定义了一个可以在其他文件中使用的全局变量或者函数,可以在其他文件中使用extern来得到已经定义的全局变量或者函数的引用,即extern是用来在另一个文件中声明一个全局变量或函数的。
  • volatile,用于通知编译器不需要优化被修饰的变量,程序可以直接从内存中读取变量,多用于多线程间数据共享。经过编译器优化的变量会将其值放入寄存器来加速读写。
  • mutable,仅用于类的实例,允许被修饰的成员通过 const 成员函数修改。
  • thread_local,指示被修饰的变量仅可以在其被创建的线程上可见,每个线程都将拥有被修饰的变量的副本。thread_local不能用于函数声明或定义。

常量定义

常量是固定值,在程序的运行期间不可更改。常量可以是任何基本数据类型,使用const关键字定义,格式为const 常量类型 常量标识符=值;

Caution

习惯上都会将常量定义为大写字母加下划线的形式以示区别。例如INITIAL_VALUE

基本数据类型

C++提供了七种基本数据类型。

类型关键字
布尔型bool
字符型char
整型int
浮点型float
双精度浮点double
无类型void
宽字符型wchar_t

基本类型都可以使用一个或者多个以下修饰符进行修饰:

  • signed,有符号类型;
  • unsigned ,无符号类型;
  • short,半长度类型;
  • long,双倍长度类型。

布尔型

布尔型是 C++中最简单的类型,使用bool来定义,只使用 1 个字节来存储数据,只表达真(true)和假(false)两个值。

整型与字符型

C++中的字符型只代表一个 ASCII 字符,在内存中占一个字节,在使用时可以像整型一样使用。

下表列出了所有整型和字符型变量的声明标识符以及在内存中所占的长度。

类型标识符所占字节位数最小值最大值
char1-128127
unsigned char10255
signed char1-128127
int4-2^312^31
unsigned int402^32
signed int4-2^312^31
short int2-2^152^15
unsigned short int202^16
signed short int2-2^152^15
long int8-2^632^63
unsigned long int802^64
signed long int8-2^632^63
float4-3.4E+383.4E+38
double8-1.7E+3081.7E+308
long doble16-1.7E+3081.7E+308
wchar_t2~4

表中各类型变量在内存中所占的字节长度根据所在系统不同会有所不同。类型wchar_t根据所存储的字符的编码方式不同,会占据 2 到 4 个不等的字节,例如 GBK 编码汉字会占据 2 字节,UTF-8 编码内容则占据 3 个字节。

C++中提供了一系列的前缀与后缀来对字面量进行类型标记,主要应用与整型和字符型类型中。整型数值可以是十进制、八进制和十六进制,C++提供了两个前缀来表示不同的进制数字,0x表示十六进制数字,0表示八进制数字,不带前缀的表示十进制数字。

从前面的表格中,可以看到整型数值是有有无符号和长短之分的,所以 C++提供了两个后缀来标记整型数值的类型,U表示无符号整数,L表示长整数,这两个后缀在使用时大小写与出现顺序没有限制,例如:19923UL

浮点与精确计算

在 C++中,浮点数是使用floatdouble两个浮点类型来操作的,但是浮点数在计算时能够保持的精度可能不能满足需要,例如float可以保持 7 个数字,而double可以保持 15 个数字。浮点数的使用与整型没有什么区别,而且很多数学函数都会返回double类型的返回值。

但是我们在大部分时刻往往会需要到精确的计算小数,这就需要使用标准库中提供的类型了。一般可选的解决方法是标准库中的iomainip或者 Boost 库中的multiprecision,具体使用可参考后文说明。

数组

数组可以用来存储相同类型元素的固定大小的集合。数组中的元素可以通过索引进行访问。数组的声明格式为类型 数组名[数组大小],例如int arr[10]。对数组内元素的访问格式为数组名[索引号],例如arr[5]。C++中数组的索引从 0 开始计算。

初始化

数组在使用前必须完成初始化,数组的初始化格式为类型 数组名[数组大小]={用于初始化的值},例如int a[2] = {1, 2}。大括号中用于初始化数组的值的数量不能大于指定的数组大小,例如这样是错误的:int a[2] = {1, 2, 4}。如果在声明数组时省略了数组大小的设定,那么数组的大小将为初始化时大括号列表中值的个数,例如int a[] = {1, 2},此时数组a长度为 2。

Caution

未初始化的数组尽量不要直接访问其中元素的内容,未初始化的数组中元素的内容是不确定的。

修改元素的值

数组的大小是固定的,但是数组中元素的值是可修改的。要修改指定元素的值,只需要对相应的索引元素直接赋值即可,例如arr[3]=50

多维数组

C++支持多维数组。要理解多维数组的概念,需要首先从二维数组开始。二维数组可以称为数组的数组,其声明格式为类型 数组名[一维大小][二维大小]。二维数组可以看做是一个二维的表格,对于其中元素的访问也同样是使用索引,只是索引号是两个,例如arr[4][5]。二维数组的初始化与一维数组一致,只是其中的每个元素都是一个新的数组。

三维数组可以看做二维数组组成的数组,也就是表的组合,在声明时只需要追加声明一个数组大小即可。

依次后推,多维数组每增加一个维度,只需要增加一个数组大小的定义即可,对其中元素进行访问时,每个索引都使用一对独立的方括号。

字符串

C++中的字符串有两种风格,第一种称为 C 风格字符串,另一种为string类。

C 风格字符串

其表现形式为一个字符数组,数组的末尾元素为\0null值)用来标记字符串的结束。例如以下两个字符串是等价的。

char greeting[] = {'H', 'e', 'l', 'l', 'o', '\0'};
char greeting[] = "Hello";

在使用 C 风格字符串时,不需要手动将\0放置在字符串的末尾,C++会自动完成这个操作。

C++在标准库的cstring模块中提供了大量的用于操作 C 风格字符串的函数,具体使用时可以查询标准库的相应模块。

string

C++中添加了一个string类来完成字符串的相关操作。string类位于标准库中,在使用时需要使用#include <string>将其包含进来,使用方法可见一下示例。

#include <string>

int main() {
    std::string str1 = "Hello";
}

如果不打算使用std::string的形式,可以使用using namespace std;来声明当前文件使用std命名空间,即可使用sting str1的变量声明格式。

string类中定义了一系列的常用字符串操作,并对一些操作符进行了重载,具体使用时可以参考标准库的说明。

类型转换

C++可以进行强制类型转换,将一种数据类型转换为另一种数据类型。强制类型转换的表达式格式为(目标类型)表达式。除了使用数据类型进行强制类型转换以外,C++还提供了一下几个常用的强制类型转换运算符。

  • const_cast<目标类型>(表达式),用于修改类型的const/volatile属性。
  • dynamic_cast<目标类型>(表达式),在运行时执行转换,转换将被验证,转换失败返回null
  • reinterpret_cast<目标类型>(表达式),将指针转换为其他类型。
  • static_cast<目标类型>(表达式),执行非动态转换,不保证转换的安全性。

复杂类型

在 C++中复杂类型通常都是使用多种基本数据类型组合起来的,用来表示一个复杂的事物。

结构

结构是 C++中的一种用户可以自定义的数据类型,允许用户存储不同的数据项。结构一般用于表示一条记录,其声明格式为:

struct 结构类型名 {
    成员1类型 成员1名称;
    成员2类型 成员2名称;
    成员3类型 成员3名称;
    ...
} 结构变量名;

// 用结构类型名来声明新的变量需要使用以下格式
结构类型名 结构变量名;

对于 结构成员的访问使用成员访问运算符.,例如book.name。对于指向结构的指针,访问结构成员时,需要用指向运算符->,例如有struct Book* book,访问其成员的方式为book->name

结构变量的初始化可以采用与数组类似的大括号列举成员值的语法,大括号中的值的顺序是结构中成员声明的顺序。

结构中可以定义成员函数,其与类的区别是,结构中的成员函数默认为public,而类中的成员函数默认为private

联合

联合与结构相似,但不同的是,联合中所有的成员都共享同一内存,以其中成员长度最大的长度作为自己占内存的长度。联合类型的变量在每一瞬间只能保存一个成员的值。由于联合类型中的成员都是共享内存的,所以不能够使用指针以及引用类型作为其成员类型。

联合中也可以定义成员函数,但其与类的区别是,联合不支持继承和虚函数。

联合使用union关键字声明,声明格式与结构相同。

枚举

枚举是 C++中一种派生类型,由用户定义的若干枚举常量集合而成。枚举是将变量可能的值一一列出,枚举类型变量的值只能从列举出的值中选择。枚举使用关键字enum定义,格式如下:

enum 枚举类型名 {
    常量标识符=整型常数,
    常量标识符=整型常数,
    ...
    常量标识符=整型常数
} 枚举变量;

枚举类型定义时的整形常数是可以省略掉的,当省略整型常数时,从第一个常量标识符开始依次由 0 开始递增赋值,但如果中间有常量标识符被赋予了其他的不冲突的值,那么其后的常量标识符将会重新开始递增。例如:enum {red, green=5, blue} color;red的整型值就是 0,而blue的整型值则是 6。

类型别名

前面说过的结构、联合、枚举等类型,都是在定义时声明相关变量,这样使用起来相当不便。C++提供了给类型声明别名的功能,来方便对复杂类型的使用和对有特定目的类型的标记。

类型别名使用typedef关键字声明,格式为typedef 原类型 类型别名。例如前面的枚举类型就可以先声明类型别名,再使用类型别名声明变量。

typedef enum {
    red,
    green,
    blue
} Color;

Color myColors;

上例中,类型别名Color就可以像一个真正的类型那样来使用了。除了可以为复杂类型声明类型别名,内置类型也可以声明类型别名。

typedef int Amount;
Amount purchase;

类型别名Amount作为内置类型int的别名声明了一个变量purchase,虽然看起来purchase的类型是Amount,但是从编译器的角度看,它的类型还是int。这种使用方法通常是在代码中为变量命名一个具有独特意义的类型。

Caution

类型别名的存在降低了C++程序的可读性,在阅读C++源码时,一定要对所有的类型别名定义格外留心。

此外,C++中目前结构、联合和枚举类型已经不需要使用typedef来声明类型别名了,可以直接使用类型声明的名称声明变量。

操作符

操作符是用于对变量进行操作和处理的,操作符与变量、函数等一起构成了表达式。这里不再对操作符做过多的说明,仅以下表按照操作符的优先级列出各个操作符的使用格式以及含义。

操作符优先级使用格式结合方向功能
()1(a+b)左 → 右括号
[]1a[0]左 → 右索引
->1a->name左 → 右指针成员访问
.1a.name左 → 右对象成员访问
::1std::string左 → 右作用域操作
!2!(a<b)右 → 左逻辑非
~2~a右 → 左位反
++2a++右 → 左自增
--2a--右 → 左自减
-2-5右 → 左负号
*2*p右 → 左指针解引用
&2&a右 → 左取变量地址,引用
sizeof2sizeof(a)右 → 左取变量位数大小
->*3成员指针
.*3成员指针
*4a*b左 → 右
/4a/b左 → 右
%4a%b左 → 右取余
+5a+b左 → 右
-5a-b左 → 右
<<6a<<2左 → 右二进制左移
>>6a>>2左 → 右二进制右移
<7a<b左 → 右逻辑小于
<=7a<=b左 → 右逻辑小于等于
>7a>b左 → 右逻辑大于
>=7a>=b左 → 右逻辑大于等于
==8a==b左 → 右逻辑等于
!=8a!=b左 → 右逻辑不定于
&9a&b左 → 右位与
^10a^b左 → 右位异或
|11a|b左 → 右位或
&&12a>b&&b<c左 → 右逻辑与
||13a>b||b<c左 → 右逻辑或
?:14a>b?5:10右 → 左三元条件选择
=15a=5右 → 左赋值
+=系列15a+=5右 → 左复合赋值
,16int a,b,c;左 → 右逗号

Tip

成员指针将在后文中说明。

控制结构

控制语句是组成实际程序功能最基本的元素之一,主要由两种语句:分支语句和循环语句。分支语句和循环语句都可以嵌套使用。

分支语句

C++中的分支语句有两种:if系列语句和switch系列语句。

if语句

if 语句用于对指定表达式进行判断,之后决定后续语句块是否执行,其格式为:

if (判断条件) {
    为真时执行语句块;
} else {
    为假时执行语句块;
}

if 语句可以叠加来执行多种判断,格式为:

if (判断条件1) {
    条件1为真时执行语句块;
} else if (判断条件2) {
    条件2为真时执行语句块;
} else {
    条件2为假时执行语句块;
}

switch语句

switch 语句用于对指定的表达式进行测试,根据其可能的值进行分支执行。格式为:

switch (表达式) {
    case 常量:
        语句块1;
        break;
    case 常量:
        语句块2;
        break;
    default:
        以上均不匹配时执行的语句块;
}

switch 语句中的 case 语句的数量没有限制,但是要注意其中 break 语句,缺少 break 语句时,switch 语句将继续向后执行代码而不是终止执行。

在 C++11 中,switch 语句中各个分支的常量可以直接使用字符串了,这在 C++11 之前的标准中是不可以的。

三元操作符

根据一个表达式的值来二选一返回一个值,通常可以使用if...else...来完成,但是 C++提供了一个三元操作符来编写的完成这个选择功能,以下两段代码是等价的。

int a, b=20;
// 使用if...else...实现
if (b >= 10) {
    a = 10
} else {
    a = 20
}
// 使用三元操作符实现
a = b >= 10 ? 10 : 20;

循环语句

C++中的循环语句有三种:for语句、do语句和while语句。

for循环

for 循环一般用于有限次数的循环执行,其格式为:

for (初始化表达式;终止条件判断表达式;变化表达式) {
    循环体;
}

for 循环中的三个表达式都可以省略,但是用于分隔的分号不可省略。

for 循环的执行流程图为:

ent入口init初始化表达式ent->initloop_cond判断终止条件表达式init->loop_condexit出口loop_cond:e->exit:w表达式为真loop_body循环体loop_cond:s->loop_body:n表达式为假change执行变化表达式loop_body->changechange:w->loop_cond:w

do循环

do 循环用于进行至少执行一次的循环,其格式为:

do {
    循环体;
} while (终止条件判断表达式);

do 循环的流程图为:

ent入口loop_body循环体ent->loop_bodyloop_cond判断终止条件表达式exit出口loop_cond:e->exit:w表达式为假loop_cond:w->loop_body:w表达式为真loop_body->loop_cond

while循环

while 循环与 do 循环类似,但是判断终止条件在最开始。使用格式为:

while (判断终止条件) {
    循环体;
}

while 循环的流程图为:

ent入口loop_cond判断终止条件表达式ent->loop_condexit出口loop_cond:e->exit:w表达式为假loop_body循环体loop_cond:s->loop_body:n表达式为真loop_body:w->loop_cond:w

循环控制

C++中的循环控制主要是由两个语句提供的:

  • break,终止并退出当前循环,当前循环体中的剩余语句将被跳过。
  • continue,结束本次循环,跳过当前循环体中的剩余语句,从条件判断继续执行。

函数定义

函数是由一组语句组合在一起执行固定功能的语句体,每个 C++程序都至少有一个函数——main()。声明一个函数需要三个元素:函数的返回值、函数名称和参数列表,完成函数定义需要一个语句体。

函数在不同的位置会有不同的称呼,例如在类中可能会称为成员函数或者方法。也有书籍中称呼函数为方法、子例程等。

声明并定义函数的格式如下:

// 函数的声明
返回值类型 函数名称(参数列表);
// 函数的完整定义
返回值类型 函数名称(参数列表) {
    函数语句体;
    return 返回值表达式;
}

函数的返回值类型和参数列表被称为函数签名,函数签名相同的函数会被认为是同一个函数。函数签名不同的函数即便是函数名称相同,也可以同时存在,这种特性称为函数重载。函数在声明时,参数列表中可以仅书写每个参数的类型,不必声明参数名称,但是函数在定义时必须给定参数名称。

Tip

使用带有默认值的参数可以在一定程度上替代函数重载。

函数通过函数名称来调用,格式为函数名称(参数列表)

如果一个函数不必返回任何值,需要将其返回值类型设置为void类型。

形参与实参

函数在声明时,参数列表中的参数被称为形式参数,简称形参。形参在函数体中可以像局部变量一样使用。在函数调用时,列举在参数列表中用于向函数传递值的参数被称为实际参数,简称实参。

C++通常按值传递参数,即将一个数值参数传递给函数,函数会将其赋给一个新的变量。

函数可以接受一个数组作为参数,但是需要注意的是,这时向函数传递的不是数组本身,而是数组作为一个指针传递进了函数。例如int sum(int arr[])int sum(int *arr)的含义是相同的。为了防止函数对传入的内容作出修改,保护传入的内存区域,一般会使用int sum(const int arr[])的方式来定义只读参数。

对于其他数据类型,当其占用内存空间较小时,使用按值传递的方式比较合理。但是无论在任何情况下,使用指针传递内存地址总是一个最快速的选择。

参数默认值

在定义函数时,可以为参数列表后的每一个参数指定默认值。当调用函数时,如果具有默认值的形参对应的实参位置留空,函数将自动使用默认值。

默认值的使用可参考以下示例:

int divide(int a, int b=2) {
    return a / b
}

int main() {
    int result = divide(20);
}

Lambda 表达式

Lambda 表达式即是匿名函数,这项功能在 C++11 标准中提供了支持。Lambda 表达式将函数作为对象,并可以像对象一样使用,例如赋予变量和作为参数传递。Lambda 表达式的定义形式为:[外部变量捕获](参数列表)->返回值类型 { 函数体 }。以下是一个 Lambda 表达式的示例:[](int x, int y)->int { return x + y; }。当需要使用 Lambda 表达式作用域以外的变量时,可以设定外部变量捕获来将外部变量传入 Lambda 表达式中,例如:[z](int x, int y)->int { return x + y + z; }

对于外部变量的捕获,可以使用以下几种设定:

  • [],不捕获任何变量,使用任何外部变量均会导致错误。
  • [a, &b],变量 a 以传值方式传入,变量 b 以引用方式传入。
  • [&],所有外部变量均以引用方式传入。
  • [=],所有外部变量均以传值方式传入。
  • [&, a],变量 a 以传值方式传入,其他外部变量都以引用方式传入。
  • [=, &a],变量 a 以引用方式传入,其他外部变量都以传值方式传入。
  • 对于this指针,必须显式列举在[]中,除非使用[=]或者[&]的设定。

内联函数

内联函数与普通函数之间的区别并不在于编写方式,内联函数比普通函数只是在返回值类型前多了一个inline关键字。内联函数在编译时会被替换到函数调用的位置,以占用较多内存的代价换取执行速度的提升。

通常会把执行频率较高但体积较小的函数定义为内联函数。一般在定义内联函数时会将内联函数的整个定义放置在函数声明所在的位置,即头文件中。以下给出一个内联函数的示例。

inline double square(double x) { return x * x; }

Caution

内联函数不能递归!

指针操作

很多人看到 C++中的指针就头皮发麻,脑袋会变成三个大,其实大可不必。C++中的指针固然复杂并且难以理解,但是指针依旧是 C++中最强大的工具。

指针变量的声明

在 C++中,指针变量就是使用一个*来声明,格式为类型名称 *变量名称;,例如int *ip;。与其他变量相同,指针变量里也保存着一个值,这个值就是指针所指向的内存空间的地址。不同类型的指针变量,虽然可以任意指向,但是只会按照其类型来读取所指向内存空间的内容。

在许多代码中,指针还经常使用int* ip的格式声明,这种写法是合法的,但是并不推荐,因为这会产生int*是一种类型的误解。

指针变量在刚声明但未初始化时,会指向地址为 0 的内存,对应标准库中的NULL

指针的实质

任何变量在内存中都有一个地址编号,操作系统根据这个地址编号来确定每个变量的位置并读取其中所保存的值。

指针变量中保存的,是一个特殊的值——内存地址。当我们更改了指针变量的值时,这个指针变量就指向了另外一段内存。所以指针的实质就是一个存放着内存地址的变量。

指针的使用

由于指针变量中保存的是内存地址,所以给指针变量赋值时就需要取得被指向变量所在的内存地址。获取内存地址使用&操作符。

int target = 20;
int *p;
p = &target; // 获取变量target的地址,赋予指针变量p

在上例中,直接输出变量 p 的内容,会是一个内存地址,如果需要取得这个内存地址中保存的内容,需要使用解引用操作符*。例如*p将会取得变量 target 的值。那么对*p进行赋值,也将会修改变量 target 的值,因为对*p赋值相当于将新的值保存到了变量 p 所保存的内存地址里,也就是对变量 target 进行了赋值。

指针的运算

指针是可以进行加减运算的,对指针变量进行加减运算可以让指针以自身类型的长度为单位向前(减法)或者向后(加法)移动指针所指向的位置。

指针也可以进行比较,但是此时比较的依旧是指针变量内保存的内存地址。指针变量也可以跟使用&获取的内存地址进行比较。但更多的是用于操作数组。

指针与数组

数组实际上就是一个指向一段连续内存空间的指针。在很多情况下,指针与数组可以互换使用。例如一个指向数组起始位置的指针,可以通过加减运算或者索引来访问数组元素。例如:

#include <iostream>

int main() {
    int arr[3] = {10, 20, 30};
    int *p;

    p = arr; // 将数组地址赋予指针
    for (int i = 0; i < 3; i++) {
        std::cout << *ptr << std::endl;
        ptr++; // 向后移动一个位置,指向下一个元素
    }
    return 0;
}

虽然数组就是一个指向连续内存空间的指针,但是数组变量不能被修改地址值,例如arr++是不允许的;但可以使用的地址操作表达式是*(arr + 2) = 50,即将地址偏移两个单位的位置赋值为新值,这个表达式能够成功执行的原因是没有改变 arr 中保存的地址值。

指针的指针

指针变量在内存中也有对应的位置,所以自然也会有指向指针变量的指针,也就是标题中所说的指针的指针。指针的指针作为一种多级间接寻址手段在创建一些复杂数据结构时非常有用。指针的指针使用两个*来声明,例如int **p;。同样指针的指针在解引用时也是使用两个*

Tip

应该避免使用这种指针的指针的复杂数据结构,这会更加容易的使你控制数据的逻辑发生混乱。

利用指针构建复杂数据结构

指针可以用来在不同的内存区块间建立关联,这就为一些复杂数据结构的建立提供了便利。以下给出两个常见的示例。

// 双向链表
struct Link {
    Link *prev;
    Link *next;
    std::string content;
};

// 二叉树
struct Joint {
    Joint *left;
    std::string content;
    Joint *right;
}

指针作为函数参数与返回值

函数可以接受指针作为函数参数,只需要将函数的形参声明为指针类型即可。所以函数也可以接受数组作为参数。例如:int some_function(int *ptr)

当函数使用指针作为参数时,在调用函数时需要传递变量的地址给函数,例如:some_function(&param)。但是需要注意,将数组传递给函数时,不需要获取地址,直接传递即可,例如:some_function(arr)

函数也可以返回一个指针作为返回值,但 C++不支持在函数外使用局部变量的地址,除非这个局部变量是使用static声明的。所以要从函数中返回指针,需要将函数内要返回的变量使用static声明。

函数指针

跟变量一样,函数在内存中也有地址,这就意味着指针也可以指向一个函数。要让一个指针指向一个函数,必须声明一个函数指针。函数指针的声明与函数的声明类似,但是不必使用函数名,函数名的位置替换成了一个指针。函数指针的声明格式为:函数返回值类型 (*函数指针名)(函数参数类型列表),例如double (*pF)(int)

当声明了函数指针后就可以将函数赋予这个指针,并且这个指针可以完成函数调用的功能。可参考以下示例。

// 定义一个函数
double some_function(int a) { ... }
// 声明一个可以指向以上函数的函数指针
double (*pF)(int);
// 将函数赋予函数指针
pF = some_function;
// 使用指针来调用函数
(*pF)(5);
// 实际上C++可以用以下方式来使用指针调用函数,但往往不推荐
pF(5);

引用

引用是一个很容易跟指针混淆的概念,在使用上也非常容易混淆。引用变量是另一个被引用变量的别名。引用与指针之间有以下三个明显不同:

  1. 存在空指针,但是不存在空引用,引用必须连接到一块合法的内存区域。
  2. 引用一旦初始化,就不可再更改其指向的对象,但指针可以随时更改。
  3. 引用必须在声明时初始化,指针则没有此项约束。

引用使用以下格式声明和初始化:类型名称& 引用变量=被引用变量;。引用变量是被引用变量所在内存区域的另一个标签,这部分内存区域既可以通过原始变量访问,也可以通过引用变量访问。

Caution

要注意引用与指针中取变量地址的操作是不同的,虽然它们使用同一个操作符。

引用作为函数参数与返回值

跟指针一样,引用也可以作为函数的参数和返回值。当作为函数的参数时,同样只需要直接声明即可,例如:int some_function(int& x)。调用这个函数时,不需要像指针一样传递变量的地址,只需要直接传递变量即可,例如:some_function(a);

Tip

在函数中对引用类型的参数进行赋值操作,会直接改变被引用内存区域的内容,使函数的实参(原变量)发生变化。

从函数中返回一个引用时,所需要遵守的限制与从函数中返回指针相同。

Caution

从函数中返回一个局部变量的引用会超出局部变量的作用域,这种情况是不被允许的,所以只可以返回一个对静态变量的引用。

内存空间的分配与释放

指针的最大用途是直接操作内存,C++提供了直接分配内存并赋予指针的方法来在不声明变量的情况下动态在内存中创建数据存储区。在 C 语言中,动态分配内存是通过malloc()来完成的,但是在 C++中提供了关键字new。动态分配一个内存区域并赋予指针的语法为数据类型 *指针变量名称=new 数据类型;,例如int *ip = new int;。这样动态分配得到的内存区域通常会被称为数据对象,以此来跟变量区分开。

除了可以动态分配普通数据类型内存以外,还可以动态创建数组。例如创建一个可以存储 10 个元素的整型数组的格式为int *a = new int [10];

在运行时动态分配的内存在使用完毕时必须使用关键字delete释放,否则这块内存区域将变为泄露区,使得程序占用的内存越来越大。要释放动态创建的数组,需要使用语法格式delete []。例如以下示例。

int *p = new int;
int *pa = new int [30];
delete p;
delete [] pa;

在使用动态内存分配和释放时,需要遵循以下规则:

  • 不要使用delete来释放不使用new分配的内存。
  • 不要使用delete释放同一个内存区块两次。
  • 如果使用new []为数组分配内存,则必须使用delete []来释放。
  • 对空指针使用delete是安全的。

使用mallocfree

malloc()free()是 C/C++的标准库函数,它们所完成的功能比newdelete更加低级。malloc()free()仅仅是向系统申请和释放固定长度的内存区块,并不会像newdelete一样可以执行目标类型中的构造函数和析构函数。

以下给出一个malloc()free()的使用示例。

Object *a = (Object *)malloc(sizeof(Object)); // 分配并将内存区块转换为目标类型
a->Init(); // 注意,目标对象必须手工初始化
// ...执行处理过程
a->Destory(); // 再次手工执行析构
free(a); // 释放相应的内存区块

关于类

C++中的类是分为两部分完成定义的,一部分是声明,位于头文件中,另一部分是定义,位于源代码文件中。类在完成声明后,可以作为一个类型来使用。

类的声明格式

类使用class关键字声明,基本声明格式如下:

class 类名称 {
    private:
        // 私有成员声明
    public:
        // 公有成员声明
}

C++中的类的私有声明和公有声明的顺序没有限制。当私有声明在前时,private关键字可以省略,因为 C++的类中成员默认都是私有的。

C++中的结构也可以实现类的功能,但其中的成员默认都是公有的。

以下给出一个复杂的类定义,其中包含了一个带有继承的类应该有的大部分声明。

class Vehicle {
    public:
        virtual void build() {} // 虚函数可以被子类重新定义
        void run()=0; // 这里指出这个类是一个抽象类,这个成员为纯虚函数
    protected:
        double length;
        double width;
        double height;
}

class Car: public Vehicle { // 继承Vehicle类
    public:
        Car(); // 构造函数
        ~Car(); // 析构函数
    private:
        std::string brand;
        std:string name;
        int wheels;
        int seats;
}

成员函数定义

实现成员函数不需要再遵循类的声明的格式,只需要在源代码文件中使用返回值类型 类名称::成员函数名(参数列表) {}的格式完成实现即可。例如上节中的Car类的成员函数即可使用void Car::build() {}来完成实现。

成员函数中可以使用this指针来访问自身对象。这里有一个小技巧,成员函数在返回class&类型时,返回*this即可实现连续操作,例如:new Builder()->setName(0)->build()。这种成员函数可以这样定义。

Builder& setName(int value) {
    this->value = value;
    return *this;
}

构造函数与析构函数

构造函数和析构函数是类中的两个比较特殊的成员函数,其实现与普通成员函数无异。但是声明有所区别。

构造函数没有返回值类型,其名称与类相同,可以携带参数。携带的参数可以在创建实例时传入。构造函数可以采用构造列表的方式来初始化成员变量,其格式可参考以下示例。

class Car {
    private:
        int seats;
        int wheels;
    public:
        Car(int m_seats, int m_wheels): seats(m_seats), wheels(m_wheels);
}

析构函数也没有返回值类型,名称与类相同,但在前面添加了~前缀,析构函数不接受任何参数。

如果一个类需要显式声明析构函数,那么它也需要重载=操作符和从其他同类型实例复制内容的构造函数(例如ClassA(const ClassA&)的构造函数)。

构造函数和析构函数可以参考前面示例中的Car类。

在类存在有单参数构造函数时,C++会使用隐式类型转换,例如以下示例是不会报错的。

class Example {
public:
    Example(bool _value): active(_value) {}
    Example(const Example& _instance) { this->active = _instance.active; }
private:
    bool active;
}

// 以下调用均会成功
Example a = false;
Example b{3};

为了防止 C++隐式调用单参数构造函数,就需要借助于explicit关键字来进行标记。将上例代码改为以下格式后,之前的调用就不会成功了。

class Example {
public:
    explicit Example(bool _value): active(_value) {}
    explicit Example(const Example& _instance) { this->active = _instance.active; }
private:
    bool active;
}

explicit关键字只能标记在单参数构造函数前,在其他构造函数前使用没有意义。

继承

类的继承使用class 类名: 访问控制标识 基类名称 {}的格式声明。其中访问控制标识可以取protectedprivatepublic,用于控制基类中成员在子类中的访问级别。访问控制遵循以下规律。

  • 使用public,基类中的成员将保持原有的访问级别。
  • 使用protected,基类中的publicprotected成员都将变为protected级别。
  • 使用private,基类中的所有成员都会变为private

子类在实例化时,必须先调用基类的构造函数创建基类对象,这就需要使用子类的构造函数采用特殊写法。例如有以下两个类BasePersonChild

Child::Child(std::string name, int age)
    :BasePerson(name, age) {
    // Child的构造函数体
}

子类的构造函数的参数列表后的:后,即为调用基类的构造函数位置,可以将构造函数收到的参数传递给基类的构造函数。

重载操作符

重载操作符是一种 C++的多态。操作符重载将重载的概念从函数扩展到操作符,赋予操作符更多的含义。重载操作符需要使用操作符函数的特殊格式,其格式为返回值类型 operator op(参数列表)。其中,op是将要重载的操作符,并且是 C++中已有的有效操作符,不能虚构一个新的符号。

例如以下类重载了+操作符。

class Time {
    private:
        int hours;
        int minutes;
    public:
        Time();
        Time(int hour, int minute=0);
        Time operator+(const Time& t) const; // const成员函数不能修改调用它们的实例
}

默认以成员函数方式重载的操作符,会默认将本类实例this作为操作符的左操作数传递进函数,所以操作符的操作数要比其他形式少一个。

创建实例

创建类的实例有两种方法,第一种是像声明一个普通变量一样,格式为类名称 变量名 = 类名称(),例如Time t = Time(23, 0)。这样创建的实例,需要使用.来访问其成员。

另一种是比较常用的,使用new来动态创建对象实例,格式为类名称 *指针名 = new 类名称(),例如Time *t = new Time(23, 0)。使用动态创建的类的实例,需要使用->来访问其成员。

友元

友元是对类中成员的另一种形式的访问权限,友元可以与类的成员函数一样访问类中的成员。友元有三种:

  1. 友元函数。
  2. 友元类。
  3. 友元成员函数。

前面Time类中重载的+操作符是以成员函数形式定义的,其调用时会默认将调用实例作为左操作数传入。但是如果当调用实例出现在操作符右侧,那么就会出现错误。使用友元可以解决这种结合律的问题。例如可以创建以下友元函数。

class Time {
    public:
        friend Time operator+(const Time& t, const Time& t2);
        friend ostream& operator<<(ostream& os, const Time& t); // 可以支持链式向cout输出的操作
}

友元函数放置在类声明中,使用friend关键字声明。但是友元函数在实现时,因为其不是类的成员,所以不需要使用::限定,直接实现函数即可(去掉friend关键字)。

友元类的声明比较简单,只需要在类的声明中添加friend class 友元类名;即可让友元类访问本类的全部成员。但是在大部分情况下并不需要让整个类成为本类的友元,所以就出现了友元成员函数,这允许让另一个类中的某个成员函数成为本类的友元,访问本类的全部成员。友元成员函数的声明格式为friend 友元成员函数返回值类型 所属类名称::成员函数名(参数列表);。例如:

class TV; // 由于Remote使用了TV类,编译器需要确定TV类的存在,所以使用前向声明
class class Remote {
    public:
        void set_channel(TV& tv, int ch);
}
class TV {
    public:
        friend void Remote::set_channel(TV& tv, int ch);
}

异常处理

程序在运行过程中往往遇到运行阶段错误,这些不可预见或者可以预见的错误都会导致程序无法正常继续运行。在遇到异常时,一般有以下几种处理方法。

调用abort()

abort()位于cstdlib头文件中,调用abort()可以向cerr发送程序异常终止的消息,并结束程序运行。

使用异常

使用异常类对错误进行处理,一般由throw关键字抛出,并使用try ... catch ...语句块进行捕获和处理。try/catch的使用格式为:

try {
    // 可能会出现异常的语句块
} catch (要相应的异常类型 捕获异常变量) {
    // 异常处理语句块
}

C++中提供了exception类,但是使用throw进行抛出的时候,并不一定必须抛出exception或者其子类的实例,也可以直接抛出一个字符串,并使用char*来进行捕获,还可以是一个自定义的类。

throw关键字还可以跟在函数声明后面,列举函数可能会抛出的异常类型,使用格式示例如下:

void may_be_errors(double z) throw(const char*, exception);

函数可以抛出的多种异常类在throw后的括号中使用逗号分隔列出,在实现函数定义时,也需要将函数声明中的异常声明也包含进来。如果throw的列表是空白的,说明这个函数不会抛出任何异常。在 C++11 中可以使用关键字noexcept来标记函数不可以抛出任何异常,大致相当于之前的throw(),但编译器的处理方式有所区别。

C++库定义了多种异常,在头文件<exception>中定义了exception类,可以作为其他异常类的基类。exception类中有一个what()虚成员函数,用来返回一个字符串,在继承实现异常时需要重载。头文件<stdexcept>中还定义了其他的几个异常类,都是从exception类中派生来的,其常用的几个类如下。

  • runtime_error,运行时错误;
  • range_error,超出值域;
  • overflow_error,计算上溢出;
  • underflow_error,计算下溢出;
  • logic_error,逻辑错误;
  • domain_error,定义域错误;
  • invalid_argument,无效参数;
  • length_error,长度错误;
  • out_of_bounds,超出范围。

const 的使用

const关键字是 C++中的神奇关键字,能够花样百出的完成各种保护工作。指针跟const关键字相比,其使用复杂程度简直是弱爆了。以下列举常见的const关键字的使用方法。

  1. 定义常量:const a = 0
  2. 对指针使用const,当const位于*左侧,const用于修饰指针的指向;当const位于*右侧,const用于修饰指针本身。
    1. (char *) const pCconst (char *) pC,表示指针本身指向不可变;
    2. const char *pCconst (char) *pC,表示指针只能指向常量;
    3. const char * const pC,表示指针只能指向常量且不可变更指向。
  3. 当函数使用const
    1. 修饰参数的情况:
      1. const int var,参数在函数内不可变;
      2. const char * var,指针所指内容不可变;
      3. char * const var,指针参数本身不可变;
      4. const class& var,引用参数在函数内不可变。
    2. 修饰返回值的情况:
      1. const int f(),无意义;
      2. const int * f(),返回不可变指针;
      3. int * const f(),返回不可变指针。
  4. 当类中使用const
    1. 当成员变量使用const修饰时,只能在初始化列表中对成员变量进行赋值,并不能够再次修改。
    2. 对成员函数使用const修饰时,const关键字一般写在成员函数声明的末尾:
      1. 使用const修饰的成员函数不允许修改任何一个数据成员。
      2. 使用const修饰的成员函数不能调用类中非const成员函数。
  5. 对于使用const修饰的类实例、指针和引用,只能调用类的const函数,并且其中的任何成员都不能修改。

在类中使用const修饰的方法是不能修改类成员变量的,但是被mutable关键字修饰的成员变量除外。关键字mutable可以突破const关键字限制,将成员变量声明为始终可变的。

泛型

泛型在 C++中是通过模板来实现的,大部分书籍都直接称为模板,此处为了与其他语言打通名词,称为泛型。泛型就是使用独立于任何特定类型的方式编写通用的代码。在泛型编程中,类和函数可以用于跨越编译时不相关的类型,一个类或者一个函数可以用来操纵多种类型的对象。泛型被广泛应用于标准库中。

C++中的模板分为函数模板和类模板两种。模板使用template关键字声明和定义。

函数模板

函数模板的声明格式如下:

template <类型名称 T>
返回值类型 函数名称(参数列表) {
    函数体
}

关键字template后接的是模板形参表,是用尖括号括住的一个或者多个模板形参的列表,形参之间使用逗号分隔,模板形参表不能为空。模板形参可以使用表示类型的类型形参,也可以是表示常量表达式的费类型形参。类型形参跟在关键字class或者typename后定义,其意义相同,只是typename是由标准 C++提供的,旧版 C++可能只能使用class关键字。类型形参的名称可以随意命名,但在之后的使用过程中需要保持一致,并且不能重用。

以下给出一个用于比较的函数模板示例。

template <typename T>
int compare(const T& v1, const T& v2) {
    if (v1 < v2) return -1;
    if (v1 > v2) return 1;
    return 0;
}

函数模板在调用时会自动进行推断,由编译器决定调用哪个版本。

类模板

类模板与函数模板的定义格式类似,也是将template关键字和模板形参表放置在类定义关键字class之前,其声明格式如下:

template <typename 类型形参名称>
class 类名称 {
    类声明
}

类模板在使用时必须显式指定类型实参,例如Queue<int> qi;

非类型模板形参

模板形参不必都是类型,非类型形参将用值代替。例如以下示例:

template <typename T, size_t N> void array_init(T (&param)[N]) {}

当进行以下调用时,编译器将会自动计算非类型形参的值。

int x[4];
array_init(x); // 相当于 array_init(int(&)[42])

文件操作

C++中使用流来完成各种输入输出操作,针对不同的操作对象提供了不同的流实现类。相关的 I/O 库头文件有:

  • <iostream>,定义cincoutcerrclog,对应标准输入流、标准输出流、非缓冲标准错误流和缓冲标准错误流。
  • <iomanip>,通过参数化的流操作器来声明对执行标准化 I/O 的服务。
  • <fstream>,为处理文件提供服务。

流操作大量使用<<>>操作符来表示数据的流转以及注入方向。

标准输入输出

要使用标准输入输出,必须引入<iostream>头文件。

对象cout是连接到标准输出设备的iostream实例,与操作符<<结合使用。cout对象放置在<<操作符左侧。<<运算符可以用来输出各种内置类型,如果需要输出自定义类型,需要进行重载。以下是一个使用cout进行输出的示例。

std::cout << "Hello, " << person << std::endl;

示例中的std::endl表示一个换行符。

对象cin是连接到标准输入设备的iostream实例,与操作符>>结合使用。cin可以在用户进行输入并回车之后进行捕获。C++编译器会根据要输入值的数据类型来选择合适的运算符来提取值。>>运算符可以在一个语句中多次使用。以下是一个使用cin进行输入的示例。

std::cin >> name >> age;

<iomanip>中提供了多个函数可以用来注入流中来对标准输入输出进行控制。主要有以下函数。

函数功能适用
resetiosflags(long f)关闭指定标志 fI、O
setbase(int base)设置数值的基本数O
setfill(int ch)设置填充字符O
setiosflags(long f)启用指定标志 fI、O
setprecision(int p)设置数值的精度O
setw(int w)设置域宽度O

cout中的方法cout.setf()cout.precision()cout.unsetf()分别对应<iomanip>中的setiosflags()setprecision()resetiosflags()。这些函数中常用来操作的标志有以下这些:

标志功能
ios::boolalpha使用英文单词输出布尔值
ios::oct使用八进制输出数值
ios::dec使用十进制输出数值
ios::hex使用十六进制输出数值
ios::left输出调整为左对齐
ios::right输出调整为右对齐
ios::scientific使用科学记数法显示浮点数
ios::fixed使用自然记数法显示浮点数
ios::showbase显示数值的基数
ios::showpoint显示小数点和额外的零
ios::showpos在非负数前显示+
ios::skipws读取时跳过空白字符
ios::unitbuf每次插入内容后清空缓冲区
ios::internal将填充字符放到符号和数值之间
ios::uppercase使用大写形式输出标记字符

以上标志可以使用|进行拼合。

<iostream>中定义了以下内容可以用来直接注入到流中。

操作符功能适用
std::boolalpha启用boolalpha标志I、O
std::dec启用dec标志I、O
std::endl输出换行符,清空缓冲区O
std::ends输出空字符O
std::fixed启用fixed标志O
std::flush清空流O
std::hex启用hex标志I、O
std::internal启用internal标志O
std::left启用left标志O
std::noboolaplha关闭boolalpha标志I、O
std::noshowbase关闭showbase标志O
std::noshowpoint关闭showpoint标志O
std::noshowpos关闭showpos标志O
std::noskipws关闭skipws标志I
std::nounitbuf关闭unitbuf标志O
std::nouppercase关闭uppercase标志O
std::oct启用oct标志I、O
std::right启用right标志O
std::scientific启用scientific标志O
std::showbase启用showbase标志O
std::showpoint启用showpoint标志O
std::showpos启用showpos标志O
std::skipws启用skipws标志I
std::unitbuf启用unitbuf标志O
std::uppercase启用uppercase标志O
std::ws跳过全部前导空白字符I

文件处理

标准库<fstream>中提供了ofstreamifstreamfstream来对文件进行操作,其中ofstream用于向文件输出,ifstream用于从文件输入,fstream用于双向操作。

ofstreamifstreamfstream中提供了open()成员函数来打开文件,open()函数的声明为:void open(const char *filename, ios::openmode mode)。其中第一个参数为要打开的文件名,第二个参数为打开模式。文件的打开模式可选择以下值。

  • ios::app,在文件末尾追加。
  • ios::ate,指针定位到问价末尾。
  • ios::in,只读方式打开。
  • ios::out,只写方式打开。
  • ios::trunc,重置已经存在的文件内容。

当文件使用完毕后,需要及时关闭,可以使用成员函数close()

具体文件操作可参考以下示例:

std::fstream file;
file.open("file.dat", ios::out | ios::in);
file << "Hello World." << std::endl;
file.close();

常用数据结构

要完成一个应用,所使用到的数据结构非常多,但是有些使用的非常频繁的数据结构,C++已经在其标准库中予以了实现。这些数据结构已经不需要再手动完成其实现了,可以直接使用。

容器类

标准库中提供了以下若干容器类,分别适用于不同的用途。

容器类所属头文件使用格式功能
vector<vector>std::vector<type>快速随机访问元素
list<list>std::list<type>快速插入与删除元素
deque<deque>std::deque<type>双端队列
stack<stack>std::stack<type>后入先出的栈
queue<queue>std::queue<type>先入先出的队列

容器类都使用泛型来存储内容,并支持一些通用的方法来访问其中的内容。容器类中元素类型必须能够支持赋值运算,并且能够可以复制,即存在使用一个同类型实例创建新实例的构造函数。

容器类一般都具有以下类型,用于辅助容器的使用。下表中以V表示容器类,T表示容器中的元素类型。

类型值与功能
V::value_typeT,获取元素的类型
V::referenceT&,元素的引用类型
V::const_referenceconst T&
V::iterator指向T的迭代器,行为与T*类似
V::const_iterator指向cosnt T的迭代器
V::different_type用于表示两个迭代器之间的距离的符号整型
V::size_type无符号整型,用于表示容器对象的长度、元素数目和下标

以下方法针对全部容器都可以使用。

方法功能
begin()返回指向第一个元素的迭代器
end()返回指向超尾的迭代器
rbegin()返回指向超尾的反向迭代器
rend()返回指向第一个元素的反向迭代器
size()返回元素数目
maxsize()返回容器的最大长度
empty()判断容器是否为空
swap()交换两个容器的内容

std::size_t

size_t 类型定义在 cstddef 头文件中,该文件是 C 标准库的头文件 stddef.h 的 C++版。它是一个与机器相关的 unsigned 类型,其大小足以保证存储内存中对象的大小。可以用来保存任何使用sizeof()获得的对象大小。

std::size_t常用于动态分配内存和动态数组中。

智能指针

当指针作为类成员时,指针成员的复制将使两个实例的指针成员均指向同一地址,将其中一个指针释放时,其他实例中的指针将会变为悬垂指针。使用智能指针类可以控制此情况的发生。

其原理是控制指针成员的删除,只有被引用对象无任何实例使用时,才会被彻底删除。智能指针类主要通过引用计数来控制被引用对象。

以下给出一个示例方案。

class HasPtr;
// 定义计数类
class U_ptr {
    friend class HasPtr;
    int *ip;
    std::size_t use;
    U_ptr(int *p): ip(p), use(1) {};
    ~U_ptr() { delete ip; }
}

// 使用计数类
class HasPtr {
    public:
        HasPtr(int *p, int i): ptr(new U_ptr(p)), val(i) {}
        HasPtr(const HasPtr& origin): ptr(origin.ptr), val(origin.val) { ++ ptr->use; } // 此处进行复制控制
        HasPtr& operator=(const HasPtr&);
        ~HasPtr() { if (-- ptr=>use == 0) delete ptr; }
    private:
        U_ptr *ptr;
        int val;
}

常用库

库是一个应用在编写的时候无法绕开的一个东西,也是我们完成代码功能复用和代码功能分拆的一个非常得力的工具。可以说库基本上就是组成现代应用程序的砖石,一个应用的设计往往就是从寻找和检查相关联的库开始的。

库根据其链接形式主要分为静态链接库和动态链接库两种,这两种库都有各自的使用特点。

如何将库加入项目依赖

对于在 C++中使用库的问题,主要是需要解决如何搜索到库的头文件以及动态链接库文件所在位置。

Tip

实际上只要在代码中引用了指定目录中或者库中的头文件,那么在编译的时候就会自动的加入相应的库。

头文件的搜索

对于头文件,C++编译器有两种搜索顺序。

当使用#include "头文件名"时,编译器会按照以下顺序进行搜索。

  1. 文件所在当前目录。
  2. 编译器使用-I指定的目录。
  3. gcc 的环境变量CPLUS_INCLUDE_PATH中包含的目录。
  4. gcc 内定的目录,包括/usr/include/usr/local/include等。

当使用#include <头文件名>时,会按照以下顺序搜索。

  1. 编译器 gcc 使用-I指定的目录。
  2. gcc 的环境变量CPLUS_INCLUDE_PATH中包含的目录。
  3. gcc 内定的目录,包括/usr/include/usr/local/include等。

静态库文件的搜索

静态库文件会按照以下顺序搜索。

  1. 编译器 gcc 使用-L指定的目录。
  2. gcc 的环境变量LIBRARY_PATH中包含的目录。
  3. gcc 内定目录,包括/lib/usr/lib/usr/local/lib等。

动态库文件的搜索

动态库文件会按照以下顺序搜索。

  1. 通过编译器 gcc 参数-Wl,-rpath指定的动态库路径。
  2. 环境变量LD_LIBRARY_PATH中包含的目录。
  3. 配置文件/etc/ld.so.conf中指定的搜索路径。
  4. 默认动态库路径/lib/usr/lib

标准库

Tip

这里只列出标准库所支持的功能内容,具体标准库中各种功能是使用,可参考标准库的文档。

C++标准库可以分为两部分:标准函数库和面向对象库。

标准函数库是由通用、独立、不属于任何类的函数组成的,继承自 C 语言。其中包含了以下内容:

  • I/O;
  • 字符串和字符处理;
  • 数学功能;
  • 时间、日期和本地化;
  • 动态分配;
  • 其他函数;
  • 宽字符函数。

面向对象库定义了大量支持常见操作的类,主要包含以下内容:

  • 标准 C++ I/O 类;
  • String 类;
  • 数值类;
  • STL 容器类;
  • STL 算法;
  • STL 函数对象;
  • STL 迭代器;
  • STL 分配器;
  • 本地化库;
  • 异常处理类;
  • 杂项支持库。

makefile 编制

makefile 用于配置 make 命令的编译和链接程序。makefile 的编写一般遵循以下规则:

  1. 如果工程没有编译过,那么所有的 C 文件都需要编译并被链接。
  2. 如果工程中仅有几个 C 文件被修改,那么只需要编译被修改的文件,之后再进行链接。
  3. 如果工程的头文件被修改,那么需要编译引用了这几个头文件的 C 文件,并进行链接。

makefile 中的内容只需要了解即可,编制 makefile 可以使用 CMake 来完成。

条目规则

makefile 由多条条目组成,每个条目都遵循以下规则:

编译目标(target): 生成目标所需的文件或者目标(prerequisites)
    需要make执行的shell命令(command)

其中编译目标可以是.o 文件、可执行文件或者是标签。

如果 prerequisites 中的文件比 target 新,那么 command 将会执行。

如果 target 使用标签的话,那么冒号后不能有任何内容。

变量

变量在 makefile 的最开始定义,直接使用赋值即可完成。变量可以替代 makefile 中的任何内容,调用时使用$(变量名)的格式。

CMake 配置

CMake 是一个跨平台的开源构建工具,用于编译生成 makefile,可以简化手写 makedile 的巨大工作量。CMake 通过编写CMakeLists.txt文件进行配置,其中内容不区分大小写。

使用 CMake 配置的项目,可以先执行cmake命令编译 makefile,然后再执行make命令使用 makefile 完成项目编译。

基本配置

CMake 的基本配置只有三行,用于定义当前的项目名称和程序源码文件。基本内容如下:

cmake_minimum_required (VERSION 2.6)
project(项目名称)
add_executable(项目名称 源码文件列表)

其中第一行指定了编译本文件所需的 CMake 程序版本。project定义了项目代号,add_executableproject定义的项目指定了需要编译的源代码文件。

基本配置中,还可以使用set(CMAKE_CXX_STANDARD n)来指定 C++的标准版本,例如使用 C++11 就可以使用配置set(CMAKE_CXX_STANDARD 11)。对于不同的系统环境,CMake 会自动寻找可用的编译器,但是也可以通过设置CMAKE_C_COMPILERCMAKE_CXX_COMPILER两个变量来指定要使用的编译器,指定时需要写明编译器可执行文件的具体路径。

Tip

CMake配置中,圆括号中的参数一般都使用空格隔开。对于字符串参数,一般可以不必书写双引号。

添加多个源码文件

当项目由多个源码文件组成时,就可以将其逐一添加到add_executable中(包括.h 头文件和.cpp 源码文件),来使 makefile 能够对其进行编译。但是当文件过多时,就会显得十分繁琐,可以使用 CMake 提供的aux_source_directory来对当前目录中的源文件进行收集。修改后的配置文件格式为:

project(Demo)
aux_source_directory(. DIR_SRCS)
add_executable(Demo ${DIR_SRCS})

aux_source_directory将源码文件收集到了DIR_SRCS变量中,然后在add_executable中使用${DIR_SRCS}的格式应用了其中的内容。

设定输出目录

使用set(EXECUTABLE_OUTPUT_PATH "${PROJECT_SOURCE_DIR}/bin")可以将可执行文件的输出目录设置为项目根目录下的 bin 文件夹中。

设置set(LIBRARY_OUTPUT_PATH "${PROJECT_SOURCE_DIR}/bin/lib")可以设定库文件的输出目录。

所有常用目录的设置变量如下:

变量设置功能
EXECUTABLE_OUTPUT_PATH可执行文件输出目录
LIBRARY_OUTPUT_PATH库文件输出目录
CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUGDebug 版本可执行文件输出目录
CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASERelease 版本可执行文件输出目录
CMAKE_ARCHIVE_OUTPUT_DIRECTORY_DEBUGDebug 版本库文件输出目录
CMAKE_ARCHIVE_OUTPUT_DIRECTORY_RELEASERelease 版本库文件输出目录
CMAKE_DEBUG_POSTFIXDebug 版本库文件后缀名
CMAKE_RELEASE_POSTFIXRelease 版本库文件后缀名

将子目录中的内容编译为链接库

当项目中包含多个子目录时,一般会将子目录中的内容编译为一个链接库来与主目录中的内容建立关联。这需要在子目录中也建立一个CMakeLists.txt文件对子目录中的内容进行配置。

假设目前有一个子目录supplier,首先看子目录中CMakeLists.txt是如何将这个子目录的内容编译为一个库的。

# 收集当前目录下的全部源文件
aux_source_directory(. DIR_LIB_SRCS)
# 生成静态链接库,注意这里库的名称,会在主配置文件中使用
add_library(SupplierLibrary ${DIR_LIB_SRCS})
# 指定链接库的安装路径
install (TARGETS SupplierLibrary DESTINATION bin)
install (FILES SupplierLibrary.h DESTINATION include)

然后可以在主配置文件用对其进行引用。

project(Demo)
aux_source_directory(. DIR_SRCS)
# 添加库所在的子目录
add_subdirectory(supplier)
# 指定生成目标
add_executable(Demo ${DIR_SRCS})
# 添加链接库
target_link_libraries(Demo SupplierLibrary)

add_library默认生成静态链接库,如果需要生成其他类型的链接库,可以添加参数,例如add_library(SupplierLibrary SHARED ${DIR_LIB_SRCS})可以用来生成动态链接库,add_library(SupplierLibrary STATIC ${DIR_LIB_SRCS})可以用来生成静态库。

自定义变量

CMake 中可以使用set来自定义变量,格式为set(变量名 变量内容),其中变量内容可以是连续多个内容的列表,其内容将全部保存在变量名中。

添加第三方库

添加第三方库需要使用以下配置项指定第三方库的头文件和库文件位置。

# 指定头文件位置到变量
set(INC_DIR /usr/local/include)
# 指定库文件位置到变量
set(LINK_DIR /usr/local/lib)

# 将以上两个位置包含进来
include_directories(${INC_DIR})
# link_directories相当于gcc命令中的 -L 参数
link_directories(${LINK_DIR})
# 包含指定库名称,以下包含了库名称为libev.so的库
link_libraries(ev)

# 将指定库与项目进行链接
# target_link_libraries相当于gcc命令中的 -l 参数
# 只需要书写库名,即 libxx.so 中间的xx部分
target_link_libraries(demo ev)

此外还可以使用find_filefind_library来寻找相应的库。这两个函数可以在系统或者指定路径中寻找相应的头文件和库文件,并将其路径保存到目录中,供项目包含使用,例如:

# 查找SDL.h,在包含SDL2的目录里
find_file(SDL2_INCLUDE_DIR NAME SDL.h HINTS SDL2)
# 查找名字包含SDL2的库文件
find_library(SDL2_LIBRARY NAME SDL2)
add_executable(Demo main.cpp)
# 向项目添加包含头文件的目录
target_include_directories(Demo ${SDL2_INCLUDE_DIR})
# 链接指定库
target_link_libraries(Demo ${SDL2_LIBRARY})

find_file命令常用的格式如下:

find_file(
    <VAR>
    name | NAMES name1 [name2 ... ]
    [HINTS path1 [path2 ... ENV var]]
    [PATHS path1 [path2 ... ENV var]]
    [PATH_SUFFIXES suffix1 [suffix2 ...]]
)

find_library命令常用的格式如下:

find_library(
    <VAR>
    name | NAMES name1 [name2 ... ] [NAMES_PER_DIR]
    [HINTS path1 [path2 ... ENV var]]
    [PATHS path1 [path2 ... ENV var]]
    [PATH_SUFFIXES suffix1 [suffix2 ...]]
)

在这两个命令中,命令的格式都是基本共通的,其中的参数含义如下:

  • <VAR>,查找到的目标文件或者库文件路径要存储到的变量。
  • NAMES,提供一系列可能的名称供查找。
  • HINTS,提供一系列可能的路径名称供查找。
  • PATHS,提供一系列可能的完整路径供查找。
  • PATH_SUFFIXES,提供一系列附加的子目录供检查。

借助 CMake 的 Module 定位第三方库

CMake 在进行第三方库查找时一般都是通过find_file寻找头文件,find_library寻找库文件,所以在进行查找时都需要编写两个查找命令。但实际上头文件和库文件集合在一起是可以被看做一个模块的,所以 CMake 提供了内置的模块查找功能,可以直接根据特征完成库文件和相应头文件的查找,这就是find_package

在 CMake 的 Module 目录下提供了数量众多的.cmake文件,针对常用的第三方库定义了其头文件和库文件的查找方式。可以通过命令行中的命令cmake -help-module-list来得到目前系统中安装的 CMake 支持的模块列表。所有被 CMake 支持的模块一般都是以FindXXX.cmake格式命名的,例如FindBZip2.cmake,在使用时只需要使用命令find_package(BZip2)即可,在后面就可以通过BZIP2_INCLUDE_DIRBZIP2_LIBRARIES变量来访问乡音的路径。模块定义的路径可以通过命令cmake --help-module 模块名称来查看。

find_package的常用命令格式如下。

find_package(<PackageName> [version] [EXACT] [QUIET] [MODULE]
    [REQUIRED] [[COMPONENTS] [components ... ]]
    [OPTIONAL_COMPONENTS components ... ]
)

借助 Pkg-Config 定位第三方库

许多第三方库都使用 Pkg-Config 维护所依赖的库路径、头文件路径、编译选项、链接选项等,可以方便第三方开发者便捷的获取相关信息。CMake 中通过FindPkgConfig模块提供了对于 Pkg-Config 的使用。

要在配置中使用 Pkg-Config,需要使用命令find_package(PkgConfig)启动所有 Pkg-Config 命令。FindPkgConfig命令提供了以下两个常用命令来供定位第三方库使用。

pkg_check_module(<prefix>
    [REQUIRED] [QUIET] [NO_CMAKE_PATH]
    [NO_CMAKE_ENVIRONMENT_PATH]
    [IMPORTED_TARGET [GLOBAL]]
    <moduleSpec> [<moduleSpec> ...]
)

命令pkg_search_modulepkg_check_module拥有完全相同的命令格式,区别是pkg_search_module只会获取第一个匹配到的模块的信息。

命令中的prefix一般是用来指示要搜索的库名称的,命令在获取匹配后给出的匹配库路径也是由prefix来命名的,这与find_package的特性是一样的。

命令中的moduleSpec可以用来定义要取得的模块的版本,在使用时需要使用库的具体名称通过比较操作符搭配版本号的形式,例如以下形式都是有效的。

  • glib-2.0>=2.10,获取库 glib-2.0 至少 2.10 以上的版本的库信息。
  • glib-2.0,获取任意版本的 glib-2.0 的库信息。

项目编译

关于编译器

一个 C++源程序或者项目要转变为可执行文件,必须要经过编译器的编译。

C++的编译器有许多种,其中主流的有以下几个:

  • GCC 编译器,由 GNU 提供,可支持主流系统上 C/C++项目的编译。
  • MSVC 编译器,由 Microsoft 提供,主要在 Visual Studio 上使用。
  • Clang 编译器,由 Apple 提供,基于 LLVM,编译速度优秀。

动态链接库与静态连接库的区别

两种链接库都是采用共享代码的方式。但是静态链接库中的指令会全部直接包含在最终生成的可执行文件中,而动态链接库中的指令则不必包含在可执行文件中。可执行文件在运行过程中会动态的引用和卸载动态链接库。

此外,静态链接库不能在包含其他的任何链接库,而动态链接库中则可以继续包含其他的链接库。

静态链接库是一个或者多个目标文件(.o或者.obj)的打包结果,在项目使用静态链接库的时候,静态链接库需要参与编译,在可执行文件编译完成后,静态链接库不需要与可执行文件一起发布。

动态链接库是共享函数库的可执行文件,可以在多个应用之间共享,在内存中只会存在一份副本。所以相较静态链接库,动态链接库更加节省内存,交换操作也更少,应用的升级也更加方便。但由于动态链接库不参与可执行文件的编译,所以动态链接库文件需要与可执行文件一起发布。如果动态链接库在使用者的机器上已经存在,那么就不需要与可执行文件一起发布了。

所以在选择链接库的时候,推荐尽量选择动态链接库使用。

g++常用命令行格式与参数

gcc 和 g++分别是 GNU 的 C 和 C++的编译器,在 g++编译器工作时,总共需要 4 个步骤:

  1. 预处理.cpp 文件生成.i 文件。由预处理器 cpp 完成。
  2. 将预处理后的文件转换成汇编语言,生成.s 文件。有编译器 egcs 完成。
  3. 将汇编语言转换为目标代码,生成.o 文件。由汇编器 as 完成。
  4. 连接目标代码,生成可执行程序。由链接器 ld 完成。

g++常用的参数和使用格式可参考下表。

参数功能使用格式
-c只激活预处理、编译和汇编生成.o 文件g++ -c hello.cpp
-S只激活预处理和编译,生成.s 文件g++ -S hello.cpp
-E只激活预处理,不生成文件需要重定向输出g++ -E hello.cpp > pp.txt
-o指定目标.o 文件的名称g++ -o hello.exe hello.cpp
-ansi关闭 GNU C++中与 ANSI C++不兼容的特性
-fno-asm禁止将asminlinetypeof作为关键字
-fPIC生成与位置无关的代码
-include-file包含某个代码,相当于在代码中使用#include <filename>g++ hello.cpp -include /roo/pp.h
-Dmarco相当于#define macro
-Dmarco=defn相当于#define macro=defn
-Umacro相当于#undef macro
-ldir指定#inlcude对于头文件的查找目录,多个目录使用:分隔
-Wa,optionoption传递给汇编程序,如果有多个选项可以使用逗号分开
-Wl,optionoption传递给连接程序,如果有多个选项可以使用逗号分开
-llibrary指定编译时使用库g++ -lcurses hello.cpp
-Ldir指定编译时搜索库的路径
-On编译器的优化选项,可以取 0-3,分别从无优化到最高优化
-static禁止使用动态链接库
-shared尽量使用动态链接库

生成静态链接库

静态链接库使用-static选项进行编译,常用以下命令:

# 编译相关的cpp源文件,以下命令生成hello.o
g++ -c hello.cpp
# 使用ar创建静态库文件,cr命令通知ar将.o文件封装
ar cr libstatic.a hello.o

无论 Linux 还是 Windows,在输出静态链接库时都需要使用打包工具,将.o 文件或者.obj 文件打包为一个集合,静态库中的内容会在打包时进行编号和索引。

生成动态链接库

Windows 与 Linux 的可执行文件格式不同,所以在生成动态链接库时会有一些差异。

  • Windows 系统中可执行文件为 PE 格式,动态链接库需要一个DllMain函数作为初始化的入口,被导出的函数需要使用__declspec(dllexport)关键字声明。
  • Linux 系统中可执行文件为 ELF 格式,动态链接库不需要初始化入口,也不需要任何额外的声明。

编译动态链接库常用以下命令:

g++ -m32 hello.cpp -fPIC -shared -o libhello.so

参数-m32表示生成 32 位动态链接库,-m64表示生成 64 位动态链接库。-fPIC-shared通常同时使用来生成动态链接库。

在使用 MinGW(g++的 Windows 版本)中创建动态链接库时,应该同样使用__declspec(dllexport)进行修饰。直接使用-shared生成的动态链接库中导出的函数尾部是带有@nn标记的,可以通过在编译时附加-Wl,--kill-at选项予以去除。使用 MinGW 编译的动态链接库一般会依赖libgcc-xx.dll或者libg++-xx.dll,这可以通过在编译时使用-static-libgcc-static-libg++将这两个库静态链接以去除。

关于extern "C"

很多生成动态链接库的代码中,都会出现这个声明。extern "C"表示其中的这部分代码按照 C 语言进行编译。主要用于以下情况中:

  1. C++语言中调用 C 语言的代码。
  2. 在 C++的头文件中使用。
  3. 交替使用 C 语言和 C++语言进行开发。

通常头文件中会这样书写以兼容两种语言:

#ifndef LIBRARY_A
#define LIBRARY_A
#ifndef __cplusplus
extern "C" {
#endif
    //其他声明内容
#ifndef __cplusplus
}
#endif

C 语言不支持函数重载,所以需要使用extern "C"来解决函数重名的问题。

__declspec的用法

__declspec是指定给定类型实例与微软相关的存储方式,是一种扩展定义,不包含在 ANSI 标准中。这个扩展定义简化并标准化了 C++关于微软的扩展。__declspec的用法为__declspec(扩展spec修饰符),下表中为常用的修饰符,一个声明中可以使用多个修饰符,修饰符之间使用空格隔开。

修饰符功能
align(n)仅用于 C++,控制数据的对齐
allocate("segname")为数据指定存储的数据段
dllimport从 dll 导入函数或者数据
dllexport向 dll 中导出函数或者数据、对象等,可免去模块定义.def 文件

在 Windows 下使用 MinGW 生成动态链接库需要在被导出的标识符,例如类名后添加__declspec(dllexport),并且类的友元函数前也需要添加这个标识。

动态链接库的使用

对于动态链接库,可以在程序中包含其头文件,并将其加入链接即可调用其中的内容。

使用 CMake 完成项目编译

使用 CMake 编译项目一般有两个步骤:生成用于编译项目 Makefile 和编译连接项目源文件。要完成这两个步骤只需要在项目目录中执行以下两条命令即可。

# 用当前目录中的内容建立Makefile,设定源文件目录为src,Makefile输出目录为build
$ cmake . -S src -B build
# 用build目录中的Makefile编译项目,并详细输出命令,在编译前要清理目标目录
$ cmake --build build -verbose --clean-first

C++11 新特性

右值引用

在 C++中,右值是一个临时的值,在使用结束后就会被销毁。基础类型的右值是不允许被修改的,但是用户自定义类型的右值是可以通过成员函数来修改的。但右值作为一个临时存在的对象,生命周期依旧很短。为了对即将被销毁的右值进行有效的利用,C++11 引入了右值引用。

右值引用可以延长右值的生命周期,在右值被废弃之前移走其资源,从而避免无意义的复制,从而提高效率。右值引用的符号是&&,以下是右值引用的基本使用示例。

int&& a = 1;    // 将不具名变量(临时内存区域)取了个别名
int b = 1;
int&& c = b;    // 这里将会出现错误,不能将一个左值赋予右值引用

看起来和左值引用没有什么区别,以下给出一个简单的示例。

class A {
    public:
        int a;
};
// 函数 getTemp() 返回的是一个右值,即临时变量
A getTemp() {
    return A();
}

A& a = getTemp();   // 这里将会报错,因为getTemp()返回的临时变量已被销毁
const A& a = getTemp(); // 不会报错,常量左值引用是万能引用,但只能读
A&& a = getTemp();  // 延长了getTemp()返回的右值的生命周期

在编写类的时候,经常会用到复制构造函数和复制赋值,在这个过程中被复制的内容通常要经过两次复制,并且是深复制。例如以下示例。

MyClass(const MyClass& instance) {
    // 对 instance 内容进行复制
    // C++ 会首先将其复制到临时区域再复制给相应的属性
}

借助右值引用带来的移动语义,就可以完美的对这个过程进行优化。要实现移动语义,只需要将上例中函数参数中的引用改为右值引用即可。此外标准库中提供了std::move()函数可以将左值转换为右值。

在右值引用与模板结合时,T&&不一定表示右值引用,既可能是左值引用,也可能是右值引用。其在使用时需要被初始化以决定其是左值引用还是右值引用。这里有一个比较简单的原则:

  1. 任何右值引用叠加到右值引用上,依旧是一个右值引用。
  2. 其他所有的引用类型之间的叠加都是一个左值引用。

统一初始化

C++11 扩大了用大括号括起的列表的使用范围。在 C++03 中,对于变量和属性的初始化有以下四种方法:

// 小括号初始化
int a = int(5);

// 赋值初始化
int a = 3;

// POD聚合初始化
int arr[2] = {0, 1};

// 构造函数初始化
class Example {
    Example(int v1, int v2): a(v1), b(v2) {}
private:
    int a;
    int b;
}

在 C++11 中,无论是类的变量、数组、STL 容器、类的构造函数,都统一使用大括号{}。以下给出一个示例。

class Example {
    int a, b, c[4];
    int * d = new int[4]{1, 2, 3, 4};
    vector<int> vec = {1, 2, 4};
    map<string, int> maps = {{"foo", 1}, {"bar", 2}};
public:
    Example(int i, int j): a(i), b(j), c{2, 3, 4, 5} {};
}

int main(int argc, const char * argv[]) {
    Example e{1, 2};
    return 0;
}

自动类型推导

C++11 的编译器增加了在变量声明时推断其类型的功能,这允许使用关键字auto来作为其类型,可以省去冗长的类型声明。例如以下示例。

// 例如有以下集合
vector<int> collection;
// 在C++11之前需要这样获得其迭代器
vector<int>::iterator it = collection.iterator();
// 在C++11以后可以这样书写
auto it = collection.iterator();

自动类型推断还可以用在泛型上,例如常见的 Builder 方法。在 C++11 之前是以下形式。

template <typename Built, typename Builder>
void makeObject(const Builder& builder) {
    Built val = builder.build();
}

在使用自动类型推断以后,可以省去一个类型参数。

template <typename Builder>
void makeObject(const Builder& builder) {
    auto val = builder.build();
}

为了通过使用auto变量的类型,C++11 还相应的引入了decltype关键字。decltypeauto是相对应的,可以在编译的时候判断一个变量或者一个表达式的类型。例如使用一个变量来定义另一个变量。

auto num = 1;
decltype(num) num2 = num;

或者使用decltype来定义函数返回值,这种函数的定义格式与 C++之前的函数不同,返回值类型是放置在参数列表之后的,因为放置在函数列表之前,可能函数中所用到的类型都尚未出现,所以 C++11 改变了语法规则。例如以下函数形式。

// 最简单的后置返回值类型的示例
auto swap(int a, int b) -> int;
// 搭配前面的示例,可以构建一个适配器语法的构建函数
template <typename Builder>
auto adapt(const Builder& builder) -> decltype(builder.build()) {
    auto val = builder.build();
    // 做一些适配操作
    return val;
}

decltype可以利用变量、表达式等来取得类型,并完成类型声明。

Tip

需要注意的是,自动类型推导不能应用于函数参数、非静态成员变量、数组以及模板参数。

常量表达式

常量表达式允许一些常量的值的计算在编译阶段确定,而不是在运行阶段确定。常量表达式是一个函数,通过constexpr关键字定义,其定义和使用遵循以下规则:

  • 函数中只能存在一条return语句。
  • 函数必须有返回值。
  • 在使用前必须定义。
  • return语句中的表达式不能使用非常量的函数、全局数据。
  • 函数所接收的参数只能是常量。

以下是一个最简单的常量表达式及其用法。

constexpr int m() {
    return 999;
}

const int a = m();

自定义数据类型也可以成为常量表达式的值,但这时需要自定义常量构造函数,但是需要遵循以下要求:

  • 函数体必须为空。
  • 所有成员变量必须要初始化。
  • 初始化列表只能由常量或者常量表达式赋值。

以下是一个自定义数据类型常量构造函数的示例。

class Date {
    constexpr Date(int y, int m, int d): year(y), month(m), day(d) {}

    constexpr int get_year() { return year; }
    constexpr int get_month() { return month; }
    constexpr int get_day() { return day; }
private:
    int year;
    int month;
    int day;
}

// 使用方式如下
constexpr Date mem{1970, 1, 1};
constexpr int month = mem.get_month();

自定义字面量

C++11 允许通过自定义后缀,使用整数、浮点数、字符以及字符串字面量来建立自定义类型的对象。自定义后缀表达式都以_开始,只有来自标准库的后缀不以下划线开始。例如:12_km0.5_Pa123._a0xFF540A_RGB"abc"_L等。

自定义字面量后缀可以通过字面量运算符来定义,格式为return_type operator "" _suffix(params)

注意,在 C++11 中,operator ""_suffix之间有一个空格。在 C++14 里可以没有空格。

如果字面量运算符是一个模板,则必须有空形参列表,并且只能有一个类型参数。格式为:

template <typename>
return_type operator "" _suffix();

形参列表用于接收字面量部分,可用的形参列表形式有以下这些。

  • (const char*)
  • (unsigned long long int)
  • (long double)
  • (char)
  • (wchar_t)
  • (char16_t)
  • (char32_t)
  • (const char*, std::size_t)
  • (const wchar_t, std::size_t)
  • (const char16_t, std::size_t)
  • (const char32_t, std::size_t)

集合遍历

在 C++11 之前,要对一个集合进行遍历,通常都是采用索引或者是迭代器,例如。

int arr[5] = {1, 2, 3, 4, 5};
for (int i = 0; i < 5; i++) { cout << arr[i]; }
vector<int> vec{1, 2, 3, 4, 5};
for (vector<int>::iterator it = vec.begin(); it != vec.end(); it++) {
    cout << *it;
}

C++11 引入了基于范围遍历容器的方法并不需要明确给出容器的开始和结束条件。

vector<int> vec{1, 2, 3, 4, 5};
for (auto it : vec) {
    cout << it;
}

默认情况下,for遍历取出的容器元素是不可修改的,如果需要修改,可以使用for (auto& it : vec),但这种修改受到容器本身的约束。容器本身在被遍历时并不能被修改,尤其是对其中元素的增减。

空指针

继承自 C,C++也使用NULL来初始化空指针,这个值通常建议用 0 来代替。但是这时空指针实际上是一个整型值。所以为了避免与整型值混淆,C++11 加入了一个新的关键字nullptr来表示空指针。

所以现在所有对于指针的初始化都可以使用nullptr来完成,并且可以与NULL完全区分开。

强类型枚举类

C/C++的对于枚举的传统处理都是将其转化为整型。例如以下两个位于同一作用域的枚举类型就不能同时使用。

enum Side { Left, Right };
enum Judgement { Right, Wrong };

C++11 引入了强类型枚举来解决这个问题,枚举值不会被默认转换为整型,也不能与整型值比较;而其中的枚举值也不能脱离枚举类型使用,只能使用枚举类型::枚举值的格式。

强类型枚举使用enum class来定义,格式可参考以下示例。

enum class Enumerate {
    Value1,
    Value2 = 9, \\ 可指定值
    Value3
};

强类型枚举默认底层类型为int,可以通过指定其底层类型来改变,但是需要注意底层类型必须为除wchar_t以外的整型。例如。

enum class Gender:char {
    Female,
    Male
};

静态断言

C++11 引入了关键字static_assert来完成编译期间的断言,也就是静态断言。其使用格式为static_assert(常量表达式, 提示文字)。静态断言会在编译期执行,对于指定表达式进行计算,如果表达式值为假,那么静态断言就会产生一条编译错误,中断编译。

默认构造函数

在定义一个类或结构体时,如果没有提供一个构造函数,C++会默认提供一个构造函数,其中会对成员变量进行默认初始化。例如以下示例。

struct Point {
    int x;
    int y;
}

当提供一个构造函数之后,默认提供的构造函数就没有了,但是 C++11 提供了一种方法可以要求 C++重新生成一个默认构造函数。具体使用可参考下例。

struct Point {
    Point()=default;
    Point(int _x, int _y): x(_x), y(_y) {}
    int x = 0;
    int y = 0;
}

委托构造函数

在 C++11 之前的版本中,构造函数之间是不能相互调用的,如果几个构造函数之间共享相同的构造逻辑,就必须编写额外的方法。例如下例。

class Example {
    string keep;
    init(string k);
public:
    Example(string s) { init(s); }
    Example() { init("default"); }
}

从 C++11 开始,利用委托构造函数,实现了构造函数的相互调用,所以可以在定义一个构造函数时调动另一个构造函数了。例如。

class Example {
    string keep;
public:
    Example(string s): keep(s) {}
    Example(): Example{"default"} {}
}

相似的,在类继承中,派生类也可以使用这种语法来调用基类的构造函数。

重写限制

对于类之间的继承,C++11 添加了overridefinal两个关键字来完成继承控制。其中final可以阻止类的进一步派生和重写,override则确保派生类中声明的函数与基类中有相同的签名。具体使用可参考下例。

// 类A完全不能被继承
class A final {}
// 类B为一个基类
class B {
    virtual void foo() const;
    virtual void bar();
}
// 类D继承类B
class D: public B {
    virtual void foo() const override;  // 重写基类的函数
    virtual void bar() override final;  // 重写基类函数并不能再重写
}

成员初始值定义

在 C++11 之前的版本中,只有静态成员变量是可以使用=来初始化的。这个特性在 C++11 中得到了扩展,现在成员变量可以通过={}来完成初始化,具体语法可参考变量初始化。

Lambda 表达式

Lambda 表达式已经在前面介绍过了,这是 C++11 才引入的功能。

模板别名

typedef关键字是不能为模板定义别名的,以下示例是无法通过编译的。

template <typename T>
typedef std::map<std::string, T> map_alias;

map_alias<int> map_int;

必须要通过一个类似于下例包装类来完成。

template <typename T>
struct map_alias {
    typedef std::map<std::string, T> map;
};

map_alias<int>::map map_int;

但是 C++11 通过关键字using提供了这个功能,允许为模板定义一个别名,简化变量的定义。

template <typename T>
using map_alias = std::map<std::string, T>;

alias_map<int> map_int;

C++14 新特性

泛型 Lambda 表达式

Lambda 表达式的形参现在可以使用auto声明其类型了。例如:

auto lambda_expr = [](auto x, auto y) { return x + y; };

变量模板

对于用途相似但类型不同的变量,C++14 中可以使用变量模板来定义。格式为

template <typename T>
T variable_name;

变量模板可以在使用时赋予不同的值,例如var<int> = 7,或者var<char> = 'a'。变量模板一般定义在全局,用来声明全局变量。