C++ Primer Plus 笔记

Last updated on 3 months ago

函数

基础

函数是如何返回返回值的

通常,函数将返回值复制到指定的CPU寄存器或内存单元中。随后,调用该函数的程序查看该内存单元。函数原型(声明)告诉调用程序返回值的类型,函数定义命令被调用函数用什么类型的数据返回。即返回函数和调用函数必须就该内存单元的数据类型必须要达成一致

关于函数原型(声明)

  • 为什么要用原型

函数原型将该函数的参数类型、数量和返回值类型告诉编译器,使得编译器可以正确解释它们

  • 原型的语法

函数原型只需要提供函数返回值类型、函数名、参数列表(参数类型、数量)(不需要参数名称,实际上参数名称相当于占位符的存在)

当然,函数的原型部分也可以不指定参数列表,函数原型的参数部分为空表示参数默认为void,并不是不指定参数列表的意思。在C++中,不指定参数列表应当用省略号:

1
void say_hi(...);

通常,仅当与接受可变参数的C函数(如printf)交互时才需要这么做

指针和const

如果数据类型本身不是指针,则可以将const数据或非const数据的地址赋值给被const修饰的指针,只能将非const数据的地址赋值给非const指针(不能将const数据的地址赋值给非const修饰的指针,这会使得const的状态很荒谬)

1
2
const int a = 10;
int* pa = &a;//INVAILD

如果数据类型本身是指针,则可以将非const数据的地址赋值给const或非const修饰的指针,只能将const数据的地址赋值给const指针(不允许将const数据的地址赋值给非const修饰的指针,这一点与上面一致)

内联函数

编译过程的最终产物是可执行程序——一组机器指令的集合。当执行该程序时,操作系统会将这些指令都载入到内存当中,因此每一条指令都会对应一个内存编号。随后,计算机将逐步执行这些指令,(当遇到循环时)有时会向前、向后跳跃到特定地址执行对应的指令。函数的调用过程也是如此,当调用该函数时,操作系统会跳到该函数的起始地址开始执行,在函数结束时返回。

程序在跳跃执行函数的过程中,会产生一定的开销。当跳跃所需的开销大于实际执行的开销,并且在重复调用该函数的时候这会十分浪费资源。为了解决这一问题,C++内联函数提供了另一种选择。

编译内联函数时,编译器会使用相应的函数代码来替换掉函数调用,这样程序就不需要跳跃到其他内存地址来执行对应的代码了。因此内联函数的执行速度快于其他函数,但代价是会带来更大的内存。如果我在一段程序中调用了该函数十次,在编译后这里就会出现该函数代码的十个拷贝。

需要注意的是:

  • 内联函数不应该过大,占用了多行就没有必要了
  • 内联函数不能递归

我们在函数的前面加上 inline ,这只是建议编译器将该函数改为 inline 的,实际上是不是还得取决于编译器。

对于在类内定义的函数,都是默认在前面加上 inline 的,对于类外定义的函数,默认是不加 inline 的。

引用变量

当我们声明一个变量时,编译器会为该变量分配一个内存地址,也就是说我可以通过该变量名来操作该内存地址当中的内容。引用变量的作用是再给这块内存地址另外一个名字,即我可以通过这两个不同的名字来操纵同一个内存地址,它们是完全等价的。

注:引用必须在其声明时就赋值,不能先初始化后赋值

1
2
3
int rats;
int & rodent = rats;
int* const ptr = &rats;//引用等价于这个

因此,引用在对其初始化之后的值就不能被改变(这里指的是该引用的指向)

当函数的形参是一个引用类型的时候,对比不加引用的情况,其实参的要求更加严格。由于引用是实际上是一个变量的别名,因此函数的实参必须是变量,一个普通的数字是不允许的。

1
2
3
4
int fun(int& e1);
fun(10);//Illeague
fun(a);//League
fun(a + 10);//Illeague

至于如何判断函数的实参是否会出问题,我们可以用一个很简单的方法——将其作为左值。如果是变量,那么它作为左值是被允许的,而非变量的数(如表达式)则不被允许

1
2
a = 5;//League
a + 10 = 5;//Illeague

那么这里就引出了一个问题:什么是左值?常规变量和 const 变量都可以视为左值,因为它们都可以通过地址来访问。常规变量属于可修改的左值, const 变量属于不可修改的左值。

  • 左值是允许通过地址来访问的,而右值不允许、

也就是说右值实际上是在一块临时的内存空间当中,上面的 fun(10) 与 fun(a + 10) ,这两个实参都是右值,它们存在于一个临时的内存空间当中

临时变量

如果实参与引用类型不匹配时,C++将生成临时变量,当然这么做只在引用被 const 修饰的时候才允许。如果引用参数为 const ,在以下两种情况下编译器会生成临时变量

  • 实参类型正确,但不是左值
  • 实参类型不正确,但可以转换成正确的类型
1
2
3
4
void fun(const int& e1);
fun(10);//类型正确,但不是左值
fun(x + 10);//与上一种情况一样,会生成临时变量
fun((double)10.5);//类型不正确,但可以转换成正确的类型

总结一下就是,对于形参为 const 引用的函数,如果实参类型不匹配,编译器会生成临时变量使得形参的引用指向该临时变量,这里与按值传递就没有区别了

引用与继承

继承实际上就是派生类具有一些基类的性质。在引用这里,当一个函数是以基类引用作为形参时,在传入的实参部分可以是基类,也可以是派生类

举一个简单的例子是:ofstream 是 ostream 的派生类,当函数的形参部分写的是 ostream& 时,我既可以传入 ostream 类型的左值,也可以传入 ofstream 类型的左值

函数重载

函数发生重载的关键是函数的参数列表——也称为函数的特征标(function signature),只要两个函数的参类型数目不同,那么它们就可以发生重载。

  • C++不会依据变量名来发生函数重载
  • C++不会依据返回值类型来发生函数重载

我们来看下面的几个函数

1
2
3
void fun(int a, int b);
void fun(long a, int b);
void fun(double a, int b);

当我调用 fun( (unsigned int)10, 5 ) 时,它不与任何一个函数原型相匹配。当出现没有原型与之匹配时,C++会尝试使用标准类型转换强制进行匹配。

放在这个例子当中,10可以被转换成三种不同的类型,这将分别对应三种不同的接口。因此在这种情况下,C++会拒绝这种函数调用,将其视为错误

有一点需要注意的是,编译器在检查特征标时,会将类型引用类型视为同一个特征标,即不能通过引用来发生重载。考虑下面的例子

1
2
void fun(int a);
void fun(int& a);

当我调用 fun(10) 时,确实会走入不同的接口,但是当我调用 fun(a) 时,两个接口都可以使用,这样就会导致错误,因此我们应当禁止这种行为的发生。

函数的形参部分是否含有 const 是可以发生重载的,原因在于将 const 对象的值赋给 non-const 对象是合法的,但将 non-const 对象的值赋给 const 对象则是非法的

实际上,我们可以通过默认参数的形式来替换掉函数重载,这样只需要写一份代码,编译时也只需要分配一份内存。但如果参数的类型不一致,那就乖乖地用函数重载吧

函数模板

函数模板是允许重载的,并且模板参数不一定非得是模板参数类型,例如:

1
2
3
4
template<typename T>
void swap(T& a, T& b);
template<typename T>
void swap(T&a, T&b, int c);//最后一个参数类型可以不是模板参数类型

当然,模板是有其局限性的。由于模板参数类型可以代表所有的类型,但有些类型可能并不支持一些运算(例如 class 就不支持+)。当然,C++允许运算符重载,这是一种解决办法,但这里要介绍另一种——为特定类型提供具体化模板

显示具体化

首先有以下三点原则:

  • 对于给定的函数名,可以有其非模板函数,模板函数,显示具体化模板函数及其重载版本
  • 显示具体化要以 template<> 开头,并在参数部分指出其类型
  • 非模板函数优先于常规模板函数和具体化模板函数,具体化模板函数优先于常规模板函数

假设我们有一个 class :

1
class KA {...};

对于上面的 swap 函数,其显示具体化模板为:

1
2
3
4
template<typename T>
void swap(T& a, T& b);//常规模板
template<> void swap<KA>(KA& a, KA& b);//具体化模板
template<> void swap(KA& a, KA& b);//也可以写出这样

实例化与具体化

在代码中包含函数模板本身并不会生成函数定义,他只是一个用于生成函数定义的方案。当编译器用模板为特定的类型生成函数定义时,得到的是模板实例(这里编译器为自定义类型 KA 生成的函数定义称为模板实例,该实例使用 KA 类型)。模板并非函数定义,但使用 int 的模板实例是函数定义

编译器之所以知道需要为该类型生成对应的函数定义,是因为程序在调用模板函数时提供了具体的类型,因此也称这种实例化方式为隐式实例化

简单来说,隐式实例化是用该函数模板来生成特定类型的函数定义,该类型来自于函数调用。既然有隐式实例化,那么自然也有显示实例化。这意味着可以直接命令编译器创建特定的类型,其语法是——在原函数模板的基础上指定其类型,并在最前面加上 template

1
2
3
4
template<typename T>
void Swap(T& a, T& b);//函数模板的声明

template void Swap<int>(int& a, int& b);//显示实例化的声明

看到上述声明后,编译器将会用函数模板来生成一个使用 int 类型的实例,再说一次,是用函数模板来生成一个使用 int 类型的实例。

我们一般会将显示实例化的声明放在头文件内

于显示实例化不同的是,显示具体化使用的是下面两个等价声明

1
2
template<> void Swap<int>(int& a, int& b);
template<> void Swap(int& a, int& b);

区别在于,这两个声明的意思是:不要使用模板来生成对应的函数定义,而是使用专门为 int 类型显示定义的函数定义。看到区别了吗?显示具体化直接绕过了通过模板生成函数定义这一步骤。显示具体化的 template 后面会带有 <> ,而显示实例化则没有。

  • 注:不允许在一个文件内同时出现同一类型的显示具体化和显示实例化

隐式实例化、显示具体化、显示实例化总称为具体化

template 隐式实例化 显示实例化 显示具体化
template void Swap(T& a, T&b) Swap(a, b) ( a, b is int ) template void Swap(char& a, char& b) template<> Swap(short& a, short& b) or template<> Swap(double& a, double& b)
1
2
3
4
template<typename T>
void Swap(T& a, T& b);//其他类型走这条,隐式实例化
template void Swap<int>(int& a, int& b);//这玩意只是声明,强制编译器用模板来生成 int 实例
template<> void Swap<short>(short& a, short& b);//short 类型走这条,显示具体化

我们来进一步区分这三者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
template<typename T>
T Lesser(const T& a, const T& b)//函数模板
{
cout << "Template" << endl;
return a < b ? a : b;
}

int Lesser(const int& a, const int& b)//函数重载
{
cout << "Overload" << endl;
return a < b ? a : b;
}

template short Lesser<short>(const short& a, const short& b);//显示实例化声明,放在头文件

template<> double Lesser(const double& a, const double& b)//显示具体化
{
cout << "Specialization" << endl;
return a < b ? a : b;
}

int main()
{
int a = 5, b = 10;
short n = 1, m = 2;
double x = 3, y = 6;
cout << Lesser(a, b) << endl;
cout << Lesser(n, m) << endl;
cout << Lesser(x, y) << endl;

cout << Lesser<>(a, b) << endl;//前两个函数模板
cout << Lesser<>(n, m) << endl;
cout << Lesser<>(x, y) << endl;//显示具体化

cout << Lesser<int>(a, b) << endl;//全部都是函数模板
cout << Lesser<int>(n, m) << endl;
cout << Lesser<int>(x, y) << endl;//三者都是显示具体化,这是因为指定了模板参数类型

return 0;
}

如果我在调用的时候指定了参数类型,那么就是显示实例化,如果没有,则为隐式实例化

decltype 和 后置返回类型(均为C++11)

考虑下面这个问题

1
2
3
4
5
template<typename T1, typename T2>
void fun(T1 a, T2 b)
{
x = a + b;
}

那么 x 应该为什么类型?

显然,在模板参数不确定的时候,我们根本没办法来判断。

这个时候, decltype 则解决了这个问题,我们可以这么使用:

1
2
int x;
decltype(x) y

这里表示,使 y 的类型跟 x 的相同。对于上面那个问题,我们可以这么写:

1
decltype(a + b) x = a + b;//这表示 x 的类型与 a + b 相同

现有声明:decltype ( expression ) var ,则 var 的类型按如下原则确定

  • 如果 expression 是一个没有括号括起的标识符,则 var 的类型与标识符的类型相同,包括 const 等限定符
  • 如果 expression 是一个函数调用,则 var 的类型与函数的返回值相同
    • 这里只是检查函数的返回类型,不会实际调用函数
  • 如果 espression 是一个左值,则 var 为指向其类型的引用
  • 如果前面的条件都不满足,则 var 的类型与 expression 相同

对于上面那个函数,我们目前解决的是 x 的类型的问题,但该函数的返回值仍然不确定,但可以明确的是,返回值的类型一定与 x 相同,而 x 的类型又依靠 a + b 。

C++11提供一种叫后置返回类型的操作,即:

1
auto fun(int a, int b) -> double

这样我们可以将返回值的类型写在后面,而 auto 是占位符。同理,对于上面那个问题,我们可以:

1
2
3
4
auto fun(T1 a, T2 b) -> decltype(a + b)
{
return a + b;
}

内存模型与名称空间

单独编译

在头文件中,我们可以包含:

  • 函数原型
  • 使用 #define 或 const 定义的符号常量
  • 结构声明
  • 类声明
  • 模板声明
  • 内联函数

在包含我们自己写的头文件是,要用 "" ,而不是 <> 。如果是用尖括号,则编译器会去存储标准头文件的目录下查找,如果是用双引号,则编译器会先在源代码目前去查找头文件,如果找不到才会去标准头文件目录下去查找

在C++中,我们需要尽可能避免多次包含头文件,具体方法如下

1
2
3
4
#ifndef HHDD__ //如果没有定义 HHDD__ 则会执行下面的代码(到 endif 为止)
#define HHDD__
//...
#endif

存储持续性、作用域和链接性

C++使用三种(C++11是4四种)不同的方案来存储数据,它们的区别在于数据保留在内存当中的时间

  • 自动存储持续性:在函数定义中声明的变量(包括函数参数)的存储连续性为自动的。当执行完函数或代码块时,它们的内存会自动释放
  • 静态存储持续性:在函数定义外定义的变量或者用 static 定义的变量。它们在程序运行的整个期间都存在
  • 动态存储持续性:用 new 所分配的内存将会一直存在,直到将这块内存 delete 掉
  • 线程存储持续性(C++11):如果变量是使用 thread_local 声明的,则其声明周期与所属线程一样长

作用域与链接

作用域描述了名称在文件的多大范围内可见。如,我在一个源文件的中间定义了一个变量,那么从中间到结尾该变量均可用,这里也是该变量的作用域,而从文件开头到中间则不是它的作用域

链接性描述了名称如何在不同文件间共享链接性为外部的名称可以在文件间共享,链接性为内部的名称只能由该文件当中的函数共享。对于上面那个例子,该变量在当前源文件中使用的范围,这关乎它的作用域,而其他文件是否可以使用该变量则关乎它的链接性

  • 全局变量的作用域为其定义位置到文件结尾
  • 在类中声明的成员的作用域为整个类
  • 在名称空间中声明的变量的作用域为整个名称空间(全局作用域可以看出名称空间作用域的特例)
  • 函数的作用域可以是整个类或者整个名称空间,但不能是局部的

自动存储持续性

在函数中声明的变量和函数参数的存储持续性为自动作用域为局部不具备链接性。我们直接看下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main()
{
int x = 5;
cout << x << endl;
cout << (int*)&x << endl;
{
int x = 10;
cout << x << endl;
cout << (int*)&x << endl;
}
cout << x << endl;
cout << (int*)&x << endl;
return 0;
}
  • 注:花括号可以单独使用,其括起来的部分为一个代码块

在进入 main 函数的时候,定义了一个 x 。在进入代码块的时候,又定义了一个 x 。后定义的 x 由于处在代码块中,它会遮掩(替换)掉原来的 x (控制流目前在代码块中,由于遮掩的存在导致程序看不到原来的那个 x ,只能看到在代码块内定义的 x ,这便是遮掩)。也就是说,这个 x 并不是对原本 x 的赋值而是重新开辟了一块内存。

两个 x 的作用域不同,因此它们存储的位置也不一样(尽管名称相同,但同一时间只会使用一个,因为存在遮掩),而如果它们的作用域相同,则后面的 x 为赋值

在C++11中, auto 用于自动类型推断,在以前版本的C++与C中,它用于显示指出变量为自动存储

在C++11中,register 用于显示指出变量是自动的。使用它的原因是:程序员想使用一个自动变量,这个变量的名称可能与外部变量相同。register 原先的用法为:建议编译器用寄存器来存储自动变量

静态持续变量

原先我们对于变量类型的分类主要是:全局变量、局部变量、静态变量(这里我们不讨论动态内存分配的变量)

实际上,这么讲是不对的,真正的分类应该只有两种:自动变量静态变量

关于自动变量,前面已经有过讨论了。作用域为局部无链接性

我们来讨论静态变量。

C++ 为静态变量提供了三种链接性,而静态变量的作用域则取决于其链接性。具体如下,括号内为作用域

  • 外部链接性(可在文件中访问)
  • 内部链接性(仅允许在当前文件内访问)
  • 无链接性(只能在当前函数或代码块中访问)

由于静态类型的变量数目在整个程序执行期间都是不变的,因此编译器会分配固定的内存来存储它们,它们在整个程序执行期间一直存在。另外,如果没有显示初始化静态变量,它们会被编译器设置为 0 (也就是零初始化)。

我们举例说明如何创建这三种类型的静态变量

1
2
3
4
5
6
7
8
9
10
11
int a = 10;//静态变量,外部链接性。等同于全局变量,其他文件也可以访问
static int b = 10;//静态变量,内部链接性。只能在当前文件访问

void fun()
{
static int c = 10;//静态变量,无链接性。只能在代码块内部或函数内部进行访问
}
int main()
{
...
}

不难看出:想要创建链接性为外部的静态变量,需要在代码块外面声明它;想要创建链接性为内部的静态变量,需要在代码块外部声明它并加上 static 进行修饰;想要创建无链接性的静态变量,需要在代码块内部声明它并加上 static

四种变量的存储方式
存储描述 持续性 作用域 链接性 如何声明
自动 自动 局部 代码块内部或函数内部
静态、无链接性 静态 代码块或函数内 在代码块内部或函数内部加上 static
静态、内部链接性 静态 当前文件内 内部 外代码块外声明并加上 static
静态、外部链接性 静态 所有文件 外部 在代码块外部声明即可

关于静态变量初始化的部分,我们在这里小提一嘴。

静态变量的零初始化会将零强制类型转换成合适的类型。并且,零初始化和常量表达式初始化统称为静态初始化,这意味着在它们是在编译阶段就已经被初始化了的,而动态初始化则意味着需要在运行阶段进行初始化。

除此之外,所有的静态变量会先零初始化,而不管该变量是否被显式地初始化过。简单来说就是会先进行零初始化,在进行静态或动态初始化。

1
2
int a;//直接就是零初始化
int b = 10;//先零初始化,然后再静态初始化

静态持续性、外部链接性(外部变量,也称为全局变量)

链接性为外部的变量称为外部变量,存储持续性为静态,作用域为整个文件。外部变量是在函数外面定义的,因此它可以在该定义往后或者其他文件当中使用。也因此外部变量也称为全局变量

首先,C++ 有「单定义规则」,即变量只能有一次定义;另一方面,每个使用外部变量的文件都需要对该变量进行声明

为了满足这种需求,C++ 提供了两种变量声明

  • 定义声明,简称定义,它会给变量分配存储空间
  • 引用声明,简称声明,它不会给变量分配存储空间,因为它只会引用已有的变量

引用声明使用关键字 extern ,并且不能进行初始化;否则此声明为定义,会给变量分配存储空间

如果需要在多个文件当中使用外部变量,只需要在一个文件当中进行定义,在其他文件当中使用 extern 进行声明即可

如果需要定义一个全局变量,最好是在源文件当中定义,在头文件当中声明(加 extern),这是因为源文件和头文件实际上是一个编译单元

值得注意的是,如果我对一个全局变量加上了 const ,那么它的链接性将会变为内部,即

1
const int a = 10;//等价于 const static int a = 10;

这么做是有原因的,我们通常不会将全局变量的定义放到头文件,但我们会将常量放到头文件。如果这个常量是外部链接性,那么就相当于是把一个全局变量放到了头文件,这是会出错的

如果处于某种原因,需要将一个常量的链接性改为外部,则我们可以通过 extern 来覆盖掉原先的内部链接性,即

1
extern const int a = 10;

那么,该变量就会跟全局变量一样,只不过不允许更改。在需要使用的文件中要加上 extern 。当然,我们也可以在源文件当中定义,在头文件当中声明(加 extern )

静态持续性,内部链接性(static 修饰的全局变量)

将 static 关键字用于作用域为整个文件的外部变量时,该变量的链接性会变为内部。链接性为内部的变量只能在其所属文件内使用

链接性为外部的静态变量可以使得在不同文件当中共享数据,链接性为内部的静态变量可以使得在同一文件的不同函数之间共享数据

如果链接性为外部的变量被 static 修饰,这样就不用担心其名称与其他文件中作用域为整个文件的变量名冲突

静态持续性,无链接性(static 修饰的局部变量)

在函数中用 static 修饰局部变量会使得该变量只在代码块中可以,当控制流离开代码块时该变量依旧存在

另外,如果初始化了静态局部变量,那么程序只会在启动时进行一次初始化,以后再调用这个函数将不会初始化它

函数和链接性

C++ 不允许在一个函数中定义另一个函数,因此所有的函数存储持续性自动为静态,在整个程序运行期间都存在。

默认情况下,函数的链接性为外部,即函数可以在文件当中共享(这一点与全局变量一致,加 extern 就行)

当然,在函数前面加上 static 后其链接性变为内部(在函数的声明和定义部分都要加),该函数只能在当前文件当中使用

与变量一样,链接性为内部的函数会遮掩链接性为外部的同名函数

变量的单定义规则适用于非内联函数,即该函数只能有一个定义,可以有很多个声明(定义在源文件,声明在头文件,做法跟全局变量很像啊)

对于内联函数,单定义规则并不适用。因此我们可以将内联函数的定义放到头文件当中。这样,包含了所有头文件的源文件当中都会有一份内联函数的定义。需要注意的是,同一个内联函数的所有定义必须相同(一个内联函数可以写在不同的头文件当中,但这些定义必须相同)

名称空间

在讨论名称空间之前,我们需要知道两个概念

  • 声明区域
    • 声明区域是可以声明该对象(可以是变量,也可以是函数)的区域
    • 例如,全局变量的声明区域为其所在文件,局部变量的声明区域为其所在的代码块
  • 潜在作用域
    • 潜在作用域从其声明点开始,一直到声明区域的结尾。显然,潜在作用域的范围小于声明区域的范围

我们在上面提到过作用域的概念。实际上,变量对于程序而言是可见的范围称为作用域。因此,作用域的范围小于等于潜在作用域的范围。

C++ 新增加了这样一种功能,即通过定义一个新的声明区域来创建命名的名称空间,这样做的目的之一是提供了一个声明名称的区域。

一个名称空间的名称不会与另一个名称空间中的相同名称起冲突,同时允许程序的其他部分使用名称空间中声明的东西。

名称空间可以是全局的,也可以声明在另一个名称空间内,但不能声明于代码块中。因此默认情况下,名称空间的链接性为外部(除非引用了常量)。

除了用户定义的名称空间外,还存在一个名称空间——全局名称空间。它对应文件级声明区域,全局变量会默认放在此名称空间内。

名称空间是开放的,既可以将名称加入到已有的名称空间内,也可以在文件后面或者另一个文件中再次使用该名称空间来提供相关的定义

名称空间当中的名称,其存储持续性为静态链接性为内部(只在名称空间内部可用)。也就是说,名称空间内部的变量一定会有零初始化

名称空间的使用

未被装饰的名称(如 cout)称为未限定名称,被装饰的名称(如 std::cout)称为限定名称。

我们不希望每次使用名称都对其进行限定,因此 C++ 提供了两种机制来简化名称空间的使用:

  • using 声明

using 声明使特定的标识符可用,它可将特定的名称添加到它所属的声明区域中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace Test
{
int n;
}

int n;

int main()
{
using Test::n;//可用将 Test 添加到使用这条语句对应的声明区域中
n = 10; //定义 Test::n,这里的 n 会直接遮掩掉全局变量 n
::n = 5; //定义全局变量 n
//直接以 :: 开头说明是在全局名称空间查找
cout << n << " " << ::n << endl;

return 0;
}

相应地,在函数外面使用 using Test::n ,可用将名称添加到全局名称空间中。

  • using 编译指令

using 编译指令使名称空间中所有地名称都可用。

在函数中使用 using 编译指令,使得该名称在函数当中可用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace Test
{
int n;
}

int n;

int main()
{
using namespace Test;

n = 10;//这样子会起冲突

return 0;
}

但如果用 using 声明的话就没问题

1
2
3
4
5
6
7
8
int main()
{
using Test::n;

n = 10;//using Test::n insted of global n

return 0;
}

当然,这两种添加指令有可能会造成名称其冲突,这样的话还是老老实实地用限定名称吧

两种导入方式的比较(问题暂留)

这个地方说实话我还不太懂,先留着

假设名称空间和声明区域定义了相同的名称。如果试图用 using 声明来导入名称的话,会导致两个名称起冲突,从而出错。如果试图用 using 编译指令来导入名称的话,局部的名称会覆盖掉名称空间的名称


需要补充的是,未命名的名称空间中声明的名称就跟持续性为静态,链接性为内部的名称一样,即:

1
2
3
4
5
6
7
static int count;

//等价于
namespace
{
int count;//完全等价啊,这东西也是静态持续性,一定会有零初始化
}

类继承

派生类具有以下特征

  • 派生类对象存储了基类数据成员(派生类继承了基类的实现
  • 派生类对象可以使用基类的方法(派生类继承了基类的接口

派生类不能访问基类的 private 成员,只能通过基类方法进行访问,这也是为什么派生类也会存在基类当中的 private 成员

  • 基类当中的变量通常是 private 权限,它们的赋值主要依靠对外提供的方法(构造函数等)。派生类继承过了之后也同样只能靠这些方法来进行访问这些 private 权限的变量。
  • 派生类的构造函数必须使用基类的构造函数,通常是在派生类构造函数的初始化列表调用基类的构造函数。
    • 如果在派生类的构造函数(默认构造、有参构造、拷贝构造都一样)处没有调用基类的构造函数,那么编译器会自动调用基类的默认构造函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Base
{
public:
Base(int rhs)
: x(rhs)
{

}
private:
int x;
};

class Derived : public Base
{
public:
Derived(int rh, int ch)
: Base(ch), a(rh)
{

}
private:
int a;
};

对于一个基类及其派生类,我们通常会将它们的声明全部放在同一个头文件它们的实现全部放在同一个源文件

基类的指针或引用可以指向派生类对象,但是该指针或引用只能调用基类的方法不能调用子类的方法(不涉及虚函数并且基类方法为 public 权限)

  • 基于这一点,我们可以用派生类对象来对基类进行赋值
1
2
Derived d(10, 5);
Base b(d);

这是因为基类的拷贝构造函数的声明为:Base(const Base&)形参部分为基类的引用,它可以指向一个派生类的对象。在这里指向的派生类对象是 d 。因此可以通过派生类对象来对基类进行赋值copy 构造和 copy assignment 操作符都一样)

静态联编和动态联编(静态绑定和动态绑定)

将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编

编译器对于非虚方法均是使用静态联编。使用静态联编时,通过该指针调用的函数就是它所定义的类型对应的函数(该指针是基类类型,那么它只能调用基类拥有的函数;该指针是子类类型,就只能调用子类拥有的函数),这在编译阶段就已经确定了

编译器对于虚方法则使用动态联编。使用动态联编时,通过该指针调用的函数跟指针所指的对象的类型有关,需要在程序运行时来确定该对象的类型

虚函数工作原理

通常,编译器对于虚函数的处理方法是:会先给每个对象添加一个隐藏成员。隐藏成员是一个指向函数地址数组的指针,这个数组称为虚函数表( vbtl )。vbtl 中存储了类当中声明的虚函数的地址。

例如,每个基类对象中包含一个指针,这个指针指向的数组包含了基类中所有虚函数的地址。每个派生类对象也会包含一个指向虚函数表的指针,不过这两个指针的值并不相同。

如果派生类从新定义了这个虚函数,那么会将新定义的虚函数的地址替换掉原来虚函数的地址;如果没有从新定义,则用原来的地址。

需要注意的是,无论有多少个虚函数,对象当中仅仅是添加了一个指针的大小,改变大小的是虚函数表。除此之外,这个虚函数表当中地址的排列是按照虚函数声明的顺序进行排列的。

对于虚函数,我们需要补充几点:

  • 构造函数不能是虚函数
    • 派生类是不会继承基类的构造函数的,所以在构造函数前面加 virtual 没有意义
  • 析构函数在做基类的时候一定要加上 virtual
  • 友元函数不能是虚函数,因为友元不是成员函数,只有成员函数才能是虚函数
  • 如果基类的虚函数的返回值是基类类型,那么对应的派生类的此函数的返回类型可以是派生类,这一例外只适用于返回值,不适用于参数
  • 如果基类声明被重载了,那么派生类需要重新定义所有的基类函数

使用类

关于运算符重载的一点说明

1
2
3
4
5
6
7
8
9
10
11
Test a, b;
Test c = a + b;
//等价于
Test c = a.operator(b);//其中 a 是调用对象, b 是作为参数传递的对象
//重载定义
Test operator+(const Test& e1) const
{
Test tmp;
//相关定义
return tmp;
}

对于有两个操作数的运算符重载,运算符左边的是调用对象,右边的是作为参数传递的对象

大多数运算符可以通过成员函数和全局函数进行重载,如:

成员函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Test
{
public:
Test(const int& e1);

Test operator+(const Test& e1);

void print() const;

private:
int num;
};

Test::Test(const int& e1)
:num(e1)
{

}

Test Test::operator+(const Test& e1)
{
return Test(this->num + e1.num);
}

void Test::print() const
{
cout << this->num << endl;
}

全局函数重载有一个问题是:一般来说我们类当中的成员属性是私有的,而用全局函数重载就属于类外访问。只能是类提供公共的接口供外部调用这些资源,或者为全局函数声明友元,不可以通过 operator 来隐式转换获取这些资源。下面就是个错误的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Test
{
public:
Test(const int& e1);

void print() const;

operator int() const;

private:
int num;
};

Test::Test(const int& e1)
:num(e1)
{

}

Test::operator int() const
{
return this->num;
}

Test operator+(const Test& e1, const Test& e2)
{
return Test(e1 + e2);//通过隐式转换获取底层资源
}

这里原本的想法是 e1e2 可以被隐式转换成 int 类型,然后两个 int 类型进行相加最后再执行 Test 的构造函数。

额。。。然后现实是 e1e2 并不会被隐式类型转换,而是会在这里一直调用 operator+ 进而导致死循环。

也就是说,通过全局函数重载运算符的话,一定要在类的类部提供外界可以访问其底层资源的方法或者为该全局函数声明友元

运算符重载的限制

  1. 重载后的运算符必须至少有一个操作数是用户定义的类型

    • 比如对于一个特殊的自定义类型 Testoperator+ 的实际意义是让两个 Test 类型的对象相减。如果违背这一条,就相当于承认可以让两个 int 类型的数据在相加时实际执行的操作是相减!!那不是乱套了吗
  2. 使用重载运算符时不能违反该运算符原来的语法规则,相应地,运算符的优先级也不会改变

    • 比如取模运算符 % 的操作数必须是两个,你不能将该操作符重载成使用一个操作数 % 5 。这是什么东西???
  3. 不能创建新的运算符

    • 比如不能定义 ** 为求幂的操作
  4. 不能重载下面的运算符

    • sizeof 运算符
    • . 类成员运算符
    • .* 类成员指针运算符(我也不知道这是啥)
    • :: 作用域解析运算符
    • ?: 条件运算符
    • typeid 一个 RTTI 运算符(求变量的类型,至于那个 RTTI ,我也不知道是啥)
    • const_castdynamic_castreinterpret_caststatic_cast 等强制类型转换运算符
  5. 以下运算符只能在成员函数中重载

    • = 赋值运算符

    • () 函数调用运算符

    • [] 下标运算符

    • -> 指针访问类成员运算符

友元

一般来说,对于 C++ 的类当中的 private 部分的访问只能通过 public 方法这一种访问途径,这实在是有点太严格。因此 C++ 允许通过友元技术来对类当中 private 成员进行访问。

友元有三种,即:

  • 友元函数
  • 友元类
  • 友元成员函数

友元函数

通过让函数成为类的友元,可以赋予该函数拥有与类的成员函数一样的访问权限

我们来看一个问题:如果我重载了 operator* 使得一个自定义类型 Test 可以与一个 int 类型相计算,并且返回一个 Test 类型,那么:

1
2
3
4
Test a;
Test b = a * 10;//这没问题,等价于
//Test b = a.operator(10);
Test c = 10 * a;//这样不行,因为运算符左边必须是调用对象,右边是作为参数传递的对象

但直觉上第三种是没问题的,要解决这个问题,我们可以通过重载全局函数来解决。当然在这之前,就需要涉及到友元函数了。

创建友元函数的第一步是将该函数的原型(也就是声明)放在类的声明中,并在前面加上 friend

1
2
3
4
5
6
7
8
9
10
11
class Test
{
friend Test operator+(const Test& e1, const Test& e2);//这玩意应该不需要访问权限
public:
Test(const int& e1);

void print() const;

private:
int num;
};

这意味着以下两点:

  • 该函数不是这个类的成员函数,因此不可以将它当中成员函数进行调用
  • 该函数虽然不是这个类的成员函数,但它拥有与这个类当中成员函数一样的访问权限

之后是编写定义。由于它不是成员函数,因此不能加类名限定符。另外,也不要使用 friend 关键字。

1
2
3
4
Test operator+(const Test& e1, const Test& e2)
{
return Test(e1.num + e2.num);
}

这样之后,刚刚那个问题也一并解决了。

除了用全局函数以外,我们依旧可以用友元函数来解决

我们刚刚在类内重载了 operator*(int) 函数,这个函数可以应对自定义类型 * int 的情况 。现在,我只需要在类外重载一个 operator*(int, const Test&) ,这样,便可以应当 int * 自定义类型 这种情况了。


这里我们来详细说一下两种方法的优缺点:

  • 全局函数重载
    • 这种依赖于隐式转换(如果函数的两个参数都是自定义类型的话),也就是说构造函数前面不能加 explicit
    • 优点是工作较少,出错机会小
    • 缺点是会增加内存开销
  • 类内函数加类外函数重载
    • 优点是运行速度更快
    • 缺点是工作更多

如果程序使用这种运算符十分频繁,则应采取第二种。如果只是偶尔使用,则采用第一种。但为了保险,也最好使用第一种(毕竟不能关掉隐式类型转换)。

重载 << 运算符

对于一个自定义类型,我们可以通过重载 << 运算符来实现对该类型的输出,即:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class TP
{
public:
TP(const int& e1, const int& e2)
: rh(e1), ch(e2), Close(false)
{ }

int re_rh() const { return rh; }
int re_ch() const { return ch; }

private:
int rh, ch;
mutable bool Close;

friend ostream& operator<<(ostream& os, const TP& rhs);
};

ostream& operator<<(ostream& os, const TP& rhs)
{
os << rhs.rh << " " << rhs.ch << endl;
return os;
}

这个重载函数只能是全局的。如果该函数是局部的,那么它只能通过对象(假设 trip 是一个 TP 对象)来调用左移操作符,这就会出现trip << cout 这样显然是有问题的,因此我们必须用全局函数来重载。这样的话就势必会涉及友元。

你可能会有一个问题是:为什么友元的声明是在自定义的类当中的ostream 类当中不需要吗?

emmm, 如果每一个类都需要去 ostream 当中声明一个友元函数的话,这将会是一件风险极大的事情(因为你改变了标准库)

实际上,我们声明友元的目的是为了访问这个类当中 private 作用域下的成员,而对于 ostream 类而言,我们是将它作为一个整体来使用的,因此不需要声明友元

还有一个问题是:当我调用 cout << trip 时,我调用的是 cout 对象本身的引用,而不是它的拷贝,因此在形参部分要加上引用

由于 ofstream 类是 ostream 类的派生类,因此也可以将 trip 输出得到文件上,即:

1
2
3
ofstream ofs;
ofs.open(...);
ofs << trip;

这么写不会有任何的问题,因为基类引用的形参也愿意接受一个派生类的对象

类的自动转换和强制类型转换

我们先来复习一下 C++ 是如何处理内置类型转换的。

当一个标准类型的变量赋值给另一个标准类型的变量时,如果二者的类型兼容,则 C++ 将自动将这个值的类型转换成接收变量的类型。

1
2
3
long a = 8;//int to long
double b = 10;//int to double
int c = 3.14;//double to int

我们不管是否会降低精度,这些都属于自动类型转换

C++ 不会自动转换类型不兼容的变量,例如

1
int* a = 10;

这个语句是非法的,因为左边是指针类型,右边是整型,二者类型并不兼容。然而,在无法自动转换时,我们可以使用强制类型转换

1
int* a = (int*)10;

这个语句是否有意义暂且不谈,但它是合法的。这便是 C++ 关于内置数据类型的转换(当然还有一些关键字,不过那个我们暂且不谈)。

下面,我们来看自定义数据类型的转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class TP
{
public:

TP()
:rh(0), ch(0), Close(false)
{ }
TP(const int& e1, const int& e2 = 0)
: rh(e1), ch(e2), Close(false)
{ }

int re_rh() const { return rh; }
int re_ch() const { return ch; }
void print() const;

private:
int rh, ch;
mutable bool Close;

friend ostream& operator<<(ostream& os, const TP& rhs);
};

int main()
{
TP t1;
t1 = 2;//看这里

return 0;
}

构造函数当中,有一个参数有默认值,因此可以认为该构造函数只有一个参数。而我们可以通过该构造函数将 int 类型的数据转换为自定义类型的数据

在 C++ 中,接收一个参数的构造函数可以将「类型与构造函数参数相同的值」转化为类的类型。

也就是说,当我写出:

1
2
TP t1;
t1 = 2;

程序将采用构造函数 TP(const int& e1, const int& e2 = 0) 来创建一个临时的对象(其内部属性为 rh = 2, ch = 0)。然后再通过 copy 构造函数来将该对象赋值给 t1 。这一过程被称为隐式类型转换

只有接收一个参数的构造函数才可以用于隐式类型转换,接收两个参数的不行。

  • 补充:
    • 如果函数声明为 TP(const int& e1, const int& e2),那么我们可以通过 t1 = { 2, 0 } 来达到与上面一样的效果,其过程也是一样(先创建临时变量,再通过 copy 构造函数进行赋值)。但这不能称为隐式类型转换
    • 在这里我们是为第二个参数添加了默认值,因此可以认为该构造函数只有一个参数

我们依旧用 TP(const int& e1, const int& e2 = 0) 来进行讨论。这种隐式类型转换有时会带来不好的结果,因此 C++ 提供了一个关键字 explicit 来关闭掉这种隐式类型转换。即:

1
2
3
explicit TP(const int& e1, const int& e2 = 0)
: rh(e1), ch(e2), Close(false)
{ }

这样过后,就不能通过隐式类型转换来转换类型,但依旧允许显示类型转换,即允许显示强制类型转换

1
2
3
4
TP t1;
t1 = 2;//Not Allow
t1 = TP(2);//OK
t1 = (TP)2;//OK

后面两个都属于强制类型转换!!!

关于隐式类型转换的分析(重点)

这里我重申一点:当构造函数只接受一个参数时,它是可以通过隐式类型转换来对自定义类型进行赋值的

TP(const int& e1, const int& e2 = 0) 下,有:

1
TP t1(2);
  • 上面这是隐式类型转换,通过将 2 这个整型隐式转换成 TP 类型。
1
TP t1 = 2;
  • 这个跟上面一样,都是将整型隐式类型转换成自定义类型

这两个的过程是:先创建一个临时对象,然后再通过 copy 构造函数来对 t1 进行赋值

1
TP t1 = TP(2);
  • 这是显示强制类型转换
1
TP t1 = (TP)2;
  • 这个也是显示强制类型转换

这两个的过程跟上面一样。

我通常写的 TP t1(2) ,这个玩意叫直接初始化,这会调用它的构造函数,属于显示类型转换不是隐式类型转换

如果是在 TP(const int& e1, const int& e2) 下,那么将不存在隐式类型转换,而显示强制类型转换跟原来的相同


题外话说完了,我们接着刚才的。也就是说,如果在构造函数 TP(const int& e1, const int& e2 = 0) 前面声明了 explicit ,那么该构造函数将只能用于显示强制类型转换,否则依旧可以使用下面的隐式类型转换:

  • 将 TP 对象初始化为 int
  • int 值赋给 TP 对象
    • TP t1 = 10;
  • int 值传递给接受 TP 类型参数的函数
    • 假定我们有一个函数,其声明为 void fun(const TP& e1)
    • 那么我在调用 fun(10) 时,这个时候就会发生隐式类型转换
  • 返回值被声明为 TP 的函数试图返回 int 类型
    • 依旧假定我们有一个函数,其声明为 TP fun()
    • 在函数体内,我们写出 return rhs; //rhs 是 int 类型的变量 时,会发生隐式类型转换
    • 这是因为需要调用构造函数将 int 转化为 TP 类型的对象
  • 在上述任何一种情况下,使用可以通过自动类型转换变为 int 的类型
    • double 可以通过自动类型转换成 int ,那么上述的所有情况对 double 也同样适用

关于最后一点,我们需要进行一点说明。

上面的函数原型是 TP(const int& e1, const int& e2 = 0) ,如果我还写了一个另一个构造函数,只是把 int 改为了 long ,其余不变,即 TP(const long& e1, const long& e2 = 0)

那么当我写 TP t1 = 2.5 时,编译器会报错。这是因为 double 类型既可以转换成 int 也可以转化成 long ,这就出现了二义性。因此下面的代码会报错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class TP
{
public:

TP()
:rh(0), ch(0), Close(false)
{ }
TP(const int& e1, const int& e2 = 0)
: rh(e1), ch(e2), Close(false)
{ }

TP(const long& e1, const long& e2 = 0)
:rh(e1), ch(e2), Close(false)
{ }

private:
int rh, ch;
mutable bool Close;

};

int main()
{
TP t1 = 2.5;
return 0;
}

转换函数

在 C++ 当中,一个参数的构造函数定义了从某种类型到「类类型」的转换,如果需要进行相反类型的转换则需要通过转换函数

转换函数的语法为:

1
operator typeName();

有几点需要注意的是:

  • 转换函数必须是类方法
  • 转换函数不能有返回值
  • 转换函数不能有参数

typeName 指定了要转换成的类型,可以是intdouble 等内置数据类型,当然也可以是自定义数据类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class TP
{
public:
TP(const double& rh)
:n(rh)
{ }

operator int()
{
return n;
}
private:
double n;
};

int main()
{
cout << int(12.6) << endl;
TP t(12.6);
cout << int(t) << endl;
return 0;
}

转换函数实际上就是用户定义的强制类型转换,即:它是强制类型转换!!!

因此强制类型转换的语法可以用在它的身上,它可以将类类型转换为用户定义的类型。

如果在类当中加一个转换函数,即:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class TP
{
public:
TP(const double& rh)
:n(rh)
{ }

operator int()
{
return n;
}

operator double()
{
return n;
}
private:
double n;
};

当我写出

1
cout << t << endl;

由于没有重载右移操作符,并且定义了两个转换函数,使得可以将 TP 类型转换为 intdouble ,那么编译器将会报错,因为这里出现了二义性的转换。

在这种情况下,当我写出

1
long a = t;

由于 TP 可以被转成 intdouble ,而这两个内置数据类型都可以转成 long ,因此在这里编译器也会报错。

当然,我前面说过。这种转换函数的语法等同于强制类型转换的语法,因此你可以通过这种方式来指定使用哪个转换函数,即

1
2
long a = int(t);
long b = double(t);

这两个都可以通过编译。

这种自动类型转换有时会发生不好的事情,

例如,你可能会将上面的 TP 类型的对象作为数组下标使用(我知道你脑子清醒的时候不会,但说不定你那天头昏呢),因为你声明了这种隐式类型转换,它将自动转换为 int ,但这很明显是错误的。要关掉它也很简单,在该函数前面加上 explicit (这一点在 C++98 是不允许的,但 C++11 则没有这种限制),即

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class TP
{
public:
TP(const double& rh)
:n(rh)
{ }

explicit operator int()
{
return n;
}

explicit operator double()
{
return n;
}
private:
double n;
};

用了之后,便不会再出现隐式调用,需要通过显示强制调用才行

总之,C++ 为类提供了两种类型转换:

  • 构造函数用于将「类型与该参数相同的值」转换为类类型
  • 转换函数用于将类类型转换为其他类型

转换函数和友元函数

我们来看一个《简单》的例子(嗯,带简单两个字的往往学起来不简单)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class TP
{
public:
TP(const int& rh)
:n(rh)
{ }


TP operator+(const TP& t)
{
return TP(this->n + t.n);
}

private:
int n;
};

注意:这里面没有写 operator int 转换函数

好,然后当你写下:

1
2
3
TP t1(10);
int a = 20;
TP t3 = t1 + a;

然后,如果你提供了友元全局函数重载的话,就允许这么做:(成员函数重载和友元函数重载只能存在一个)

1
TP t3 = a + t1;

当然,这里的故事我们前面已经说过了(操作符左边的为调用函数的对象,右边为作为参数传递的对象),并且解决办法在前面也有讨论(在 友元函数 那里)。

下面是重头戏!!!

我们要再此基础上加上转换函数,即:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class TP
{
public:
TP(const int& rh)
:n(rh)
{ }

operator int()
{
return n;
}

TP operator+(const TP& t)
{
return TP(this->n + t.n);
}

private:
int n;
};

当我写出:

1
2
3
TP t1(10);
int a = 20;
TP t3 = t1 + a;

由于可以TP 对象转换为 int ,也可以将 int 类型转换为 TP 。因此这里再次出现了二义性

解决办法也很简单,需要为转换函数声明一个 explicit 就行。这样就只能是 int 转换为 TP

注意,这里不能在构造函数前面加上 explicit ,因为这样只是不允许将 int 类型转换为 TP 。因此 a 将没办法转换为 TP 类型,自然也不能参加加法运算,所以会导致报错。


C++ Primer Plus 笔记
https://nishikichisato.github.io/2022/07/30/C++ Primer Plus/C++ primer plus笔记/
Author
Nishiki Chisato
Posted on
July 30, 2022
Updated on
November 25, 2023
Licensed under