Note

文档内容由过往经验与实践编写。部分内容参考 C++ Primer Plus(第6版)并进行简化。适用于速览 C++ 主流标准(C++11 ~ C++20)新增的语法内容;

文档不完全使用专业术语进行描述;文档的正确性已部分经过人工智能检查。

如果文档存在错误,请单击此处发送邮件并指出问题所在。非常感谢!

Note

这篇文档只包含 C++ 基于 C 扩展的新语法内容,C 已经具备的内容不在编写范围内。

C++ 是支持面向对象的程序设计语言,它继承自 C 的通用高级语言句法形式并加以扩展。它们的基本语法结构(类与对象、函数、访问控制等)是高级语言中的一个典型,在其他高级语言当中也有它们的身影。

类 * Class

是 C++ 支持面向对象的关键。C++ 也支持 C 的结构体 struct,除了默认访问控制↘不相同以外,其他可用功能均相同,两者可以相互交换。在 C++ 程序中应该优先使用类,结构体通常仍被视为如同 C 那样的简单数据整合。

理解面向对象

面向对象 (Object Oriented) 的主要思想是:将事物的共有属性与行为提炼出来,归为一类。

例如有一些不同品种的鸟,它们有不同的体型长度,有不同的喙长,有不同的颜色等固有属性;此外它们还拥有飞行、进食、捕捉昆虫和漫无目的地游荡等行为。在这种情境下可以简单地将他们抽象化为鸟类。

将此思想运用到程序设计上,有利于程序的代码复用,进而提升程序的可维护性;还可以增强程序的灵活性,使复杂功能的程序更易开发。

是描述同一类事物的蓝图。类将属性(变量)和行为(函数)统一在一起,如同上例那样;

对象是从类诞生的具体事物。被创建的一个对象拥有一组类定义的属性值(例如,体长:12cm;喙长:2cm),这个对象还能借助自己的属性值,进行不同的行为(例如:喙足够长才能吃到蚁窝里的蚂蚁,否则只能吃菜青虫)。

变量与常量很多时候统称为变量,通过自定类型或外部库类型定义的变量称为对象。

Warning

C++ 内置基本数据类型不被认作是类,尽管具有相似的概念。

类的基本内容

创建自定类型

要创建一个类,需要以 class <类名> 开始,并用花括号作为类体,任何声明语句都需要以分号结束,它的句法如下:

class 类名 {
访问控制符:
    成员变量声明语句;
访问控制符:
    成员函数声明语句;
    友元声明;
};

以上任意一项都是可选的。

在类中声明的变量称为该类的成员变量;在类中声明的函数称为该类的成员函数;它们统称为类的成员

class Parrot {
	std::string name;
	unsigned int age;
    
    unsigned int get_age() { return age; }
};

这指示 Parrot 类拥有两个变量,一个函数。后续使用该类定义一个对象时,这个对象就包含这些成员变量,以及成员函数(成员函数是同类对象共享的,请关注)。成员函数可以直接访问成员变量,并进行一系列操作。

也可以在外部定义函数,只需在函数定义前额外指示类名。由两个冒号 :: 组成的运算符称为作用域↘解析运算符,这里用于告诉编译器,应该去 Parrot 类查找 get_age() 声明:

class Parrot {
	std::string name;
	unsigned int age;
    
    unsigned int get_age();
};
 
unsigned int Parrot::get_age() {
    return age;                    // 🟡 类成员函数可直接访问成员变量。用于操作调用该函数的对象的数据
}

上例 get_age() 需要 Parrot 对象调用,调用后返回该对象的 age 值。

Tip

将声明与定义分离是常见的共享类型的方法和项目设计规范。按上例来看,若希望共享 Parrot 类,其声明应该置于头文件,成员函数定义(aka. 成员函数实现)应该置于同名的源代码文件。非静态成员变量在实例化对象时初始化;静态成员变量在源代码文件定义,[自 C++17] 或者使用 inline 在头文件进行定义。

初始化对象

自定类型是除基本数据类型以外的,用户编写的和来自外部库的类型(例如 std::string)。

通过一个自定类型声明对象的过程称为实例化

std::string letter = "Roasted steak";

Note

以自定类型声明指针时,因为指针仅负责存储指定类型的地址值,所以不会实例化对象:

std::string* letter_ptr = 0;

C++ 支持多种方式初始化对象:

  1. 仅实例化

    std::string word;

    此处会隐式调用不接受任何参数或形参均有默认值的 std::string() 构造函数↘

  2. 隐式调用构造函数

    std::string word = "Snowflake";

    根据右侧变量类型决定调用哪个构造函数,此处调用 std::string(char*) 构造函数。

  3. 显式调用构造函数

    std::string word = string("experimental");

    像函数调用一样通过传递实参以实例化对象。 [自 C++11] 实例化左侧对象,然后实例化右侧对象,并隐式调用复制构造函数; [自 C++14] 直接通过右侧构造函数实例化。

  4. 直接初始化

    std::string word("Biscuit");

    将变量名称和隐式调用构造函数结合的写法。

  5. [自 C++11] 初始化列表

    std::string word = {"phanomenon"};
    std::string word[2] {"phanomenon", "paradise"};

    初始化列表方式也是由编译器找到合适的构造函数完成初始化。初始化列表 std::initialize_list 也是一种标准库提供的类型。

  6. [自 C++11] 聚合初始化

    当类符合以下条件时,称为聚合类型

    • 没有用户声明的构造函数;
    • 非静态成员均为 public
    • 没有基类;
    • 没有虚函数;
    • [C++17 前] 没有类内初始值设定项。

    符合聚合类型的类,编译器可以直接在编译时完成赋值,减少构造函数调用开销。

    class Condense {
    public:
        int value;
        int status;
    }
     
    int main() {
        Condense cds_data {10, 15};  // 🟢 聚合初始化
    }

通过对象访问类的成员

要在类外通过类对象访问类成员,首先需要在成员前标注访问控制↘关键字为 public;然后在对象标识符之后使用点运算符 .,并指定一个类成员。

自标注处开始,影响后续成员的可访问性,允许多个标注:

class Parrot {
public:
    std::string name;
	unsigned int age;
private:
    unsigned int get_age() { return age; }  // 🟡 无法从类外部访问
};
 
int main() {
    Parrot sally;
    sally.age = 15;
    std::cout << sally.age << std::endl;
    return 0;
}

构造函数

构造函数是类的成员函数,用于指定实例化对象时所需进行的一系列操作。当定义一个对象时,自动调用该类的构造函数。构造函数的名称始终与该类的名称保持一致。

构造函数是始终存在的。当未编写时,编译器隐式地添加一个默认构造函数:

(public) Parrot() {}

Note

  • 定义一个指针时不会调用构造函数;定义数组会按数组元素个数多次调用构造函数;
  • 括号内表示默认构造函数的访问控制类型,以下特殊成员函数相同;
  • C++ 规定,不可为构造函数指定返回值类型,以下特殊成员函数相同。

可通过构造函数,在实例化 Parrot 对象时,将对象的成员变量进行赋值:

Parrot(std::string& the_name, unsigned int the_age) {
    name = the_name;
    age = the_age;
}

以上示例出现了引用↘,目前你可以忽略它的具体作用。

在实例化新对象时,可通过调用构造函数初始化对象数据:

Parrot sally = Parrot("Sally", 1);

或通过初始化列表完成,这会隐式调用构造函数:

Parrot sally {"Sally", 1};

→ 成员初始化列表

对于只需简单初始化值的个别变量,构造函数还可以写成:

Parrot(std::string& the_name, unsigned int the_age) : name(the_name), age(the_age) {}

以上初始化方式称为成员初始化列表,这种方式适用于初始化普通成员变量或常量。

成员初始化列表的每个元素中,括号前的是成员变量,括号后的是初始化值;元素间以逗号分隔:

: 成员变量(值|形参变量), ...;
// 或者
: 构造函数(值|形参变量), ...;

若成员变量是常量或引用,必须使用该方法。也可以为成员变量直接提供初始值,请转到成员变量初始化默认值↘进一步查看。

Note

成员初始化列表方式只能用于构造函数。成员变量初始化的顺序由声明的顺序决定。

Important

接下来的特殊函数在满足以下任何条件时,需要全部编写:

  • 有指针成员变量直接存储堆内存地址,例如 int* data; 并且在构造函数中使用了 data = new int();(使用智能指针库除外);
  • 有成员变量持有运行时的资源,例如打开一个文件后的文件指针,或者开启一个网络连接后的套接字。

此外,还需要重载赋值运算符。

构造函数也可用于重新赋值:

Parrot multiname_parrot = Parrot("Denky", 1);
multiname_parrot = Parrot("Gel", 2);

第2行会先调用构造函数实例化临时对象,然后用临时对象赋值。临时对象随后被清理。

析构函数

析构函数也是类的成员函数,用于在对象不再被使用时,回收其资源所需进行的一系列操作。当一个对象离开作用域时,自动调用该类的析构函数。析构函数的名称始终与该类的名称保持一致,且需在名称前添加波浪符 ~

析构函数也是始终存在的;析构函数始终不接受任何参数。当未编写时,编译器隐式地添加一个默认析构函数:

(public) ~Parrot() {}

复制构造函数

复制构造函数也是类的成员函数,用于当对象被复制时所需进行的一系列操作。自定类型对象发生复制时,自动调用该类的复制构造函数。复制构造函数的名称始终与该类的名称保持一致,且拥有固定的参数列表。

复制构造函数的参数列表固定,仅接受一个同类型的引用参数(建议将此参数声明为 const 常量)。

满足以下条件时,编译器隐式地添加一个默认复制构造函数:

  • 未编写复制构造函数;
  • 类内成员皆可复制;
  • 未添加移动构造函数。
(public) Parrot(const Parrot&) {
    // 🟡 用形参提供的对象逐个赋值
}

默认复制构造函数不会为新对象申请堆内存,若有成员变量持有堆内存,必须手动编写该函数。

Note

  • 若将对象作为值传递给函数,复制构造函数会被调用。

  • 以下情形调用复制构造函数:

    Parrot sally = belly;

    以下情形调用构造函数,赋值运算符函数:

    Parrot sally;     // 🟡 构造函数
    sally = belly;    // 🟡 赋值运算符函数

移动构造函数 (自 C++11)

移动构造函数也是类的成员函数,用于将对象的数据移动到另外一个对象。

复制构造函数的参数列表固定,仅接受一个同类型的右值引用参数。

(public) Parrot(Parrot&&) {}

对象指针

指针也可以用于指向自定类型创建的对象。当对象被分配在堆内存时,便需要该方法以访问对象。

通过指针访问指向的对象,也需要首先对其解引用;若还需要访问对象的成员,使用点成员解析运算符 . 即可:

Parrot* sally = new Parrot();
std::cout << (*sally).get_age() << std::endl;

因为解引用运算符 * 优先级低于点成员解析运算符,所以括号不能省略。

还有另外一种更简洁的写法:->,两者效果完全相同:

std::cout << sally->get_age() << std::endl;

this 指针

仅限非静态的成员函数使用。

this 指针是一个用于代表对象本身的指针,它指向对象的地址。它常用于在成员函数内访问调起对象的数据:

void Parrot::function() {
    std::cout << this->age() << std::endl;
}
 
int main() {
    Parrot sally { "Sally", 1 };
    sally.function();
    return 0;
}

在上例,当 sally 调用 function() 时,function() 通过 this 访问了 sally 的数据。

当成员函数的形参标识符与成员变量标识符相同时,成员变量标识符会被覆盖。以 Parrot 类的构造函数为例:

Parrot(std::string& name, unsigned int age) {
    name = name;   // 🟡 语句无效
    age = age;     // 🟡 语句无效
}

上例表示将形参 name 赋值给形参 name;形参 age 赋值给形参 age;语句没有效用。

为解决此问题,可使用 this

Parrot(std::string& name, unsigned int age) {
    this->name = name;
    this->age = age;
}

this 代表对象本身,通过指针成员运算符 -> 可访问到对象自己的成员变量数据,从而解决标识符覆盖问题。

静态成员

有时候希望类能够存储不依赖于对象的数据(例如希望类本身能统计现存对象数量),或者定义一组类似于枚举的常量,或是只为调整类的行为而不针对特定对象的函数,这时就需要使用 static 标注这些成员。

继续上例,在头文件中为 Parrot 声明一个统计已实例化对象个数的变量:

static unsigned int count;  // 🟢 将会自动初始化为零

然后在源代码文件中实现构造函数对其自增,析构函数对其自减:

Parrot() {
    count++;
}
 
~Parrot() {
    count--;
}

(可选)在全局位置处对其定义并初始化。若没有手动初始化,该值为零:

unsigned int Parrot::count = 0;

自 C++17,允许使用 inline 关键字在声明处初始化,为确保可读性建议优先这么做:

static inline unsigned int count = 0;

静态成员函数适用于编写不依赖于特定对象的操作,例如读取类信息,修改类的行为。

例如希望读取类对象个数:

static unsigned int get_object_amount() {
    return count;
}

因为静态成员函数独立于对象存在,能被直接调用,所以静态成员函数不能使用 this

Parrot::count;
Parrot::get_object_amount();

静态成员函数也可以通过对象调用,但应该优先通过指定类作用域访问。

引用 * Reference

为减少操作指针可能带来的潜在问题,使操作原始数据的过程透明化,C++ 引入了引用

左值引用

引用是为一个对象设置另一个标识符的机制,相当于设置别名。通过引用创建的新标识符也可直接访问原始标识符对应的数据,操作方法与行为完全一致。

一般讨论的引用默认为左值引用。关于左值与右值的含义、右值引用的内容,请阅读后文。

引用变量

要使用引用变量,需要在类型后添加 & 并完成初始化:

// 初始化一个对象
std::string letter {"Horizon"};
// 初始化一个引用对象
std::string& str_horizon = letter;
 
// 以下两个语句都是操作同一组数据
// 原数据内容随后变为:"ForzaHorizon"
letter.insert(0, "For");
letter.str_horizon(3, "za");

应注意区分引用声明和取地址操作。取地址时无类型指定,& 单独出现在对象名称标识符的前面。

Note

引用的特性:

  • 因为引用本质上是对象的别名,绑定一个已实例化的对象,所以引用必须在声明时完成初始化;
  • 因为引用完全充当原对象的别名,后续操作与行为均与原对象保持一致,所以无法将引用重新绑定到另一个对象。

引用参数

当引用出现在函数的参数列表时,表明函数可借此别名访问到原始数据,而不是创建其副本。任何修改都直接改变原始数据。

// 🟡 函数仅修改副本的值,对外不起作用
void set_value(int original_value, int new_value) {
	original_value = new_value;
}
// 🟢 此时函数修改原始数据
void set_value(int& original_value, int new_value) {
	original_value = new_value;
}

C++ 下层会自动对引用对象完成解引用,在实际使用时和普通对象完全一样。

Warning

当一个形参为(左值)可变引用时,现代 C++ 编译器不允许将表达式或字面值常量作为实参传入函数:

// 定义
void set_age(unsigned int& new_age) {
    age = new_age;
    return;
}
// 使用
unsigned int value = 5;
set_age(value + 10);      // ❌
set_age(value);           // 🟢 OK

引用返回值

返回类型的引用时有这些特性:

  • 不需要将返回值复制到临时变量;
  • 允许将此函数置于左侧,以允许赋值等修改引用对象的操作;
  • 允许链式调用,例如 object.function().function()

Warning

不要返回存储于栈的局部变量的引用。在返回后,引用对象已经被清除:

int& function(int& value) {
    int inter_value = value;
    return inter_value;        // ❌
}

左值与右值

最初讨论左值与右值时,可以赋值语句入手,它具备最基本的形式:<左值> = <右值>

可置于等号左侧的称为左值,可置于等号右侧的称为右值。

左值 (lvalue)基于安全访存角度上可接受并存储语句操作结果的变量或对象。常见的左值有:

  • 用户定义的所有变量、对象(包括常量*);
  • 左值引用,返回左值引用 Type & 的函数;
  • 最终返回左值引用的表达式。

*注释:用户定义的常量在基于安全访存角度上可以修改,因为它们被分配在可修改的内存部分。

右值 (rvalue) 是描述语句操作过程的表达式。常见的右值有:

  • 字面值常量*;
  • 一段基本算术运算式,也包括自增;
  • 任何具有返回值的函数。

*注释:字面值常量是代码中直接编写的字符串,数值。它们也被称为硬编码值。它们在内存中存储于代码区,代码区不可被修改。

右值通常表示一段操作过程,右值的操作结果是即时返回的,右值操作产生的临时结果会消失。

右值引用 (自C++11)

右值引用是一种为了便于对象数据移动而产生的新引用机制。常用于将对象数据通过函数的右值引用形参,直接转移给另外一个对象。对于分配在堆上的足够大的对象,移动的作用将会真正显现:本质上是直接将指针传递给另一个对象。

右值引用经常出现在函数形参列表,要在形参列表上使用右值引用,需在类型名后面添加 &&

Parrot(Parrot&& original) {
    this->name = std::move(original.name);
    this->age = std::move(original.age);
    original.name.clear();
    original.age = 0;
}

上例 std::move() 用于将传入的对象转换为右值引用形式。

回到最初的定义上,右值引用形参也可用于函数需要接收右值表达式的时候:

void function(int&& expression) {}
// 调用
int val_1 = 10, val_2 = 15;
function(val_1 + val_2);

常引用形参也可以:

void function(const int& expression) {}

但右值表达式优先匹配到接收右值引用形参的函数。

作用域 * Scope

作用域是指标识符的有效作用范围,是任何标识符皆有的一个基本属性。例如在 main() 中创建的变量仅在 main() 中可用。

类型定义、枚举定义、名称空间定义和函数定义等,都会各自构成作用域,并且它们被称为“有名作用域”;

条件语句各分支体、循环体等也会各自构成作用域,它们被称为“无名作用域”,不能自外部解析作用域访问。

访问作用域

要访问有名作用域内的特定对象,需要指定作用域标识符以及作用域内的对象标识符:

<作用域标识符>::<作用域内对象标识符>

若有名作用域嵌套,应按序指定作用域,最后指定其标识符:

<第1层作用域标识符>::<第2层作用域标识符>::  ...  ::<指定作用域内对象标识符>

要访问全局范围内的特定对象,需要指定全局范围的对象标识符:

::<全局对象标识符>

名称空间 * Namespace

为了避免导入多个外部库时发生的标识符重名问题,C++ 使用名称空间以创建作用域,只有显式地指定名称空间才能访问到该名称空间内的标识符。

C++ 标准库将许多函数、对象封装在了名称空间 std 中,例如标准输出对象 cout 和换行操作对象 endl。应通过作用域解析运算符 :: 访问:

#include <iostream>
 
int main() {
    cout << "Go to dashboard" << endl;            // ❌
	std::cout << "Go to dashboard" << std::endl;  // 🟢 OK
	return 0;
}

为了便于使用,可用 using 声明一个名称空间内的标识符,将特定名称空间内的标识符加入声明范围内:

#include <iostream>
using std::cout;
using std::endl;
 
void function() {
    cout << "Function called" << endl;            // 🟢 OK
}
 
int main() {
	cout << "Go to dashboard" << endl;            // 🟢 OK
	std::cout << "Go to dashboard" << std::endl;  // 🟢 OK
	return 0;
}

若有同名全局变量,使用 using 声明会在声明范围内隐藏全局变量,需使用 :: 将标识符解析至全局变量:

#include <iostream>
int count = 10;
 
namespace extra {
    int count = 20;
}
 
int main() {
    using extra::count;
    std::cout << count << endl;     // 🟡 20
    std::cout << ::count << endl;   // 🟡 10
    return 0;
}

也可以将 using 声明范围限定在函数内:

#include <iostream>
 
void function() {
    cout << "Function called" << endl;            // ❌
}
 
int main() {
    using std::cout;
	using std::endl;
    
	cout << "Go to dashboard" << endl;            // 🟢 OK
	std::cout << "Go to dashboard" << std::endl;  // 🟢 OK
	return 0;
}

更进一步,可以将整个名称空间内的标识符全部“暴露”在 using 范围内:

#include <iostream>
 
void function() {
    cout << "Function called" << endl;            // ❌
}
 
int main() {
    using namespace std;
    
	cout << "Go to dashboard" << endl;            // 🟢 OK
	std::cout << "Go to dashboard" << std::endl;  // 🟢 OK
	return 0;
}

using namespace 称为 using 编译指令,使整个名称空间的标识符在声明范围内可直接使用。

Warning

在中大型项目当中,应避免在全局范围内使用 using namespace,避免不同库之间的同名标识符冲突。

作用域嵌套时,按嵌套层级逐个指定:

#include <iostream>
 
namespace data {
    namespace extra {
        int strength = 5;
    }
    int count = 3;
}
 
int main() {
    std::cout << data::extra::strength << std::endl;
}

声明名称空间时若名称较长,可以为其取别名:

#include <iostream>
 
namespace data_from_a_zone_datacenter_database {
    // Code
}
 
int main() {
    namespace a_ds_db_data = data_from_a_zone_datacenter_database;
    using namespace a_ds_db_data;
    return 0;
}

名称空间也可以不设置标识符,这与声明 static 的效用一致,即仅限当前文件可访问该名称空间:

static int count = 0;
// 等效替换
namespace {
    int count = 0;
}

作用域限定枚举 (自 C++11)

继承自 C 的枚举在作用域范围内可直接使用,而无需指定枚举类型名称。假设在相同作用域范围内有以下两个枚举类型:

enum Fish { Cod, Salmon, Tuna };
enum Foods { Maple_syrup, Salmon };  // ❌

两个枚举类型拥有相同常量标识符,这会导致编译错误。

为了解决此问题,C++11 起引入了作用域限定枚举,使访问枚举常量必须先指定枚举类型名称。它的语法是在 enum 之后添加 class 标注:

enum class Fish { Cod, Salmon, Tuna };
enum class Foods { Maple_syrup, Raspberry, Salmon };

使用作用域解析运算符以访问枚举常量:

std::cout << Fish::Salmon << " " << Foods::Salmon << std::endl;

Warning

C++ 充分确保类型安全,因此作用域限定枚举不能隐式转换为整型;作用域限定枚举必须拥有名称,关键原因是无名称则不能引用枚举常量。

因编译器的实现情况而不同,枚举的底层表示可能为 int (常见)或其他整型。

要改变底层整型类型,可在枚举类型名称后指定一个整型:

enum class Weekday : short { Mon = 1, Tue, Wed, Thu, Fri, Sat, Sun };

Tip

枚举适合用于表示对象内部状态,通过状态以决定当前对象可以进行的一系列操作分支。仅用于类内部时,在类中声明普通枚举即可。

封装 * Encapsulation

封装是 C++ 三大特性之一。其核心是将属性数据与操作行为高度绑定在一起,例如仅能通过类提供的函数访问类内部数据,阻止外部直接访问(例如一个网络类的数据传输函数尝试访问一个窗口类的窗口大小信息),使用者只需关注输入和输出结果,这也称为过程透明

C++ 使用类、访问控制关键字以实现封装。

访问控制

在类当中,可使用一些 C++ 规定的关键字,用于控制类成员能否从外部被直接访问。类型声明中的访问控制符从标注开始处,到下个标识符处之前或类型声明结束。

在未指定时,类 class 的默认访问控制为 private;结构体 struct 默认访问控制为 public

关键字外部直接通过对象访问?
public🟢 允许
protected🔴 禁止
private🔴 禁止

继续 Parrot 类型的例子,现在实例化一个 Parrot 类对象:

class Parrot {
	std::string name;
	unsigned int age;
    
    unsigned int get_age() { return age; }
};
 
int main() {
    Parrot sally;
    sally.name = "Sally";   // ❌
    std::cout << sally.name << "'s age is:" << sally.get_age() << std::endl;   // ❌
}

sally.namesally.get_age() 都试图访问私有成员,编译器将报告错误。

此时在类型声明的下一行添加 public 即可:

class Parrot {
public:                  // 🟡 新增
	std::string name;
	unsigned int age;
    
    unsigned int get_age() { return age; }
};

为了确保数据管理得当,同时避免不必要的修改,一个良好实践是:将成员变量设为私有,将访问函数开放,使外部只能通过类的成员函数访问成员变量,确保数据一致性:

class Parrot {
private:
	std::string name;
	unsigned int age;
public:
    unsigned int get_age() { return age; }
    void set_age(unsigned int new_age) { age = new_age; }
};

默认值机制

函数形参的默认值

C++ 为函数编写增添了一个新机制:允许调用函数时传入更少的参数,后续参数会自动使用函数提供的默认值。

函数的形参列表可以设定默认值,在调用函数时可以省略部分实参。

在形参标识符后添加 = <值> 即可设定:

void function(unsigned int value, bool switchs = false) {}
 
// 调用时
function(10, true);   // 🟢 OK
function(6);          // 🟢 OK, same as function(6, false);

默认参数设定是不可间断的:在一个形参设定默认值后,后续形参也必须设定默认值,直到最后一个形参:

void function_1(unsigned int value = 0, bool switchs) {}                             // ❌
void function_2(unsigned int value, std::string letters = "none", bool switchs) {}   // ❌

这也是为了保证函数调用语句的完整性而做出的要求:函数调用不能跳过某个实参而留空。

Note

函数的形参默认值只能出现在函数声明的位置。

成员变量初始化默认值 (自 C++11)

在类中,可以为类的成员变量提供初始化默认值:

class Parrot {
private:
    std::string name = "not_set";
    unsigned int age = 0;
}

与它最终效用相同的构造函数是:

Parrot::Parrot(std::string name = "not_set", unsigned int age = 0) {
    this->name = name;
    this->age = age;
}

此方法适用于需要为成员变量取别名时建立引用:

class Parrot {
private:
    std::string name;
    unsigned int age;
    std::string& nick_name = name;
}

构造函数的初始化列表直接提供成员变量初始化值的方式可任选其一。它们都适用于初始化成员常量或引用。

在构造函数体内重新赋值,将会覆盖前述两种方式提供的默认值。

Note

默认参数的出现会改变函数调用必须传入的实参数量,有时调用函数时会引发多重候选错误。所以应根据实际需要以平衡函数重载与函数默认值机制的使用。

友元

友元高度影响封装,所以它被放在这里 :D 友元并非破坏封装,可视为另一种形式的类接口

C++ 通过访问控制机制确保封装性。但有时候这种封装性会影响程序设计的简洁性,导致对对象的任何操作只能通过类提供的函数完成。为了平衡封装性与简洁性,C++ 提供友元功能,以使类声明的特定目标可以直接访问该类的受限成员。其中,特定的目标可以是:类、全局函数、类的成员函数。

友元的出现首要解决了一些重载运算符使用的迫切需要与访问控制的局限性,有利于简化运算符函数编写。

→ 将全局函数设为友元

要允许全局函数访问类成员,需要在类中使用 friend 声明该函数。也可以在类中实现该函数:

class Parrot {
private:
    std::string name = "not_set";
    unsigned int age;
    
    friend std::ostream& operator<< (std::ostream& ost, Parrot& parrot) {
        ost << parrot.name << " " << parrot.age;
        return ost;
    }
};

友元声明不受访问控制关键字影响。

Warning

标注为 friend 的函数不是该类的成员函数,它没有 this 对象可用;friend 是用于声明的标注。

→ 将类设为友元

在类中使用 friend 声明另外一个类,可使这另外一个类访问当前类的全部成员:

class Parrot {
private:
    std::string name = "not_set";
    unsigned int age;
    
    static inline unsigned int parrot_count = 0;
 
    friend class Manager;  // 🟢 声明 Manager 为友元类
 
};
 
class Manager {
public:
    unsigned int get_age(const Parrot& p) {  // 🟢 Manager 可以直接访问类成员
        return p.age;
    }
    unsigned int get_count() {
        return Parrot::parrot_count;  // 🟢 一种访问 Parrot 静态成员的方法
    }
};

→将类的成员函数设为友元

该友元声明与全局函数的友元声明结构基本相同,仅需在函数标识符前指定类型名称。

单独指定成员函数为友元会稍复杂:

  • 友元声明之前,指定的成员函数必须已声明;
  • 指定的成员函数必须在友元声明处可访问;
  • 友元函数在访问目标类成员时,目标类成员应该已声明。
class Manager {
public:  // 🟡 必要:成员可以被 Parrot 访问
    unsigned int get_age(const Parrot& p);  // 🟡 必要:成员声明在友元声明之前
    unsigned int get_count();               // 🟡 必要:成员声明在友元声明之前
};
 
class Parrot {
private:
    std::string name = "not_set";
    unsigned int age;
    
    static inline unsigned int parrot_count = 0;
 
    friend Manager::get_age(const Parrot& p);  // 🟢 声明 Manager::get_age() 为友元成员
    friend Manager::get_count();               // 🟢 声明 Manager::get_count() 为友元成员
 
};
 
unsigned int Manager::get_age(const Parrot& p) {  // 🟡 必要:延后实现,使 Parrot 成员可用
    return p.age;  
}
 
unsigned int Manager::get_count() {
    return Parrot::parrot_count;
}

嵌套类

可以在一个类声明当中,再声明一个全新的类,这称为嵌套类。相对于全局声明来说,这样可以避免名称冲突,同时可使嵌套类为该类专用。要使外部可以实例化嵌套类,可将该嵌套类设为 public

class Stack {
public:
    class Node {
        int value;
        Node* next;
    };
    Node* top;
    Node* base;
};
 
Stack::Node node;  // 🟢 OK

Note

使用类外部声明的其他类型作为当前类的成员变量,这称为组合类。在实例化当前类对象时也会实例化这个成员变量。

继承 * Inheritance

继承是 C++ 三大特性之一。C++ 的继承特性允许在现有自定类型的基础上,新建一个类用于扩充现有类的额外功能。这极大提高了代码的可重用性。

被继承的类称为父类/基类,用于扩展的新类称为子类/派生类。请记住,父类与子类的概念是相对的,这取决于选取的基准类。

新的类可以从原始类那里继承原始类的变量与函数,并拥有自己专属的变量和方法。

要实现继承,需在新类标识符后紧随待继承的已存在类名 : <访问控制> <类名>;若有多个类,以逗号分隔类名:

class Parrot : public Birds, protected Flyable {
    ...
}

若省略继承访问控制类型,C++ 默认设为私有继承。

构成继承后,冒号←左侧称为子类/派生类;冒号右侧→称为父类/基类。

继承下的访问控制

对于每一个被继承的父类,它的现有成员将按照访问控制收窄原则继承。例如:

父类成员访问控制类型继承时访问控制类型继承后的父类成员访问控制类型
🟢 public🟢 public🟢 public
🟡 protected🔴 private🔴 private
🔴 private🟢 publicprivate,且子类不能直接访问

同时,在可访问性上进一步扩展到被子类继承的父类成员:

关键字被继承的子类直接访问?
public🟢 允许
protected🟢 允许
private🔴 禁止

在后续的继承关系里涉及到链式继承时,若类未继承自任何类,将被称为最父类;若类继承一些类,但未被其他类再次继承,将被称为最子类。以此代表继承关系中的两种端点。

Tip

通常建议子类以 public 方式继承父类,子类通过 getter/setter 族函数*访问父类成员变量。如果对性能要求非常苛刻,可以考虑将父类变量设为 protected

* getter/setter 族函数:使用一系列诸如 get_value()set_value() 的函数间接访问父类成员变量。

继承下的实例化

子类对象实例化时,需要从最父类开始,依次通过各级父类构造函数完成。

子类应该编写构造函数才能正确地实例化子类对象,子类构造函数还需要通过构造函数的成员初始化方式主动调用上一级父类的构造函数:

class Birds {
private:
    unsigned int age;
public:
    Birds(const unsigned int& age) : age(age) {}
    Birds(const Birds& bird) {}
};
 
class Parrot : public Birds {
private:
    std::string name;
    unsigned int feather_color[3];
public:
    Parrot(const std::string& name, const unsigned int& age) : Birds(age), name(name) {}
    // 🟡 调用父类的构造函数
    Parrot(const std::string& name, const Birds& bird) : name(name), Birds(bird) {}
    // 🟡 调用父类的复制构造函数
};

未显式调用父类构造函数时,子类构造函数会调用父类的默认构造函数。

另外,即使父类允许子类直接访问,也应当优先通过父类提供的函数来访问这些父类的成员。

→ 多重继承问题

若实例化一个最子类对象,将从最父类起,依次调用继承链路上的构造函数;析构函数与此相反:

class Parrot : public Birds {};
class Birds : public Animals {};
class Animals : public Creatures {};
class Creatures {};
 
// 构造路线与析构路线
Creatures() -> Animals() -> Birds() -> Parrot()
~Parrot() -> ~Birds() -> ~Animals() -> ~Creatures()

在实际代码编写中,子类只需要调用直接父类的构造函数:

class Parrot : public Birds {
    Parrot() : Birds() {}
};
class Birds : public Animals {
    Birds() : Animals() {}
};
class Animals : public Creatures {
    Animals() : Creatures() {}
};
class Creatures {
    Creatures() {}
    virtual ~Creatures() {}
};

在进入子类构造函数的函数体内部之前,会像递归那样逐层寻找继承链的最父类,并首先调用最父类的构造函数。

Important

只要涉及继承关系,请为父类析构函数标注 virtual。以便析构子类对象时使用的始终是子类提供的版本。

→ 菱形继承问题

如果有以下继承关系(左侧为最父类,右侧为最子类),那么称这种继承关系为菱形继承:

                AquaticAnimals()
             /                    \
Creatures()                          Frog()
             \                    /
                 LandAnimals()

AquaticAnimalsLandAnimals 的构造函数先后实例化,这时有两组来自 Creature 的成员变量。若此时实例化一个 Frog 对象,将会产生二义性错误。

当构成菱形继承,在菱形分支上的类继承父类时都需要添加 virtual

class AquaticAnimals : public virtual Creatures {};
class LandAnimals : public virtual Creatures {};

关于此关键字的更多信息,请转到 [virtual 关键字↗](# virtual)下的虚继承条目。

Note

子类不被认为继承了父类的构造、析构函数,赋值运算符。这三个用于设置初值的函数,它们只对自己类声明的成员变量负责。若允许继承,在子类未定义构造或析构时,将导致子类扩展的成员变量无法正常实例化。所以各类构造和析构函数相互独立。

继承下的指针与引用

C++ 允许🟢父类指针指向子类对象,也允许🟢父类引用绑定子类对象,且前述过程不需要显式类型转换。这称为向上类型转换

允许这么做的一个关键原因是,子类拥有父类的所有成员,可将子类对象安全地转换为父类对象。使用父类指针调用子类对象的函数时,当这个函数没有标注 virtual,将调用父类函数版本。

class Birds {
public:
    void speak() {
        // something to speak
    }
};
 
class Parrot : public Birds {
public:
    void speak() {
        // something to singing 🎼
    }
};
 
Parrot sally;
sally.speak();       // 🟡 调用 Parrot::speak()
Birds* birds_ptr = &sally;
birds_ptr->speak();  // 🟡 调用 Birds::speak()

这项规则同样适用于用子类对象实例化父类对象:

Parrot sally;
Birds a_bird = sally;  // 🟡 调用 Bird::Bird(const Bird&)

如上,也可以用子类对象,初始化一个父类对象。这使用了父类复制构造函数。

Note

如果父类函数有 virtual,要使子类对象能访问这个父类函数,可以手动标注:

Kid kid;
kid.Parent::function();

继承下的对象存储

一个实例化对象的可能内存布局为:

该列表的顺序为低地址→高地址。

  • [可能] 虚函数表指针;
  • 自最父类开始,按声明顺序存储的非静态成员变量;
  • [可能] 当前类声明的非静态成员变量;
  • [可能] 用于字节对齐的存储,单个对齐长度取决于最大成员大小,但不超过 8 或 16 字节。

对于空类来说,为了使其实例化的对象会存储在不同地址,C++ 规定其应当形式上地占用 1 字节。当继承这个空类的子类实例化时,该 1 字节可能会被优化清除。

Note

继承机制应当用于从逻辑上具有包含关系的两个事物之间(🟢is-a关系,从属关系:例如锤子、剪刀是工具。适合公有继承)或不完全包含的两个事物之间(🟢has-a关系,拥有关系:例如午餐有肉片。适合私有继承,不常用。如非必要,可嵌套对象),这样能很好地简化程序设计过程。

非包含关系的两个事物之间不适合使用继承:

🟡is-implemented-as关系(实现关系):例如,希望取得一笔资金时,可以通过 ATM 机取款。但这不意味着货币继承于 ATM 机,前往柜台同样也能取款。这和队列既能使用链表实现,又能使用数组实现的逻辑是一致的。

🟡uses-a关系(使用关系):例如,计算机通过电源适配器获取电能,但这不意味着电源适配器继承于计算机或反之。它们的继承是没有实际意义的。

若在不适合继承的情形下使用继承机制只会适得其反,增加程序设计的难度,降低程序易读性。

继承下的函数

→ 父类成员函数的可访问性

父类的 publicprotected 函数可以被子类对象直接调用。调用方式为:

  1. 子类未重写同名函数,直接通过成员访问符完成;
  2. 父类同名函数未标注 virtual,通过父类指针或引用指向子类对象访问父类成员;
  3. 子类重写,且父类同名函数标注 virtual,仅可通过强制转换访问父类同名函数。(🟡通常不建议,子类定义该函数后应优先遵循子类的规则运作)

静态或非静态子类成员函数/友元函数可以通过解析作用域到父类,以访问前述父类函数。

→ 友元

友元声明仅对当前类有效,不随继承关系传递。父类友元不能访问子类成员,一方面也是因为父类不知道子类的存在;

若子类有友元函数,那么该友元函数的成员可访问性与该子类对象的成员可访问性一致。

可以子类对象或其指针/引用转换为父类,这样可以匹配父类提供的友元函数。

继承下的赋值运算符

→ 子类赋值至子类

如果有以下语句:

子类对象 = 子类对象;
  • 当子类存在赋值运算符函数定义,就使用它;
  • 如果子类没有,就使用父类的;
  • 如果都没有,使用默认赋值运算符函数,进行逐个值复制。

如果子类没有成员变量持有运行时资源,可不编写。对父类成员变量访问受限时,子类赋值运算符需要将子类对象指针强制转换为父类指针,然后调用父类赋值运算符。

→ 父类赋值至子类

如果有以下语句:

子类对象 = 父类对象;

父类对象无法直接转换为子类对象,需要提供一个能够接收父类对象引用的赋值运算符函数;或者提供一个可用于转换的构造函数:它接收一个父类对象引用,以及一些拥有默认值的子类成员变量。但请注意,该构造函数的默认值会覆盖子类对象原来的值,这可能不是期望的结果。

后续赋值运算符调用规则与上方一致。

→ 子类赋值至父类

如果有以下语句:

父类对象 = 子类对象;

子类对象会被转换为父类对象(子类对象的非父类成员会被忽略),然后调用父类赋值运算符。

Warning

使用继承机制时,需要重点留意父子类型之间的潜在转换情况。例如当有一个函数接收父类对象的引用时,其既可以用于父类对象,也可以用于子类对象;但如果接收的是父类对象的值,那么传入子类对象时会发生裁切,属于子类扩展的成员会被忽略。

多态 * Polymorphism

多态是 C++ 三大特性之一。编译器根据调用者的属性或实参序列决定该调用哪个函数。借助多态机制,C++ 代码的灵活性得到了极大提高。

多态机制在程序编译过程,以及程序运行期间均可有体现。

在程序编译过程,编译器根据函数调用位置给出的实参序列,决定调用同名函数的其中一个版本。这就是函数重载,它属于静态多态;在程序运行期间,程序通过调用者类型,决定调用其所在类型的函数。这就是函数重写的多态体现,它属于动态多态。

函数重载 * Function Reload

函数重载允许多个函数使用相同的标识符,用于解决功能相同但类型不同的函数的多重命名问题,要做到同名函数重载,需要满足以下条件之一:

  • 形参个数不一样;
  • 形参类型不全相同;
  • 若有特殊限定符 constvolatile,那么特殊限定符应该限制的是原始对象,而不是值。

构成重载的核心:函数在调用者看来有所区别

第3条较难理解,以下进一步解释:

  1. 指针和引用都能表示原始对象;
  2. 若两个重载版本的特定形参只是指向常量的指针 const Type * 和指向可变量指针 Type * 的区别,或者只是常引用 const Type & 与可变引用 Type & 的区别,那么它们也是可以重载的。

以下是5个有效的 function() 重载版本,它们可共存:

void function() {}                              // 1
void function(int value) {}                     // 2
void function(int* value_ptr) {}                // 3
void function(float value) {}                   // 4
void function(float value, bool controller) {}  // 5

在调用函数时,编译器通过调用时给出的实参自动选择匹配的一个函数原型:

function();    // -> 1
function(10);  // -> 2
function(5.5); // -> 4

引用是一种标记,用于调整参数本身的行为,代表函数直接访问实参本身,它不会改变参数的类型。因此以下两个函数在重载角度上看是相同的:

void function(int value) {}
void function(int& value) {}  // 🔴 引用与值不构成重载,因为从外部调用看来,两者没有区别

一般来说,编译器会因以上两个语句报告错误;但 GCC 编译器将检查放宽至调用语句时的函数候选。

传入一个整型字面值(例如 5)时,编译器可能并不会报告错误:字面值是右值,只能使用重载版本1;

在传入一个整形可变变量时,编译器将报告多重匹配错误。

如果将 const 用于区分传入形参对应的原始对象的可修改性,那么是可用于重载的:

void function(int* value) {}         // 指向可变量的指针
void function(const int* value) {}   // 指向常量的指针
void function(int& value) {}         // 可变引用
void function(const int& value) {}   // 常引用

以上形参都可指向到原始对象数据,且表示原始对象的可修改性。调用哪个函数,取决于传入的实参的可修改性。这4个版本是可以共存的。

const 区分值本身是否可被修改,这类重载属于重复定义:

void function(int value) {}
void function(const int value) {}   // 🔴 常量值与可变值 *不* 构成重载
// 或者
void function(int* value) {}
void function(int* const value) {}  // 🔴 地址常量指针与地址可变指针 *不* 构成重载,实际上它们和上面一样

因为这两个版本都不考虑传入实参的原始对象能否被修改,而只关心值在被复制到函数后的副本能否被修改,这从外部来看是没有区别的。因此会造成重复定义错误。

总之,要使多个重载版本能够共存,就必须让它们从调用者的角度来看是有所区分的;另外函数重载只在处理相同任务但数据类型有所不同时使用,如果只是形参个数不相同,请优先考虑使用默认参数。

运算符重载 * Operator Reload

运算符重载是函数重载的一类特别情况,它使自定类型创建的对象可以使用基本运算符完成操作,使代码更简洁且直观。

以下运算符可以重载:

+-*/%^
&|~=!=<
>+=-=*=/=%=
^=&=|=<<>>>>=
<==!=>=&&
||++,*
()[]newdeletenew[]delete[]

相同运算符可能具有不同的功能,例如 << 不仅能进行左移运算,还能用于输出流。这也是依靠运算符重载完成的。

以下运算符只能是成员函数:

  • =:赋值运算

    连续赋值运算方向:←向左;需要返回引用,确保行为正确,降低复制性能开销

  • ():函数调用运算

  • []:下标运算

  • :对象指针解引用运算

Note

若类中含有引用变量,那么必须重载赋值运算符。因为默认赋值运算符函数会将右侧对象的每个变量重新赋值给左侧对象,但引用变量不允许重新赋值。

许多运算符函数具有约定的范式,编写时尽可能按照范式规则,使代码易于理解与编写。

运算符重载应该符合直觉要求与运算符的句法规则;运算符重载函数必须至少有一个参数的类型为自定类型(成员函数可用的 this 也计入其中,所以成员函数可不在参数列表指定自定类型的参数)。

运算符重载函数的基本结构是:

<返回类型> operator<运算符> () {}

假设有一个 Goods 类声明,它记录一种商品的信息:

class Goods {
public:
    unsigned int id;
    std::string name;
    unsigned int price;
    unsigned int stock;
    
    Goods operator+ (const Goods& r_goods) const;
};

如果希望两个对象能够直接相加,得出库存大小,那么可以这样重载加法运算:

Goods Goods::operator+ (const Goods& r_goods) const {
    Goods t_goods { 0, "TEMP", 0, 0 };
    t_goods.stock = this->stock + r_goods.stock;
    return t_goods;
}

可按照下例使用,编译器将自动查找可用的加法运算重载函数:

int main() {
    Goods toycar_1 { 1, "Mercedes AMG ONE 2021", 255, 5 };
    Goods tape_1 { 2, "Levitating - Dua Lipa", 69, 3 };
 
    Goods in_stock = toycar_1 + tape_1;
    std::cout << in_stock.stock << std::endl;
    
    return 0;
}

通过以上方法即可得到总库存。

Note

使用运算符时,等效于调用函数。例如在上例:

Goods in_stock = toycar_1 + tape_1;

其函数形式如下:

Goods in_stock = toycar_1.operator+(tape_1);

因为 Goods 类有一个加法运算符成员函数,所以函数内能使用 this

也可以重载为全局函数。但请注意,以下编写的全局函数要求成员变量是公开的:

Goods operator+ (const Goods& left, const Goods& right) const {
    Goods temp { 0, "TEMP", 0, 0 };
    temp.stock = left.stock + right.stock;
    return temp;
}

使用运算符时,对应的函数形式如下:

Goods in_stock = operator+(toycar_1, tape_1);

以上两种方式均有效,但应优先考虑重载为成员函数,其次是友元化全局,最后再考虑普通全局。以上两个重载示例不能共存,这会使编译器找到多重候选函数,从而引发编译错误。

重载运算符函数时,参数列表的编写遵循着一种内定的句法。根据上例可以得出双目运算符函数的特点:

  • 重载为成员函数时,参数列表仅需1个参数:

    运算符左值会成为 this 对象;

    运算符右值会成为第1个参数。

  • 重载为全局函数时,参数列表需要2个参数:

    运算符左值会成为第1个参数;

    运算符右值会成为第2个参数。

有时因为句法与直观性的要求,应将运算符重载为友元化全局函数。例如输出运算符:

std::cout << toycar_1 << std::endl;

如果重载为成员函数:

std::ostream& Goods::operator<< (std::ostream& ost) const {
    return ost << id << " " << name << " " << price << " " << stock;
}

输出流运算符是双目运算符,那么可匹配此函数的用法是:

(Goods对象) << (ostream对象)

这违背了输出流运算符的直觉感受,所以应将其重载为友元化全局函数。

首先在类中声明友元函数原型:

friend std::ostream& opeator<< (std::ostream& ost, const Goods& goods);

然后在源代码文件实现该函数:

std::ostream& operator<< (std::ostream& ost, const Goods& goods) {
    return ost << goods.id << " " << goods.name << " " << goods.price << " " << goods.stock;
}

函数重写 * Function Rewrite

在继承关系下,如果父类有成员函数实现,且在子类将函数体重新编写,这称为函数重写。

C++ 规定的有效重写,父类成员函数与子类成员函数必须满足:

  • 函数名称相同;
  • 函数参数列表相同(形参标识符除外);
  • 返回类型相同。若返回的是父类的指针或引用,那么可以修改为子类的;(这称为返回类型协变)
  • 其他特征标识相同(主要是 const)。

Tip

自 C++11,可为子类成员函数添加 [override 关键字↗](# override (自 C++11)),使编译器检查是否为有效的重写。

→ 虚函数表

当父类有虚函数时,属于该继承关系的任何类型实例化的对象都将有一个隐藏的成员,该成员中有一个指针,它存储指向虚函数的数组地址。这个存储函数地址的数组称为虚函数表 (virtual function table)。

只要父类存在虚函数,在父类及后续继承链路的每个类,都各有一个虚函数表;每个对象都通过自己的指针访问其所在类型的虚函数表

编译器会逐个对父类虚函数及其可能的重写版本进行检查,并将适合的版本放入该类的虚函数表中。

以下是根据不同情境,虚函数表存储内容的差异:

  • 子类有重写父类的虚函数吗? 🟢:保存被重写的函数地址; 🔴:保存父类的函数地址。
  • 子类有创建新的虚函数吗? 🟢:保存新的虚函数地址; 🔴:无动作。

Warning

  • 不能将构造函数设为虚函数。一个关键原因是,父类不知道子类的存在。所以子类构造函数负责调用父类构造函数;

  • 若父类有多个重载版本的虚函数,子类应当依次进行重写,或者直接调用父类提供的版本:

    virtual void Parent::function() {}
    void Top::function() { Parent::function(); }
    // 或者
    class Top : public Parent {
        using Parent::function;  // 🟡 父类所有 function() 重载版本都将引入
    };

    若未完全重写,当通过子类对象直接访问其成员时,未重写的重载版本无法使用;

  • 必须将父类构造函数设为虚函数。当用父类指针管理子类对象时,应确保子类对象的运行时分配的资源被释放;事实上,普遍采用的实践是使用父类指针数组或容器管理这些类的对象,因此建议始终为父类析构函数标注 virtual

模板 * Template

模板是 C++ 提高代码复用率的关键特性。模板特性使得类型名称能够像参数一样传递给模板函数或模板类,以使它们能够用相同代码操作不同的类型。

使用模板进行程序设计,有时候称为通用程序设计;模板也称为参数化类型。

模板函数

要在函数使用模板,应该在函数定义前添加以下代码,告诉编译器接下来是一个模板函数声明:

template<typename Anytype>

其中 typename 用于标注后续标识符为类型。现阶段可用 class 代替 typename,目前没有任何区别;但建议优先使用 typename

Anytype 代表一个类型(如同函数的形参那样),它可以在标识符规则下任意命名,实践中多命名为 TType 。编译器会根据调用者传递的实参和函数本身自动确定具体类型。

紧随上行,声明一个返回两者间更大值的函数:

Anytype Max(Anytype& a, Anytype& b) {
    return (a >= b ? a : b);
}

这样的函数称为模板函数

→ 模板的实例化

调用模板函数时,编译器根据传入的实参确认可用的模板函数。这称为请求实例化

int a = 15, b = 25;
Max(a, b);

ab 的类型相同,所以 Max(Anytype&, Anytype&) 被选中,然后编译器生成 int Max(int&, int&)

int Max(int& a, int& b) {
    return (a >= b ? a : b);
}

编译器生成函数的过程称为实例化函数模板;在调用模板函数时生成函数,称为隐式实例化

若需要函数支持多个不同类型参数,可在模板处用逗号分隔这些类型:

template<typename Anytype_1, typename Anytype_2>
void function(Anytype_1& at1, Anytype_2& at2);

除了调用时生成,也可以通过声明直接让编译器生成特定函数。这称为显式实例化函数模板:

template int Max<int>(int& a, int& b);

→ 显式具体化

虽然模板可以提升代码复用率,但是可能并非所有类型都适用于同一套模板。例如传入两个结构体调用该函数时,编译器会报告错误,因为两个复杂类型不能直接比较。

为解决此问题,C++ 还允许为个别类型单独指定同名函数,并指定这个特别对待的类型,这称为显式具体化函数模板。

参考以下示例以编写用于特定类型的模板函数。该示例将 Parrot 类对象的年龄进行比较:

//  🔴此处的尖括号不可省略,需要区分实例化与具体化
//      v
template<> Parrot Max<Parrot>(Parrot& p1, Parrot& p2) {
    Parrot temp { "NO", 0 };
    auto p1_age = p1.get_age();
    auto p2_age = p2.get_age();
    temp.set_age(p1_age > p2_age ? p1_age : p2_age);
    return temp;
}

其中 <Parrot> 部分可以省略。

隐式实例化、显式实例化和显式具体化统称为模板函数具体化

Tip

以上显式具体化的问题示例,建议优先使用重载比较运算符的方法,以使自定类型能够使用通用的模板函数。

模板类

允许接受类型作为参数的类型称为模板类,模板类一般将接受的类型作为自己的成员变量类型。常用于管理存储的容器类型。

假设要编写一个队列类型容器,用于需要排队的场合使用,那么可以这样编写这个队列容器的成员:

template<typename Anytype>
class Queue {
    struct Node {
        Anytype data;
        Node* next_node = nullptr;
    };
private:
    Node* queue_head;
    Node* queue_end;
    const unsigned int MAX_LENGTH;
    unsigned int current_length;
};

和模板函数一样,使用时,需要指定模板参数:

Queue<int> int_queue;

只要是遇到需要实例化对象的场合,编译器将生成对应类型的模板类(生成类定义),用给出的模板参数逐个替换。同样,指针定义不会生成模板类:

Queue<int>* int_queue_ptr;  // 🟡不实例化对象,不生成模板类

模板类也可以显式实例化,依据以上示例,应该编写以下声明:

template class Queue<int>;

模板类也可以显式具体化,可以通过以下声明实现:

template<> class Queue<int> {
    (...)
}

显式具体化是为一组指定的模板参数提供特别定制的类型。当某些类型不适合使用通用版本时,可通过这个方法调整模板类的代码。

若模板参数有多个,也可以部分具体化。现假设 Queue 有第 2 个模板参数,其接收任意类型:

template<typename Anytype_1, typename Anytype_2>
class Queue {
    (...)
};

要部分具体化,可以通过以下声明实现:

template<typename Anytype_1> class Queue<Anytype_1, int> {
    (...)
}

能够使用该具体化的类型声明结构是:

Queue<???, int>

要使用具体化提供的特例,可以通过以下声明实现:

Queue<int, int> int_ptr;
Queue<double, int> double_ptr;

仔细观察,是不是发现和函数重载很相似 :D

→ 表达式参数

可以在模板声明头接收非类型名称的参数,例如接收一个 int 值,char 值。这种参数称为表达式参数或者非类型参数。这可以更进一步地设计模板类型。这个值必须是编译时可以确定的常量。

在容器类型中,表达式参数通常用于指定所需大小:

template<typename Anytype, int size>
class Queue {
	(...)
};

这个模板类的部分具体化可以是:

template<typename Anytype> class Queue<Anytype, 5> {
    (...)
};

能够使用该具体化的类型声明结构是:

Queue<???, 5>

Warning

在模板声明处的非类型参数,与模板实例化时使用的参数,应当注意区分。

Important

模板类型的声明和定义无法分离到多个文件,因为编译器需要在编译每个编译单元时知道模板类的定义,才能编译使用了模板类的对象。可以将模板类定义直接置于头文件;也可以将模板类的定义置入 .tpp 文件,然后在头文件中的模板类声明之后,导入该 .tpp 文件。同时,请为该 .tpp 文件添加定义检查预处理指令 #ifndef-#define,防止重复定义。

→ 嵌套模板参数

在使用模板类实例化对象时,也可以进行类型嵌套。这一般用于接收类型与大小的数组型容器类,接续上例有:

Queue<Queue<int, 5>, 10> two_dim_queue;

这段定义表明:首先创建一个能容纳 10 个 Queue<> 对象的队列,然后创建 10 个能容纳 5 个 int 对象的子队列,并将其置于主队列中。

这很适合集中管理容器类对象。

同时,模板类当中也可以嵌套其他模板类作为成员,如同以下示例:

//             🟡符合这个声明的 外部 模板类才可作为参数
template< template <typename Anytype> class Container, typename Typ4Cont>
//        -------------------------------------------
//             🟡类名称 Container 用于当前声明的类当中
class ContainerStack {
    Container<Type4Cont>* container_ptr_array[5];
    unsigned short stack_top_index = 0;
};

要使用 ContainerStack 实例化对象时,需要按下例声明:

ContainerStack<Queue, int> int_queue_stack {0};

→ 模板成员

模板类也可以嵌套模板类,或者为其提供模板函数。这将极大提高代码泛用性。

如果将含有模板的成员的定义与声明分离,在定义时应当标注对应的模板嵌套结构:

// 🟡声明
template<typename Anytype>
class Container {
public:
    template<typename RetType>
    RetType Max(Anytype value);
};
// 🟡定义
template<typename Anytype>
	template<typename RetType>
	RetType Container<Anytype>::Max(Anytype value) {
	    (...)
	}

类型转换 * Typecast

C/C++ 是严格类型程序设计语言。当一个类型的变量与期望的类型不符时,需要进行类型转换。转换可能造成数据丢失。

C++ 提供了 4 种强制类型转换运算符,请转至 C++ 运算符条目下的强制类型转换↘查看。

→ 隐式类型转换

当类型不符时,C++ 编译器会自动寻找可用的类型转换规则以使语句指定的操作完成。这个过程并不是由用户手动决定的,所以它称为隐式类型转换

在 C++,隐式类型转换发生于:

  • 内置整型与浮点型的相互转换;

    int digit = 10.233;

    double 将转换为 int,然后将 10 赋值给 digit 变量。

  • 为对象赋值时,使用特定类型的变量;

  • 接受特定类型作为参数的函数,传入了其他类型;

  • 返回特定类型的函数,返回了其他类型。

单参数构造函数用于类型转换

若构造函数只有1个参数,它会被用于为对象赋值的语句:

// Declaration
CustomType::CustomType(int);
// Usage
CustomType object_ = 5;

以上语句会先将 int 传入构造函数,实例化一个 CustomType 临时对象,然后将该对象复制给 object_

类型转换函数

C++ 提供一种机制,以便将当前自定类型转换到其他类型。它的句法是:

operator 类型名称() {}

类型转换函数的声明条件是:

  • 只能是类的非静态成员函数;
  • 不可指定返回类型;
  • 始终无参数。

现在为 Parrot 类实现一个可能的类型转换函数:

Parrot::operator unsigned int() { return age; }

以下语句将会转换 Parrot 对象为 unsigned int

Parrot sally { "Sally", 1 };
int sally_age = sally;

→ 显式类型转换

C++ 提供了更清晰的强制类型转换语法,用以明确类型转换,避免某些不恰当的转换存在。

最好为类型转换函数标注 explicit,使该转换只能显式完成:

explicit Parrot::operator unsigned int() { return age; }
 
Parrot sally { "Sally", 1 };
// 1
int sally_age = (unsigned int)(sally);             // 🟢 OK
// 2
int sally_age = (unsigned int) sally;              // 🟢 OK
// 3
int sally_age = static_cast<unsigned int>(sally);  // 🟢 OK

以上类型描述关键字由两个组成,因而方法 1 的类型标识需要使用括号组合。

C++ 提供了一些用于显式转换的运算符,请转到[此处↗](# 强制类型转换)查看。

Note

自 C++11 起,explicit 可用于类型转换函数。

异常 * Exception

在程序运行期间,可能由于各种原因导致程序运行不能继续顺利运行。例如堆内存获取失败(抛出 bad_alloc)、连接超时、访问的文件不存在、用户输入的内容疏于检查而直接进入处理导致问题等。在运行时出现的问题统称为异常。管理以上操作的这些函数,在失败后通常会返回一个特定类型的整型值代表特定的错误,或者返回空指针等内容。但这种方式依赖调用者主动检查状态。如果调用者忘记检查,仍使程序继续运行,程序的行为会变得不确定,直至被操作系统直接终止。

为了更规范地制定这些错误响应的代码,C++ 使用一套全新的异常处理机制。异常发生时,程序会转移到上层能够处理异常的代码中运行,以此确保后续作业正常。

异常处理机制用于处理极其少见或者不能恢复的错误,且会带来少量性能开销。对于大多数错误而言,仅进行条件检查即可。

应用异常处理机制,主要通过以下 3 个组件实现:

  • 异常产生源:通过 throw 语句产生特定类型的异常。这称为抛出异常
  • 异常检测:检测 try 块内的代码是否抛出了异常;
  • 异常捕获:通过 catch 语句,捕获 try 内产生的指定类型的异常。

下面简易解释这 3 个关键字。

Note

要使用标准库提供的基本异常类,请导入 exception;要使用标准库提供的扩展异常类,请导入 stdexcept,它提供了一些常见的错误类型系列,例如 logic_errorruntime_error

throw

用于语句内,指明抛出一个异常,抛出后当前函数立刻返回。异常实际上是一种特定类型的值:

double divide(double dividend, double divisor) {
    if (divisor == 0)
        throw "Can't be divided by zero.";  // 🟡 抛出 const char* 类型的异常
        // 其他形式
        throw std::runtime_error("Can't be divided by zero.");  // 🟡 抛出 std::exception 类型的异常
    return dividend / divisor;
}

throw 语句的行为类似 return,但是 throw 跳转到合适的异常处理代码段内。

try / catch

try 用于标注一段代码,以检测这段代码是否产生了异常;

catch 用于语句内,紧随 try 之后声明需要捕获的异常类型。catch 有点类似函数,且允许有任意个数:

int main() {
    try {  // 🟡 检测区间内可能产生的异常
        divide(15, 0);
    }
    catch (const char* str) {  // 🟡 捕获 const char* 类型的异常
        std::cerr << str << std::endl;
    }
    catch (const std::exception& exce) {  // 🟡 捕获 std::exception 类型的异常
        std::cerr << exce.what() << std::endl;
    }
    catch (...) {  // 🟡 捕获 任何 类型的异常
        std::cerr << "Unknown error occurred." << std::endl;
    }
    return 0;
}

try 内有函数抛出了异常,将按顺序查找类型匹配的异常捕获程序,并交给这个异常捕获程序处理。如果本层函数无 try,或者没有合适的 catch 语句,异常将使程序返回到更上层函数的运行位置。以此重复直至运行位置有合适的 try / catch 组合。

如果最终 main() 也没有合适的异常处理语句,那么 main() 立刻返回,异常将会触发 std::terminate(),使当前程序直接结束。

因为异常导致函数直接返回的,函数不能回到原处重新运行。异常发生后,catch 代码段内必须充分处理,确保程序后续运行正常稳定。

如果 try 没有产生异常,try 内所有语句都会正常运行,并跳转到 catch 之后继续运行。或者异常经由 catch 处理完成,直接跳转到 catch 之后继续运行。

Note

即使函数抛出的是一个局部变量,该局部变量也会进行复制之后再传递给 catch。类似于返回 “值” 的函数。

Tip

  • 如果函数会抛出异常,那么不应直接申请堆内存(有时称为裸指针),而是改用智能指针,确保内存正确释放;
  • 尽可能抛出 std::exception 或继承自 std::exception 类型,以及其他标准异常类型的异常,确保规范;
  • 捕获异常时,使用常引用,减少复制开销。引用类型使得父类引用也可以引用子类。

→ 自定终止函数入口

如果程序因异常未捕获而终止,可以使用 set_terminate() 设置终止函数:

位于头文件 exception

std::terminate_handler std::set_terminate(std::terminate_handler) noexcept

其中,std::terminate_handler 接受一个函数名称,该函数必须是无返回值 void,且不接受任何参数。

C++ 功能型关键字

const

C++ 继承了 const 的用法,但略有改变和扩充。

const 在绝大多数位置都具有传递性。简单来说,若声明方标注变量为 const,那么变量接收方也必须是 const。否则应当进行 const 限定转换。

→ 用于全局变量

当对全局变量标注 const 时,C++ 会限定其作用范围为当前文件,因此可将常量安全地置于头文件中。

Tip

自 C++11 起,推荐使用 constexpr 关键字代替 #define 定义常量。constexpr 在编译时处理,要求表达式在编译时期就拥有确定的类型和值,且受定义位置而改变作用域。

→ 保护调用的对象

仅限非静态成员函数使用。

当在非静态成员函数头部末尾标注 const 时,该成员函数将不能修改调用它的对象的变量:

void Parrot::function() const {
    name = "Invaild";        // ❌
    // aka
    this->name = "Invaild";  // ❌
}

Warning

被声明为常量的对象只能调用函数头部末尾标注 const 的成员函数:

decltype(Parrot::age) Parrot::get_age() const {
    return age;
}
 
void Parrot::set_age(decltype(Parrot::age) new_age) {
    age = new_age;
}
 
// usage
Parrot sally { "Sally" };
sally.set_age(1);
const Parrot belly { "Belly", 2};
belly.set_age(1);  // ❌

nullptr (自 C++11)

在 C 代码中,常用整型值 0 或者未指定类型指针 (void*) 0 表示空指针值。但在 C++ 引入重载后,这种旧方法可能引起多重候选函数的问题:

void function(int) {}
void function(void*) {}
// Usage
function(NULL);  // 🟡 Can't determine which one could be called

不同平台上的标准库,对 NULL Macro 的定义不完全相同,跨平台程序的行为可能不一致。

因此 C++11 引入了 nullptr 关键字用于标识空指针,它是 std::nullptr_t 类型。

nullptr 能够转换为任意类型的指针 Typename* 与未指定类型指针 void*,必要时可转换为整型值 0

以下语句会产生编译错误:

nullptr == 1;       // ❌
nullptr == false;   // ❌
int var = nullptr;  // ❌

inline

→ (自C++17) 多重定义合并

在多个文件处出现的多个类型与名称相同的标识符,且都标注 inline 的定义,编译器自动合并。

// public.hpp
extern char letter;
 
// file_1.cpp
#include "public.hpp"
inline char letter = 'A';
 
// file_2.cpp
#include "public.hpp"
inline char letter = 'A';

→ (自C++17) 静态成员变量声明时初始化

可在类静态成员变量处标注 inline,以便允许定义时赋值。

class Test {
public:
    static inline unsigned int value = 10;
}

→ 性能优化

标注 inline 的函数意在告知编译器尝试将此函数代码直接嵌入至调用处以减少跳转。最终由编译器决定,无法干预。现代 C++ 编译器将自动决定函数是否内联,此功能现已不重要。

explicit

用于构造函数,当对象接受一个不同类型的变量或对象(可以是初始化或赋值),需要类型转换时,阻止隐式调用构造函数,必须显式调用。

例如一个完整 Parrot 类:

class Parrot {
	std::string name = "net_set";
	unsigned int age;
    
    Parrot(unsigned int _age) : age(_age) {}
};

以下语句可以调用该函数:

Parrot pecky = 1;

但在特定场合下,这种便捷的特性会引发不当的对象修改。要关闭通过构造函数隐式转换的特性,可以为构造函数标注 explicit 关键字,使其必须被显式调用:

explicit Parrot(unsigned int _age) : age(_age) {}

可通过以下方式完成类型转换:

Parrot pecky = Parrot(1);  // 🟢
Parrot muffin;
muffin = (Parrot) 1;       // 🟢

值得补充的是,该类型转换还适用于多种场合:

  • 函数接受 Parrot 对象,但传递的是 int 类型实参;
  • 函数返回 Parrot 对象,但返回的是 int 类型;

此外,若一个类型能隐式转换为 int,它也将可以转换为 Parrot 对象。

Warning

若有多种构造函数接收不同类型,当用于给对象赋值的变量能够被转换为多个构造函数可接受的类型时,将会产生多个候选函数的编译错误。例如:

class Parrot {
public:
    (omitted...)
    
    explicit Parrot(const unsigned int age) : age(age) {}
    explicit Parrot(const double age) : age(age) {}
};
 
int main() {
    Parrot ketty = static_cast<Parrot>(1);  // ❌
}

因为变量 1 既可直接传给 Parrot(const unsigned int),又可以通过隐式转换传给 Parrot(const double),这会引发多重候选编译错误。

virtual

用于继承关系的关键字,可以启用多态行为。除此以外还能够调整继承行为,以及设置纯虚函数(aka. 接口函数)。

以下示例均假设有一个普通会员父类 Membership 和一个高级会员子类 UltimateMembership

class Membership {
private:
    std::string name;
    unsigned int member_status = 0;
    unsigned int point = 0
public:
    
    // 🟡 已省略 get() 族成员函数和 set() 族成员函数
    
    unsigned int transform_to_currency() const {
        return point * 0.5;
    }
};
 
class UltimateMembership : public Membership {
    unsigned int transform_to_currency() const {
        return get_point() * 1;
    }
}

其中,transform_to_currency() 用于计算积分可折算的货币。这两种会员的现有区别是,高级会员拥有更高的积分兑换比例,即可以用更少的积分兑换与普通会员一样多的货币。

Note

请单击此处↘查看 Membership 类的完整定义;

→ 核心功能:启用函数多态行为(虚函数)

现有一个数组,它保存 Membership 类对象的指针;另外有两个会员对象。实例化以后,它们的位置记录于数组:

Membership* members[5] { 0 };
Membership claris { "Claris" };
UltimateMembership virgo { "Virgo" };
 
members[0] = &claris;
members[1] = &virgo;

因为 C++ 允许用父类指针或引用指向子类对象(子类可以隐式转换为父类),当调用父子类同名的成员函数时:

members[0]->transform_to_currency();
members[1]->transform_to_currency();

它们都会调用父类的成员函数 Memmbership::transform_to_currency()

现在解释以上现象。对于未标注 virtual 的父类成员函数来说,编译器会根据引用或指针的类型,调用该类型下的成员函数。这一方面也是因为,引用或指针类型在编译时就已经确定。

要使子类对象能调用子类的成员函数,换句话说,如果希望编译器根据对象的类型调用成员函数,应当为这个父类成员函数标注 virtual。这会使编译器将该父类成员函数置于虚函数表,在运行时调用合适的函数。

在上例中,应修改 Membership 类下的 transform_to_currency()

virtual unsigned int Membership::transform_to_currency() const {
    return point * 0.5;
}

再次调用父子类同名的成员函数时,此时 member[1] 就会调用 UltimateMembership::transform_to_currency()

自父类开始,后续继承的类中的此函数都无需再标注 virtual,多态行为会传递。

Note

当父类成员函数被子类重写时,需要将父类的被重写函数设为虚函数;

父类启用虚函数功能后,程序会在运行时确定调用目标,这会带来少量性能开销。仅在确实需要时,才使用虚函数多态功能。

→ 虚继承/虚父类

virtual 也可以在继承父类时标注继承关系为虚继承/虚父类,这可以防止菱形继承后的子类对象持有一组以上父类成员:

class Base {};
class Mid_1 : virtual public Base {
    int shapes;
    Mid_1(int shapes) : Base(), shapes(shapes) {}
    //                    ^           ^
    //                 🟡跳过    🟢正常初始化
};
class Mid_2 : virtual public Base {
    Mid_2() : Base() {}
    //          ^
    //       🟡跳过
}
class Top : public Mid_1, public Mid_2 {
    Top(int shapes) : Base(), Mid_1(shapes), Mid_2() {}
};

本质上来说,虚继承使得 Mid_1Mid_2 中,调用 Base 类构造函数的代码被忽略。

因此,在合并继承的 Top 类上,需要主动调用 Base 类的构造函数。也就是说,实例化父类对象的任务交给了合并继承的 Top 类。

Warning

虚继承应当用于继承链路的分支开始处的这些类型;最后完成多重继承的类型必须主动调用虚父类的父类构造函数;

仅在菱形继承发生时使用虚继承。错误地使用虚继承会降低代码的可读性,并带来少量性能损耗。

→ 纯虚函数

当成员函数被声明为以下形式时:

virtual 返回类型 Parent::function() = 0;

这表示该成员函数为纯虚函数。纯虚函数的存在,表明该类未实现函数功能,需要由后续继承的子类实现。

只要一个类中存在纯虚函数,该类就是抽象类(有时也称为接口类),不能实例化对象。

即使将成员函数声明为纯虚函数,仍可在该类实现该函数。

若子类未实现父类指定的纯虚函数,那么子类也将是抽象类。但建议至少再在该子类中继续标注未实现的纯虚函数,以便明确传递给下一个继承子类实现。

override (自 C++11)

用于继承关系的关键字,明确子类成员函数重写自父类成员函数。被标注的函数将会在编译时检查其标识符是否在父类中存在。它的句法如下:

返回类型 子类名称::函数名称() override {}
// 或者
auto 子类名称::函数名称() -> 返回类型 override {}

父类同名函数必须标注 virtual

建议在继承类中重写父类函数时始终标注 override,避免不经意地创建了新函数。

final

用于自定类型或重写自父类的子类成员函数,使该类型禁止被其他自定类型继承,或禁止成员函数被再次重写。

→ 禁用继承

它的句法如下:

class 类型名称 final {};

→ 禁用重写

它的句法如下:

返回类型 类型名称::函数名称() final {}
// 或者
auto 类型名称::函数名称() -> 返回类型 final {}

noexcept

用于函数声明,明确该函数不会返回任何异常。如果函数仍返回异常,异常会直接调起 std::terminate() 结束程序。

它的句法如下:

返回类型 函数名称() noexcept {}
// 或者
auto 函数名称() noexcept -> 返回类型 {}

mutable

在类或结构体中,使用 mutable 标注的特定成员变量,即使类或结构体对象被标注为 const,该成员仍可以被修改。

此关键字会破坏程序的封装性,降低代码可读性,应尽可能减少使用。

C++ 运算符

new & delete

C++ 支持新的动态内存分配方式以提高安全性,适应面向对象的内存分配的需要。

当需要在堆内存上实例化对象时,C++ 使用 new 完成:

Parrot* sally = new Parrot() {"Sally", 1};

也可以分配数组型变量的内存。[自 C++11] 若需要初始化,按初始化列表规则完成:

Parrot* takusan_parrots = new Parrot[2] {{"Sally", 1}, {"Belly", 2}};

对象分配在堆上时,应在程序结束前使用 delete 释放:

delete sally;

如果是数组,需要额外添加方括号:

delete[] takusan_parrots;

Caution

通过 new 获取的堆内存只能由 delete 释放;通过 new[] 获取的堆内存只能由 delete[] 释放。混用导致的行为是不确定的。

→ 类成员变量使用动态内存

如果有类成员变量使用堆内存,必须手动编写以下函数:

  • 构造函数:用于为变量申请堆内存;
  • 析构函数:用于释放变量申请的堆内存;
  • 复制构造函数:使用已存在对象初始化一个新对象时,为新对象的成员变量申请堆内存;
  • 赋值运算符函数:一个已存在对象B赋值给另一个已存在对象A时,若A与B非同一个对象,清除A的当前堆内存,为A申请与B同样大小的新堆内存,将B的数据复制到A的新堆内存。

Note

用对象实例化新对象时,调用复制构造函数:

Parrot belly { "Belly", 1 };
Parrot sally = belly;  // 🟡 copy constructor called

将对象赋值给另一个对象时,调用赋值运算符:

Parrot belly { "Belly", 1 };
Parrot sally;
sally = belly;  // 🟡 assignment operator invoked

→ 定位 new 运算符

C++ 还为 new 提供了使用户自己决定对象的存储位置,即在 new 之后添加圆括号并选定一个指针:

char storage[100] { 0 };
Parrot* parrot = new (storage) Parrot();
// OR
unsigned char* storage = new unsigned char[100];
Parrot* parrot = new (storage) Parrot();

用于获取内存的变量称为内存池。如果分配给内存池的是堆内存,必须先让使用了堆内存的对象调用析构,最后再将内存池释放。

再次使用定位 new 分配内存池内存给新对象时,需要手动修改地址偏移,否则将覆盖原来的对象:

char storage[100] { 0 };
Parrot* sally = new (storage) Parrot("Sally", 1);
// 忽略下行 - OMIT NEXT LINE
Parrot* belly = new (storage) Parrot("Belly", 1);  // 🟡 sally will be overridden
// 忽略上行 - OMIT PREVIOUS 1 LINE
Parrot* venkie = new (storage + sizeof(Parrot)) Parrot("Venkie", 2);
 
// Release
sally->~Parrot();
venkie->~Parrot();

Warning

若无法完成堆内存分配,new 会抛出异常。

强制类型转换

static_cast<>()

静态类型转换

用于一般场合下的类型转换需求。编译器会在编译时检查有效性,有效的转换是:1. 内置数据类型转换定义;2. 自定类型有可用于类型转换的函数(这包括单个参数的构造函数)。

int variable[2] { 2.5, 8 };                    // ❌ 初始化列表不允许窄化转换
int variable[2] { static_cast<int>(2.5), 8 };  // 🟢

以下是可用于类型转换的示例:

Parrot::Parrot(const int age) : age(age) {
    this->name = "Sally";
}
 
explicit Parrot::operator int() {
    return age;
}
 
Parrot sally = 1;                             // 🟢
int sally_age = sally;                        // ❌ 需要显式转换
int sally_age = static_cast<int>(sally);      // 🟢
short sally_age = static_cast<short>(sally);  // 🟢

Caution

不要使用 static_cast<>() 将父类对象转换为子类对象,或是将指向父类对象的父类指针转换为子类指针,这可能会导致不正确的内存访问。

dynamic_cast<>()

动态类型转换

用于在继承关系链上的类型转换需求。程序会在运行时检查有效性,如果转换无效,转换表达式将返回 nullptr

只要指针或引用类型是指向目标类型的父类或同类型,该转换有效。即转换目标类型相同或收窄时才有效。

如果转换无效,dynamic_cast<>() 会返回空指针。

要使用动态类型转换,两个类型之间必须有继承关系,且至少有一个父类成员函数标注了 virtual

class Parent {
public:
    virtual void function() {
        std::cout << "Parent" << std::endl;
    }
};
 
class Kid : public Parent {
public:
    void function() {
        std::cout << "Kid" << std::endl;
    }
};
 
int main() {
    Kid rojay;
    dynamic_cast<Parent>(rojay).function();
    
    return 0;
}

const_cast<>()

移除或添加变量或对象的 const 特性。也可以用于添加或移除 volatile 特性。

→ 移除 const

const int value = 10;
int var_value = const_cast<int>(value);

注意,这并不能改变原始变量 valueconst 属性,只是 value 的值允许被复制给另一个可变量。

对于部分指向常量的指针而言,这种操作有时候是需要的:

void function(const int* pointer) {
    int* var_pointer = const_cast<int*>(pointer);
    if (var_pointer != nullptr)
        *var_pointer = 99;
}

→ 添加 const(不常用)

int value = 10;
const int const_value = const_cast<const int>(value);

Warning

如果指针目标本身处于不可修改的内存部分,请勿通过此方法尝试修改。

reinterpret_cast<>()

以指定的类型解释内存区域。主要用于内存级操作的程序,一般应用程序不应使用它,这会带来许多问题。

class DataPack {
public:
    int id;
    int strength;
};
 
int main() {
    long long enormous_value = 0x589AD1E;
    //                                             v 🟡 不要遗漏这个取地址符号
    DataPack* dp_ptr = reinterpret_cast<DataPack*>(&enormous_value);
    std::cout << dp_ptr->id << std::endl;
    return 0;
}

long long 使用 8 字节,这会以 DataPack 的结构解读该内存数据;此时 dp_ptr->id92908830

decltype() (自 C++11)

能够通过括号内的表达式结果确定一个类型,用于解决多个模板类型共同参与运算时,结果类型不确定的问题。

例如,以下结果变量无法确定具体类型:

template<typename Anytype_1, typename Anytype_2>
void calculate(Anytype_1& value_1, Anytype_2& value_2) {
    (??TYPE??) result = value_1 + value_2;                // 🟡 类型不定
    std::cout << result << std::endl;
}

此时可借助 decltype() 让编译器在代码编译期推测出恰当的类型:

template<typename Anytype_1, typename Anytype_2>
void calculate(Anytype_1& value_1, Anytype_2& value_2) {
    decltype(value_1 + value_2) result = value_1 + value_2;
    std::cout << result << std::endl;
}

Tip

若需要重复使用 decltype() 的结果,可以使用 typedef <已存在定义> <该定义的别名>; 设置类型别名。

若返回值是由多个模板类型形参构成的结果,按下例编写将无法通过编译,因为返回值中的变量或对象尚未定义:

template<typename Anytype_1, typename Anytype_2>
decltype(value_1 + value_2) calculate(Anytype_1& value_1, Anytype_2& value_2) {  // ❌
//         ^          ^   🔴 variable does not declared in this scope
    return value_1 + value_2;
}

此时应该使用后缀返回值表示法,将原来的返回值位置改为 auto,在函数末尾标注 -> 并紧跟返回类型。句法如下:

auto function() ?const? -> Typename;

将返回值类型声明放在形参声明之后,解决未定义问题:

template<typename Anytype_1, typename Anytype_2>
auto calculate(Anytype_1& value_1, Anytype_2& value_2) -> decltype(value_1 + value_2) {
    return value_1 + value_2;
}

noexcept()

判断给定的表达式是否能够抛出异常。

typeid()

判断并返回表达式的类型。

与 C 的兼容性

在 C++ 调用 C 外部库

在 C 中,链接函数时,为了内部实现需要,编译器可能会修改函数标识符。例如将标识符 add() 修改为符号名称 _add 用于符号表;但在 C++,为了支持函数重载,原来 C 的方法必须进行调整。例如将 add(int, int) 修改为 _add_i_i,将 add(double, double) 修改为 _add_d_d

外部库常以二进制文件形式保存,本质上就是已经过编译但未链接的二进制机器码(仅静态库),所以其包含符号表。对于 C/C++ 库来说,也应当含有头文件,因为一个编译单元内要使用外部函数或变量,就必须在当前编译单元内有声明。为此 C/C++ 通过预处理指令 #include 导入头文件将声明加入源代码文件。

对于 C 库来说,其使用 C 编译器完成编译,二进制机器码中包含的符号命名风格也是 C 的风格;若 C++ 程序使用了 C 库,且直接导入 C 库头文件,编译时会将编译单元下的 C 库头文件声明的标识符转换为 C++ 符号名称风格,导致在链接时因与 C 库二进制机器码文件符号名称不同,引发未定义错误。

为了解决此问题,C++ 使用 extern "C"extern "C" {} 来指定被包含的头文件中的声明应当以 C 风格形式符号化。若导入了 C 库到 C++ 程序使用,请始终记住这一点。

Note

此处的符号名称仅为简单示例,不表示任何编译器实际行为;

经 C++ 调整后的原始 C 库已适配 C++ 语法,例如 C 的 stdio.h 已更改为适合 C++ 程序的 cstdio,无需使用 extern "C" 指定链接符号风格。

Membership 及其继承类源代码

membership.h

#ifndef MEMBERSHIP_H
#define MEMBERSHIP_H
 
#include <string>
 
class Membership {
private:
    std::string name;
    unsigned int member_status;
    unsigned int point;
 
public:
 
    Membership(const std::string& name, const unsigned int& point);
 
    Membership(const Membership& member);
 
    const std::string& get_name() const;
    
    unsigned int get_status() const;
 
    void set_status(const unsigned int& new_status);
    
    unsigned int get_point() const;
 
    void set_point(const unsigned int& new_point);
    
    virtual unsigned int transform_to_currency() const;
 
    virtual void show_info() const;
};
 
class UltimateMembership : public Membership {
private:
 
    unsigned int shopping_seal;
 
public:
 
    UltimateMembership(const std::string& name, const unsigned int& point, const unsigned int& shopping_seal = 0);
 
    UltimateMembership(const Membership& membership, const unsigned int& shopping_seal = 0);
 
    unsigned int get_shopping_seal() const;
 
    void set_shopping_seal(unsigned int& shopping_seal);
 
    unsigned int transform_to_currency() const;
 
    void show_info() const;
};
 
#endif
 

membership.cpp

#include "membership.h"
#include "iostream"
 
    Membership::Membership(const std::string& name, const unsigned int& point) : name(name), point(point) {
        member_status = 0;
    }
 
    Membership::Membership(const Membership& member) {
        name = member.name;
        member_status = member.member_status;
        point = member.point;
    }
 
    const std::string& Membership::get_name() const {
        return this->name;
    }
 
    unsigned int Membership::get_status() const {
        return member_status;
    }
 
    void Membership::set_status(const unsigned int& new_status) {
        member_status = new_status;
    }
    
    unsigned int Membership::get_point() const {
        return point;
    }
 
    void Membership::set_point(const unsigned int& new_point) {
        point = new_point;
    }
    
    unsigned int Membership::transform_to_currency() const {
        return point * 0.5;
    }
 
    void Membership::show_info() const {
        std::cout << "--- Member info ---" << std::endl;
        std::cout << "Name: " << this->get_name() << std::endl;
        std::cout << "Status: " << this->get_status() << std::endl;
        std::cout << "Point: " << this->get_point() << std::endl;
        std::cout << "Equivalent currency: " << this->transform_to_currency() << std::endl;
    }
 
    UltimateMembership::UltimateMembership(const std::string& name, const unsigned int& point, const unsigned int& shopping_seal)
    : Membership(name, point), shopping_seal(shopping_seal) {}
 
    UltimateMembership::UltimateMembership(const Membership& membership, const unsigned int& shopping_seal)
    : Membership(membership), shopping_seal(shopping_seal) {}
 
    unsigned int UltimateMembership::get_shopping_seal() const {
        return shopping_seal;
    }
 
    void UltimateMembership::set_shopping_seal(unsigned int& new_shopping_seal) {
        shopping_seal = new_shopping_seal;
    }
 
    unsigned int UltimateMembership::transform_to_currency() const {
        return get_point() * 1;
    }
 
    void UltimateMembership::show_info() const {
        std::cout << "*** UltimateMember info ***" << std::endl;
        std::cout << "Name: " << this->get_name() << std::endl;
        std::cout << "Status: " << this->get_status() << std::endl;
        std::cout << "Point: " << this->get_point() << std::endl;
        std::cout << "Equivalent currency: " << this->transform_to_currency() << std::endl;
        std::cout << "Seal: " << this->get_shopping_seal() << std::endl;
    }