C/C++ 编译流程

以 Linux 环境下 C/C++ 语言的编译过程为例,讲解其编译过程。首先编写以下 hello.c 程序:

// hello.c
#include <stdio.h>
int main(){
    printf("hello world!\n");
}

编译过程只需

$ gcc hello.c # 编译
$ ./a.out # 执行
hello world!

上述 GCC 命令其实依次执行了四步操作:

  1. 预处理(Preprocessing)
  2. 编译(Compilation),编译完其实就是 .s 文件了。这一步生成的仍然是文本文件,其实不出真正的二进制文件。
  3. 汇编(Assemble),会变完就是 .o 文件里,里面就是 ELF 格式的代码了,是二进制文件。
  4. 链接 (Linking)

如下图所示:

预处理

预处理用于将所有的 #include 头文件以及宏定义替换成其真正的内容,预处理之后得到的仍然是文本文件,但体积会大很多。

编译

这里的编译不是指程序从源文件到二进制的全部过程,而是指将程序转换成特定汇编代码(Assembly Code)的过程,生成的仍然是代码文本。

汇编

汇编将汇编代码转换成机器码,这一步产生的文件叫做目标文件,是二进制格式。

链接

链接过程将多个目标文件以及所需的库文件链接成最终的可执行文件。

C++ 输入输出

In C++, you can read a single whitespace-separated token of input using cin

C++ 数值类型

C++ 中有符号整数都是以补码形式来表示的,无符号整数都是以原码形式来表示的。

C++ 中的计算其实说白了都是映射到了 x86 上对应的指令,比如:

  • add:以传统二进制加减法的方式进行计算;
  • sub:以传统二进制加减法的方式进行计算;
  • mul/imulmul 无符号乘法、imul 有符号乘法;
  • div/idivdiv 无符号除法、idiv 有符号除法。

addsub 指令不区分有符号 / 无符号,且全程基于二进制加减法进行运算;所谓的 “有符号 / 无符号”,只是编译器对运算结果的解释方式不同,而非运算本身有差异,和硬件是无关的,纯粹是软件层面的解读:

  • 两个有符号的数,按照二进制加法进行运算,结果的表示仍然正确,因为编译器默认是以补码视角对二进制位进行解读的;
  • 两个无符号的数,按照二进制加法进行运算,结果的表示也正确(除非溢出),因为编译器默认是以原码正数视角对二进制位进行解读的。
  • 两个有符号的数,按照CPU 减法运算器(实际上就是补码) 进行运算,结果的表示仍然正确,因为编译器默认是以补码视角对二进制位进行解读的;
  • 两个无符号的数,按照CPU 减法运算器(实际上就是补码) 进行运算,结果的表示也正确,这是因为一个大正数减去一个小正数的场景,因为结果也是正数,所以按照补码运算方式(取反 + 1)也是通过的。

各种码之间的差异,其实在硬件层面是感知不到的,对码值如何进行解读,这是编译器做的事情。硬件能作的只有一件事,老老实实进行基础的二进制加减法运算(你也可以说硬件就是基于补码规则的,这个在加法上体现不出来,但是在减法上能体现出来)。

补码的好处:

  • 加法:正数和负数都可以用同一套规则进行计算。
  • 减法:可以转化成加法进行计算(CPU 硬件会自动进行转换)。正数到负数补码的转换是通过各位取反 + 1 实现。

int And unsigned int

枚举类型(Enum)

枚举类型中,第一个枚举常量默认值为 0,后续的枚举常量值依次加 1。

当枚举变量没有显式初始化时,它会被自动初始化为枚举列表中的第一个枚举常量的值,即 x1 的值 0。

结构体(Struct)

结构体的大小 / Data Alignment

基础规则:

  1. 结构体起始对齐:结构体的起始地址必须是其最大基本数据类型成员大小的整数倍。
  2. 成员起始对齐:每个成员变量的起始地址,必须是该成员自身大小的整数倍(若不足则填充空白字节)。
  3. 结构体末尾对齐:结构体的总大小,必须是其最大基本数据类型成员大小的整数倍(若不足则在末尾填充)。

结构体起始地址需要对齐的原因是:保证结构体的大小是一样的,也就是 sizeof 一个结构体不会因为结构体的位置发生改变。

结构体末尾需要对齐的原因是:数组存储,保证后面的结构体起始地址也是对齐的。

下面代码输出为 12,自己去品为啥。

#include <stdio.h>

// 定义结构体
struct Test1 {
    char a;    // 1字节
    int b;     // 4字节
    short c;   // 2字节
};

int main() {
    // 打印结构体大小
    printf("struct Test1 的大小:%zu 字节\n", sizeof(struct Test1));
    return 0;
}

当有结构体嵌套时,是结构体内最大基本数据类型而不是整个结构体的大小,注意基本二字。

为什么编译器遵循上述规则可以提高内存访问效率?

在 64bit 机器上,现代 CPU 访问内存不是 “一个字节一个字节” 读,而是直接读取 8 字节(毕竟 CPU 寄存器就是 8 字节大小的,内存数据总线也是 8 字节宽的)。而且这个读取其实是读取地址所在的那个 align 的 8 字节而不是从所提供地址开始的 8 字节。这就让起始地址的 align 变的很重要,和数据本身的大小有关。

内存地址对齐一般最多到 8 Bytes(因为现在系统最多就是 64bit 的),在网上就没有必要了。

类(Class)

构造函数

构造函数不能为虚函数,原因有以下几点:

  1. 从存储角度,虚函数对应一个指向 vtable 虚函数表的指针,可是这个指针其实是存储在对象的内存空间中。如果构造函数是虚的,就需要通过 vtable 来调用,可是对象还没有实例化,所以构造函数不能是虚函数
  2. 不存在使用到虚构造函数的使用场景,因为在对象构造时调用者的信息是完整且透明的。

注意,在这种场景下:

Node n;

类 Node 的构造函数也是会执行的。

默认构造函数

如果用户定义的类中没有显式的定义任何构造函数,编译器就会自动为该类型生成默认构造函数,称为合成的构造函数(synthesized default constructor)。

拷贝构造函数

拷贝构造函数,又称复制构造函数,是一种特殊的构造函数,它由编译器调用来完成一些基于同一类的其他对象的构建及初始化。其形参必须是引用

应用场景

在 C++ 中,下面三种对象需要调用拷贝构造函数(有时也称“复制构造函数”):

  1. 一个对象作为函数参数,以值传递的方式传入;
  2. 一个对象作为函数返回值,以值传递的方式从函数返回;
  3. 一个对象用于给另外一个对象进行初始化(常称为赋值初始化);

使用方式

拷贝构造函数必须以引用的形式传递(参数为引用值)。其原因如下:当一个对象以传递值的方式传一个函数的时候,拷贝构造函数自动的被调用来生成函数中的对象。如果一个对象是被传入自己的拷贝构造函数,它的拷贝构造函数将会被调用来拷贝这个对象这样复制才可以传入它自己的拷贝构造函数,这会导致无限循环直至栈溢出(Stack Overflow)。

构造函数是可以重载的,因为在进行实例初始化时可能需要提供的形参个数不同。

析构函数

当对象结束其生命周期,如对象所在的函数已调用完毕时,系统自动执行析构函数。

  • 析构函数不可重载,因为没有参数。
  • 析构函数可以是虚函数,因为我们往往通过基类的指针来销毁对象。如果析构函数不是虚函数,就不能正确识别对象类型从而不能正确调用析构函数。

C++ 类的对象的大小

如果有虚函数,就有 vptr,大小可能是 4/8 字节(32bit/64bit 的机器)。

成员变量对齐规则:和结构体一样。

静态成员:

  • 静态数据成员不算在类的对象大小之内,因为被所有成员共享,不属于具体哪一个对象,定义在内存全局区中。

VPTR

每一个对象有一个 vptr,指向所对应类的虚函数表和 RTTI 信息,从而能够在运行时知道是一个什么对象。

注意,对于虚函数,虚函数表^只有一份(也就是类级别的),但是虚函数表指针(vptr)是每一个对象都有的。

类模版

#include <iostream>
#include <string>
using namespace std;

// 定义一个通用的“容器”类模板
template <typename T>  // T是类型参数,代表任意类型
class Container {
private:
    T value;  // 成员变量的类型是占位符T
public:
    // 构造函数:参数类型是T
    Container(T v) : value(v) {}

    // 成员函数:返回值类型是T
    T get_value() const {
        return value;
    }

    // 成员函数:参数类型是T
    void set_value(T v) {
        value = v;
    }

    // 打印值的成员函数
    void print() const {
        cout << "值:" << value << endl;
    }
};

int main() {
    // 1. 使用int类型实例化类模板
    Container<int> int_container(100);
    int_container.print();  // 输出:值:100
    int_container.set_value(200);
    int_container.print();  // 输出:值:200

    // 2. 使用string类型实例化类模板
    Container<string> str_container("Hello 类模板");
    str_container.print();  // 输出:值:Hello 类模板

    // 3. 使用double类型实例化类模板
    Container<double> double_container(3.14159);
    double_container.print();  // 输出:值:3.14159

    return 0;
}

模板类是用类模板实例化出的具体类。

指针与数组

指针的指针

下面代码的输出是:

0x16d28a8fc 0x16d28a8f0 0x16d28a900 0x16d28a900 1 5⏎ 
#include <stdio.h>

int main() {
  int a[] = {1, 2, 3, 4, 5};
  int b = 0;
  // 之所以 printf 输出最后一个 byte 有区别,是因为这个指针指向的是一个栈区的变量。
  // 这个指针本身也是放在栈区的,和它所要指向的变量是挨着的。
  int *p = &b;

  printf("%p\n", p);
  printf("%p\n", &p);

  // 这个是数组的首元素地址。
  printf("%p\n", a);

  // 这个是整个数组的地址,和上面值相同,但是类型不同,所以 +1 后得到的值也是不同的。
  // *(&a) == a 
  printf("%p\n", &a);
  printf("%d\n", *a);
  
  // ① &a:整个数组的地址,类型是int (*)[5](指向 5 个 int 的数组指针)
  // ② &a + 1:数组指针 + 1,跳过整个数组(5 个 int,20 字节),指向数组末尾的下一个地址
  // ③ *(&a + 1):解引用后,类型变回int[](数组),等价于数组末尾下一个位置的地址
  // ④ *(&a + 1) - 1:地址减 1,回到数组最后一个元素(5)的地址
  // ⑤ *(*(&a + 1) - 1):解引用取最后一个元素的值,即 5
  printf("%d", *(*(&a + 1) - 1));
}

这是为什么呢?

int *ptr[4] 的外层是一个四个元素的数组,数组里面每一个元素都包含了一个 int* 类型的指针。

new / delete

对比 C 语言的 malloc/freenew/delete 不仅分配 / 释放内存,还会自动调用对象的构造函数和析构函数,这是最核心的区别。

初始化:

  • 垃圾值:new
  • 初始化值:new 类型(初始值)
  • 未初始化值:new 类型()

数组名

从下面代码可以得知,对于二维数组,其表现不像是一个指针,而是一个指针的指针,这是因为:

  • 一维数组名指向了一堆基础数据类型,比如说 int double;所以直接解引用一次就是对应数据类型。
  • 二维数组名指向了一堆数组(这些数组包含基础数据类型,也就是说比如指向的是 int[3]),你没有办法输出一个 int[3] 是什么东西,所以如果要访问还需要再解引用一次。
#include <stdio.h>

int main() {
  int a[3][4];
  a[0][0] = 1;
  // 输出 0x16dcd68e8
  printf("%p\n", *a);
  // 输出 1
  printf("%d\n", **a);
}
#include <stdio.h>

int main() {
  int a[3][4];
  // 输出是 48,表示这个大小其实是数组的大小。
  printf("%lu\n", sizeof(a));
  // 你以为只是多了一个 int 也就是 4 个 bytes,其实不是
  // 两者输出相差 0x10,也就是 16 个 bytes。这是因为 a 指向
  // a[0][0],而 a + 1 其实指向的是 a[1][0]。
  printf("%p %p\n", a, a + 1);
}

和一维数组一样,二维数组名(如 arr)会隐式转换为指针,但这个指针的类型不是指向单个 int 的指针(int*),而是指向「一维数组」的指针(比如 int (*)[4],指向包含 4 个 int 的数组)。

指针的大小

指针会绑定一个自己指向的类型。这样在对指针进行加减的时候就可以知道每次需要加减多少。

注意,sizeof 一个指针是这个指针的大小,而不是这个指针指向内容的大小,即使是一个 MyClass *asizeof(a) 还是等于 4,也就是 4 个字节,这是因为指针的大小就是 4 个字节(在 32 位的系统上)。

继承

两种关键关系

  • 整体部分关系:一辆车有轮子、引擎。聚合/组合都属于整体部分关系。
  • 特殊一般关系:管理者是员工。

多重继承

C++ 允许多重继承(存在多个直接基类)。

class Temporary{ /* ... */ };
class Secretary : public Employee { /* ... */ };
class Tsec : public Temporary, public Secretary { /* ... */};
class Consultant : public Temporary, public Manager {/* ... */};

抽象类

一些类代表的抽象概念不允许有实例存在。比如,形状只有在有些类继承他时才有意义。

class Shape {
public:
  virtual void rotate( int ) { error("invalidate invoke"); }
  virtual void draw( ) { error("invalidate invode"); }
};

当调用到该函数时,只能报错。一种解决方式是把虚函数定义为纯虚函数(纯虚函数就是后面有一个 = 0)。

class Shape {
public:
  virtual void rotate( int ) = 0;
  virtual void draw( ) = 0;
};

有一个或者多个抽象函数的类称为抽象类,这种类不允许有实例出现。

三种控制级别

Public

一般用户和友元能够进行访问。

Protected

派生类成员函数和友元能够进行访问。

Private

自身成员函数和友元能够进行访问。

继承访问控制

仅影响派生类所继承的基类成员派生类的用户代码中的能见度。这里的派生类用户代码指外界任何函数与 D 的派生类。

class B {
 /* … */ 
};

// D 可以继承 B(访问级别通过 public 这个关键字来设置)
class D : public B {
   /* …  */  
};

Public 继承

不改变基类成员的能见度。如 B 的 public 成员可被任何函数访问。B 的 protected 成员只可被 D、D 的友元、D 的派生类及其友元访问。

Protected 继承

将 B 之 public 成员的能见度降低为 protected,其余不变。如 B 的 public、protected 成员只可被 D、D 的友元、D 的派生类及其友元访问。其他任何函数无法访问 B 的这些成员。

Private 继承

B 的所有成员能见度降低为 private。如 B 的 Public、Protected 成员只能被 D、D 的友元访问。D 的派生类和其他任何函数无法访问 B 的这些成员。

封装

定义

封装,即隐藏对象的属性和实现细节,仅对外公开接口,控制在程序中属性的读和修改的访问级别。

多态

多态是面向对象的灵魂。虚函数是实现运行时多态的关键。

定义

多态(英语:polymorphism)指为不同数据类型的实体提供统一的接口。允许将子类类型的指针赋值给父类类型的指针。 计算机在得到一个指针时,能够根据该指针值的类型而非指针的类型进行调用,即赋予了子类能够被归类为父类的能力。

实现多态的关键机制则是虚函数。

例子

比如有动物(Animal)之类别(Class),而且由动物继承出类别鸡(Chicken)和类别狗(Dog),并对同一源自类别动物(父类别)之一消息有不同的响应。如类别动物有“叫 ()”之动作,而类别鸡会“啼叫 ()”,类别狗则会“吠叫 ()”,则称之为多态。

实现原理

每个类有自己的虚函数表。编译器会为每个对象,在内存布局的最开头(通常)添加一个隐藏的成员变量:虚表指针(vptr)。 这个指针会指向类的虚函数表(vtable),对象创建时(构造函数执行阶段),vptr 会被初始化,指向对应类的虚表。

动态链接库

定义

动态链接库(Dynamic Link Library, DLL),是一个可以被其他应用程序共享的模块。DDL 是 Windows 上的叫法、.so 是 Linux 上的叫法。

动态链接库文件(DLL 文件)与可执行文件(EXE 文件)非常类似,区别在于 DLL 虽然包含了可执行代码却不能单独执行,而应由 Windows 程序直接或者间接调用。

背景

DLL 的最初目的是节约应用程序所需的磁盘和内存空间。在一个传统的非共享库中,一部分代码简单地附加到调用的程序上。如果两个程序调用同一个子程序,就会出现两份那段代码。相反,许多应用共享的代码能够切分到一个 DLL 中,在硬盘上存为一个文件,在内存中使用一个实例(instance)。DLL 的广泛应用使得早期的 Windows 能够在紧张的内存条件下运行。

绑定

动态绑定(运行时多态)

一般发生在直接通过指针来进行调用。

动态绑定是指在执行期间(非编译期)判断所引用对象的实际类型,根据其实际的类型调用其相应的方法。程序运行过程中,把函数(或过程)调用与响应调用所需要的代码相结合的过程称为动态绑定。

class Employee {
  string first_name, family_name;
  char middle_initial;
  short department;
  // ... 
public:
  virtual void print() const;
  string full_name() const;
  Employee(const string& n, int d);
  // ... 
};

class Manager : public Employee {
  list<Employee*> group;
  short level;
  // ... 
public:
  void print() const;//重置,虚函数
  Manager(const string& n, int d.
                int lvl);
  // ... 
};

// example of dynamic binding
void  printall( Employee * p){
  p -> print( );
}

// example of dynamic binding
Employee e1("张三", 1);
Manager  m1("李四", 1, 2);
Employee *p1 = &e1, *p2 = &m1;

printall( p1 ); // Employee::print
printall( p2 ); // Manager::print

静态绑定(编译时多态)

一般发生在直接通过对象名字来调用。

静态绑定发生在编译期,因此不能利用任何运行期的信息。把函数(方法或者过程)调用与响应调用所需的代码结合的过程称之为静态绑定。

class Employee {
  string first_name, family_name;
  char middle_initial;
  short department;
  // ... 
public:
  virtual void print() const;
  string full_name() const;
  Employee(const string& n, int d);
  // ... 
};

class Manager : public Employee {
  list<Employee*> group;
  short level;
  // ... 
public:
  void print() const; //重置,虚函数
  Manager(const string& n, int d, int lvl);
  // ... 
};

// example of static binding
Employee e1("张三", 1);
Manager  m1("李四", 1, 2);
  // ... 
e1.print(); 
m1.print(); 

在上面代码中,程序对于两个变量的所属类型十分清楚,所以程序也明白应当调用所对应类型的对应函数,因为在编译期,程序知道其类型。

reinterpret_cast

重新解释二进制位的含义。随便转换,非常霸道:

typedef int (*FunctionPointer)(int);
int value = 21;
FunctionPointer funcP;
funcP = reinterpret_cast<FunctionPointer> (&value);
funcP(value);

虚函数与纯虚函数

虚函数

在某基类中声明为 virtual 并在一个或多个派生类中被重新定义的成员函数,用法格式为:virtual 函数返回类型 函数名(参数表) {函数体};实现多态性,通过指向派生类的基类指针或引用,访问派生类中同名覆盖成员函数。

class A{
    public:
        virtual void print(){cout<<"This is A"<<endl;}
};

class B : public A{
    public:
        void print(){cout<<"This is B"<<endl;}
};

虚函数与非虚函数都可以在子类中进行重载,但它们的区别在于:

  • 非虚函数中,通过基类指针(指向了子类)调用重载函数时,实际上调用的仍然是基类的函数,未实现多态;
  • 类的对象内部会有指向类内部的虚表地址的指针。通过这个指针调用虚函数。虚函数的调用会被编译器转换为对虚函数表的访问,从而不会导致访问基类的虚函数。注意虚表是每一个类一个,而指向虚表的指针是每一个对象一个。

也就是,虚函数实现了运行时确定调用而非编译时确定调用,这为 C++ 实现多态提供了支持。基类中的虚函数可以有自己的实现,不能在基类中实现的函数为纯虚函数。

纯虚函数

纯虚函数是一种特殊的虚函数,在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。

class A {
    public:
        A();
        virtual void f()=0;
};

非虚函数

(非虚函数无多态)当在基类中定义的函数为非虚函数时,基类类型的指针值为子类类型时,运行调用该函数结果仍为基类中的函数,即未实现多态。

若在基类中定义的函数为虚函数,基类类型指针为子类类型时,调用该函数,结果为子类中的函数,实现了多态。

#include<iostream>

using namespace std;

class A{
public:
    int a;
    A(){
        a = 5;
    }
    void f1(){
        cout<<"f1"<<endl;
    }
    virtual void f2(){
        cout<<"f2"<<endl;
    }
};

class B:public A{
public:
    B(){
        this->a = 5;
    }
    void f1(){
        cout<<"f3"<<endl;
    }
    void f2(){
        cout<<"f4"<<endl;
    }
};

int main(){
    A* ptr;
    B b;
    ptr = &b;

    ptr->f1();
    ptr->f2();
}

这段代码的运行结果为“f1 f4”。

重载、覆盖与隐藏

C++ 规定,当一个成员函数被声明为虚函数后,其派生类中的同名函数都自动成为虚函数。因此,在子类重新声明该虚函数时,可以加,也可以不加,但习惯上每一层声明函数时都加 virtual,使程序更加清晰。

重载(Overloading)

  • 具有相同的作用域,不涉及到继承,即同一个类中,甚至可以不在类中
  • 函数名相同;
  • 参数类型不同;
  • virtual 关键字可有可无。

覆盖(Override)

覆盖指子类虚函数重新实现了父类的虚函数,其特征是:

  • 不同的作用域(分别位于派生类和基类中,即虚函数的重写) ;
  • 函数名相同;
  • 参数列表完全相同;
  • 基类函数必须是虚函数。

覆盖仅仅针对虚函数,覆盖是一种实现多态的机制。

隐藏(Hide)

  • 如果基类不是虚函数,子类又重新定义了该函数,无论参数相同不相同都是隐藏。
  • 如果基类是虚函数, 参数相同就是覆盖,参数不同就是隐藏

“隐藏” 本质上完全是编译期的静态绑定(静态多态) 行为,和运行时的动态绑定(动态多态 / 真正的多态)没有任何关系,甚至会阻断动态绑定的生效。

虚函数表

注意的是,编译器会为每个有虚函数的创建一个虚函数表,该虚函数表将被该类的所有对象共享。类的每个虚成员占据虚函数表中的一行。

虚函数是通过虚函数表来实现的。简称为 V-Table。在这个表中,主要是一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其真实反应实际的函数。这样,在有虚函数的类的实例中分配了指向这个表的指针的内存。

一般虚函数表放在只读数据段(.rodata)中。

友元函数

定义

在面向对象编程中,友元函数(friend function)是一个指定类(class)的“朋友”,该函数被允许访问该类中 private、protected、public 的数据成员。普通的函数并不能访问这些数据,然而宣告一个函数成为一个类的友元函数则被允许访问这些数据。

一个友元可以是一个全局函数,也可以是另外一个类的成员函数,也可以是另外一个类。友元的加入破坏了类的封装和信息隐藏,所以宁缺勿滥

没有 public/protected/private 友元之区分,大家权限都是一样的。

代码示例

当一个函数需要访问两个不同类型对象的私有数据成员的时候,可以使用友元函数。有两种使用的方式:

  • 该函数作为全域函数,在两个类中被宣告为友谊函数
  • 作为一个类中的成员函数,在另一个类中被宣告为友谊函数
#include <iostream>
using namespace std;

class Bezaa; // Forward declaration of class Bezaa in order for example to compile.
class Aazaa
{
private:
    int a;
public:
    Aazaa() { a = 0; }
    void show(Aazaa& x, Bezaa& y);
    friend void ::show(Aazaa& x, Bezaa& y); // declaration of global friend
};

class Bezaa
{
private:
    int b;
public:

    Bezaa() { b = 6; }
    friend void  ::show(Aazaa& x, Bezaa& y); // declaration of global friend
    friend void Aazaa::show(Aazaa& x, Bezaa& y); // declaration of friend from other class 
};

// Definition of a member function of Aazaa; this member is a friend of Bezaa
void Aazaa::show(Aazaa& x, Bezaa& y)
{
  cout << "Show via function member of Aazaa" << endl;
  cout << "Aazaa::a = " << x.a << endl;
  cout << "Bezaa::b = " << y.b << endl;
}

// Friend for Aazaa and Bezaa, definition of global function
void show(Aazaa& x, Bezaa& y)
{
  cout << "Show via global function" << endl;
  cout << "Aazaa::a = " << x.a << endl;
  cout << "Bezaa::b = " << y.b << endl;
}

int main()
{
   Aazaa a;
   Bezaa b;

   show(a,b);
   a.show(a,b);
}

常量

非指针常量

定义方式为

const 类型 ID = 初值。

  • 当类型不是 T* 时,不允许修改的是 “ID” 的值。
  • 当类型是 T* 时,不允许修改的是 *IDID[下标] 的值,而不是 “ID” 的值。

不管怎么样,不允许更改的都是值而非指针的值

指针常量

定义方式为

T* const ID = 初值

不允许修改的是 “ID” 的值,而不是 *IDID[下标] 的值。

所以,const 在前表示值不能变,而 const 在后表示指针不能变。

引用

引用是另一个已经存在对象的别名,二者代表同一对象(同一块存储空间)。

  • 在声明时必须初始化。
  • 不能为空值。
  • 此后不能再引用其他对象。

访问控制

只读成员

如何表示一个操作是“只读”而不修改数据成员?向外界表明,const 类型的成员函数不改变任何数据成员的值。

int  Date::year( ) const
{
  return y;
};

注意:一个 const 成员函数能够被 const 和非 const 对象同时调用,而一个非 const 成员函数无法被 const 对象进行调用。

操作符重载

操作符(Operator)是对某个操作/计算的简化表示。对于 C++ 中的用户自定义类型(Class),操作符的具体内涵可以被重新定义,这被称为操作符重载。

#include <iostream>
#include <cmath>
using namespace std;

// 复数类:包含实部(real)和虚部(imag)
class Complex {
private:
    double real;  // 实部
    double imag;  // 虚部

public:
    // 构造函数
    Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) {}

    // 1. 成员函数重载 + 操作符(复数相加)
    Complex operator+(const Complex& other) const {
        // 实部+实部,虚部+虚部
        return Complex(real + other.real, imag + other.imag);
    }

    // 2. 成员函数重载 - 操作符(复数相减)
    Complex operator-(const Complex& other) const {
        return Complex(real - other.real, imag - other.imag);
    }

    // 3. 成员函数重载 += 操作符(复合赋值)
    Complex& operator+=(const Complex& other) {
        real += other.real;
        imag += other.imag;
        // 返回自身引用,支持链式赋值(如 a += b += c)
        return *this;
    }

    // 4. 成员函数重载 == 操作符(判断两个复数是否相等)
    bool operator==(const Complex& other) const {
        // 浮点数比较需注意精度,这里简化为直接相等
        return (fabs(real - other.real) < 1e-9) && (fabs(imag - other.imag) < 1e-9);
    }

    // 友元函数:重载输出流 << 操作符(需要访问私有成员)
    friend ostream& operator<<(ostream& os, const Complex& c);
};

// 5. 全局友元函数重载 << 操作符(不能定义为成员函数,因为左操作数是ostream)
ostream& operator<<(ostream& os, const Complex& c) {
    os << c.real;
    if (c.imag >= 0) os << "+";  // 虚部非负时加+号,更易读
    os << c.imag << "i";
    return os;  // 返回ostream引用,支持链式输出(如 cout << a << b)
}

// 测试代码
int main() {
    Complex c1(2.5, 3.5);
    Complex c2(1.5, -2.5);

    // 使用重载的 + 操作符
    Complex c3 = c1 + c2;
    cout << "c1 + c2 = " << c3 << endl;  // 输出:4+1i

    // 使用重载的 - 操作符
    Complex c4 = c1 - c2;
    cout << "c1 - c2 = " << c4 << endl;  // 输出:1+6i

    // 使用重载的 += 操作符
    c1 += c2;
    cout << "c1 += c2 后:" << c1 << endl;  // 输出:4+1i

    // 使用重载的 == 操作符
    if (c1 == c3) {
        cout << "c1 和 c3 相等" << endl;
    } else {
        cout << "c1 和 c3 不相等" << endl;
    }

    return 0;
}
  • 不能改变操作符的结构规则(包括语法结构、操作数个数、优先级、结合律等);
  • 不能与其他类型用操作符表示的操作之间存在冲突(即在某一特定表达式中,某个操作符应具有明确的、唯一的含义)。

左值与右值?

不允许重载的操作符

::..*?:sizeoftypeid.

C++ 不允许定义语言本身未定义的操作符,或组合定义操作符,例如**。

模板

引入模板的目的:对结构特征和行为特征相似,但数据类型不能保证相同的一组类(或函数)进行高一级的抽象,以提高程序的重用性和规格化程度。

一个类是一组对象的抽象;一个类模板是对一组类的抽象。类的实例是对象,类模板的实例是类。

实参

合法的模板实参种类:

  • 一个类型。
  • 一个常量表达式。

对于以下模板:

template<class T, int i>
class Buffer{
   T v[i];
   int sz;
public:
    Buffer():sz(i) { }
    // …
};

这样使用是正确的:

Buffer<double, 10> d_buf;
const int csz = 100;
Buffer<char, csz+10> c_buf;

这样使用是错误的:

void f ( int size ){
   Buffer<char, size> eBuf;
}

C++ 当中的类型转换(Cast)

Upcasting and Downcasting

Upcasting is an operation that creates a base class reference from a subclass reference. (subclass -> superclass) (i.e. Manager -> Employee)

Employee emp = (Employee)mgr; //mgr is Manager

We can think of up in word upcasting as more abstract or more generic.

Downcasting is an operation that creates a subclass reference from a base class reference. (superclass -> subclass) (i.e. Employee -> Manager)

Employee m = new Manager();
Employee e = new Employee();
  
if(m is Manager) Console.WriteLine("m is a manager");
if(e is Manager) Console.WriteLine("e is a manager");

Downcasting refers to the procedure of using a base class pointer to an object and querying it at run time to find out type information, used to explicitly cast the pointer to a subclass pointer so that the subclass API can be used.

We can think of down in word downcasting as more concrete or more specific.

C++ 类型转换分为显式类型转换和隐式类型转换 ,隐式类型转换由编译器自动完成,这里只讨论显式类型转换。

旧式风格的类型转换

type(expr); // 函数形式的强制类型转换
(type)expr; // C语言风格的强制类型转换

现代 C++ 风格的类型转换

cast-name<type>(expression)

static_cast()

相比于传统的 C 风格的转换方式:

  • 都是编译器静态转换;
  • 有一些限制和禁止,更加安全一些。

Condition 1: static_cast()is the first cast you should attempt to use. It does things like implicit conversions between types (such as int to float, or pointer to void*), and it can also call explicit conversion functions (or implicit ones). In many cases, explicitly stating static_cast() isn't necessary. For example:

float a = int(5);

Condition 2: static_cast() can also cast through inheritance hierarchies, For example:

void *p = &d;
double *dp = static_cast<double*>(p);

static_cast() doesn't do checking.

dynamic_cast()

What is a polymorphic type? 多态类。

A class having at least one virtual function is called a polymorphic type. This can be only a destructor also.

So the following is a polymorphic type:

struct Test {
  virtual ~Test();
};

dynamic_cast is exclusively used for handling polymorphism. You can cast a pointer or reference to any polymorphic type to any other class type (a polymorphic type has at least one virtual function, declared or inherited).

相比 static_castdynamic_cast 会在运行时检查类型转换是否合法,具有一定的安全性。由于运行时的检查,所以会额外消耗一些性能。dynamic_cast 使用场景与 static_cast 相似,在类层次结构中使用时:

  • 上行转换和 static_cast 没有区别,都是安全的;
  • 下行转换时,dynamic_cast 会检查转换的类型,相比 static_cast 更安全。这个检查是通过 RTTI^ 实现的,防止一个指向 A 子类的父指针被转换成为指向 B 子类。
// 下行转换
class A { virtual void f(){}; };
class B : public A{ };
void main()
{
     A* pA = new B;
     B* pB = dynamic_cast<B*>(pA); 
}

注意类 A 和类 B 中定义了一个虚函数,这是不可缺少的。因为类中存在虚函数,说明它可能有子类,这样才有类型转换的情况发生,由于运行时类型检查需要运行时类型信息,而这个信息存储在类的虚函数表中,只有定义了虚函数的类才有虚函数表。

const_cast()

它的核心作用是修改表达式的 const/volatile 限定符:既可以移除 const/volatile,也可以添加 const/volatile。

它不会改变变量的类型,只改变变量的常量限定。

绝对不要修改「本身就是 const 的变量」:如果变量本身被声明为 const(如 const int a = 10;),即使通过 const_cast 移除了 const 限定并尝试修改,也会导致未定义行为。

#include <iostream>
using namespace std;

// 一个接收非const指针的函数
void modifyValue(int* ptr) {
    *ptr = 100;
}

int main() {
    // 场景1:变量本身不是const,只是被const指针引用
    int num = 10;
    const int* const_ptr = &num;  // const指针指向普通变量
    
    // 错误:const int* 不能直接传给 int*
    // modifyValue(const_ptr);
    
    // 正确:用const_cast移除const限定
    modifyValue(const_cast<int*>(const_ptr));
    cout << "修改后的值:" << num << endl;  // 输出:100

    // 场景2:变量本身是const(非法操作!)
    const int const_num = 20;
    int* ptr = const_cast<int*>(&const_num);
    *ptr = 200;  // 未定义行为!编译可能通过,但运行结果不可预测
    cout << "const变量被修改后:" << const_num << endl;  // 结果可能还是20

    return 0;
}

reinterpret_cast()

直接重新解释(reinterpret)变量的二进制内存布局,不做任何类型检查、数值转换或内存调整,仅简单地将一段内存的字节序列 “看作” 另一种类型。

比如,把指针转换成为无符号整数,double 强行解读为指针等等。

Reference

异常处理

对错误的处理

  • 对于不能在本地处理的错误,进行报告;
  • 对于不是在本地检测出来的错误,进行处理。

错误处理举例

char to_char(int i)
{
  if (i < CHAR_MIN || CHAR_MAX < i)
    throw Range_error(i); 
    // 出现了异常,向调用者报告
    // 以异常方式返回调用者
  return i;
}
void  g(int i) {  // to_char()的使用者
  try { // test-block
     // 在此期间若出现异常,由下面的 catch 处理
     // 异常时从to_char跳出,由调用方处理
    char c = to_char(i);
  }
  // 当“有人throw”出Range_error时,激活该异常处理
  catch (Range_error) { // exception-handler
    cerr << "oops" << "\n"
  }
}
#include <iostream>

int main(int argc, char *argv[]) {

  /* An annoying "Hello World" example */
  for (auto i = 0; i < 0xFFFF; i++)
    cout << "Hello, World!" << endl;
   
  char c = '\n';
  unordered_map <string, vector<string> > m;
  m["key"] = "\\\\"; // this is an error

  return -2e3 + 12l;
}

STL

<vector>

可以用来模拟一个栈 stack(可以进一步看栈 std::stack 的实现)。

增删改查:

  • v1.push_back(6):尾部插入 6;
  • v1.pop_back():尾部删除。

注意 vector 为了避免头部操作,所以没有 *_front() 的接口。

#include <iostream>
#include <vector>   // 必须包含头文件
using namespace std;

int main() {
    // 1. 初始化
    vector<int> v1;                // 空 vector
    vector<int> v2(5, 10);         // 5个元素,每个值为10
    vector<int> v3 = {1,2,3,4,5};  // 列表初始化(C++11+)

    vector<int> v2(5, 10);         // 5个元素,每个值为10
    // 2. 常用操作
    v1.push_back(6);               // 尾部插入:v1 = [6]
    v1.push_back(7);               // v1 = [6,7]
    v1.pop_back();                 // 尾部删除:v1 = [6]

    // 3. 访问元素(三种方式)
    cout << v1[0] << endl;         // 直接访问(无越界检查)
    cout << v1.at(0) << endl;      // 安全访问(越界抛异常)
    for (auto it = v1.begin(); it != v1.end(); ++it) { // 迭代器访问
        cout << *it << " ";
    }
    cout << endl;

    // 4. 容量管理(关键!)
    cout << "size: " << v1.size() << endl;       // 元素个数:1
    cout << "capacity: " << v1.capacity() << endl; // 已分配内存:通常是2(默认扩容)
    v1.reserve(10);                              // 预分配10个空间,避免频繁扩容
    cout << "after reserve, capacity: " << v1.capacity() << endl; // 10

    // 5. 清空元素(注意:clear()不释放内存)
    v1.clear();
    cout << "after clear, size: " << v1.size() << ", capacity: " << v1.capacity() << endl; // size=0, capacity=10
    v1.shrink_to_fit(); // 释放多余内存(C++11+)
    cout << "after shrink, capacity: " << v1.capacity() << endl; // 0

    return 0;
}

<list>

常用的增删改查操作:

  • lst.push_front(0) 在头部插入;
  • lst.push_back(6) 在尾部插入;
  • lst.pop_front() 在头部删除;
  • lst.pop_back() 在尾部删除;
  • lst.insert(it, 99)it 处插入;
  • it = find(lst.begin(), lst.end(), 4) 找到第一个值为 4 的元素;
  • lst.erase(it) 删除 it 处的节点;
  • lst.remove(2) 删除所有等于 2 的节点。

其他的常用操作:

  • lst.size()
  • lst.sort()
  • lst.reverse()
  • lst.clear()
  • lst.empty()

<deque>

增删改查(接口和 <list> 很像):

  • dq.push_back(1)
  • dq.push_front(0)
  • dq.pop_front()
  • dq.pop_back()

<stack>

当然 std 本身也提供了栈 std::stack,它正是以容器适配器(adapter)的形式,基于 vector/deque 等底层容器封装的栈。

  • empty():空栈;
  • size():栈大小;
  • top():栈顶;
  • push():压入栈顶;
  • pop()

STL 容器

vectordequelist 是 STL 中最基本的一级容器。setmultisetmapmultimap 等关联容器是建立在一级容器上的二级容器。

F&Q

  • C 当中的 static 关键字作用是什么?

stackoverflow 上的答案如下:

主要有两个作用:

  • 一个函数中的静态变量在多次调用之间保持它的值;
  • 一个静态的全局变量或者函数仅在它声明的文件中可见(作为一种访问控制工具),因为开发者仅仅想暴露其想暴露的接口。
  • C 当中的 volatile 关键字是什么?

如果将变量定义为 reinterpret_cast 类型,意味着该变量可能随时被修改,每次使用时应重新读取,而非使用保存在寄存器中的值(Condition 2)。

volatileof()

sizeof() 一个指针,那么是这个指针本身的大小,而不是指针所指向对象的大小。但是如果是 sizeof 数组的话,就是数组本身的大小:

#include<iostream>

using namespace std;

void fun(int *P) {
    cout << "在函数中" << sizeof(P) << endl;
}

int main() {
    int A[10];
    int *B = new int[10];
    cout << "数组名" << sizeof(A) << endl;
    cout << "指针" << sizeof(B) << endl;
    fun(A);
}

输出:

数组名40
指针4
在函数中4

RTTI(运行时类型信息)

因为 C++ 有多态机制,基类指针可能指向子类对象,RTTI 就是为了运行时明确基类指向的是个什么子类。

作用原理:

  • 每个有虚函数的类,编译器会在只读数据段为它创建一个唯一的 std::type_info 对象,这个对象包含该类的类型名称、哈希值、类型比较逻辑等核心 RTTI 信息。
  • 编译器会在类的虚函数表中,专门预留一个位置(通常是 vtable 的第一个 / 最后一个条目),指向这个 type_info 对象。也就是说,RTTI 信息是通过虚函数表间接关联到对象的,而非直接存在对象里。