第 2 章 类型、常量及变量

# 单词

c++ 使用 ASCII 字符集(主要包括英文字母、阿拉伯数字、运算符以及各种标点符号,还有不可显示的字符)中的字符来构造 “单词”,常量、变量名、函数名、参数名、类型名、运算符、关键字等都是 “单词”。关键字也被称为保留字,程序员不能用它来定义变量名、函数名或者参数名。

空格符和制表符都是分隔符,都能用于分隔两个标识符,例如用于分隔类型名与变量名。运算符和标点符号也能起分隔符的作用。例如 a+b 中的 + 。标点符号(如逗号)也可以用作运算符,用逗号构成的表达式如 3, 6, 9 称为逗号表达式,其结果为最后一个逗号后的操作数的值。

ASCII 字符集对小写字母、大写字母和阿拉伯数字均采用连续编码的方式, A 为 65, a 为 97, 0 为 48。将字符 2 转化为数字的方式:用字符’2‘的 ASCII 值减去字符’0‘的 ASCII 值,即 '2'-'0'

建议使用 wchar_t 和 wcout 实现国际文字(包括中文)的输出。

# 预定义类型及值域和常量

预定义类型是指 c++ 预先定义的保留类型,这些类型通常是简单类型(如 bool、char 等)。简单类型的数值在计算时,一般遵循从有符号数向无符号数转换、从字节小的类型向字节大的类型转换的原则。

auto、register、static 和 extern 说明变量的存储位置特性;const、constexpr、volatile 和 mutable 等说明变量的存储可变特性。mutable 用于说明实例变量(即示例数据成员)的存储可变特性。

存储位置特性用于说明变量的存储位置。如 static 定义的静态变量和 extern 说明的全局变量编译后再数据段内分配内存。作为程序一部分的数据段会随程序存放于磁盘文件中,数据段内没有初始化的变量在磁盘对应位置上的值通常为 0.auto 说明的局部变量或函数参数在栈段(Stack Segment,SS)分配内存,函数内未使用 static 定义的局部变量默认为 auto 变量。编译程序会自动地分配和回收栈段分配的内存。

register 用于在函数内定义寄存器变量。如果数量有限的寄存器被分配完,或者寄存器变量出现取地址操作,register 变量就被编译成 auto 变量。另一方面,如果有多于的寄存器没被使用,即使局部变量被定义为用 auto 来分配栈内存,也有可能被编译优化成使用 register 存储。因此 register 和 auto 是可以相互转化的。

对于布尔类型,false 为 0,true 为 1. 非零的数值转化为 true,为零的数值转化为 false。

字符常量值在内存中存储的是该字符的 ASCII 值

'\113''\x48' 分别为 'K' 的八进制(3 位,不足则在最高位补 0)和十六进制(2 为)的 ASCII 表示, \ 为转义字符,不区分其中十六进制数的大小写。注意,u'a' 是 char16_t 类型大小为 2 个字节的常量,而 U'a' 是 char32_t 类型大小为 4 个字节的常量。

字符串类型可以解释为字符数组类型,同时,字符数组的首地址也可当做字符指针使用,其类型为 const char *,即指向只读字符的指针。当字符串中出现双引号时,使用转移字符。

整数除了可以用十进制表示外还可以用八进制或十六进制表示,其中八进制以 0 开始,十六进制以 0x 或 0X 开始。如果要表示无符号数,则可以在整数后面加 u 或者 U。

有符号长整型常量以 l 或 L 结束;无符号长整型常量以 UL、Ul、ul 或 uL 结束,其中 U、u 同 L、l 的位置可以互换,建议书写使均用大写字母。0LL 是 8 个字节的 long long 类型有符号超长整数。

浮点常量默认当做 double 类型处理,例如 5.、.32、0.34E-10 等。如果浮点常量以小写字母 l 或大写字母 L 结束,则其类型被认为是 long double 类型。浮点常量也可用 E 和 e 的科学计数法表示,e 后面的整数表示 10 的整数次方。

浮点常量的书写格式:

  1. 小数点两边至少一边有数
  2. e 或 E 的两边必须都有数,且 e 或 E 的右边必须是整数

注:非扩展 ASCII 表中的字符常量采用 1 个字节的内存存储;而扩展的字符集常量则默认按 4 个字节存储,即默认字符类型为 char32_t

# 一些关键字

# constexpr

# 常量表达式

所谓常量表达式,指的就是由多个(≥1)常量组成的表达式。换句话说,如果表达式中的成员都是常量,那么该表达式就是一个常量表达式。这也意味着,常量表达式一旦确定,其值将无法修改。

实际开发中,我们经常会用到常量表达式。以定义数组为例,数组的长度就必须是一个常量表达式:

// 1)
int url[10];// 正确
// 2)
int url[6 + 4];// 正确
// 3)
int length = 6;
int url[length];// 错误,length 是变量

上述代码演示了 3 种定义 url 数组的方式,其中第 1、2 种定义 url 数组时,长度分别为 10 和 6+4,显然它们都是常量表达式,可以用于表示数组的长度;第 3 种 url 数组的长度为 length,它是变量而非常量,因此不是一个常量表达式,无法用于表示数组的长度。

常量表达式的应用场景还有很多,比如匿名枚举、switch-case 结构中的 case 表达式等,

C++ 程序的执行过程大致要经历编译、链接、运行这 3 个阶段。值得一提的是,常量表达式和非常量表达式的计算时机不同,非常量表达式只能在程序运行阶段计算出结果;而常量表达式的计算往往发生在程序的编译阶段,这可以极大提高程序的执行效率,因为表达式只需要在编译阶段计算一次,节省了每次程序运行时都需要计算一次的时间。

如何才能判定一个表达式是否为常量表达式,进而获得在编译阶段即可执行的 “特权” 呢?除了人为判定外,C++11 标准还提供有 constexpr 关键字。

constexpr 关键字的功能是使指定的常量表达式获得在程序编译阶段计算出结果的能力,而不必等到程序运行阶段。C++ 11 标准中,constexpr 可用于修饰普通变量、函数(包括模板函数)以及类的构造函数。

注意,获得在编译阶段计算出结果的能力,并不代表 constexpr 修饰的表达式一定会在程序编译阶段被执行,具体的计算时机还是编译器说了算。

# constexpr 修饰普通变量

定义变量时可以用 constexpr 修饰,从而使该变量获得在编译阶段即可计算出结果的能力。
使用 constexpr 修改普通变量时,变量必须经过初始化且初始值必须是一个常量表达式(等号右边是包含变量的时候会报错)。举个例子:

#include <iostream>
using namespace std;
int main()
{
    constexpr int num = 1 + 2 + 3;
    int url[num] = { 1,2,3,4,5,6 };
    cout << url[1] << endl;
    return 0;
	// 程序执行结果为 2
}

将此示例程序中的 constexpr 用 const 关键字替换也可以正常执行,这是因为 num 的定义同时满足 “num 是 const 常量且使用常量表达式为其初始化” 这 2 个条件,由此编译器会认定 num 是一个常量表达式。

注意,const 和 constexpr 并不相同,关于它们的区别,我们会在下一节做详细讲解。

当常量表达式中包含浮点数时,考虑到程序编译和运行所在的系统环境可能不同,常量表达式在编译阶段和运行阶段计算出的结果精度很可能会受到影响,因此 C++11 标准规定,浮点常量表达式在编译阶段计算的精度要至少等于(或者高于)运行阶段计算出的精度。

# constexpr 修饰函数

constexpr 还可以用于修饰函数的返回值,这样的函数又称为 “常量表达式函数”。

注意,constexpr 并非可以修改任意函数的返回值。换句话说,一个函数要想成为常量表达式函数,必须满足如下 4 个条件。

# 该函数必须有返回值,即函数的返回值类型不能是 void。
constexpr void display() {
    // 函数体
}

像上面这样定义的返回值类型为 void 的函数,不属于常量表达式函数。原因很简单,因为通过类似的函数根本无法获得一个常量。

# 函数在使用之前,必须有对应的定义语句

我们知道,函数的使用分为 “声明” 和 “定义” 两部分,普通的函数调用只需要提前写好该函数的声明部分即可(函数的定义部分可以放在调用位置之后甚至其它文件中),但常量表达式函数在使用前,必须要有该函数的定义。
在调用前的声明和定义可以同时存在。

#include <iostream>
#include <algorithm>
using namespace std;
constexpr int display(int x) {
    int ret = 1 + 2 + x;
    return ret;
}
int main()
{
    int num[display(1)];
    fill(num, num + display(1), 1);
    for (int i = 0; i < display(1); i++)
        num[i] = i;
    for (int i = 0; i < display(1); i++)
        cout << num[i] << endl;
    
    return 0;
}
#include <iostream>
#include <algorithm>
using namespace std;
constexpr int display(int x) {
    int ret = 1 + 2 + x;
    return ret;
}
constexpr int func(const int n)
{
    return 10 + n;
}
int main()
{
    int x = 1;
    cout << display(x) << endl;
    //int arr [display (x)];  // 错误
    int num[display(1)];
    fill(num, num + display(1), 1);
    for (int i = 0; i < display(1); i++)
        num[i] = i;
    for (int i = 0; i < display(1); i++)
        cout << num[i] << endl;
    int i = 2;
    cout << func(i);
    return 0;
}

程序员告诉编译器尽管信心十足地把 func 当做是编译期就能计算出值的程式,但却欺骗了它,程序员最终并没有传递一个常量字面值到该函数。没有被编译器中止编译并报错的原因在于编译器并没有 100% 相信程序员,当其检测到 func 的参数是一个常量字面值的时候,编译器才会去对其做优化,否则,依然会将计算任务留给运行时。

我的理解:如果在编译时无法判断这个是否可以在编译时就可以计算,就不会

# return 返回的表达式必须是常量表达式
#include <iostream>
using namespace std;
int num = 3;
constexpr int display(int x){
    return num + x;
}
int main()
{
    // 调用常量表达式函数
    int a[display(3)] = { 1,2,3,4 };
    return 0;
}

该程序无法通过编译,编译器报 “display (3) 的结果不是常量” 的异常。

常量表达式函数的返回值必须是常量表达式的原因很简单,如果想在程序编译阶段获得某个函数返回的常量,则该函数的 return 语句中就不能包含程序运行阶段才能确定值的变量。

#include <iostream>
using namespace std;
int num = 3;
constexpr int display(int& x) {
    return x + 3;
    //return num + x;
}
int main()
{
    // 调用常量表达式函数
    int x = 3;
    int a[display(x)] = { 1,2,3,4 };
    return 0;
}

# const 和 constexpr 的比较


const 并不能代表 “常量”,它仅仅是对变量的一个修饰,告诉编译器这个变量只能被初始化,且不能被直接修改(实际上可以通过堆栈溢出等方式修改)。而这个变量的值,可以在运行时也可以在编译时指定。

constexpr 可以用来修饰变量、函数、构造函数。一旦以上任何元素被 constexpr 修饰,那么等于说是告诉编译器 “请大胆地将我看成编译时就能得出常量值的表达式去优化我”。

对于 f2 () ,胆小的编译器并没有足够的胆量去做编译期优化,哪怕函数体就一句 return 字面值;
编译期大胆地将 func () 做了优化,在编译期就确定了 func 计算出的值 10 而无需等到运行时再去计算。

这就是 constexpr 的第一个作用:给编译器足够的信心在编译期去做被 constexpr 修饰的表达式的优化。

# 2.3 变量及其类型解析

变量是标识符标记的内存单元的数据载体。在说明简单类型的变量时,只需要知名变量的类型和名称;在定义简单类型的变量时,还需要给出变量的初始值。

变量说明的一般形式为 extern 存储可变特性 类型名 变量名; ,其中定义存储可变特性的关键字有 const、constexpr、volatile 和 mutable,而 mutable 只能用于在 extern 不出现时说明实例数据成员。

const 和 constexpr 的变量为只读变量,当前进程只能取其值,不能对其进行修改,即赋新值;volatile 的变量会 “自主” 发生变化。说明或定义变量时可以使用 const volatile 或者 constexpr volatile,表示当前进程没有修改变量的值,但可能另一个进程在修改其值,从而引起该变量的值 “自主” 发生变化。

同一个变量可以被重复说明多次,因此通常将变量说明和函数说明放在.h 文件中。不可以对变量进行多次定义。

变量定义的一般形式为 存储位置特性 存储可变特性 类型名 变量名=初始值; ,在函数体内定义局部变量时,存储位置特性为四选一,若不选择在默认使用栈存储即 auto;在函数外部定义变量时,只能在 static 和 extern 中二选一,若不选择则默认使用 extern,即定义在数据段中存储的全局变量。

定义变量时必须指定或默认有初始值,变量定义在整个程序范围内只能进行一次。若一个程序由多个.cpp 文件构成,则总共只能在一个.cpp 文件中定义一次。提倡只在某一个.cpp 文件中定义变量,在其他.cpp 文件或.h 文件中使用 extern 说明该变量。

可以使用运算符 & 声明和定义有址引用。当 & 出现在声明或定义中时,表示声明或定义变量、函数参数或者函数返回值为有址引用;当 & 出现在表达式中的变量前时,表示获取该变量的地址。有址引用变量在定义的同时必须初始化,即必须指明被有址引用变量所引用的实体。一旦指明被有址引用变量引用的实体,有址引用变量和被引用实体的关系就被固定,即有址引用变量以后就不能再引用其他的实体。

传统左值指的是 C 语言定义的能够出现在赋值号左边的变量或者表达式,传统右值指的是 C 语言定义的只能出现在赋值号右边的变量或者表达式。传统左值是分配了内存的、有地址的,或有址的。一个传统左值就是一个传统右值,反之则不一定成立。在传统右值中,有一部分右值是有址的,例如只读变量,而另一部分右值(如常量)则是无址的。有址引用变量可以说明为传统左值或传统右值。

逻辑上有址引用变量不分配内存,而是共享被引用变量的内存,使用取址运算会得到与被引用变量相同的地址。

当 C++ 程序被编译为低级语言程序(如汇编语言和机器指令程序)时,有址引用变量被编译为指针。

位段成员是有名无址的,它既可能是左值,也可能是右值。常量通常认为是不分配内存的,在汇编语言中,常量是汇编指令 “操作数” 的立即数,不会再数据段为其分配内存。

const int &w = 7;

针对上面的程序,编译程序的实现方法是:生成一个只读匿名变量,在读内存中为该变量分配内存,常量 7 用于初始化该匿名变量,然后 w 引用该有址匿名变量,并共享该匿名变量的内存。常量 7 是用过即死的立即数。

y++ 和 y-- 是传统右值

无址引用变量是指使用 && 定义的引用无址右值的变量,包括传统左值无址引用变量和传统右值无址引用变量。




# 第 3 章 语句、函数及程序设计


声明函数的时候,可以共用函数的返回和存储特性同时进行多个函数的声明

# 3.2

# constexpr 函数

constexpr 函数指的是在编译的时候就能得到其返回值的函数,也就是说编译器将 constexpr 函数直接转换成其返回值,因此,constexpr 函数都是被隐式地定义为内联函数。使用 constexpr 关键字来修饰 constexpr 函数。

constexpr int myFunc()
{
  return 1;
}
constexpr int i = myFunc() * 4;

此时,编译器会将 myFunc () 函数用其返回值 1 来代替,在编译时就可知 i 的值是 4。

# 3.3 作用域

# 第 4 章 类

# 4.1 类的声明和定义

类的属性用数据成员表示,类的方法用函数成员表示。

对象即类的示例,也就是某个类的值。对象可以是变量,也可以是常量。类是一种数据结构,而对象则代表一段内存,存储了数据结构的值。当产生一个对象时,必须调用构造函数初始化对象,为对象申请各种资源;当一个对象 “死亡” 时,则需要调用析构函数,释放对象占用的资源。

实例函数成员是指间接或直接通过对象即实例调用的函数成员,因此实例函数成员都有隐含参数 this。如果类没有自定义某些特殊的函数成员如析构函数、构造函数和赋值运算函数,则编译程序会为该类生成默认析构函数、构造函数、赋值运算函数。自动生成的构造函数参数表无显式参数,通常不会初始化数据成员,仅保证类的正常运作,如维护函数的多态性。

构造函数是与类同名的实例函数成员,用于对对象的数据成员进行初始化(申请各种资源),对象必须初始化且仅能初始化一次。数据成员如果为内存指针则分配内存,为文件指针则打开文件。

析构函数用于对数据成员申请的资源进行回收。析构函数与类同名且名前有 ~ 的实例函数成员。析构函数可以执行多次,但应避免。自定义和默认生成的析构函数的参数表都没有参数,只能有一个隐含参数 this。

构造函数和析构函数都不能定义返回类型,没有 return,一般为公开函数。

函数绑定是指为函数调用寻找入口地址并调用的过程。早期绑定是指在程序代码运行之前完成的绑定,通常由编译程序静态连接或者由操作系统动态连接完成。而在程序运行期间由程序自己根据调用对象的类型,寻找合适的多态函数入口地址并调用的过程称为晚期绑定。完成晚期绑定过程的代码由编译程序预先插入程序中,早期绑定不需要编译程序插入完成绑定过程的代码。

如果在类的外部定义函数成员,则必须在函数名前加上类名即作用域运算符 ::

使用 . -> :: .* ->* 调用的函数成员都称为显式调用函数成员。除了构造函数和析构函数能被编译程序自动调用外,其他任何函数成员都只能被显式调用。构造函数只能被编译程序隐式或自动调用,析构函数还可以被程序员显式调用。

构造函数是唯一不能被显式调用的函数成员。

STRING str("abc");

上面的代码隐式调用 STRING("abc")

对于未初始化的 class、struct 和 union 类型的变量,如果这些类没有基类和对象成员,也没有自定义构造函数、虚函数、纯虚函数,则可以对这样的类的变量以 {...} 列表的形式初始化;对于这些类型没有初始化的全局变量和静态变量,编译程序将其实例数据成员的值默认初始化为 0 或 nullptr。

析构函数既能被显式调用,又能被隐式或自动调用。当一个对象的生命期结束时,编译程序会自动调用该对象的析构函数。

如果程序在运行期间非正常退出,则析构函数可能没被自动调用。在通常情况下,应该使用 return 语句正常退出程序,或者使用异常处理机制处理异常。exit 和 abort 属于非正常退出,exit 退出时还会执行收工函数,而 abort 不会,两者都不析构当前函数已经构造的局部对象,abort 还不会执行全局对象的析构。

如果显式调用了析构函数,而隐式析构又照常进行,则同一对象会被多次,从而使系统资源反复释放。为了防止同一对象反复析构,析构后应设置已被析构的标志。虽然操作系统允许内存反复释放,但不允许文件和设备多次析构关闭。(在析构函数中判断内存是否已被释放)

对象在其声明期结束时自动析构,且对象自动析构的顺序和其创建顺序相反。常量对象(如有一条语句为 STRING("constant"); ) 在构造之后立即析构,其生命期为该常量所在的数值表达式的计算期间,数值表达式一旦计算完成,常量对象就会立即析构;若数值表达式有多个常量对象,则按创建顺序的逆序进行析构。

当没有为类自定义任何构造函数,如果该类自定义或继承了虚函数或纯虚函数、或自定义了只读或引用数据成员但没指定默认值,或者该类的对象成员或基类必须用实参调用构造函数初始化,则编译程序会为该类自动生成拷贝构造、移动构造及没有显式形参的构造函数。若该类的基类或者对象成员存在析构函数,或者该类继承或自定义了虚函数或纯虚函数,若该类没有自定义析构函数,则编译程序会自动生成析构函数。

# 4.2 成员访问权限及突破方法

private 声明的成员是私有的,仅同一类中的成员能够访问私有成员;protected 声明的成员是受保护的,其所在类及派生类的成员函数可以访问;public 声明的成员是公开的,任何成员或非成员函数都能访问。

对于由 class 定义的类,在刚进入 class 类体的内部时,成员的访问权限默认为 private;而 struct 和 union 的默认为 public。

不管什么访问权限的成员,都可被该类的友元访问。

如果两个类的结构完全相同,只是对应数据成员的某些访问权限不同,通过强制类型转换,便可改变对应成员的访问权限,从而可以访问原有类型所不允许访问的数据成员。

# 4.3 内联、匿名类及位段

不管是否出现 inline 内联关键字,在类体内定义函数体的任何函数都会自动称为内联函数。内联函数成员也可以在类体外定义,但必须用 inline 关键字显式地加以说明(类内的 inline 可省,类外的 inline 不可省)。

若内联函数成员使用了分支、循环、开关及函数调用等分支类型的语句,或在还未定义其函数体以前调用了该内联函数成员,或该函数成员被定义为虚函数或纯虚函数,则该内联函数成员的内联动作就会失败。内联失败不代表编译程序会报告语法错误,而是意味着该内联函数成员将被当做常规的函数成员调用。

匿名类没有类名,因此不可以定义构造函数和析构函数,函数成员也不可以在类体外定义。

有虚函数成员时无法定义有参构造函数,从而不能用 {...} 来初始化

无对象的匿名联合具有如下特点:

  • 必须定义存储位置特性 static
  • 只能定义公开实例数据成员
  • 其实例数据成员和联合本身的作用域相同(不能再定义同名变量)
  • 所有实例数据成员共享内存空间

对于函数外部无对象的匿名联合,其实例数据成员被编译成共享内存的模块静态变量;对于函数内部无对象的匿名联合,如果该联合前面出现了 static,则其实例数据成员被编译成共享内存的局部 static 变量,否则,为局部 auto 变量,若没有初始化,它们的值为栈上的随机值。

class CLERK{
    union{        // 无对象的内部匿名 union 等价于 static union,有对象时不等价
        int wage;
    };
    char *name;
}

局部类是指在类体中或函数体中定义的类。因为 c++ 不支持在函数内嵌套定义函数,故局部类的函数成员只能在局部类中定义函数体。

class、struct 和 union 的实例数据成员都能定义位段,但位段成员的类型必须是字节数较少的数据类型,如 char、short、int、long、long long 等有符号或无符号类型,不能是浮点型、数组和类等类型。枚举也可。

# 4.4 new 和 delete 运算符

new 负责分配对象内存,在分配内存之后,调用构造函数构造对象;delete 负责释放内存,在释放内存之前,调用析构函数析构对象。

一般来说,如果一个 class 定义了构造函数和析构函数,并且在构造函数中使用 new 分配了内存,则在析构函数中应使用 delete 释放在构造函数中用 new 分配的内存。

下面为示例代码

ARRAY y(3, 5), *p; //ARRAY 为定义的一个类,
p = new ARRAY(5, 7);  // 不能用 malloc,因为 ARRAY 有构造函数,在分配一个 ARRAY 对象的内存时初始化
delete p;  // 不能用 free,因为 ARRAY 有析构函数

通过 new 产生的对象是有地址的

编译程序不会自动调用通过 new 产生的对象的析构函数,必须由程序员使用 delete 析构对象并释放对象所占用的内存。语句 delete p 将通过调用析构函数 p->~ARRAY() ,析构释放 p->a 的内存空间,然后调用 free (p) 释放对象占用的内存。

在用 new 为数组分配空间时,数组的第一维下标可以是动态的,即可以使用任意整型表达式;而数组的其他维必须是静态的,即必须使用整型常量表达式。如果数组元素的类型为类,且希望用 new 创建对象数组,则该类应该自定义无参构造函数;如果类没有自定义任何构造函数,则可使用编译程序生成的无参构造函数。使用 delete []s 析构数组中的每个对象,s 为数组名,不管数组多少维都使用这种形式析构。

体现对象思想的语句

int x(3);  //int x = 3;
int *m = new int(3);

# 4.5 隐含参数 this

类有两种类型的成员:实例函数成员和静态函数成员。静态函数成员使用 static 说明,实例函数成员不能使用 static 说明;实例函数成员比静态函数成员多了隐含参数 this。隐含参数 this 是实例函数成员的第一个参数,其类型与实例函数成员参数表后的修饰符有关,一般为指向此类对象的 const 指针。

当一个对象调用一个实例函数成员时,对象地址作为被调函数的第一个实参,通过压栈将实参传递给该函数的隐含参数 this。构造函数和析构函数的隐含参数 this 的类型只能为 C * const ,而不能为 const C * const 或 volatile C * const,

this 的作用:

  • 使用 this 访问实例数据成员可区分与其同名的成员函数参数(也可以使用 类名:: 来区分)
  • 在使用对象调用该实例函数成员时,this 将会指向调用当前函数的对象
  • 当函数需要返回一个对象时,或者返回针对调用对象的引用时,可以使用 *this 作为函数的返回值

实例函数成员参数表后可以出现 const 或 volatile,它们用于修饰函数隐含参数 this 指向的对象。实例函数成员的参数表后出现 const,表示 this 所指向的对象是不能修改的只读对象,

# 4.6 对象的构造与析构

若一个类自定义了构造函数,则其对象必须用构造函数初始化。可以采用 {} 的形式初始化对象成员,也可在有构造函数时调用构造函数。对象数组的初始化必须调用无参构造函数。

当类含有只读和引用实例数据成员时,如果这些只读和引用实例数据成员没有默认值,则必须为初始化这些成员自定义构造函数。假定类 A 存在一个类型为 B 类的实例数据成员,且 B 类所有构造函数都存在没有默认值的参数,则类 A 必须自定义构造函数来调用类 B 的有参构造函数。

构造函数必须在其参数表及 : 的后面,调用其基类或虚基类的构造函数,初始化其只读和引用类型的变量,以及初始化类型为类的实例数据成员(简称对象成员)。构造函数参数表后的 :{ 之间被称为该构造函数的初始化位置。在构造函数体内,也可以对数据成员赋值,此种赋值不等价于初始化。对于定义了默认值的实例数据成员,若它没有出现在构造函数的初始化位置,则编译程序会使用默认值对其初始化,否则该默认值被编译程序忽略。默认值可以是简单类型的值或者类的对象。

可写成员既可以出现在构造函数的初始化位置,也可以出现在构造函数的函数体内。

注意构造函数是不能被直接调用的,也不能取构造函数的入口地址,构造函数是不可被访问的。

# 第 5 章 成员及成员指针

# 5.1 实例成员指针

运算符 .*->* 均为双目运算符,其运算的优先级均为第 14 级,结合时按自左向右的顺序进行,运算符 .* 的左操作数为类的对象,右操作数为指向该对象成员的指针;运算符 ->* 的左操作数为对象指针,右操作数为指向该对象实例成员的指针。

实例成员指针是一种数据类型,包括实例数据成员指针和实例函数成员指针。在获取实例成员地址时,要注意实例成员的访问权限是否允许访问。普通变量,数据成员、函数参数、函数返回值都可以定义为成员指针类型。

在类中,未用 static 定义的数据成员和函数成员均为实例成员,必须通过对象才能访问这些成员。实例成员指针要和对象一起使用。

实例数据成员实际上是一个偏移量,是某个成员的内存地址与所属对象的首址之差。当实例成员指针指向某个实例数据成员时,不能移动该指针指向其他实例数据成员。因此,也不能将实例成员指针强制转换为其他类型或者进行反向操作。否则通过 “转换成整型 -> 整型数值运算 -> 从整型转回” 等操作,便可以间接实现实例成员指针的移动。

# 5.2 const、volatile 和 mutable

# 5.3 静态数据成员

静态成员包括静态数据成员和静态函数成员,它们有关访问权限的规定和实例成员一样。在类体内声明的静态数据成员用于描述类的总体信息,除了定义为只读类型并且在类体内以常量表达式初始化以外,静态数据成员都必须在类体外定义并且初始化

即使没有产生任何对象,即对象的个数为 0,在类体外定义并分配内存的静态数据成员仍然存在,可见数据成员在物理上独立于对象分配内存。因此,在使用 sizeof 获得类或者对象的字节数时,不包括静态数据成员的字节数,但逻辑上静态数据成员的内存被所有对象所共享。

若一个对象修改了静态数据成员的值,则意味着所有对象关于该成员的值同时被修改。在定义一个类时,类体中的静态数据成员只是一个说明,它未被定义和初始化,除非它被定义为只读静态数据成员并给出了初始值。

class A{
    static int a; // 声明静态数据成员
}
int A::a = 0;
A x;

静态数据成员的 3 种访问形式

A::a;  // 此种形式说明静态数据成员可以脱离对象而存在,提倡使用
x.A::a;
x.a;

没有在类体外定义,所以编译出错了

./images-20211105002647530

./images-20211105002845493

一旦在里面赋值,就是定义了,这个时候不能在类体外再赋值定义。

./images-20211105003136330

./images-20211105003359015

./images-20211105003512742

./images-20211105003922962

作用域局限于类体内的类称为嵌套类,作用域局限于函数的类称为局部类。局部类不能定义静态数据成员,否则便会造成静态数据成员的生存矛盾。因为在函数内部定义局部类后,还可定义该类的局部自动变量,以及该类的局部静态变量,这两种变量如果都共享静态数据成员,该成员在函数返回后会产生生存矛盾。

void f()
{
    class T{
        int c;
        //static int d; // 错误,局部类不能定义静态数据成员
    }
    
    T a;
    static T s;
}

静态数据成员不能定义为位段类型,因为位段成员没有地址无法独立分配内存。

全局联合和嵌套联合定义的静态数据成员不共享内存。嵌套联合是类体内定义的联合,局部联合是函数体内定义的联合。局部联合中不能定义静态数据成员。联合的静态数据成员也不能定位为位段。

非只读静态数据成员在类体外定义初始化时,必须以全局作用域形式定义并初始化,不能用 static 定义该成员使其局限于当前代码文件。

./images-20211102235237217

./images-20211102235251422

./images-20211102235530638

./images-20211102235557374

以上代码说明了类中的 const 成员的赋值只是默认值,在构造时若没有赋值则采用该值,并不是一般的只读变量。

./images-20211102235624468

使用 inline 定义的模块(静态)变量和只读静态数据成员,分别称为内联模块(静态)变量和内联静态数据成员。内联静态数据成员 r 的内存在逻辑上仍由所有对象共享;而内联模块静态变量同模块普通静态变量一样,只能在该变量所属的.cpp 代码文件内访问。

./images-20211103062849770

在类中未用 static 定义的的类只能通过对象访问成员

# 5.4 静态函数成员

静态函数成员的访问权限和继承规则同实例函数成员一样。

静态函数成员没有隐含参数 this。由于无法访问 this 指向的实例数据成员,因此静态函数成员一般用来访问类的静态数据成员。

调用方式

类名::静态函数成员;  // 提倡
对象.静态函数成员;
对象.类名::静态函数成员;
*静态函数成员指针;

静态函数成员用来对静态数据成员进行操作。

# 5.5 静态成员指针

# 第 6 章 继承与构造

# 6.1 单继承类

在继承已有类型的数据成员和函数成员的基础上,定义新类只需定义原有类型没有的数据成员和函数成员。新类可以接受单个类提供的数据成员和函数成员,也可以接受多个类提供的数据成员和函数成员。这两种继承形式分别称为单继承和多继承。

接受成员的新类称为派生类,而提供成员的类称为基类。

继承方法有:

  • 在基类的基础上增加新的成员;
  • 改变基类成员继承后的访问权限;
  • 重新定义和基类成员同名的成员。

联合既不能作为基类,也不能作为派生类。

// 使用作用域或者 this 隐含参数修改成员
void LOCATION::moveto(int x, int y)
{
    LOCATION::x = x; // 或者使用 this->x = x;
    LOCATION::y = y;
}
// 继承的写法
//POINT2D 和 LOCATION 之间的 public 为基类的继承方式
class POINT2D: public LOCATION {
    // 新的成员
    
public:
    POINT2D(int x, int y) : LOCATION(x, y) // 需要先调用基类的构造函数
    {
        // 初始化派生类的数据成员
    }
    
    
}
POINT2D p(2, 6);
p.LOCATION::moveto(7, 8);  // 访问基类函数
p.moveto(9, 18);

在 POINT2D 类的体外定义 POINT2D::moveto 函数时, POINT2D:: 用于表示 moveto () 是属于 POINT2D 的函数成员,在 POINT2D::moveto () 的函数体中调用 moveto 时,可使用限定名 LOCATION::moveto() 调用基类的函数成员 moveto ()。

派生类也可以用 struct 声明,使用 class 和 struct 声明的不同之处在于:使用 class 声明时,基类的继承方式和进入派生类体后的访问权限默认为 private;使用 struct 声明时,基类的继承方式和进入派生类体后的访问权限默认为 public。

在派生类的初始化位置没有显式调用基类的构造函数时,会默认调用基类的无参构造函数,若基类没有无参构造函数,则报错。

#include <iostream>
using namespace std;
class A {
	int x;
};
class B :public A {
	int y;
	B() { y = 2; }  // 等价于 B ():A (){y=2}
};
int main()
{
}

# 6.2 继承方式

派生类的函数成员可以访问基类的保护成员和公开成员。除非定义为基类的友元函数,否则派生类的函数成员不能访问基类的私有成员。

假定访问权限和继承方式满足 private < protected < public,如果基类成员的访问权限高于基类的继承方式,则基类成员继承到派生类后,该成员的访问权限和继承方式一致;否则,该成员继续保持其在基类时的访问权限不变。

基类对象是派生类对象的一部分,基类对象先于派生类对象构造。在构造派生类对象时,先自动执行派生类的构造函数,并由其调用基类可被访问的构造函数,然后在派生类构造函数的初始化位置,按派生类定义其数据成员的顺序初始化派生类的数据成员,最后执行派生类自己的构造函数体。

注意:派生类一定会调用基类的构造函数,如果基类构造函数没有在派生类构造函数的初始化位置显式列出,则编译程序会自动调用基类的无参构造函数。如果基类定义的构造函数都是有参的,则编译程序将报告 “基类没有定义无参构造函数” 的错误;

基类和派生类都定义了函数 moveto (),对 POINT2D 类的对象和函数成员来说,类 POINT2D 自动的 moveto () 函数被访问的优先级更高。因此,除非使用限定名 LOCATION::moveto () 调用 moveto,否则,在类 POINT2D 的函数成员 moveto 内调用的 moveto 一定是 POINT2D 自定义的 moveto 函数成员。

派生类对象不应再调用基类的函数成员,为了防止此种情况,可以将继承方式设置为 private。

为了修改继承到派生类中的 LOCATION::getx () 的访问权限,可以将 LOCATION::getx 放在派生类中希望的访问权限范围,或者使用 using LOCATION::getx 将 LOCATION::getx 放在派生类中希望的访问权限范围

class POINT2D: private LOCATION{
    
public:
    LOCATION::getx; // 修改访问权限,还可被新定义的 getx 覆盖
    LOCATION::gety; // 修改访问权限,还可被新定义的 gety 覆盖
}

需要指出的是,选用 private 作为继承方式并不是最好的选择。

在派生类中修改 getx () 的访问权限以后,通常不会在派生类中再定义参数表和基类 getx () 相同的 getx () 实例函数。此时,在没有派生关系的类或非成员函数中,通过 POINT2D 类对象调用 getx (),就是调用 LOCATION::getx ()。而一旦派生类自定义了 getx () 实例函数,则派生类 POINT2D 对象将优先调用自定义的 getx () 实例函数,而基类的 getx () 只能通过限定名 LOCATION::getx () 去调用。

# 6.3 成员访问

如果希望访问基类作用范围更大的标识符,则可以用 基类类名::标识符 构成的限定名进行访问。

# 6.4 构造与析构

派生类构造函数的执行顺序是:

  1. 若派生类存在虚基类,则先调用虚基类的构造函数
  2. 若存在直接基类,则接着调用直接基类的构造函数
  3. 然后按照派生类数据成员的声明顺序依次调用它们的构造函数或对其进行初始化(而不是初始化位置列出的顺序)
  4. 最后执行派生类构造函数的函数体

析构函数的执行顺序相反

如果派生类定义了没有默认值的引用或只读实例数据成员,或者定义了必须调用有参构造函数初始化的的对象成员,或者继承或自定义虚函数或纯虚函数,或者其虚基类和基类必须用实参调用构造函数初始化,则派生类必须自定义构造函数,而不能依赖编译程序为其自动生成构造函数。

如果被 r 引用的对象是通过 new 生成的有址对象,则引用变量 r 必须用 delete &r 析构该对象,否则该对象将因未释放内存而产生内存泄漏。

class A{
    int i;
    int *s;
public:
    A(int x)
    {
        s = new int[i = x];
    }
    
    ~A()
    {
        delete s;
    }
}
int main()
{
    int &p = *new A(3);
}

new A(3) 的执行过程为:

  1. 先为对象 A (3) 分配一块内存,其大小为 sizeof (A);
  2. 调用构造函数初始化 A (3),构造函数为其成员 s 分配一块内存,该内存大小为 sizeof (int [3])。

由此可见, new A(3) 导致总共分配了两块内存。

如果仅用 p.~A() 析构由 new 生成的对象 A (3),则只释放了其成员 p.s 指向的大小为 sizeof (int [3]) 的内存块,没有释放 p 引用的对象 A (3) 所占用的内存,造成内存泄漏。

如果用 delete &p 处理由 new 生成的对象,则会完成

  1. 通过 p.~A () 调用析构函数析构由 new 生成的对象 A (3),析构时释放 p.s 指向的内存;
  2. 使用 free (&P) 释放 p 引用的对象 A (3) 占用的内存。

# 6.5 父类和子类

如果派生类继承基类的方式为 public,则这样的派生类称为该基类的子类,而相应的基类则称为派生类的父类。C++ 允许父类指针直接指向子类对象,也允许父类引用变量直接引用子类对象。

编译时无法知道指向或引用的对象类型,指向或引用的对象类型只能在程序运行时确定,故编译程序只能根据变量的类型定义进行静态语法检查,即把父类指针变量指向的所有对象都当做父类对象。在通过父类指针变量访问对象的数据成员或函数成员时,如果父类定义的成员访问权限不允许当前访问,则编译程序就会报告访问权限超出限制等错误。父类引用变量也存在类似的静态语法检查。(父类和子类存在同名但参数表不同的函数,如果通过指向子类对象的父类指针以子类函数参数表的形式调用该函数,则编译出错)

若一个父类指针指向的是子类对象,如果父类和子类都定义了同名的函数且都可访问,由于编译程序只能做静态语法检查,故调用的同名函数是父类的实例函数成员。(定义为虚函数可解决问题)。同理,编译程序只能假定父类引用变量引用的都是父类对象。在通过引用变量访问对象的数据成员或函数成员时,不能突破父类对象为成员所规定的访问权限。

// 父类引用变量可以直接引用子类对象,否则必须进行强制类型转换
class A {
	int a;
};
class B :A {
	int b;
};
class C :public A {
	int c;
};
int main()
{
	A& p = *new C;
	A& q = *(A*) new B; // 强制类型转换
}

没有继承关系的两个不同的类的两个对象的指针不能通过强制类型转换来赋值。

如果基类和派生类没有构成父子关系,且非成员函数 main () 不是派生类的友元函数,则 main () 定义的基类指针变量不能直接指向派生类对象,而必须通过强制类型转换才能指向派生类对象。同理,函数 main () 定义的基类引用变量也不能直接引用子类对象,而必须通过强制类型转换才能引用派生类对象。

#include <iostream>
using namespace std;
class A {
	int a;
};
class B :A {
	int b;
	friend int main();
};
class C :public A {
	int c;
};
int main()
{
	A& p = *new C;
	A& q = *new B;
	cout << "hello";
}

在作为派生类友元的函数 main () 中,基类和派生类默认满足父子类关系。这是因为基类对象继承到派生类后,可被视为匿名的具有某种访问权限的对象成员。而 main () 函数作为派生类的友元函数,可以访问派生类的所有成员,包括作为匿名对象的基类对象。这种对基类的无障碍访问,就像基类以公开方式继承到派生类,使基类可被派生类当做父类看待。

在派生类的函数成员中,基类和派生类默认满足父子关系,因为基类对象可被视作派生类的匿名成员,而派生类函数可以访问自己的所有成员,包括继承的作为匿名对象的基类对象。在派生类的函数成员中,这种对基类对象的无障碍访问,使基类可被当做派生类的父类看待。因此,基类指针或引用可以直接指向或引用派生类对象。

注意:以上操作的关键是基类指针可以指向子类对象,并不能突破访问权限

将上面代码中的类 B 的定义如下:

class B :A {
	int b;
	friend int main();
public:
	void f()
	{
		cout << "b" << endl;
		A* p = new A;
	}
};

# 6.6 派生类的内存布局

静态数据成员在对象之外分配内存,函数成员也不是对象内存的一部分,函数成员编译后得到的二进制代码不占用对象内存。

基类对象是派生类对象内存的一部分,通常出现在派生类实例数据成员之前。因此,派生类对象所占用的内存通常由两部分构成:基类对象所占用的内存和派生类实例数据成员占用的内存,

实例数据成员在对象内部连续分配内存。

从子类的内存布局来看,其首部恰巧就是父类的内存布局,所以若把子类对象当做父类对象使用,不会导致父类对象所要求的内存布局与预期不一致的问题。

# 第 7 章 可访问性

# 7.1 作用域

# 7.2 名字空间

同一名字空间中的标识符必须唯一,不同名字空间中的标识符可以相同。当一个程序使用多个名字空间而出现成员同名时,可以用名字空间加作用域运算符限定各自的成员。

关键字 namespace 用于定义名字空间。名字空间必须在程序的全局作用域内定义,不能在函数及函数成员内定义,最外层名字空间的名称必须在文件作用域内唯一。

在当前.cpp 代码文件内可定义匿名名字空间,同一个.cpp 代码文件可分多次定义匿名名字空间,并且匿名名字空间定义后即默认被自动使用(即 using)。对于不同的 cpp 文件,若在它们的匿名名字空间内定义同名的全局变量、函数或类型成员,将不会引起二义性冲突。

# 7.3 成员友元

友元函数(简称 “友元”)不是声明该友元的类的函数成员,但是它能像类的函数成员一样访问该类的所有成员。友元分为普通友元和成员友元两种类型。普通友元是将普通函数定义为某个类的友元,成员友元是就将一个类的函数成员定义为另一个类的友元。

友元使用 friend 声明,友元只是声明它为类的朋友,而不是该类的函数成员,故不受该类的访问权限的限制,因此可随意访问在该类的 private、protected 和 public 下声明友元。如果一个类是另一个类的友元类,则前者的所有函数成员都能访问后者的任何成员,包括类型成员、数据成员和函数成员。

类的构造函数和析构函数也可以定义为另一个类的成员友元,由于构造函数和析构函数没有返回类型,故将他们定义为另一个类的友元时不需要指定它们的返回类型。对于其他具有返回类型的函数,在声明为某个类的友元时,必须说明其真实返回类型。

不能将尚未完整声明的类作为函数参数,因为此时该类的字节数或者类型大小尚不确定。但是,相互依赖的类可以使用类的引用或类的指针作为参数,因为引用式指针都会被编译为汇编语言的指针类型,而存储地址的指针变量所需要的字节数总是固定的。

#include <iostream>
using namespace std;
class INTSET; // 前向声明
// 没有前向声明,就无法在后面使用 INSET& 或者 INSET * 等引用或指针类型
class REALSET {
	float* elems;
	int card, maxcard;
public:
	// 不可定义 REALSET (INTSET s):因为 INTSET 字节数不定
	REALSET(INTSET& s);
	~REALSET() { delete elems; };
};
class INTSET {
	int* elems, card, maxcard;
	friend REALSET::REALSET(INTSET& s);
public:
	INTSET(int maxcard);
	~INTSET() { delete elems; };
};
REALSET::REALSET(INTSET &s)
{
	// 本函数声明为 INTSET 的友元,可直接访问 INTSET 的所有成员
	elems = new float[maxcard = s.maxcard];
	card = s.card;
	for (int i = 0; i < card; i++)
		elems[i] = s.elems[i];
}
INTSET::INTSET(int max)
{
	elems = new int[maxcard = max];
	card = 0;
}
int main()
{
	cout << "hello" << endl;
	INTSET iset(20);
	REALSET rset(iset);
}

友元函数的声明可以出现在类的任何访问权限下,因为它不是该类的函数。

一个类的任何函数成员,如实例函数、静态函数、虚函数、构造函数以及析构函数等,都能成为另一个类的成员友元,而且能够同时成为多个类的成员友元。如果类 A 的所有函数成员都是类 B 的友元,则类 A 被称为类 B 的友元类,可在类 B 中以如下形式定义友元类 A: friend Afriend class A ,或 friend struct A

# 7.4 普通友元及其注意事项

任何普通函数即非成员函数,包括非成员函数 main () 在内,都可以定义为类的普通友元。这种友元也称为非成员友元,声明其为友元的类称为宿主类。由于普通友元函数不是宿主的函数成员,故普通友元可定义于宿主类的任何访问权限下。一个普通函数可以定义为多个类的普通友元。

将非成员函数定义为类的友元时,可以在类中同时定义该友元的函数体。由于函数体是在类中定义的,因此该友元函数将自动称为 inline () 函数,且其作用域局限于当前代码文件(存储未知特性默认为 static)。但是,建议在类体外定义友元的函数体,因为此时类的所有成员都已定义完毕,作为该类的友元可以访问该类的所有成员。在类外定义友元时,如果希望友元具有局部作用域,可在函数前加上 inline 或 static。

由于全局 main () 函数的存储特性默认为 extern,因此不能在类体中定义全局 main () 函数的函数体。

普通友元可以访问宿主类的任何函数成员。

#include <iostream>
using namespace std;
class A {
	int x = 2;
	friend int main();
};
int main()
{
	cout << "hello" << endl;
	A a;
	cout << a.x;
}

全局函数 main () 和析构函数不能重载,故不能定义多个 main () 和析构函数友元。在出现多个非成员重载函数时,未声明为友元的非成员函数只能访问类的公开成员,只有声明为友元的非成员函数才能访问类的所有成员。此外,同普通函数和函数成员一样,友元的参数也可以省略或指定默认值。

#include <iostream>
using namespace std;
class A {
public:
	int x = 1;
};
class B: public A {
public:
	int x = 2;
};
int main()
{
	B b;
	cout << (&b)->x << endl;
	// 访问基类内存的方法
 	cout << ((A*)(&b))->x << endl;
	cout << b.A::x << endl;
	cout << "hello" << endl;
}

友元关系是不能传递的,即一个函数定义为基类的友元后,并不代表它是其派生类的友元。

在非成员函数(如 main ())中定义局部类时,该局部类可以说明某个函数为友元,但不能同时定义这个友元的函数体。但是,在全局类的嵌套类中定义友元函数时,可以同时定义该友元的函数体。

# 第 8 章 多态与虚函数

多态是面向对象程序设计最显著的特点,在运行期间绑定虚函数到具体类的实函数,使对象在运行过程中做出同其类型相符的行为。多态需求是随着泛型的出现而出现的,当一个指针或者引用变量能够存储多种类型的数据时,就需要根据运行时变量关联的对象类型去调用相应的实函数。

# 8.1 虚函数

重载函数是一种静态多态函数,虚函数是一种动态多态函数。在编译时早期绑定完成重载函数的调用,在运行时晚期绑定完成虚函数的调用。虚函数到对象的函数成员的映射是通过存储在对象之中的一个指针完成的。

虚函数是用关键字 virtual 声明的实例函数成员。通常只需在基类中定义虚函数,派生类中原型相容的实例函数成员将自动成为虚函数。不管进行了多少级派生,虚函数的这一特性将一直延续传递。

实例函数成员的原型相容是指:

  1. 该实例函数和基类的虚函数同名,并且除隐含参数的类型不同外,两个函数的所有显式参数类型都对应相同;
  2. 若基类虚函数的返回类型是基类指针 p(或引用 r),则派生类实例函数的返回类型必须是可向 p(或者 r)赋值的基类指针(或引用),或者是可向 p(或者 r)赋值的派生类指针(或引用),否则两个实例函数的返回类型必须相同。

虚函数具有隐含参数 this,因此,虚函数不能定义为没有 this 的静态函数成员。构造函数虽有隐含参数 this,但要构造的对象类型明确且无须表现多态,构造函数不需要虚函数的多态特性,故 C++ 不允许将构造函数定义为虚函数或纯虚函数。

当父类和子类都定义了原型相容的虚函数时,如果父类指针(或引用)指向(或引用)的是子类对象,则通过父类指针或引用调用的是子类的函数成员;如果父类指针或引用指向的是父类对象,则通过父类指针或引用调用的是父类的函数成员。根据对象的真实类型不同而调用不同的实例函数,由此展现出的多种行为被称为虚函数的多态特性。

由于引用类型的变量被编译为指针,因此通过父类引用变量调用虚函数同指针调用一样表现多态特性。

虚函数是类自身的实例函数成员,而 friend 说明的函数不是当前类的成员,因此不能同时用 virtual 定义 friend 函数。此外虚函数不能定义为 constexpr 函数,不能接受类似 inline 的优化而丧失虚函数入口,虚函数入口用于填写虚函数入口地址表,该地址表的首址将成为对象存储的一个内部指针。

//下面的代码中,A和B不是父子类关系,但由于定义了虚函数,最终也可以准确调用相应的函数

#include <iostream>
using namespace std;
class A {
public:
	virtual void f(); //声明为虚函数
	virtual ~A() {};
};

//在类的外部定义虚函数的时候不需要再加virtual,这点和static静态变量类似
void A::f()
{
	cout << "A" << endl;
}

class B : A {
public:
	virtual void f() { cout << "B"; };
};

int main()
{
	cout << "hell" << endl;
	B b;
	A& p = *(A*) &b;  //不是父子类关系,需要强制类型转换才能赋值
	p.f();
    p.A::f();  //明确调用A::f()
	int x = 2;
}


/*
以上代码若只去掉A中的virtual,则会打印出A


*/

当父类中有(纯)虚函数 f () 时,不允许在子类中定义函数名与 f 相同且参数表也相同仅返回类型不同的函数(理解为无法准确调用),但是如果函数名称相同,参数表不同,不管返回类型怎么样,都是可以的。

若 POINT2D* show () 和 CIRCLE * show () 为父类和子类的两个函数,则可以在父类的 show 前加 const、volatile 或 const volatile 都可以,但是不可以在子类的 show () 前加,这就导致原型不相容了。类似的,对于 POINT2D & show () 和 POINT2D && show () 也得出相似的结论。

不允许虚函数的返回类型不相容

./images-20211105151608227

参数表不同就可以了,这种情况下无法通过父类指针调用子类的同名函数了

./images-20211105152056899

虚函数的访问权限可以不同。

在 C++ 的同一个类中,不能定义参数个数及类型完全相同,仅返回类型不同的静态函数成员;也不能定义参数个数及类型完全相同、仅返回类型不同的实例函数成员,包括基类继承下来的 this 说明相同的实例函数成员。

设继承关系为:A->B->C

先检查 C 是否定义了相应的实例函数,若没有则继续往上检查,直到找到原型相容的实例函数然后调用。若在 A 中函数为公开的,而在 C 中为私有的,最终也会成功调用 C 中的该实例函数。因为函数调用在运行时进行,此时不再进行语法检查,因此被调用的实例函数的访问权限已无关紧要。

#include <iostream>
using namespace std;
class A {
public:
	virtual A* f();
	virtual ~A() {};
};
A* A::f()
{
	cout << "A" << endl;
	return this;
}
class B : public A {
private:
	B* f() { cout << "B"; return this; };
};
int main()
{
	cout << "hell" << endl;
	B b;
	A& p = *(A*) &b;
	p.f();
	int x = 2;
}

C++ 可为类自动生成一些实例成员函数。例如,对类 A 来说,这些实例函数成员包括:

  • 参数表无参的构造函数,如 A ()
  • 拷贝构造 A (const A&) 和移动拷贝构造 A (A &&)
  • 拷贝赋值 A & operator=(const A&) 和移动赋值 A & operator=(A &&)
  • 析构函数 A::~A ()

一旦类 A 自定义了任何构造函数,就会禁止编译程序自动生成任何构造函数。同理,一旦类 A 自定义了任何赋值函数,就会进制编译程序自动生成任何赋值函数。

虚函数可以声明为 inline 函数,也可以重载、指定默认值或者省略参数。同实例函数成员一样,类体里定义了函数体的虚函数将自动成为内联函数。多个原型不同的虚函数也可以称为重载函数,重载时虚函数的参数个数或函数类型必须有所不同。

因为 friend 声明的函数不是宿主类的函数成员,所以它不能和声明实例函数成员的 virtual 一起使用,除非被声明的函数已经是另一个类的虚函数成员。

./images-20211105164825337

./images-20211105165232612

#include <iostream>
using namespace std;
static void p(){}
struct A {
	static void h() {};
	static void i() {};
	virtual void j() {};
	virtual void k() {};
};
struct B {
	friend static void A::h();
	friend void A::i();
	friend virtual void A::j();
	friend void A::k();
	//static virtual void m ();  // 有无 this 矛盾
	//virtual friend void n ();  // 是否成员函数矛盾
	friend static void p();   // 正确,非成员函数 p 已定义为静态函数
	friend static void q() {}; // 正确,自动生成静态非成员函数 q
	//friend static void r ();  // 编译时可以通过,但是一旦调用 r () 编译就通不过了,因为没有定义
};
int main()
{
	cout << "helloworld";
}

# 8.2 虚析构函数

析构函数除了类型固定不变的隐含参数 this 外,它的显式参数表不能再定义其他任何类型的参数。因此,析构函数不可能有重载函数,也不可能有指定默认值的参数或者省略参数。

析构函数可以定义为虚函数。如果基类的析构函数定义为虚析构函数,则派生类的析构函数就会自动称为虚函数。

在形式如 delete p; 的语句中,p 可定义为指向父类对象的指针,为了使其能根据 p 指向的对象类型进行多态析构,最好将父类的析构函数定义为虚函数。 delete &q 类似。

编译程序不能自动生成虚函数,需要自己定义。

如果为基类和派生类对象分配了内存,或者为派生类的对象成员分配的动态内存,则一定要将基类和派生类的析构函数定义为虚函数,否则便极有可能造成内存泄漏,甚至导致操作系统出现内存保护错误。

class A {
public:
	void f() { cout << "A"; };
	virtual ~A() {};
};
class B : public A {
public:
	virtual void f() { cout << "B"; };
};

# 8.3 类的引用

常规对象常量的生命期局限于当前表达式,在表达式结束时就立刻进行析构。但无址引用变量引用的是对象常量,C++ 为无址引用引入了移动语义的概念,随着移动语义的引入,被无址引用的常量对象的析构将推迟到该无址引用变量的生命期结束。此时常量对象实际上被编译为存储于缓存的无址匿名只读变量。虽然无址引用变量不负责常量对象的析构,但被其引用的常量对象的析构确实与其生命期相关。

#include <iostream>
using namespace std;
class A {
public:
	int x;
	A(int x) : x(x) { cout << "构造:x = " << x << endl; };
	~A() { cout << "析构:x=" << x << endl; };
};
int main()
{
    // 还可 A& m = *new A (1);
	A a(2);
	A& p = a;
	//A& t = A (1);  // 报错显示:非常量引用的初始值必须为左值
	const A& q = A(3); // 传统右值有址引用,A (3) 延迟析构
	A&& r = A(4);  // 传统左值无址引用,A (4) 延迟析构
	const A&& s = A(5); // 传统右值无址引用,A (5) 延迟析构
	r.x = 10;
	cout << r.x << endl;
	(*((A*)&s)).x = 20;
	cout << s.x << endl;
	//A&& t = a; // 报错显示:无法将右值绑定到左值
	cout << "main return" << endl;
}  // 退出 main 时按逆序自动析构所有已构造的对象
/*
构造:x = 2
构造:x = 3
构造:x = 4
构造:x = 5
10
20
main return
析构:x=20
析构:x=10
析构:x=3
析构:x=2
*/

有址引用变量的拷贝构造和赋值函数实现为深拷贝,而无址引用变量的移动拷贝构造和移动赋值函数实现为浅拷贝。无址引用变量和参数最好用常量对象初始化,否则移动拷贝构造和移动赋值可能引发内存保护错误。

在调用包含无址引用参数的函数时,需要将无址表达式传给该无址引用参数。如果将同类型的传统左值或者有址变量作为实参传递,则编译程序会给出已经错误警告或直接报错。即使能通过类型转换将前述传统左值作为实参转换类型传递,这种用法也是不提倡的,尤其是当参数为引用对象的无址引用类型时。因为当函数返回时会析构该无址引用参数引用的对象,与该左值共享内存的实参被析构了,但该左值的生命期不应在此结束,它可能被主调函数继续使用。

对形参不是引用类型而是一般对象类型来说,形参相当于局限于当前函数的局部变量。因此,这种形参对象的析构是在函数调用返回时完成的,而该形参对象的构造则是在调用时通过值传递完成的。如果定义了拷贝构造函数,将调用相应的拷贝函数完成。值参传递将实参对象数据的值相应地赋给形参对象的数据成员,而指针类型的数据成员则只浅拷贝复制指针的值赋给形参,而没有深拷贝复制指针所指向的存储单元内容的值。

因此,一般值参传递所进行的赋值又称为浅拷贝赋值,浅拷贝赋值会导致形参对象和实参对象指向共同的存储单元。由此造成的后果是:在被调用的函数返回时,形参析构会释放其指针成员指向的内存,该内存可能又被操作系统立即分配给其他程序,而当前程序并不知道该内存已分配给其他程序。此时,不知道内存已被析构的实参对象若通过指针成员访问内存,就会造成一个程序非法访问另一个程序的内存,这就是操作系统经常报告的内存保护错误或者一般性保护错误。

当非引用类型的形参对象包含指针数据成员时,必须进行深拷贝构造才能避免出现内存保护错误。将深拷贝构造函数的形参定义为类的传统右值有址引用,并另外定义一个传统左值无址引用形参的构造函数,这样就能隐藏编译程序自动生成的深拷贝构造和移动构造函数,从而在传递实参时优先调用自定义的深拷贝构造和移动构造函数。移动构造函数通常实现为浅拷贝构造函数,由于浅拷贝构造函数不分配内存,故不会因为内存分配失败而产生异常。

# 8.4 抽象类

纯虚函数是不必定义函数体的特殊虚函数。在定义虚函数时,说明其函数体 =0 表示定义的虚函数为纯虚函数。纯虚函数有隐含参数 this,故不能同时定义为静态函数

含有纯虚函数的类称为抽象类,抽象类常常用作派生类的基类。

如果派生类继承了抽象类的纯虚函数,却未定义原型相容且带函数体的虚函数;或者派生类自定义了基类所没有的新纯虚函数,不管新纯虚函数是否在当前派生类定义了函数体,当前派生类都会自动称为抽象类。

在多级派生中,如果到某个派生类为止,所有的基类纯虚函数都在派生类中定义了函数体,并且该派生类没有自定义新的纯虚函数,则该派生类就会称为非抽象类(或者称为具体类)。只有非抽象类才能产生对象

#include <iostream>
using namespace std;
struct A {  // 定义 A 为抽象类
	virtual void f1() = 0;
	virtual void f2() = 0;
};
void A::f1()  // 在类 A 中定义了 f1 () 的函数体,A 仍然是抽象类
{
	cout << "A::f1" << endl;
}
void A::f2()
{
	cout << "A::f2" << endl;
}
class B :public A {  // 覆盖 A::f2 () 的函数体,但未覆盖 A::f1 (),B 仍为抽象类
	void f2()
	{
		cout << "B::f2" << endl;
	}
};
class C :public B { //A::f1 () 和 A::f2 () 均被覆盖定义,C 为非抽象类
	void f1()
	{
		cout << "C::f1" << endl;
	}
};
int main()
{
	cout << "helloworld" << endl;
	C a;
}

抽象类不能定义或产生任何对象(没有可被引用或指向的对象),包括用 new 创建对象、定义对象数组以及定义对象参数或函数返回值。但是抽象类可作为父类引用和指针,引用或指向具体子类对象。

内存管理函数 malloc () 可以为抽象类对象分配空间,但不会调用抽象类的构造函数来初始化该对象,因此内存管理函数 malloc () 不能完整地初始化抽象类对象(对象中的虚函数入口地址表指针未被初始化)。只有成功完整地初始化了某个类的对象,才能通过抽象类指针或引用调用到这个类的虚函数。

#include <iostream>
using namespace std;
class A {
public:
	int x = 1;
	virtual void f() = 0;
};
void A::f()
{
	cout << "A::f" << endl;
}
class B: public A {
public:
	int x = 2;
	void f()
	{
		cout << "B::f" << endl;
	}
};
class C : A {
public:
	int x = 2;
	void f()
	{
		cout << "C::f" << endl;
	}
};
int main()
{
	A& p = *new B;
	p.f();
	p.A::f();
	cout << "===========" << endl;
	A& q = *(A*)(new C);
	q.f();
	q.A::f();
}

# 8.5 虚函数友元与晚期绑定

纯虚函数和虚函数都是类的实例函数成员,都能定义为另一个类的成员友元。由于纯虚函数一般不会定义函数体,此时纯虚函数就不应该定义为某个类的成员友元,成员友元应当是定义了函数体的函数。

友元关系不能传递或者继承。如果类 A 的函数成员 f () 定义为类 B 的友元,那么 f () 就可以访问类 B 的所有成员,包括数据成员、函数成员及类型成员。但是,f () 并不能访问从类 B 派生的类 C 的所有成员,除非 A 的函数成员 f () 也被定义为类 C 的成员友元。

假定基类 B 及其派生类 D 都定义了虚函数,基类 B 和派生类 D 将分别产生虚函数地址表 TB 和 TD。在构造派生类 D 的对象 d 时,首先将 d 作为一个基类对象构造,故将 TB 的首址存放到 d 的起始单元,此时 B::B () 调用的虚函数将和 TB 中的虚函数绑定;然后一旦基类对象的构造函数 B::B () 执行完毕,在执行派生类的构造函数 D:😄() 之前,会将 TD 的首址存放到 d 的起始单元,此后 D:😄() 调用的虚函数就会和 TD 中的虚函数绑定。

同理,在析构派生类 D 的对象 d 时,一旦析构函数~D:😄() 的函数体执行完毕,就立即将 TB 的首址存放到 d 的起始单元,接着将 d 作为基类对象执行基类 B 的析构函数 ~B::B() ,此后, ~B::B() 调用的虚函数就会和 TB 中的虚函数绑定。这样,对象 d 就会根据其类型来调用正确的虚函数,从而表现出恰当的多态特性。

# 8.6 有虚函数时的内存布局

单继承派生类对象的内存由基类和派生类的实例数据成员构成。当基类或派生类定义了虚函数或者纯虚函数时,派生类对象的内存还包括虚函数入口地址表首址所占用的存储单元,存储单元通常是包含虚函数的基类对象的初始单元。

如果基类定义了虚函数或者纯虚函数,则派生类对象将共享基类对象的起始单元,用于存放虚函数入口地址表首址。派生类的构造函数和析构函数会选择合适的时机,在共享的存储单元中更新基类和派生类的虚函数入口地址表首址。

// 纯虚函数的函数体仍然是可以正常调用的
#include <iostream>
using namespace std;
class A {
	static int b;
	int a;
	virtual int f();
	virtual int g();
	virtual int h();
};
class B : A {
	static int y;
	int x;
	int f();
	virtual int u();
	virtual int v();
};
int main()
{
	cout << sizeof(int) << endl;
	cout << sizeof(void*) << endl;
	cout << sizeof(A) << endl;
	cout << sizeof(B) << endl;
}

./images-20211105230616235

如果基类没有定义虚函数,而单继承派生类定义了虚函数,则单继承派生类的内存由 3 个部分组成:第一部分为基类内存,第二部分为派生类虚函数入口地址表首址,第三部分为该派生类新定义的实例数据成员。

# 第 9 章 多继承与虚基类

# 9.1 多继承类

当需要定义多继承派生类的对象时,常常通过对象成员的聚合实现多继承。对象聚合在多数情况下能够满足需要,但当对象成员和基类的类型相同,或者在逻辑上和基类对象存在共享的内存时,就可能对同一物理对象重复初始化。

多继承派生类可以定义任意数目的基类,但是不得定义名称相同的直接基类。当派生类有多个基类时,多个基类成员继承到派生类后可能同名,基类与派生类成员之间也可能同名。在出现成员同名时,除了可以根据作用域大小确定访问的优先级外,还可用作用域运算符限定要访问的类的成员。

#include <iostream>
#include <string>
using namespace std;
struct A
{
	int a;  // 未定义构造函数 A::A (),可用 {} 初始化
};
struct B {
	int a;
};
struct C : A, B {
	int c;  // 类 C 继承 A 和 B 的两个成员 a
	int setc(int);
	constexpr C() : A{ 2 }, B{}, c(0) {c = 2; };  //A {2} 使 c.A::a=2,B {} 使 c.B::a=0
};
int C::setc(int c)
{
	C::c = c;  // 使用作用域运算符限定访问数据成员 C::c
	return c;  // 返回的是参数的值,其作用域更小,访问优先级更高
}
int main()
{
	A a;  //a.a 为随机值
	A b{};  //b.a=0,如果 {} 中为空,则所有元素初始化为 0
	B* c = new B; //c->a 为随机值
	B* d = new B{};  //d->a=0 。等价于 new B ()
	C g;
	//int j = g.a;  // 出现二义性访问
	int j = g.B::a;
	delete c;
	delete d;
}

# 第 10 章

# 10.1 异常处理

抛出来的需要是一个完整定义的对象或者指向完整对象的指针,因此异常类型要定义在抛出异常语句的前面

#include <iostream>
using namespace std;
class INDEX {
	int index;
public:
	INDEX(int i) : index(i) {};
	int getIndex()const { return index; };
};
class ARR {
	int size;
	int* arr;
public:
	ARR(int size);
	int getIn(int i);
	~ARR();
};
ARR::ARR(int size)
{
	if (arr = new int[size])
	{
		ARR::size = size;
		for (int i = 0; i < size; i++)
			arr[i] = i + 1;
	}
	else
		throw INDEX(0);
}
int ARR::getIn(int i)
{
	if (i < 0 || i >= size)
		throw INDEX(i);
	else
		return arr[i];
}
ARR::~ARR()
{
	if (arr)
	{
		delete arr;
		arr = nullptr;
		size = 0;
	}
}
int func(ARR& arr)
{
	int m;
	try {
		//int m = arr.getIn (2);  // 如果在这里面定义 m,那么出来这个语句块之后就无法使用 m 了
		m = arr.getIn(3);
	}
	//int flag = 0; //try 和 catch 语句之间不能再有其他语句
	catch (const INDEX& ind)
	{
		switch (ind.getIndex())
		{
		case 0:
			cout << "内存分配失败" << endl;
			break;
		default:
			cout << "下标" << ind.getIndex() << "越界" << endl;
		}
		throw;  // 传播异常
	}
	catch (...)
	{
		cout << "异常被上面捕获,这里不会执行" << endl;
	}
	return m;
}
int main()
{
	ARR arr(3);
	// 一定要使用 try 语句,try 和 catch 是配套使用的
	int m, flag = 0;
	try {
		//int m = arr.getIn (2);  // 如果在这里面定义 m,那么出来这个语句块之后就无法使用 m 了
		m = arr.getIn(3);
	}
	//int flag = 0; //try 和 catch 语句之间不能再有其他语句
	catch (const INDEX &ind)
	{
		flag = 1;
		switch (ind.getIndex())
		{
		case 0:
			cout << "内存分配失败" << endl;
			break;
		default:
			cout << "下标" << ind.getIndex() << "越界" << endl;
		}
	}
	
	// 在执行 catch 语句之后,下面的语句将继续执行
	if (flag)
		m = 100;
	cout << m << endl;
	cout << "----------------------------" << endl;
	// 异常的传播用在函数的多级调用中,同级不能传播异常
	try {
		func(arr);
	}
	catch (...)
	{
		cout << "接受到传播的异常" << endl;
	}
	return 0;
}
#include <iostream>
#include <exception>
using namespace std;
class EPISTLE : exception {
public:
	EPISTLE(const char* s) :exception(s)
	{
		cout << "Construct:" << s;
	}
	~EPISTLE()noexcept
	{
		cout << "Deconstruct:" << exception::what();
	}
	const char* what()const throw()
	{
		return exception::what();
	}
};
void h()
{
	EPISTLE h("I am in h()\n");
	throw new EPISTLE("I have throw an exception\n");
}
void g()
{
	EPISTLE g("I am in g()\n");
	h();
}
void f()
{
	EPISTLE f("I am in f()\n");
	g();
}
int main()
{
	try {
		f();
	}
	catch (const EPISTLE* m)
	{
		cout << m->what();
		delete m;
	}
	return 0;
}

# 第 11 章 运算符重载

C++ 默认提供赋值运算符重载,但这种浅拷贝赋值易造成内存泄漏。

# 11.1 运算符概述

运算符有时称为运算符函数,运算符的操作数相当于函数参数。

运算结果为传统左值的运算符称为传统左值运算符,这样的运算符构成的表达式可以出现在等号左边(必有一个传统左值变量代表其运算结果)。前置 ++、-- 以及赋值运算符 =、+=、*= 和 &= 等均为传统左值运算符。某些单目运算符要求其操作数为传统左值,如前置和后置运算符 ++ 和 --;一些双目运算符要求第一个操作数为传统左值,如赋值运算符 =、 *= 、&= 等。有些运算符仅要求其操作数为传统右值,如加、减、乘、除运算符等。

int main()
{
    int x = 0;
    ++x;
    ++ ++x;
    --x = 10;
    (x = 5) = 12;
    (x += 5) = 7;
}
  • sizeof . .* :: ?: 不可以重载

  • = -> () [] 只能重载为实例函数成员,不能重载为静态函数成员或非成员函数

  • new 和 delete 不能重载为实例函数成员,即可以重载为静态函数成员或非成员函数

# 第 12 章 类型解析、转换与推导

# 12.1 隐式与显式类型转换

C++ 为简单类型提供了自动类型转换机制,即

# 12.3 类型转换实例

# 12.4 自动类型推导

#include <algorithm>
#include <iostream>
#include <typeinfo>
#include <stdlib.h>
using namespace std;
auto a = 1;
auto b = 'A'; //char
auto e = "abce";
static auto x = 3;
//static g = 0;  // 必须明确说明类型,不能认为 g 的默认类型为 int
// 推导返回类型为 int
inline auto f()
{
    return 1;
}
class A {
    inline auto const volatile static m = x;  // 有 inline 时可以使用任意表达式初始化 m?更改编译器的版本可以编译通过
};
int main()
{
    cout << typeid(b).name();
    cout << endl << typeid(e).name() << endl;
    cout << sizeof(e) << endl;
    int y = f();
    cout << y;
    system("pause");
}

#include <algorithm>
#include <iostream>
#include <typeinfo>
#include <stdio.h>
#include <stdlib.h>
using namespace std;
int a[10][20];
auto c = a;
auto* d = a; //c 和 d 的类型相同
auto e = &a; // 对 a 取地址也是正确的
auto f = printf;
int main()
{
	auto m = { 1,2,3,4 };
	auto n = new auto(1);
	cout << typeid(a).name() << endl;
	cout << typeid(a[0]).name() << endl;
	cout << typeid(c).name() << endl;
	cout << typeid(d).name() << endl;
	cout << typeid(e).name() << endl;
	cout << typeid(f).name() << endl;
	cout << typeid(m).name() << endl;
	cout << typeid(n).name() << endl;
}

# 12.5 Lambda 表达式

#include <algorithm>
#include <iostream>
#include <typeinfo>
#include <stdio.h>
#include <stdlib.h>
using namespace std;
int main()
{
	int x = 2;
	auto f = [&x](int i)mutable->int
	{
		x = i;
		cout << "jjj" << endl;
		return 1;
	};
	f.operator()(4);
	cout << x << endl;
	f(3);
	cout << x << endl;
	// 圆括号,形参列表,箭头和返回类型同时不出现
	auto g = []
	{
		return 1;
	};
	// 有圆括号和形参列表,但是没有说明返回值,然而在函数体中有返回值,在下面的代码中依然正确返回了
	auto h = [](int i)
	{
		int m = i;
		return i;
	};
	int m = 10;
	cout << "----------------" << endl;
	cout << m << endl;
	m = h(100);
	cout << m << endl;
}

#include <algorithm>
#include <iostream>
#include <typeinfo>
#include <stdio.h>
#include <stdlib.h>
using namespace std;
int f(int x)
{
	return x;
}
int (*g)(int) = f;  // 利用函数名赋值给一个函数指针
int main()
{
	int x = 1;
	auto f = [](int x = 1) ->int {return x; };  // 捕获列表为空,当准函数使用
	auto g = [](int x)throw(int) ->int {return x; };
	int (*h)(int) = [](int x)->int {return x * x; };
	h = f;  // 由这两个赋值可以看出这三个 lambda 表达式当作准函数使用
	auto m = [x](int i)->int {return i; };  //m 是准对象
	int (*k)(int);
	//k = [x](int i) ->int {return i; }; // 准对象不能赋值给函数指针
	//k = m;
	cout << typeid(f).name() << endl;
	cout << typeid(g).name() << endl;
	cout << typeid(h).name() << endl;
	//cout << typeid([](int i)->int {return i; }).name() << endl;
	cout << typeid(f.operator()()).name() << endl;
	cout << typeid(f.operator()).name() << endl;
	cout << typeid(m).name() << endl;
}

#include <algorithm>
#include <iostream>
#include <typeinfo>
#include <stdio.h>
#include <stdlib.h>
using namespace std;
int m = 2;
int n = 3;
auto f = [](int x)->int {return x * m * n; };
int main()
{
	int a = f(1);
	cout << a << endl;
	auto g = [](int x)->int {return x * m * n; };
	a = g(2);
	cout << a << endl;
}

在捕获列表中填入 & 或者 this 即可解决问题

静态函数没有 this 隐含参数,lambda 表达式中当然不能有 this,此时不能访问到实例数据成员,但静态变量是可以随意访问的

#include <algorithm>
#include <iostream>
#include <typeinfo>
#include <stdio.h>
#include <stdlib.h>
using namespace std;
int m = 2;
class A {
public:
	int a = 2;
	void f()
	{
		auto L1 = [&]
		{
			a++;
		};
	}
};
class B {
public:
	int b = 2;
	static const int c = 2;
	static int d;
	static void f()
	{
		auto L1 = [](int i)->int
		{
			*(int*)(&c) = 3; // 不能修改静态变量的值
			d = 4;
		};
	}
};
int B::d = 3;
int main()
{
	B::f();
	cout << B::c << "  " << B::d << endl;
}

# 偏僻知识

# 引用

右值引用使用的符号是 && ,如

int&& a = 1; // 实质上就是将不具名 (匿名) 变量取了个别名
int b = 1;
int && c = b; // 编译错误! 不能将一个左值给赋值一个右值引用
class A {
  public:
    int a;
};
A getTemp()
{
    return A();
}
A && a = getTemp();   //getTemp () 的返回值是右值(临时变量)
// 此时 a 的值不会因为再调用了其他的函数而改变,说明不是在栈中了,而是另外

getTemp() 返回的右值本来在表达式语句结束后,其生命也就该终结了(因为是临时变量),而通过右值引用,该右值又重获新生,其生命期将与右值引用类型变量 a 的生命期一样,只要 a 还活着,该右值临时变量将会一直存活下去。实际上就是给那个临时变量取了个名字。

注意:这里 a类型是右值引用类型 ( int && ),但是如果从左值和右值的角度区分它,它实际上是个左值。因为可以对它取地址,而且它还有名字,是一个已经命名的右值。

所以,左值引用只能绑定左值,右值引用只能绑定右值,如果绑定的不对,编译就会失败。但是,常量左值引用却是个奇葩,它可以算是一个 “万能” 的引用类型,它可以绑定非常量左值、常量左值、右值,而且在绑定右值的时候,常量左值引用还可以像右值引用一样将右值的生命期延长,缺点是,只能读不能改。

const int & a = 1; // 常量左值引用绑定右值, 不会报错
class A {
  public:
    int a;
};
A getTemp()
{
    return A();
}
const A & a = getTemp();   // 不会报错 而 A& a 会报错
int&& f()
{
    return 2;
}
int& g()
{
    int k = 2;
    return k;
}

./images-20211025230306748

完整代码如下

#include <iostream>
#include <stdio.h>
using namespace std;
const int& a = 1; // 常量左值引用绑定 右值, 不会报错
class A {
public:
    int a = 1;
};
A& getTemp()
{
    A d;
    return d;
}
int&& f()
{
    return 2;
}
int& g()
{
    int k = 2;
    return k;
}
const A& c = getTemp();   // 不会报错 而 A& a 会报错:无法从 “A” 转换为 “A &”
int main()
{
    int e = f();
    printf("%d %d %d %d %d %d %d \n", 8, 8, 8, 8, 8, 8, 8);
    cout << e;
    cout << c.a;
    return 0;
}

在调用 getTemp 函数和 printf 函数后,栈中的值会发生变化,之后输出的 e 不是 2。但是把这两行代码注释掉之后,就可以正确输出 2 了。说明值都在栈中。