码农日记 · 叁

Last updated on June 7, 2025 pm

C++技术百问

指针常量和常量指针

指针常量,理解为:常量的指针,即指向一个常量的指针,修饰的const类型是底层const,指针指向的内容不可变:

int a = 10, b = 50;
const int *p = &a;  // 指针常量,底层const
int const *q = &a;  // 同上
cout << *p << endl;  // 10
p = &b;  // 可以改变指针指向
cout << *p << endl;  // 50
*p = 20;  // 报错,不可以修改指向的值!

常量指针,理解为:指针是个常量,即指针变量本身不可变,修饰的const类型是顶层const,指针的指向不可变:

int a = 10;
int * const p = &a;  // 常量指针,顶层const
int * const q;  // 报错,必须在定义时初始化
cout << *p << endl;  // 10
*p = 20;  // 可以改变指针指向的内容
cout << *p << endl;  // 20
p = &b;  // 报错,不可以改变指针的指向!

引用和常量指针的关系

在底层实现上,引用和常量指针的汇编代码完全相同,因此可以认为引用本质上就是一个常量指针。

在高级语言层面,引用与常量指针有一些相同点和不同点。相同点在于它们都占用4或8字节的存储空间,并且必须在定义时初始化。不同点在于常量指针毕竟是指针,允许寻址,可以通过 &p 获取指针变量本身的地址,而引用不允许直接寻址,&r 返回的是被引用对象的地址。引用不能为空,而指针可以是空(nullptr)。sizeof(引用)得到的是被引用对象的大小,而sizeof(指针)得到的是指针本身的大小。

此外,数组元素可以是指针但不能是引用,这是为了避免二义性。例如,如果允许数组array的元素是引用类型,那么 array[0]=8 这样的语句将无法明确是修改数组元素 array[0](把int引用替换成int值8)还是该元素所引用的对象(把引用的对象值改为8)。

另外,在函数参数传递中,指针传递是值传递,传递的是地址的副本,被调函数对形式参数的任何操作都是作为局部变量进行,不会影响主调函数实参变量的值,只能通过操作指针的解引用来映射实参。而引用传递是直接操作实参的地址,虽然被调函数为形参在栈中开辟了空间,但是存储的是主调函数传入的实参变量的地址,对这个“形参”本身操作就相当于是操作实参。

面向对象和面向过程的区别

面向对象和面向过程是两种不同的编程思想。面向过程将问题分解为一系列步骤,通过函数逐步实现,使用的时候一个一个依次调用。而面向对象则将问题分解为多个对象,建立对象不是为了完成某一个步骤,而是为了描述其在解决问题过程中的行为。

例如五子棋,面向过程设计会按照步骤实现游戏流程:1、开始游戏,2、黑子先走,3、绘制画面,4、判断输赢(赢了执行9),5、白子走,6、绘制画面,7、判断输赢(赢了执行9),8、返回步骤2,9、输出最终结果。

而面向对象设计则会划分为玩家(黑白双方,行为是一样的:在棋盘上选择一个空地落子)、棋盘系统(负责绘制画面)、规则系统(判定输赢、是否犯规等)等对象,各司其职。玩家对象接收用户输入,棋盘对象接收棋子变化并根据变化在屏幕上显示,同时利用规则对象对棋局进行判定。

面向过程的优势在于性能较高,因为类实例化有一定的开销,但维护性、复用性和扩展性较差;面向对象则更易维护和扩展,更加灵活,耦合程度低,但性能比面向过程低。

include<> 和 include""

#include<> 和 #include"" 的区别在于头文件的查找路径。

#include<> 用于查找标准库文件,搜索编译器设置的路径,而 #include"" 优先在当前源文件目录查找,找不到时再按 #include<> 的方式搜索。

自定义头文件应使用 #include"",因为标准库路径通常不包含用户文件。

编译器优化选项

编译器优化选项包括 -O0、-O1、-O2 和 -O3。

-O0 不进行优化。

-O1 优化代码的分支、常量和表达式,会减小代码尺寸、缩短执行周期。

-O2 在打开 O1 优化的前提下,增加了寄存器的优化和指令级优化,编译期占用的内存和编译时间也会相应增加。

-O3 在 O2 的基础上进一步优化,但可能引入不可预测行为,通常推荐使用 -O2 平衡性能与稳定性。

编译过程

GCC编译的过程包括:预处理、编译、汇编和链接。

预处理(Preprocessing)

预处理是C++编译的第一阶段,也叫预编译,主要处理源代码文件中的预处理指令(以#开头的指令)。

预处理器的主要任务包括:头文件展开、宏替换、删除注释等。

预处理器的具体任务如下:

  • 宏展开:将宏定义替换为相应的代码(直接文本替换,不进行类型检测)。
  • 包含头文件:将#include指令指定的头文件内容插入到源代码中。
  • 条件编译:根据#ifdef#ifndef#if#else#elif#endif指令,选择性地包含或排除代码块。
  • 删除注释:移除源代码中的注释。

预处理器的输入:源代码文件(.cpp.c),输出:通常是.i(一般是C)或.ii(C++)文件。

编译(Compilation)

编译阶段将预处理后的文件转换为汇编语言代码。

编译器的主要任务包括:词法分析、语法分析(语法树)、语义分析(类型检查、构建符号表)、代码优化等。

编译器具体任务如下:

  • 词法分析:将源代码分解为标记(tokens)。
  • 语法分析:根据C++的语法规则,将标记组合成抽象语法树(abstract syntax tree,AST)。
  • 语义分析:检查代码的语义是否正确,例如类型检查、符号表的构建等。
  • 中间代码生成:将AST转换为中间代码(如三地址代码)。
  • 优化:对中间代码进行优化,提高代码的执行效率。
  • 目标代码生成:将优化后的中间代码转换为汇编语言代码。

编译器的输入:预处理后的文件(.i.ii),输出:汇编语言文件(.s)。

汇编(Assembly)

汇编阶段将汇编语言代码转换/翻译为机器代码。

汇编器的主要任务有:

  • 符号解析:将汇编语言中的符号(如标签、变量名等)解析为具体的地址。
  • 指令转换:将汇编指令转换为机器指令。
  • 生成目标文件:生成可重定位的目标文件。

汇编器的输入:汇编语言文件(.s),输出:目标文件(.o)。

链接(Linking)

链接阶段将多个目标文件和库文件组合成一个可执行文件。

链接器的主要任务有:

  • 符号解析:解析目标文件中的符号引用,将它们与定义符号的目标文件或库文件关联起来。
  • 地址分配:为每个目标文件分配内存地址,确保它们在内存中正确加载。
  • 重定位:根据分配的地址,调整目标文件中的指令和数据的地址。
  • 生成可执行文件:生成最终的可执行文件。

链接器的输入是汇编生成的多个目标文件(.o)和库文件,库文件有两种:静态库(Linux:.a;Windows:.lib)、动态库(Linux:.so;Windows:.dll)。

链接器的输出是可执行文件(Linux:.out,GCC编译默认带后缀名,但可以 -o 指定不带后缀的文件名;Windows:.exe),在命令行窗口用 ./$FILE_NAME$ 可以运行。

C++四种变量存储类型分别是什么?

C++中的变量存储类型分为四种:auto、static、extern和register。

auto是默认的自动存储类型,在函数内部定义的变量,若不指定其存储类型,那么就是auto类变量。变量在函数内生存,函数结束后消失,初始值不确定。

void func() {
    int a;  // auto变量
    auto int b;  // auto变量
    // 每调用一次函数,就对变量进行一次初始化
}

static是静态存储类型,使全局或局部变量在程序的整个生命周期内存在,且只初始化一次。静态全局变量只能在本文件内部使用,静态局部变量作用域仍然在其定义的代码块内(比如函数内,但多次调用函数也只会在首次调用时初始化一次,该变量的值会保存到下次调用函数改变为止)。

extern是外部存储类型,用于声明其他文件中定义的外部变量,便于多文件编程。大型程序为了易于维护和理解,通常需要把程序划分为多个文件来保存,每个文件都可以单独编译,最后再把多个文件的编译结果(即目标文件)链接到一起生成一个可执行程序。这种情况下,如果在一个文件中需要引用另一个文件中的外部变量,就需要利用extern说明。

register是寄存器存储类型,通知编译器将变量存储在寄存器中以提高访问速度,但不能对其取地址,因为取地址符&只能获取内存空间中的地址。

this指针相关

this指针只有在类的成员函数中才有定义,你获得一个对象后,也不能通过对象使用this指针。但在成员函数内部可以通过 &this 获取this指针的位置,也可以直接使用this指针访问该对象的成员变量。

在类对象的内存空间中,只有数据成员和虚函数表指针,类的成员函数单独放在代码段中。在调用成员函数时,隐含传递一个this指针作为第一个参数,让成员函数知道当前是哪个对象在调用它。

如果在成员函数中调用 delete this,会导致该对象的内存被释放,后续任何涉及this指针的操作都可能引发未定义行为。

如果在析构函数中调用 delete this,会形成无限递归,导致堆栈溢出和程序崩溃。因为delete的本质是:为将被释放的内存调用一个或多个析构函数,然后释放内存。显然,delete this 会去调用本对象的析构函数,而析构函数中又调用 delete this,从而形成无限递归。这一点很像拷贝构造函数,当它的参数不是引用类型时,也会造成无限递归。

哪些函数不能是虚函数?

虚函数在运行时表现出多态性,通常不能将虚函数作为内联函数,因为内联函数的展开是在编译期。但若编译器能确定具体的调用对象(比如不使用动态绑定/运行时多态,直接使用对象调用虚函数),则可能内联。内联函数是编译器的优化建议(只是建议编译器将其看作内联函数,具体是否实践仍取决于编译器的决策),定义需放在头文件中以便编译器可见。

另外,虚函数表(vtable)通过this指针访问(this指针 -> 虚表指针vptr -> 虚表vtable),而内联函数在编译时展开,没有地址,因此虚函数与内联机制存在冲突。

除了内联函数外,还有一些函数是不能声明为虚函数的,包括:友元函数、静态成员函数、构造函数和成员函数模板。

友元函数不属于类成员,只有类成员(具有继承关系)使用虚函数才有意义。

若声明静态成员函数为virtual会编译失败。因为静态成员函数没有隐含的this指针,与任何对象实例无关,无法调用对象的虚表指针,从而没办法访问对象所属类的虚表,自然就没办法通过虚表找到要调用的那个虚函数。虚函数调用需要运行时确定对象类型,但静态函数调用时根本没有对象上下文,因为即使程序员使用对象名调用静态成员函数(obj.staticFunc()),编译器也会将其转换为:ClassName::staticFunc(),不会使用对象的任何信息(相当于对象名调用是个语法糖),静态和多态机制本身是互斥的,因此静态成员函数自己也没办法作为虚函数的一员存储于虚表中。

构造函数负责初始化对象的存储空间,建立对象的类型信息(包括虚表指针),而虚函数依赖已构造的对象的虚表指针来动态决定调用哪个版本的虚函数(基类还是派生类),如果构造函数是虚函数,就需要虚指针(vptr)已经正确初始化,但 vptr 又需要构造函数来初始化,形成了死锁。

另外,当创建一个对象时,构造函数的调用顺序是:基类构造函数 → 派生类构造函数,在基类构造函数执行中,派生类对象还未完全构造,此时 vptr 指向的是当前类(父类)的虚表。如果构造函数是虚函数,需要通过虚表来动态绑定调用,但此时 vptr 尚未正确指向派生类的虚表,这与创建派生类对象发生矛盾。

成员函数模板是在编译时实例化,但函数模板的实例化是动态的,也就是说,类模板中的成员函数或成员函数模板在调用的时候才会创建具体的函数实例。虚表需要在编译时确定大小和内容,编译器无法预先知道会有多少个模板实例,因此无法在虚表中预留位置。如果允许一个虚函数是模板函数,那么编译器就需要在解析这个类之前扫描所有的代码,找出这个模板成员函数的调用(实例化),然后才能确定虚表的大小,这显然是不可行的,除非改变当前编译器的工作机制。

成员变量的初始化顺序

派生类对象构造顺序严格遵循:基类构造函数(多继承则按继承声明顺序) → 成员类对象构造函数(按类内声明顺序,与参数初始化表顺序无关) → 派生类构造函数。析构顺序则完全相反。

静态成员和静态代码块最先初始化(按出现顺序),普通成员随后初始化。成员变量初始化顺序仅取决于定义顺序,与初始化列表顺序无关。const成员变量必须通过初始化列表进行初始化,static成员变量必须在类外单独初始化。

综上,变量的初始化顺序如下:

  1. 静态成员初始化(所有类)
  • 静态成员变量:在程序启动时(main函数之前)按定义顺序初始化(非声明顺序)。
  1. 基类构造阶段
  • 虚基类构造(如果有):按继承链从最顶层祖先开始(深度优先)。

  • 直接基类构造:若是多继承,则按继承声明顺序(从左到右)。

  • 基类成员变量构造:按类内声明顺序(与初始化列表顺序无关)。

  • 基类构造函数体执行。

  1. 派生类构造阶段
  • 派生类成员变量构造:按类内声明顺序。

  • 派生类构造函数体执行。

  1. 补充说明
  • 对象的虚表指针(vptr):在基类构造函数执行开始时初始化,并在进入派生类构造函数前被更新为当前类的虚表地址。当创建派生类对象时,首先调用基类构造函数。在基类构造函数的最开始,编译器插入代码将 vptr 指向当前类(基类)的虚表。在基类构造函数中调用虚函数时,调用的是基类的版本(因为此时 vptr 还未指向派生类的虚表)。父类构造完后,开始构造子类,这时编译器又把 vptr 改成子类的虚函数表地址。之后调虚函数就是子类的版本了。

  • 虚函数表(vtable):在编译时生成,并在程序加载时(main函数执行前)存入内存的只读数据段(.rodata)。虚表是类级别的(每个类只有一个),而非对象级别的,为该类的所有对象共享。

  • 参数初始化表:在构造函数体执行之前,按成员变量的声明顺序初始化。进入构造函数时,先初始化所有成员变量:如果有初始化列表,按列表中的表达式初始化。如果没有初始化列表,执行默认初始化(基本类型不初始化,类类型调用默认构造函数)。然后按成员在类中的声明顺序初始化。最后执行构造函数体内的代码。

虚函数表和虚表指针

虚函数表(虚表,vtable)是针对类的,同一个类的所有对象共享同一份虚函数表。在gcc编译器中,虚函数表存放在可执行文件的只读数据段.rodata中,由编译器在编译阶段处理完成。虚函数表的内容在编译时就已经确定,是一组常量函数指针,程序运行前会直接加载到内存中。

虚函数表指针(虚表指针/虚指针,vptr)是对象相关的,作为对象的隐藏成员,其位置取决于对象的分配方式:如果对象分配在堆上,那虚指针也在堆上;如果对象分配在栈上,那么虚指针也在栈上。编译器处理派生类的虚函数表时,会先拷贝基类的虚函数表,然后替换派生类中重写的虚函数地址,最后添加派生类新增的虚函数到自身的虚函数表中。

在构造函数或析构函数中调用虚函数是不推荐的行为。基类构造函数中调用虚函数时,由于派生类尚未构造完成,虚函数会静态绑定到基类版本,无法实现多态。而基类的析构函数调用虚函数时,派生类部分已经析构,虚函数会绑定到基类版本,同样无法实现多态。因此,在构造和析构期间,虚函数的动态绑定机制会失效,没有意义。

再谈多态性

C++的多态剖析:

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

class Base {  // 基类
  public:
    // 成员变量
    int a;
    
    // 构造函数
    Base() : a(0) {}
    Base(int _a) : a(_a) {}
    virtual ~Base() {}
    
    // 基类虚函数
    virtual void print() {
        cout << a << endl;
    }
};

class Derive : public Base {  // 派生类
  public:
    // 子类成员变量
    int b;
    
    // 构造函数
    Derive() : b(1) {}
    Derive(int _b) : b(_b) {}
    Derive(int _a, int _b) : Base(_a), b(_b) {}
    virtual ~Derive() {}
    
    // 重写的虚函数
    virtual void print() override {
        cout << b << endl;
    }
    // 子类新的虚函数
    virtual void print1() {
        cout << "new virtual func" << endl;
    }
    // 子类新的成员函数
    void print2() {
        cout << "new func" << endl;
    }
};

int main() {
    // 多态实现
    Base *ptr = new Derive();
    ptr->print();  // 输出:1

    // 用基类指针访问派生类对象的成员
    cout << ptr->a << endl;  // 输出:0
    cout << ptr->b << endl;  // 报错
    ptr->print1();  // 报错
    ptr->print2();  // 报错

    return 0;
}

多态机制,是针对虚函数进行的,基类指针指向派生类对象后,可以根据不同的派生类重写的虚函数版本进行相应的实现,实现方法的多态性。

如果将派生类对象赋值给基类对象,会存在对象切片问题,派生类中超出基类的那部分数据(新增的成员变量)会被“切掉”,即只保留基类部分的信息。

编译时,派生类新增的成员对基类来说不可见。若用基类指针访问派生类自己的成员变量或调用派生类新增的成员函数,编译器在编译时会因为基类中不存在相关成员而报错。如果确实想通过基类指针访问派生类的成员,只能通过类型转换(如 dynamic_cast)的方法进行操作。

注意:基类有默认构造函数(无参构造函数)时,派生类中可以不显式调用基类的构造函数进行初始化,此时编译器会自动调用基类的默认构造函数进行基类成员变量的初始化。如果基类没有默认构造函数,派生类需要利用初始化列表(参数初始化表)显式调用基类的构造函数。

explicit关键字

explicit关键字用于修饰类的构造函数,禁止隐式类型转换。它主要适用于单参数构造函数或除第一个参数外其余参数均有默认值的构造函数,这类构造函数也被称为转换构造函数。隐式类型转换是编译器自动完成的类型转换行为,例如将int转换为double,或将派生类对象隐式转换为基类的指针/引用。通过explicit关键字,可以避免意外的隐式转换,提高代码的明确性。

int i = 3;
double j = 3.1;
i + j;  // i会被转换成double类型,然后才做加法运算

class A{};
class B: public A {};  // B是子类
void Fun(A& a) {}
B b;
Fun(b);  // 隐式类型转换:向上转型,父类型引用/指针指向子类型对象

class Test {
publicTest(int i) {}
};
// 正确,由于隐式类型转换,1先被Test构造函数构造成Test对象,然后才被赋值给t1
// 相当于:Test t1 = Test(1);
// 若Test构造函数被explicit修饰,那么就无法进行隐式类型转换了,会报错
Test t1 = 1;
// 正确,直接调用构造函数(无论构造函数是否被explicit修饰)
Test t2(1);

创建对象的细节

在C++中,创建对象有两种方式:静态建立和动态建立。

静态建立由编译器在栈上分配内存并直接调用构造函数;动态建立是通过 new 运算符在堆上分配内存,分为两步:首先调用 operator new() 分配内存,然后调用构造函数初始化对象。

operator new() 不是对 new 的重载(不要被名字误导了,不过它的存在就像是 new 的重载版本),new 操作符是 C++ 语言中的一个运算符(也是关键字),用于动态分配内存并构造对象。它是一个语法糖,封装了内存分配和对象构造的过程。

使用 new 时,编译器会自动调用 operator new() 函数来分配内存,然后在分配的内存上调用类的构造函数实例化一个对象。operator new() 是一个全局函数,返回一个指向分配内存的 void* 指针,默认实现通常会调用底层的内存分配机制(malloc),但可以重载它以提供自定义的内存分配策略。

// operator new 分配内存
void* rawMemory = operator new(sizeof(MyClass));
// placement new 创建对象
MyClass* obj = new (rawMemory) MyClass();

想在一块已经获得指针的内存里建立一个对象,就用:placement new。

使用 delete 时,首先调用对象的析构函数清理资源,接着调用标准库的全局函数 operator delete() 释放分配的内存(返还给内存池或被操作系统回收)。

使用 new[] 可以创建对象数组,若一个对象大小为\(N\),数组长度是\(K\),那么需要申请 \(K*N\) 的堆内存空间。释放数组对象的内存时,delete[] 需要处理多个对象的内存。为了正确释放数组内存,delete[] 需要知道数组的大小。通常,new[] 在分配数组内存时会在数组空间的头部额外申请一个 int 类型的空间,这4字节的空间用于存储数组的长度(K),delete[] 会使用这个信息来逐个调用析构函数(析构K次,释放 \(K*N+4\) 字节大小的内存空间)。

对于一些非内部数据类型(类对象)来说,仅使用 malloc/free 无法满足要求。对象在创建的同时要自动执行构造函数,对象在消亡的时候要自动执行析构函数,而由于 malloc/free 是库函数而不是运算符,不在编译器的控制权限内(库函数是编译好的代码,由链接器链接到我们的代码中),也就不能自动执行构造函数和析构函数。所以,在 C++ 中需要一个能完成动态内存分配和初始化工作的运算符 new,以及一个能完成清理和释放内存工作的运算符 delete。

C++ 不把 malloc/free 淘汰的原因是: C++ 程序经常要调用 C 代码,而 C 程序只能用 malloc/free 管理动态内存。如果用 free 释放 new 创建的动态对象,该对象是无法执行析构函数的,极有可能导致程序出错。如果用 delete 释放 malloc 申请的动态内存,理论上讲程序不会出错,但是可读性很差。所以 new/delete 尽量(必须)配对使用,malloc/free 也一样。

// 静态创建对象
A a;
// 动态创建对象
A *a = new A;

如果希望类只能在堆上生成对象,也就意味着不能在栈上构建对象,在栈上是编译器分配内存空间,调用构造函数来构造栈对象。栈上创建的对象当对象生命周期结束后,编译器会调用析构函数来释放栈对象所占的空间,也就是说编译器管理了对象的整个生命周期。编译器在调用构造函数为类的对象分配空间时,会先检查析构函数的访问性(不光是析构函数,编译器会检查所有非静态函数的访问性)。因此,如果类的析构函数是私有的或受保护的,编译器就不会为对象在栈上分配内存空间。

因此,可以将析构函数设为私有private或受保护protected,这样编译器就不会在栈上创建对象了。但需要注意,私有析构函数会导致new生成的对象无法直接调用delete,因为delete会调用对象的析构函数,但析构函数在类外无法访问。通常需要提供静态工厂方法或自定义销毁函数。如果需要子类析构,那就设为protected。

class A {
protected:
    A() = default;
    ~A() {}
public:
    // 构造函数受保护,外部无法创建对象
    // 因此将成员函数create设置为静态,可通过类名直接调用
    static A* create() {
        return new A();
    }
    void destory() {
        delete this;
    }
};
/* 上述代码非线程安全,需要加锁,请参考懒汉单例模式的实现。 */

只有使用 new 运算符才会在堆上创建对象。如果希望类只能在栈上生成对象,可以将 C++ 标准库提供的全局函数 operator new() 和 operator delete() 设为私有,这样 new 运算符无法被调用,就只能在栈上创建对象了。

class A {
private:
    // 重载 operator new() -> 设为私有
    void* operator new (size_t t) {}
    // 重载 operator delete() 配套使用
    void operator delete(void *ptr) {}
public:
    A() {}
    ~A() {}
}

阻止类创建对象的方法有两种:一是将类声明为抽象类,即包含纯虚函数,这样无法直接实例化;二是将构造函数设为私有,这样外部无法调用构造函数创建对象。抽象类通常用于定义接口,而私有构造函数常用于工具类或单例模式,确保对象的创建方式受控。

有哪些构造函数?

类对象被创建时,编译器为对象分配内存空间,并自动调用构造函数,由构造函数完成成员的初始化工作。

默认构造函数:如果没有人为编写构造函数,编译器会自动生成一个无参构造函数(编译器的默认行为)。

一般构造函数:用户自定义的带参数的构造函数,支持重载。当用户自定义构造函数后,编译器就不会自动生成默认构造函数了,因此用户最好也写一个无参构造函数。

拷贝构造函数:拷贝构造函数的函数参数为对象本身的引用,用于根据一个已存在的对象复制出一个新的该类的对象。一般在函数中会将已存在对象的数据成员的值一一复制到新创建的对象中。如果没有显式的写拷贝构造函数,系统会默认创建一个拷贝构造函数进行浅拷贝。所以当类中有指针成员时,不要使用编译器提供的默认拷贝构造函数,最好自己定义并且在函数中执行深拷贝。

移动构造函数:有时候我们会遇到这样一种情况,我们用对象a初始化对象b后,对象a就不再使用了,但是对象a的空间还在(在析构之前)。既然拷贝构造函数是把a对象的内容复制一份到b中,那么为什么我们不能直接使用a的空间呢?这样就避免了新的空间分配,大大降低了构造的成本。这就是移动构造函数设计的初衷。拷贝构造函数中,对于指针我们一定要采用深层复制,而移动构造函数中,对于指针采用浅层复制。浅层复制之所以危险,是因为两个指针共同指向一片内存空间。若第一个指针将其释放,另一个指针的指向就不合法了(悬空指针)。所以我们只要保证空间只释放一次就可以:将对象a的指针置空,析构a的时候(有判空语句)就不会回收其指向的空间,在析构b的时候再回收(是同一块空间)。通过"窃取"临时对象a的资源来提升效率。

赋值运算符重载:类似拷贝构造函数但不属于构造函数的范畴,实现对象间的赋值操作。注意:并不是出现 = 就是拷贝构造或赋值操作,如:

A a1;
A a2(a1);  // 直接调用拷贝构造函数
A a2 = a1;  // 直接调用拷贝构造函数

A a1, a2;
a1 = a2;  // 拷贝赋值操作

类型转换构造函数:支持隐式转换,但可用explicit关键字禁止。一般来说带一个参数的构造函数,或者除第一个参数之外的其他参数都指定默认值的构造函数,也叫类型转换构造函数。

委托构造函数:C++11的新特性,构造函数调用同类其他构造函数(委托构造)。

构造函数/析构函数能否抛出异常?

对象只有在构造函数执行完成之后才算构造妥当,C++只会析构已经完成的对象。因此如果构造函数中发生异常,控制权就需要转移出构造函数,执行异常处理函数。在这个过程中系统会认为对象没有构造成功,导致不会调用析构函数。

在构造函数中抛出异常会导致当前函数执行流程终止,在构造函数流程前构造的成员对象会被释放,但是如果在构造函数中申请了内存操作,则会造成内存泄漏。使用智能指针管理内存资源可以解决这个问题。

另外,如果有继承关系,派生类中的构造函数抛出异常,那么基类的构造函数和析构函数是可以照常执行的。

C++标准指明析构函数不能、也不应该抛出异常。C++的异常处理模型最大的特点和优势就是对C++中的面向对象提供了强大的无缝支持。如果对象在运行期间出现了异常,C++异常处理模型有责任清除那些由于出现异常所导致的已经失效了的对象(也即对象超出了它原来的作用域),并释放对象原来所分配的资源(调用这些对象的析构函数来完成释放资源的任务)。所以从这个意义上说,析构函数已经变成了异常处理的一部分。

析构函数不能抛出异常原因有两个:

  1. 如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成资源泄漏的问题。

  2. 异常发生时,C++的异常处理机制在异常的传播过程中会进行栈展开(stack-unwinding)。在栈展开的过程中会调用已经在栈中构造好的对象的析构函数来释放资源,此时若其他析构函数本身也抛出异常,则前一个异常尚未处理又出现新的异常,会造成程序崩溃。

解决办法:把异常完全封装在析构函数内部,决不让异常抛出函数之外。

智能指针

C++ 标准库中提供了用于智能指针创建的函数:make_uniquemake_shared,包含在头文件 <memory> 中。

unique_ptr

常规使用:

unique_ptr<int> ptr(new int(10));  // 创建一个指向整数 10 的 unique_ptr

推荐使用:

unique_ptr<int> ptr = make_unique<int>(10);  // 创建一个指向整数 10 的 unique_ptr

注:make_unique 是 C++14 开始支持的用法。

unique_ptr 是一种独占所有权的智能指针,它不允许拷贝构造,但支持移动语义 move,这意味着同一时刻只能有一个 unique_ptr 指向给定的资源,避免了多个指针指向同一块内存的情况,减少了内存泄漏的风险。

auto ptr1 = make_unique<int>(10);
unique_ptr<int> ptr2 = move(ptr1);  // 转移所有权给 ptr2,此时 ptr1 为空
unique_ptr<int> ptr2 = ptr1;  // 错误,单一所有权不可复制

shared_ptr

shared_ptr 是一种引用计数智能指针,它允许多个指针拥有同一个对象(共享所有权)。当最后一个拥有该对象的 shared_ptr 被销毁时,它会自动释放所指向的内存。使用 std::make_shared 可以高效地创建一个 shared_ptr 实例。

make_shared 通过一次性分配内存来存储控制块和对象(在堆上开辟连续空间,一次性为对象和控制块分配空间),减少了内存分配的次数,而手动创建 shared_ptr(使用 new)需要两次内存分配:一次用于对象本身,一次用于控制块(用于引用计数等)。如果在第一次分配内存(用于对象)后,第二次分配内存(用于控制块)失败,或者构造函数抛出异常,可能会导致第一次分配的内存无法被释放(未成功构造,就不会析构,或者发生异常后跳转异常处理代码,如果没有手动释放则会跳过第一次分配的内存释放),从而导致内存泄漏。

// 不推荐╳✘✗
shared_ptr<int> ptr(new int(20));

// 推荐✓✔
shared_ptr<int> ptr = make_shared<int>(20);
cout << *ptr << endl;  // 输出:20

// 可以共享所有权
auto ptr1 = make_shared<int>(10);
cout << ptr1.use_count() << endl;  // 获取引用计数,输出:1
shared_ptr<int> ptr2 = ptr1;  // ptr2 也指向 ptr1 指向的空间
cout << *ptr2 << endl;  // 输出:10
cout << ptr1.use_count() << endl;  // 获取引用计数,输出:2

weak_ptr

weak_ptr 是为了配合 shared_ptr 而引入的一种智能指针,主要用于解决 shared_ptr 循环引用的问题。weak_ptr 指向一个由 shared_ptr 管理的对象而不影响所指对象的生命周期。将一个 weak_ptr 绑定到一个 shared_ptr 不会改变 shared_ptr 的引用计数,因此可以用来打破潜在的循环引用,防止内存无法释放。

weak_ptr 用来表达临时所有权的概念:当某个对象只有存在时才需要被访问,而且随时可能被他人删除时,可以使用 std::weak_ptr 来跟踪该对象。与 shared_ptr 不同,weak_ptr 不能直接访问所指向的对象。要访问对象,需要先调用 lock() 方法将其转换为 shared_ptr。如果所指向的对象已经被销毁,则 lock() 方法返回空指针。

weak_ptr 只可以从一个 shared_ptr 或另一个 weak_ptr 对象构造,它的构造和析构不会引起引用记数的增加或减少。弱引用能检测到所管理的对象是否已经被释放,从而避免访问非法内存。

// 创建一个 shared_ptr 管理一个 int
auto sp = make_shared<int>(42);
cout << *sp << endl;  // 42  

// 创建一个 weak_ptr 指向 shared_ptr
weak_ptr<int> wp = sp;
cout << *wp.lock() << endl;  // 42

// weak_ptr 不会增加引用计数
cout << sp.use_count() << endl;  // 1

原子操作

C++11 标准引入了原子操作,在头文件 <atomic> 中,std::atomic 是 C++ 标准库中的一个模板类,用于对单个变量进行原子操作。它支持多种数据类型,包括基本数据类型(如 int、float、double 等)和指针类型。

定义一个原子变量(保证多线程环境中对该变量的访问和修改都是原子的,即线程安全的)并使用:

atomic<bool> a_z(true);
atomic<int> a_y;  // 默认:0

atomic<int> a_x{4};
cout << a_x << endl;  // 4

// load 加载:获取原子变量的值
int value = a_x.load();
cout << value << endl;  // 4

// store 存储:设置原子变量的值
a_x.store(10);
value = a_x.load();
cout << value << endl;  // 10

// exchange 交换:将原子变量的值与另一个值交换
int old_value = a_x.exchange(100);  // 返回旧值
value = a_x.load();
cout << old_value << ' ' << value << endl;  // 10 100

// 原子运算
a_x.fetch_add(10);  // 原子加
a_x.fetch_sub(5);  // 原子减
value = a_x.load();
cout << value << endl;  // 105

排序算法

总结七大排序算法。

冒泡排序

稳定,时间复杂度O(n^2),比较和移动次数可能都很多。

// 冒泡排序(优化版)
void bubbleSort(vector<int>& v) {
    // 定义标记,当某一轮都没有元素移动时,说明已经有序了,无需进行后面的遍历比较了
    bool flag = true;
    // flag为false则退出循环
    for (int i = 0; i < v.size()-1 && flag; i++) {
        flag = false;
        // 从后往前进行两两相邻元素比较,可以尽可能地将小元素向前移动,会提高效率
        for (int j = v.size()-1; j >= i; j--) {
            // 不断把小的交换到j的位置,直到与i进行比较,最小元素"冒泡"到最左边,升序排序
            if (v[j] > v[j+1]) {
                swap(v[j], v[j+1]);
                flag = true;  //只要有交换,就继续遍历下一轮
            }
        }
    }
}

简单选择排序

不稳定,时间复杂度O(n^2),由于交换次数相对较少,性能略优于冒泡。

// 简单选择排序
void selectSort(vector<int>& v) {
    for (int i = 0, mi; i < v.size()-1; ++i) {
        mi = i;  //记录待排序区最小值的索引
        for (int j = i+1; j < v.size(); ++j) {
            if (v[mi] > v[j]) {  //找待排序区的最小值
                mi = j;
            }
            if (mi != i) {  //只要最小值与当前i不同,就与i交换,若相同就不交换(最小化交换次数)
                swap(v[mi], v[i]);  //i代表待排序区的最开头,每次将最小值选出,与i交换,进行升序排序
                // i与mi交换,mi可能是任意位置,次序会乱,即不稳定
            }
        }
    }
}

直接插入排序

稳定,时间复杂度O(n^2),比较和移动次数在概率上相近/固定,性能略优于冒泡和选择。当记录本身基本有序(小的基本在前、大的基本在后),或者记录数较少时,直接插入排序的优势较明显。

// 直接插入排序
void insertSort(vector<int>& v) {
    // 默认首元素是已排序元素,已排序区长度为1,未排序区从第二个元素开始
    for (int i = 1, j; i < v.size(); i++) {
        j = i;
        while (j > 0 && v[j] < v[j-1]) {
            swap(v[j], v[j-1]);
            j--;
        }
    }
}

希尔排序

不稳定,时间复杂度不固定,取决于gap序列,一般介于O(nlogn) ~ O(n^2)之间,如\(n^{1.5}\)。当记录数很多时,对其分组,每组记录数变少,在各子序列中用直接插入排序,然后全局再用一次。相当于直接插入的升级版。

// 希尔排序
void shellSort(vector<int>& v) {
    int len = v.size();
    
    // 定义间隔(经典gap序列,从大到小,每次是长度向下除以2,直到1,复杂度n^2)
    int gap = len / 2;

    // 也可以定义其他缩小规则,这里使用Knuth序列,复杂度是O(n^(3/2)),性能更优
    int gap = 1;
    while (gap < len / 3) {
        gap = 3 * gap + 1;
    }

    // 希尔排序的最后一步必须是gap=1,否则无法保证完全有序
    while (gap >= 1) {
        // 从gap处开始遍历,每次与 v[i-gap] 比较,就是在与该分组子序列中前面的元素比较
        for (int i = gap; i < len; i++) {
            // 每个子序列使用直接插入排序的思想
            int j = i;
            // j<gap说明j到该分组的首元素位置了,gap下标之前的元素,分别是当前各个分组的首元素
            // 相当于在分组区间(子序列)中应用直接插入排序算法,所以只是判断条件变一变
            while (j >= gap && v[j] < v[j-gap]) {
                swap(v[j], v[j-gap]);
                j -= gap;
            }
        }

        // 一轮希尔排序结束,按照既定规则缩小gap
        gap /= 3;
    }
}

堆排序

不稳定,时间复杂度稳定为O(nlogn),简单选择排序的升级版,性能强于冒泡、简单选择和直接插入。利用大顶堆排序,每次弹出堆顶元素(最大值),与待排序序列末尾元素交换(最大值在最后,是升序排序),然后"去掉"最后的已排序序列,对剩余元素重构大顶堆……如此反复执行。由于初始构建堆需要的比较次数较多,不适用于待排序序列个数较少的情况。

// 堆排序
void heapSort(vector<int>& v) {
    int n = v.size();  //元素个数
    if (n <= 1) {
        return;  //v为空或只有一个值,不需要排序
    }
    
    // 堆排序Stage1:建堆阶段,由于初始数组是无序的,必须确保所有子树都满足堆的性质
    // 叶子节点没有孩子,所以从第一个非叶子节点开始,从下往上逐层构造大顶堆
    // 根据构建大顶堆函数可知,每次构建只比较当前层的父节点与孩子节点大小,然后向下递归
    // 所以必须保证自底向上构造,才能将最大值向上传递,确保每棵子树都是堆
    for (int i = n/2-1; i >= 0; i--) {
        buildHeap(v, i, n-1);  //传入数组尾元素下标,左闭右闭用于判断是否到叶子节点了
    }

    // 堆排序Stage2:排序阶段,只需从上往下调整(从根节点开始)修复破坏的部分即可
    // 构建好大顶堆后,可以进行正儿八经的堆排序了
    // j控制待排序序列,j指向待排序序列的末尾元素
    for (int j = n-1; j >= 0; j--) {
        // 交换堆顶元素与待排序区间的尾元素,让最大值放在末尾,下次循环j--更新待排序区间
        swap(v[0], v[j]);
        // 将剩余元素重构大顶堆,由于事先已经构造了大顶堆,数据有序(除了根节点,其他子树已经是堆)
        // 把尾元素换上来只有根节点被破坏了(原根节点换到尾部,是有序区,不参与构建堆进行排序了)
        // 因此只需要自顶向下修复根节点即可
        buildHeap(v, 0, j-1);
    }
}

// 堆排序辅助函数:构建大顶堆(堆就是一棵特殊的完全二叉树,根据节点值的有序性分为大顶堆和小顶堆)
// 若父节点下标是i,则左孩子节点下标是2*i+1,右孩子是2*i+2(注意判空),最后一个非叶子节点下标是n/2-1
void buildHeap(vector<int>& v, int i, int len) {
    int root = i;  //定义堆顶"指针",应该存放最大值
    int leftChild = 2 * i + 1;  //左孩子节点下标
    int rightChild = 2 * i + 2;  //右孩子节点下标
    // 先判空:len是末尾元素下标(防止超界),比较节点值让堆顶元素指向最大值
    if (leftChild <= len && v[root] < v[leftChild]) {
        root = leftChild;
    }
    if (rightChild <= len && v[root] < v[rightChild]) {
        root = rightChild;
    }
    // 若需要,则交换
    if (root != i) {
        swap(v[root], v[i]);
        // 交换后,子树节点排布改变了,需要对子树重新按大顶堆排序
        buildHeap(v, root, len);  //递归构造子树大顶堆,传入子树的根节点下标root
    }
    // 到这里则结束递归,递归终止条件:到叶子节点了,或不需要交换(当前层父节点比孩子节点的值大)
}

归并排序

稳定,时间复杂度稳定在O(nlogn),空间复杂度较高。思想:分治,每次将子序列一分为二,直到分割为单个元素(必定有序),再两两归并为一个有序序列。归并排序是一种比较占用内存,但效率高且稳定的排序算法,这里的是递归实现,实际工程应用中优先考虑非递归实现。

// 归并排序
void mergeSort(vector<int>& v, int left, int right) {
    // 递归终止条件
    if (left >= right) {
        return;
    }

    int mid = left + (right - left) / 2;
    // 递归开始
    mergeSort(v, left, mid);  //递归分解左区间
    mergeSort(v, mid+1, right);  //递归分解右区间
    // 递归到最底层后(单个元素为一组),两两合并,然后返回
    // 每次返回之前都会归并区间,先左后右,直到最后一层归并左右半区间后得到完整的有序序列
    merge(v, left, mid, right);  //归并左右区间
}

// 归并排序辅助函数:将有序的两个分组合并/归并为一个
// 将有序的 v[i,...,m] 和 v[m+1,...,n] 归并为一个有序的 v[i,...,n]
void merge(vector<int>& v, int i, int m, int n) {
    // 临时存放归并的结果
    vector<int> record{};
    record.reserve(n - i + 1);

    // j遍历前一个有序子区间,k遍历后一个有序子区间
    int j = i, k = m + 1;

    // 归并两个子序列,"双指针法",小值先放,升序排列
    while (j <= m && k <= n) {
        if (v[j] <= v[k]) {
            record.push_back(v[j++]);
        } else {
            record.push_back(v[k++]);
        }
    }

    // 比较归并后会有遗漏(j或k中的一方遍历完超界了另一方还没完),需要处理剩余的数据(另一方)
    while (j <= m) {
        record.push_back(v[j++]);
    }
    while (k <= n) {
        record.push_back(v[k++]);
    }

    // 利用下标原地更新相应的归并区间
    // 一般情况下,只读访问建议写成:for(const auto& c: xxx),此处是基本数据类型,拷贝成本低
    for (auto c: record) {
        v[i++] = c;
    }
}

快速排序

不稳定,平均时间复杂度是n(logn),是冒泡排序的升级版(属于交换排序)。快排有各种优化版本,优化后的综合性能最好,这里给出的是部分优化的结果。

// 快速排序
void quickSort(vector<int>& v, int low, int high) {
    // 递归终止条件
    if (low >= high) {
        return;
    }

    // 枢轴,即一个关键字/基准值,想办法让其左边的值都比它小,右边的值都比它大,是快排的关键所在
    // 这里直接取区间首元素作为枢轴,可以优化为三数取中或九数取中
    int pivot = v[low];

    int i = low, j = high;

    // 下标有效,就找枢轴并将其放在正确位置,i==j就说明指针重合了,已经遍历完了
    while (i < j) {
        // "分区"排序,将待排序数组划分成两部分,将小于pivot的数全移到左边,大于pivot的数全移到右边
        // 枢轴定的首元素,那么要先遍历j(从右向左遍历)
        while (i < j && v[j] >= pivot) {
            // 要找第一个小于基准的值,如果j指向的元素>=基准,那就继续找
            j--;
        }
        while (i < j && v[i] <= pivot) {
            // 要找第一个大于基准的值,如果i指向的元素<=基准,那就继续找
            i++;
        }

        // 当前处i指向的元素是大于基准的,j指向的元素是小于基准的
        if (i < j) {
            // 交换元素值,使其符合排序规则:小的在左,大的在右
            swap(v[i], v[j]);
        }
    }

    // i和j相遇,正常情况下,j停留的位置是第一个小于pivot的位置,i是第一个大于pivot的位置
    // i==j的两种可能之一:j--后j==i,j是先执行的,说明此时i往后(i肯定<=枢轴,上次交换的结果)全是大于枢轴的
    // i==j的两种可能之二:i++后i==j,此时已经寻找过j了,i<j成立说明j肯定<=枢轴,j往前也全是小于枢轴的
    // 交换枢轴与相遇点的值,就可以正确放置枢轴了,然后整个区间就"基本"有序(小的全在枢轴左边,大的全在枢轴右边)
    v[low] = v[i];
    // pivot存的是枢轴值,即交换前的v[low],i是枢轴的位置(索引/下标)
    v[i] = pivot;

    // 递归,对分区后的左右子序列进行上述排序操作(不包含枢轴,因为枢轴本身位置是有序的)
    quickSort(v, low, i-1);
    quickSort(v, i+1, high);
}

码农日记 · 叁
https://afly36-swordsman.github.io/2025/04/19/Programming04/
Author
Zenitsu
Posted on
April 19, 2025
Updated on
June 7, 2025