C++重点知识总结

前言

比较具体的示例都在我前面的文章中,这里主要是用文字阐述重点的内容。

基础语法

函数重载

函数重载:是函数的一种特殊情况,C++允许在同一作用域中声明几个功能类似的同名函数,这些同名函数的形参列表(参数个数 或 类型 或 类型顺序)不同,常用来处理实现功能类似数据类型不同的问题。

调用时,编译器会根据传入的实参自动匹配最匹配的同名函数执行。

注意:

值参数的 const,不构成重载:

cpp 复制代码
void fun(int a) {}
void fun(const int a) {} // 编译错误!值参数const无意义,视为同一函数

C 语言不支持函数重载,而 C++ 支持,核心原因是编译器对函数名的处理方式不同:

  • C 语言:编译器直接使用原函数名作为符号表中的名字(比如add就是add),同名函数会导致符号冲突,因此不支持重载;
  • C++ 语言:编译器会根据函数名 + 参数列表,对原函数名进行名字修饰(Name Mangling),生成唯一的符号名,同名但参数不同的函数,修饰后的名字不同,因此不会冲突。

函数重载的匹配规则

用重载函数时,编译器会按优先级从高到低匹配,匹配失败则编译报错(歧义调用),优先级如下:

  1. 精确匹配:实参类型与形参类型完全一致(最高优先级);
  2. const/volatile 转换:非 const 实参匹配 const 形参(比如非 const 变量传给 const&);
  3. 隐式类型转换:实参做基础类型的隐式转换(int→float,char→int 等,低优先级);
  4. 模板匹配:如果有模板函数,会生成匹配的模板实例(比隐式转换优先级高,后续讲模板时会补充)。

歧义调用:

cpp 复制代码
void fun(int a, float b) {cout << "int-float" << endl;}
void fun(float a, int b) {cout << "float-int" << endl;}

int main() {
    fun(1, 2); // 编译错误!1→int/float,2→int/float,两个重载都能匹配,无最优解
    return 0;
}

函数重载易错点

  1. 作用域不同,不构成重载
    重载的前提是同一个作用域,不同作用域的同名函数是隐藏 / 覆盖,不是重载(比如全局函数和类的成员函数,类的基类和派生类成员函数):
cpp 复制代码
// 全局作用域
void fun(int a) {cout << "全局int" << endl;}

namespace N {
    // 命名空间作用域,和全局不是同一作用域
    void fun(float a) {cout << "命名空间float" << endl;}
}

int main() {
    fun(10);        // 调用全局fun(int)
    N::fun(10.5f);  // 调用命名空间fun(float)
    fun(10.5f);     // 全局无float版本,int隐式转float,调用全局fun(int)
    return 0;
}
  1. 缺省参数(默认参数)可能导致歧义
cpp 复制代码
void fun(int a) {cout << "int" << endl;}
void fun(int a, int b = 10) {cout << "int-int" << endl;}

int main() {
    fun(10); // 编译错误!既可以匹配fun(int),也可以匹配fun(int,10)
    return 0;
}
  1. extern "C" 修饰的函数,不能重载
  2. 类的成员函数,const 修饰符可构成重载
  3. 重载和重写(覆盖)、隐藏的区别?
  • 重载:同一作用域、同名、参数列表不同,编译器期匹配;
  • 重写(覆盖):基类和派生类、同名、参数列表相同、基类函数加virtual,运行期多态匹配;
  • 隐藏:不同作用域(基类 / 派生类、全局 / 局部)、同名,无论参数列表是否相同,都会隐藏外层作用域的函数,编译器期匹配。

引用和指针

指针是一个「变量」,它的值是另一个变量的内存地址,通过这个地址可以间接访问目标变量。

  • 指针本身占用内存(32 位系统占 4 字节,64 位系统占 8 字节);
  • 指针可以指向不同的变量(地址可修改),也可以指向NULL(空指针,不指向任何有效内存);
  • 访问目标变量需要用解引用运算符*。

引用(Reference)

引用是一个变量的「别名」,它和目标变量共享同一块内存空间,对引用的操作等价于对目标变量的直接操作。

  • 引用本身不占用独立内存(底层实现虽用指针,但语法上视为别名,无独立地址);
  • 引用必须在定义时初始化,且一旦绑定某个变量,终身不能再绑定其他变量(引用的指向不可变);
  • 访问目标变量无需解引用,直接用引用名即可(语法上和普通变量一致)。

引用和指针的不同

  1. 引用概念上定义一个变量的别名,指针存储一个变量地址。
  2. 引用在定义时必须初始化,指针没有要求
  3. 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体
  4. 没有NULL引用,但有NULL指针
  5. 在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32位平台下占4个字节)
  6. 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小
  7. 有多级指针,但是没有多级引用
  8. 访问实体方式不同,指针需要显式解引用,引用编译器自己处理
  9. 引用比指针使用起来相对更安全

右值引用

右值引用是C++11他的出现解决了

临时常量拷贝开销大的问题。后续讲到移动构造再细谈。

内联函数

以inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数调用建立栈帧的开销,内联函数提升程序运行的效率。

  1. inline是一种以空间换时间的做法,如果编译器将函数当成内联函数处理,在编译阶段,会用函数体替换函数调用,缺陷:可能会使目标文件变大,优势:少了调用开销,提高程序运行效率。
  2. inline对于编译器而言只是一个建议,不同编译器关于inline实现机制可能不同,一般建议:将函数规模较小(即函数不是很长,具体没有准确的说法,取决于编译器内部实现)、不是递归、且频繁调用的函数采用inline修饰,否则编译器会忽略inline特性。
  3. inline不建议声明和定义分离,分离会导致链接错误。因为inline被展开,就没有函数地址了,链接就会找不到。

为什么建议用内联函数代替宏函数

核心原因是内联函数保留了宏函数 "消除调用开销" 的优势,同时解决了宏函数的类型不安全、语法坑多、不支持 C++ 特性等致命缺陷

  1. 宏函数是预处理文本替换,无类型检查、不遵循运算符优先级、参数可能多次求值,易引入隐藏 bug;
  2. 内联函数是编译期的函数优化,具备强类型检查、遵循 C++ 语法规则,无宏函数的语法坑;
  3. 内联函数支持 C++ 的所有特性(类、模板、引用等),调试友好,而宏函数不支持 C++ 特性,难以调试;
  4. 模板 + 内联可实现类型安全的泛型,替代宏函数的泛型效果。

nullptr的引入

nullptr是 C++ 为「空指针」设计的专属常量,它解决了 C 语言中NULL作为空指针带来的类型歧义、重载决议失效等问题,让空指针的表示更类型安全、语义更明确,是现代 C++ 替代NULL的标准做法。

NULL实际上就是宏定义的0,在函数重载和类型判定上有一定歧义。

  1. 在使用nullptr表示指针空值时,不需要包含头文件,因为nullptr是C++11作为新关键字引入的。
  2. 在C++11中,sizeof(nullptr) 与 sizeof((void*)0)所占的字节数相同。
  3. 为了提高代码的健壮性,在后续表示指针空值时建议最好使用nullptr。

类和对象

面向对象和面向过程

讲到类和对象就绕不开面向对象。

  1. 面向过程和面向对象的核心区别是什么?
    核心区别在于核心思想和组织方式:
  • 面向过程以过程 / 函数为核心,关注 "怎么一步步做",数据和函数分离,程序是函数的线性调用;
  • 面向对象以对象 / 类为核心,关注 "谁来做",数据和函数封装在一起,程序是对象之间的交互协作;
  • 面向对象拥有封装、继承、多态三大特性,而面向过程没有,这是两者最关键的技术差异。
  1. 面向对象的三大特性是什么?各自的作用是什么?
    封装、继承、多态是 OOP 的三大支柱:
  • 封装:把属性和方法封装在类中,隐藏实现细节,暴露统一接口,提高数据安全性,降低耦合度;
  • 继承:让子类复用父类的代码,减少冗余,提高开发效率;
  • 多态:同一方法作用在不同对象上产生不同结果,提高代码扩展性,符合开闭原则。

类和结构体的区别

  1. 成员的默认访问控制权限不同
  • struct:成员(变量 / 函数)的默认权限是public(公有),外部可以直接访问;
  • class:成员的默认权限是private(私有),外部不能直接访问,只能通过公有成员函数访问。
  1. 继承时的默认继承方式不同
    当使用A : B的形式继承时,默认的继承权限由子类(派生类)的类型决定(而非父类):
  • 子类是 struct:默认是public 继承(公有继承);
  • 子类是 class:默认是private 继承(私有继承)。

类大小的计算

计算类的大小和计算结构体类似,都需要内存对齐。但我们还需要排除一一部分不计入对象内存空间的成员:

  1. 成员函数(普通成员函数、静态成员函数、内联函数):所有对象共享一份函数代码,存储在代码段,不占用对象的堆 / 栈内存;
  2. 静态成员变量:属于类本身(而非对象),存储在全局 / 静态存储区,被所有对象共享,不占用单个对象的内存;
  3. 访问控制符(public/protected/private):编译器编译时会忽略,仅做语法检查,不占用内存;
  4. 空的代码块 / 注释:无实际内存意义,编译器会优化掉。

最后:类的大小 = 所有非静态成员变量的大小之和 + 内存对齐的填充字节 + 虚函数 / 继承带来的额外开销(如虚函数表指针)

虚函数部分后面会细谈。

  • 普通类的大小 = 非静态成员变量的大小之和 + 内存对齐填充
  • 空类的大小是1字节,核心是为了让不同的对象有唯一的地址:
cpp 复制代码
class Solution {
public:
	void test() {
		cout << "hello world" << endl;
	}
};
int main() {
	Solution s;
	s.test();
	cout << sizeof(s) << endl;
}
复制代码
hello world
1
  • 虚函数是 C++ 实现多态的核心,编译器为了支持虚函数,会为包含虚函数的类的每个对象,添加一个虚函数表指针(vptr)------ 这是一个隐藏的指针成员,占用4 字节(32 位系统)/8 字节(64 位系统),会直接影响类的大小。

易错

  1. 含虚函数的空类大小是多少?(x64环境)
    8 字节;含虚函数的类会生成一个虚表指针(64 位 8 字节,32 位 4 字节),作为对象的隐藏成员,此时类不再是 "纯空类",大小为虚表指针的大小(无需 1 字节占位符)。
  2. 继承空基类的子类,大小是否会增加 1 字节?
    不会;主流编译器均支持空基类优化(EBO),子类会复用空基类的 1 字节占位符,不会继承该占位符,因此继承空基类不会增加子类的大小(除非子类无任何非静态成员)。

this指针

C++编译器给每个"非静态的成员函数"增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有"成员变量"的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。

this指针只是参数,不是成员变量,不存储在类中哦。

this指针的一些特性:

  1. this指针的类型:类类型* const,即成员函数中,不能给this指针赋值。
  2. 只能在"成员函数"的内部使用
  3. this指针本质上是"成员函数"的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针。
  4. this指针是"成员函数"第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递

常见问题

  1. this 指针是什么?它的作用是什么?
    this 指针是非静态成员函数中隐藏的常量指针,类型为类名* const this;作用是让成员函数知道自己正在操作哪个对象的成员,实现同一个类的不同对象调用成员函数时,精准访问自身的属性和方法。
  2. 静态成员函数为什么没有 this 指针?
    静态成员函数属于类本身,不依赖于具体的对象,可通过类名::函数名直接调用,无 "当前对象" 可言;而非静态成员函数依赖于对象调用,编译器需要通过 this 指针标识当前对象,因此静态成员函数中不会添加 this 指针。
  3. const 成员函数的 this 指针类型是什么?为什么 const 成员函数不能修改对象成员?
    const 成员函数的 this 指针类型为const 类名* const this;
    该类型中第一个const修饰指针指向的对象,表示对象的成员为只读,因此不能修改对象的非静态成员(除非成员被mutable修饰)。
  4. this 指针可以为空吗?空指针调用成员函数会崩溃吗?
    语法上 this 指针可以为空(对象指针为空时,调用非静态成员函数,this 就会为空);
    空指针调用成员函数不一定崩溃:若函数内部未通过 this 访问对象的成员变量 / 调用其他访问成员的函数,程序正常执行;若访问成员,会触发空指针解引用,程序崩溃。
cpp 复制代码
class A
{
public:
	void Print()
	{
		cout << "Print()" << endl;
	}
private:
	int _a;
};
int main()
{
	A* p = nullptr;
	p->Print();
	return 0;
}
复制代码
Print()
cpp 复制代码
class A
{
public:
    void PrintA()
    {
        cout << _a << endl;
    }
private:
    int _a;
};
int main()
{
    A* p = nullptr;
    p->PrintA();
    return 0;
}
  1. this 指针存储在哪个内存区域?
    this 指针是非静态成员函数的隐式形参,和普通函数的形参一样,存储在栈区,函数调用结束后,形参(包括 this)会被栈帧销毁。

八个默认成员函数

类的八个默认成员函数:构造函数、析构函数、拷贝构造、赋值重载、移动构造、移动赋值、取地址重载、const取地址重载

最后两个通常不会重写。

  1. 构造函数

构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。

无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。

若程序员未显式实现任何构造函数,编译器会生成默认无参构造函数,其默认行为:

对内置类型成员(int/char*/ 指针等):不做初始化(值为随机值);

对自定义类型成员(如string _s;):调用其无参构造函数完成初始化。



这里是内置类型的初始化是编译器优化行为。

关键知识点:

  • 初始化列表:类成员的唯一初始化位置,比函数体内赋值更高效(避免先默认构造再赋值);
  • explicit 关键字:修饰单参构造函数,禁止隐式类型转换,提升代码安全性;
  • 若显式实现了任意构造函数,编译器不再生成默认无参构造
  1. 析构函数

通常来说析构函数、拷贝构造、赋值重载三者是绑定的,只有需要深拷贝的或者申请了资源的,才需要这三。C++11之后,和他们绑定又多了移动构造和移动赋值。

必须显式实现:类包含动态分配的资源(如new的堆内存、文件句柄、网络套接字),默认析构不会释放这些资源,会导致内存泄漏。

关键知识点:

  • 析构函数的执行顺序:先析构子类,再析构父类(继承场景);先析构后创建的对象(栈对象,后进先出);
  • 堆对象(new Person)需手动调用delete才会触发析构,否则内存泄漏;
  • 若类中只有内置类型(无动态资源),无需显式实现析构(编译器默认版本足够)。
  1. 拷贝构造

拷贝构造需要注意的是唯一参数是当前类的 const 左值引用(const 类名& obj),不可缺省;

如果传值就会无限递归。

浅拷贝对包含动态资源的类是致命的:源对象和新对象的指针成员会指向同一块堆内存,导致两个问题:

  • 双重释放:对象销毁时,两个对象的析构函数会先后释放同一块内存,触发程序崩溃;
  • 资源共享:修改一个对象的指针成员,另一个对象的成员也会被修改(因为指向同一块内存)。

所以需要深拷贝的类必须实现拷贝构造。

  1. 赋值运算符重载函数

需要注意的是返回值:当前类的左值引用(类名&),支持链式赋值(如p3 = p2 = p1;):

cpp 复制代码
// 赋值运算符重载:返回Person&,参数const Person&
Person& operator=(const Person& obj) {
    cout << "Person& operator=(const Person&):赋值重载(深拷贝)" << endl;
    // 1. 自赋值判断:若自己给自己赋值,直接返回*this
    if (this == &obj) {
        return *this;
    }

    // 2. 释放当前对象原有动态资源,避免内存泄漏
    if (_name) {
        delete[] _name;
        _name = nullptr;
    }

    // 3. 深拷贝:重新分配内存,拷贝源对象数据
    _name = new char[strlen(obj._name) + 1];
    strcpy(_name, obj._name);
    _age = obj._age;

    // 4. 返回*this,支持链式赋值
    return *this;
}

移动拷贝和移动构造就是swap两者即可。

默认函数生成规则

  1. 若程序员显式实现了以下任意一个函数,编译器不再生成默认的拷贝构造和赋值重载:
    构造函数;
    析构函数;
    拷贝构造;
    赋值重载。
  2. 若程序员显式实现了以下任意一个函数,编译器不再生成默认的移动构造和移动赋值:
    拷贝构造;
    赋值重载;
    析构函数;
    移动构造;
    移动赋值。

常见问题

  1. 构造函数和析构函数的特性有哪些?
  • 构造函数:与类名相同,无返回值,可重载,对象创建时调用,用于初始化成员;显式实现构造后,编译器不再生成默认无参构造;
  • 析构函数:~+ 类名,无返回值,不可重载,对象销毁时调用,用于释放资源;析构顺序:子类→父类,后创建→先析构。
  1. 浅拷贝和深拷贝的区别?什么情况下需要深拷贝?
  • 浅拷贝:直接拷贝成员变量的值,源对象和新对象共享动态资源,会导致双重释放、资源共享;
  • 深拷贝:为新对象重新分配独立的动态资源,拷贝资源数据,源对象和新对象资源相互独立;
  • 需要深拷贝的场景:类包含动态分配的资源(如 new 的堆内存、文件句柄)。
  1. 拷贝构造和赋值运算符重载的核心区别?
  • 拷贝构造:用已有对象初始化新对象,新对象尚未创建,无需判断自赋值,无返回值;
  • 赋值重载:用已有对象给已存在对象赋值,目标对象已初始化,必须判断自赋值,返回 * this 支持链式赋值。

初始化列表

构造函数中可以使用初始化列表来初始化对象,其中有一些细节需要注意。

基础语法

cpp 复制代码
类名(参数列表) : 成员1(初始值1), 成员2(初始值2), ..., 成员n(初始值n) {
    // 构造函数体(可空,仅做额外逻辑,无需再赋值成员)
}

构造函数体内的赋值(name = n; age = a;)是先创建成员(默认初始化),再赋值,而初始化列表是直接创建并初始化,效率更高;更重要的是,某些成员必须用初始化列表初始化,无法在函数体内赋值,这是语法强制要求:

  1. 初始化const常量成员
cpp 复制代码
class A {
private:
    const int num;  // const常量成员
public:
    // 必须用初始化列表给const成员赋值,函数体内赋值会报错
    A(int n) : num(n) {}
    void show() { cout << num << endl; }
};

int main() {
    A a(10);
    a.show();  // 输出:10
    return 0;
}
  1. 初始化引用成员
cpp 复制代码
class B {
private:
    int& ref;  // 引用成员
public:
    // 初始化列表绑定引用到外部变量x
    B(int& x) : ref(x) {}
    void change() { ref++; }  // 修改引用的变量
};

int main() {
    int a = 5;
    B b(a);
    b.change();
    cout << a;  // 输出:6(引用直接修改原变量)
    return 0;
}
  1. 初始化无默认构造的自定义类型成员
cpp 复制代码
// 自定义类C:无默认构造,只有带参构造
class C {
private:
    int x;
public:
    C(int n) : x(n) {}  // 无默认构造(默认构造是无参的C(){})
};

// 类D包含C类型的成员c
class D {
private:
    C c;  // 成员c是类C的对象,无默认构造
public:
    // 必须通过初始化列表给c传参,调用C的带参构造
    D(int n) : c(n) {}
};

int main() {
    D d(10);  // 正常创建对象,c被初始化为10
    return 0;
}

初始化列表细节:

  1. 初始化顺序:与成员在类中的声明顺序一致,与初始化列表的书写顺序无关
  2. 子类构造函数初始化父类成员
    C++ 中子类的构造函数无法直接初始化父类的成员,必须通过初始化列表调用父类的构造函数,让父类自己初始化其成员(后续继承的核心知识点):
cpp 复制代码
// 父类
class Father {
protected:
    int money;
public:
    Father(int m) : money(m) {}  // 父类带参构造
};

// 子类,继承Father
class Son : public Father {
private:
    int toy;
public:
    // 初始化列表先调用父类构造函数Father(m),再初始化自己的toy
    Son(int m, int t) : Father(m), toy(t) {}
    void show() { cout << "钱:" << money << ",玩具:" << toy << endl; }
};

int main() {
    Son s(100, 5);
    s.show();  // 输出:钱:100,玩具:5
    return 0;
}

运算符重载

C++为了增强代码的可读性 引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。

函数名字为:关键字operator后面接需要重载的运算符符号。

运算符重载不是艺术的随意发挥,要遵循一定规则:

  1. 不能发明新运算符:只能重载 C++ 已有的运算符(比如不能重载**表示幂运算);
  2. 优先级 / 结合性不变:重载后运算符的优先级(比如*比+高)、结合性(比如+从左到右)和原生一致,无法修改;
  3. 操作数个数不变:比如+是二元运算符(左右各一个操作数),重载后也必须是二元;++是一元运算符,重载后也必须是一元;
  4. 部分运算符不能重载:极少数运算符禁止重载,核心是.*、::、sizeof、?:(三目运算符)、.,这 5 个记住就行;
  5. 可以作为成员函数 / 全局函数重载:这是最关键的区别,有的运算符必须作为成员函数,有的推荐作为全局函数,后面会详细讲;
  6. 重载要符合语义:比如+重载成 "减法逻辑" 语法上没问题,但违背语义,代码可读性极差(比如p1 + p2实际是p1 - p2),开发中严禁这样做。

有些操作符重载必须作为全局函数,因为成员函数默认第一个参数是this指针,所以成员只能作为左操作数,但有时候需要作为右操作数:

cpp 复制代码
// 重载<<:ostream& 作为返回值,支持链式输出
ostream& operator<<(ostream& os, const Point& p) {
    // 自定义输出格式,比如(10,20)
    os << "(" << p.x << "," << p.y << ")";
    return os; // 必须返回os,否则无法链式输出
}

还有我们如何区分前置++和后置++。首先肯定会有人想到,前置++返回引用,后置++返回临时对象。所以用返回值区分。这是大错特错的!不要忘记了,构成函数重载必须行参列表不同!因此我们用一个占位符参数来区分前置++和后置++:

cpp 复制代码
class Point {
public:
    // 成员函数重载:前置++(无参数,返回Point&)
    Point& operator++() {
        this->x++; // x坐标自增
        this->y++; // y坐标自增
        return *this; // 返回自身,支持++(++p)这样的链式操作
    }

    // 成员函数重载:后置++(int占位参数,返回Point值)
    Point operator++(int) {
        Point temp = *this; // 先创建临时对象,保存当前原值
        this->x++; // 自增
        this->y++;
        return temp; // 返回原值的临时对象
    }
    int x = 0;
    int y = 0;
};
int main() {
    Point p1 = Point()++;
    Point p2 = ++Point();
    cout << p1.x << ' ' << p1.y << endl;
    cout << p2.x << ' ' << p2.y << endl;
}
复制代码
0 0
1 1
  • \]、()、-\>、=必须是成员函数:C++ 语法强制要求

声明为static的类成员称为类的静态成员,用static修饰的成员变量,称之为静态成员变量;用static修饰的成员函数,称之为静态成员函数。静态成员变量一定要在类外进行初始化

我们可以用静态成员变量作一个类的全局计数:

实现一个类,计算程序中创建出了多少个类对象。

cpp 复制代码
class A
{
public:
	A() { ++_scount; }
	A(const A& t) { ++_scount; }
	~A() { --_scount; }
	static int GetACount() { return _scount; }
private:
	static int _scount;
};
int A::_scount = 0;
void TestA()
{
	cout << A::GetACount() << endl;
	A a1, a2;
	A a3(a1);
	cout << A::GetACount() << endl;
}

特性:

  1. 静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区
  2. 静态成员变量必须在类外定义,定义时不添加static关键字,类中只是声明
  3. 类静态成员即可用 类名::静态成员 或者 对象.静态成员 来访问
  4. 静态成员函数没有隐藏的this指针,不能访问任何非静态成员
  5. 静态成员也是类的成员,受public、protected、private 访问限定符的限制

使用场景,单例模式:

cpp 复制代码
class Singleton {
private:
    // 1. 私有构造函数:类外不能创建对象
    Singleton() {}
    // 2. 私有静态成员变量:唯一的对象实例
    static Singleton instance;
public:
    // 3. 公有静态成员函数:提供唯一的访问入口
    static Singleton& getInstance() {
        return instance;
    }

    void func() { cout << "单例对象的方法" << endl; }
};
// 类外初始化:程序启动时创建唯一实例
Singleton Singleton::instance;

// 调用:全程只有一个对象
int main() {
    Singleton& s1 = Singleton::getInstance();
    Singleton& s2 = Singleton::getInstance();
    s1.func(); // 输出:单例对象的方法
    cout << &s1 << " " << &s2 << endl; // 地址相同,是同一个对象
    return 0;
}

拷贝对象时编译器的优化行为

读者可以自行运行尝试:

cpp 复制代码
class A
{
public:
	A(int a = 0)
		:_a(a)
	{
		cout << "A(int a)" << endl;
	}
	A(const A& aa)
		:_a(aa._a)
	{
		cout << "A(const A& aa)" << endl;
	}
	A& operator=(const A& aa)
	{
		cout << "A& operator=(const A& aa)" << endl;
		if (this != &aa)
		{
			_a = aa._a;
		}
		return *this;
	}
	~A()
	{
		cout << "~A()" << endl;
	}
private:
	int _a;
};
void f1(A aa)
{
}
A f2()
{
	A aa;
	return aa;
}
int main()
{
	// 传值传参
	A aa1;
	f1(aa1);
	cout << endl;
	// 传值返回
	f2();
	cout << endl;
	// 隐式类型,连续构造+拷贝构造->优化为直接构造
	f1(1);
	// 一个表达式中,连续构造+拷贝构造->优化为一个构造
	f1(A(2));
	cout << endl;
	// 一个表达式中,连续拷贝构造+拷贝构造->优化一个拷贝构造
	A aa2 = f2();
	cout << endl;
	// 一个表达式中,连续拷贝构造+赋值重载->无法优化
	aa1 = f2();
	cout << endl;
	return 0;
}

内存管理

内存分布

结合Linux学习时的进程信号部分,我们知道C/C++的虚拟内存分布:

其中代码区又称为,代码段。

代码区上方就是数据段,存储全局数据和静态数据。

new和new[]的底层实现

new:

cpp 复制代码
int* p = new int; // 底层仅等价于 int* p = (int*)malloc(sizeof(int));
int* p = new int(10); // 分配内存后,直接给地址赋值10(无构造,简单初始化)

new[]:

cpp 复制代码
// 程序员写的代码
A* p = new A[5];

// 编译器自动拆解后的底层逻辑(伪代码)
// 步骤1:计算总分配内存 = 额外信息字节(4/8) + 数组总大小(5*sizeof(A))
size_t totalSize = 4 + 5 * sizeof(A); 
// 步骤2:分配原始内存(包含额外信息的总大小)
char* rawMem = (char*)malloc(totalSize); 
// 步骤3:记录数组元素个数到额外信息区域(rawMem开头的4/8字节)
*(int*)rawMem = 5; 
// 步骤4:遍历数组,在对应地址调用构造函数(共5次)
A* objStart = (A*)(rawMem + 4); // 对象数组的真正起始地址(跳过额外信息)
for (int i = 0; i < 5; i++) {
    new(objStart + i) A(); // 对每个数组元素调用默认构造
}
// 步骤5:返回对象数组的真正起始地址(隐藏额外信息,程序员感知不到)
A* p = objStart;

这也说明了为什么delete和delete[]混用会报错。因为delete[] new创建的对象会野指针访问。而delete new[]对象则会内存泄漏。

常见问题

  • malloc/free和new/delete的区别

malloc/free和new/delete的共同点是:都是从堆上申请空间,并且需要用户手动释放。不同的地方是:

  1. malloc和free是函数,new和delete是操作符
  2. malloc申请的空间不会初始化,new可以初始化
  3. malloc申请空间时,需要手动计算空间大小并传递,new只需在其后跟上空间的类型即可, 如果是多个对象,[]中指定对象个数即可
  4. malloc的返回值为void*, 在使用时必须强转,new不需要,因为new后跟的是空间的类型
  5. malloc申请空间失败时,返回的是NULL,因此使用时必须判空,new不需要,但是new需要捕获异常
  6. 申请自定义类型对象时,malloc/free只会开辟空间,不会调用构造函数与析构函数,而new在申请空间后会调用构造函数完成对象的初始化,delete在释放空间前会调用析构函数完成空间中资源的清理
  • 什么是内存泄漏,内存泄漏的危害
  1. 什么是内存泄漏:内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
  2. 内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。
  • 内存泄漏分类
  1. 堆内存泄漏(Heap leak)
    堆内存指的是程序执行中依据须要分配通过malloc / calloc / realloc / new等从堆中分配的一块内存,用完后必须通过调用相应的 free或者delete 删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。
  2. 系统资源泄漏
    指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。
  • 如何避免内存泄漏
  1. 工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps:这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智能指针来管理才有保证。
  2. 采用RAII思想或者智能指针来管理资源。
  3. 有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。
  4. 出问题了使用内存泄漏工具检测。ps:不过很多工具都不够靠谱,或者收费昂贵。

模板

欢迎读者回顾我的C++模板以及C++模板进阶使用技巧

概念

模板的本质是C++ 的编译期泛型编程工具,核心作用是让代码与具体类型解耦------ 写一份通用代码,编译器根据传入的实际类型,在编译期自动生成对应类型的具体代码,实现 "一次编写,多次复用",且无运行时开销

C++ 模板主要分两类,也是所有模板知识的基础:

  1. 函数模板:针对函数的泛型,实现通用函数(如通用的交换、排序函数);
  2. 类模板:针对类的泛型,实现通用类(如 STL 的vector/map,支持任意类型的容器)。

基本语法:

函数模板

cpp 复制代码
// 模板参数列表:typename/class 均可(typename更推荐,class是历史遗留)
template <typename T> // T:模板类型参数
T max(T a, T b) {
    return a > b ? a : b;
}
// 调用:自动推导/显式指定
max(1,2);          // 自动推导T=int
max<double>(1,2.2);// 显式指定T=double,强制类型转换

类模板

cpp 复制代码
template <typename T, int N> // T:类型参数,N:非类型参数(编译期常量)
class Array {
public:
    T arr[N];
    T get(int i) { return arr[i]; }
};
// 使用:必须显式指定参数(C++17前)
Array<int, 5> arr; // T=int,N=5
arr.arr[0] = 10;

模板底层实现原理

  1. 实例化是编译期行为:模板代码仅在被使用时才会实例化,未使用的模板不会生成任何代码,无冗余;
  2. 一份模板,多份实例:不同类型会触发生成完全独立的具体代码,相互无影响(如 int 版和 double 版 add 是两个不同的函数);
  3. 无运行时开销:泛型的代价在编译期(生成多份代码,增加编译时间和可执行文件大小),运行时执行的是普通代码,和手写具体类型代码效率一致;
  4. 模板参数推导:函数模板支持编译器自动推导模板参数(如add(1,2)自动推导 T=int),类模板 C++17 前需显式指定(vector v),C++17 后支持类模板参数推导(CTAD)。

模板的特化

模板特化的核心是:当通用模板对某些特殊类型不适用 / 需要优化时,为这些类型编写「专属的模板实现」,编译器会优先选择特化版本而非通用版本,分为函数模板特化和类模板特化,其中类模板特化又分全特化和偏特化(函数模板无偏特化)。

使用案例:

cpp 复制代码
// 通用模板:适用于数值类型
template <typename T>
T Max(T a, T b) {
    cout << "通用模板:";
    return a > b ? a : b;
}

// 函数模板全特化:针对const char*类型(字符串)
template <>
const char* Max<const char*>(const char* a, const char* b) {
    cout << "特化模板(const char*):";
    // 字符串比较用strcmp,而非>
    return strcmp(a, b) > 0 ? a : b;
}

int main() {
    cout << Max(1, 2) << endl; // 调用通用模板:2
    cout << Max("hello", "world") << endl; // 调用特化模板:world
    return 0;
}
复制代码
通用模板:2
特化模板(const char*):world

类模板的全特化和偏特化:

cpp 复制代码
template <typename T, int N>
class Array {
public:
    T arr[N];
    void print() { cout << "通用Array:" << sizeof(arr) << endl; }
};

// 类模板全特化:T=int,N=5
template <>
class Array<int, 5> {
public:
    int arr[5];
    void print() {
        cout << "全特化Array<int,5>:";
        for (int i=0; i<5; i++) arr[i] = i;
        cout << arr[0] << arr[1] << endl;
    }
};

int main() {
    Array<double, 5> a1; a1.print(); // 通用模板:40(5*8)
    Array<int, 5> a2; a2.print();     // 全特化模板:01
    return 0;
}
cpp 复制代码
// 偏特化:N=10,T任意
template <typename T>
class Array<T, 10> {
public:
    T arr[10];
    void print() { cout << "偏特化Array<T,10>:" << arr[0] << endl; }
};

Array<int,10> a3; a3.arr[0] = 100; a3.print(); // 偏特化模板:100
cpp 复制代码
// 偏特化:T为任意指针类型(T*),N任意
template <typename T, int N>
class Array<T*, N> {
public:
    T* arr[N];
    void print() { cout << "偏特化Array<T*,N>:指针数组" << endl; }
};

int a = 10;
Array<int*, 5> a4; a4.arr[0] = &a; a4.print(); // 偏特化模板:指针数组

万能引用

T&&万能引用,指的不是右值引用,而是会自动识别是左值引用还是右值引用,通常搭配完美转发使用:

cpp 复制代码
// 目标函数:分别处理左值和右值
void target(int& a) { cout << "处理左值:" << a << endl; }
void target(int&& a) { cout << "处理右值:" << a << endl; }

// 万能引用+完美转发:将t以原类型转发给target
template <typename T>
void forward_func(T&& t) {
    target(std::forward<T>(t)); // 关键:std::forward保持值类别
}

int main() {
    int a = 10;
    forward_func(a);    // 左值转发 → 调用target(int&)
    forward_func(20);   // 右值转发 → 调用target(int&&)
    return 0;
}
复制代码
处理左值:10
处理右值:20

也可以:

cpp 复制代码
template <typename T>
void forward_func(T&& t) {
    target((T)(t)); 
}

非类型模板参数

模板是否一定是要接受类型参数呢?能否接受其他类型参数,答案是可以的。

C++的stl中有一种类array其模板为:template < class T, size_t N > class array;

也就是说我们可以这样使用它:

cpp 复制代码
array<int, 10> a1;

可变参数模板

C++11 引入可变参数模板,支持任意个数、任意类型的模板参数,语法用...表示(参数包),是实现std::tuple、std::emplace_back、折叠表达式的核心,语法:

cpp 复制代码
// 可变参数函数模板:Args是参数包,包含任意个数的类型参数
template <typename... Args>
void print(Args&&... args) {
    // C++17折叠表达式:展开参数包,打印所有参数
    (cout << ... << args) << endl;
}

int main() {
    print(1, "hello", 3.14, 'a'); // 任意个数、任意类型的参数
    return 0;
}

模板的声明与定义分离

普通函数 / 类的声明与定义分离(头文件声明,源文件定义)是 C++ 的基本编程规范,但模板的声明与定义不能直接分离------ 直接分离会导致链接错误(undefined reference to 模板实例),这是模板的编译期实例化原理导致的,下面讲解三种正确的分离方法。

  1. 将模板的声明和定义都放在头文件中
  2. 头文件声明 + 源文件定义 +显式实例化
cpp 复制代码
// template.h(头文件:仅声明)
#pragma once
template <typename T>
T add(T a, T b);

// template.cpp(源文件:定义+显式实例化)
#include "template.h"
// 模板定义
template <typename T>
T add(T a, T b) {
    return a + b;
}
// 显式实例化:手动指定需要的类型,编译器生成这些类型的代码
template int add<int>(int, int);
template double add<double>(double, double);

// main.cpp(主文件:调用模板,只能用显式实例化的类型)
#include "template.h"
int main() {
    add(1,2);        // 正常:使用显式实例化的int版
    add(1.1,2.2);    // 正常:使用显式实例化的double版
    // add('a','b'); // 链接错误:未显式实例化char版,无对应代码
    return 0;
}
  1. 头文件声明 + 定义放在头文件的.inl 后缀文件中,头文件包含.inl 文件

继承和多态

来到了C++重中之重,继承多态.

继承

概念

继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。

继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。

语法:

cpp 复制代码
// 继承语法:class 派生类 : 继承方式 基类
class 子类名 : 继承方式 父类名 {
    // 子类新增的成员
};
// 继承方式:public(公有)、protected(保护)、private(私有),默认private

切片

赋值兼容规则是 C++ 继承中最基础的类型转换规则,核心是:派生类对象可以直接赋值给基类对象 / 基类指针 / 基类引用(反之不行),因为派生类 "包含" 基类的所有成员。

cpp 复制代码
class A {};
class B : public A {};

int main() {
    B b; // 派生类对象
    // 1. 派生类对象赋值给基类对象(切片:只拷贝基类部分)
    A a = b;
    // 2. 派生类指针赋值给基类指针(最常用,多态的基础)
    A* ptra = &b; 
    A* ptra2 = new B;
    // 3. 派生类引用赋值给基类引用
    A& ra = b;
    // 4. 派生类对象可以直接作为参数传给基类参数的函数
    void func(A a) {}
    func(b);
    return 0;
}

隐藏

隐藏(也叫重定义)是指:子类定义了和父类同名的成员(成员变量 / 成员函数),父类的同名成员会被 "隐藏",子类优先访问自己的成员。

隐藏不是重定义哦!

  1. 成员变量的隐藏:
cpp 复制代码
class A {
public:
    int a = 10;
};
class B : public A {
public:
    int a = 20; // 隐藏父类的a
};

int main() {
    B b;
    cout << b.a << endl; // 20(访问子类自己的a)
    cout << b.A::a << endl; // 10(加作用域,访问父类的a)
    return 0;
}
  1. 成员函数的隐藏
    只要函数名相同,不管参数 / 返回值是否一致,父类函数都会被隐藏(和重写的区别:重写要求函数签名完全一致 + 父类函数是虚函数)
cpp 复制代码
class A {
public:
    void func(int a) { cout << "A::func(int)" << endl; }
};
class B : public A {
public:
    // 函数名相同,隐藏父类的func(int),即使参数不同
    void func() { cout << "B::func()" << endl; }
};

int main() {
    B b;
    b.func(); // 20(访问子类的func())
    // b.func(10); // 编译报错!父类的func(int)被隐藏,无法直接调用
    b.A::func(10); // 正确:加作用域访问父类的func(int)
    return 0;
}

子类的默认成员函数

  1. 构造函数
    子类构造函数必须先调用父类的构造函数,初始化父类部分的成员;
    若子类没写构造函数,编译器生成的默认构造会调用父类的默认构造;
    若父类没有默认构造,子类必须显式调用父类的有参构造(初始化列表中):
cpp 复制代码
class A {
public:
    A(int a) : m_a(a) {} // 父类无默认构造
    int m_a;
};
class B : public A {
public:
    // 子类构造必须在初始化列表调用父类有参构造
    B(int a, int b) : A(a), m_b(b) {}
    int m_b;
};
  1. 析构函数
    子类析构函数自动调用父类的析构函数(顺序:先子类析构,再父类析构);
    若父类析构是虚函数,子类析构自动继承虚属性(多态的核心);
    子类析构函数不能显式调用父类析构(编译器自动处理)。
    因为子类析构函数内仍可能使用父类的成员,现析构父类可能导致异常。
  2. 拷贝构造 / 赋值重载
    子类拷贝构造:先调用父类的拷贝构造,拷贝父类部分,再拷贝子类自己的成员;
    子类赋值重载:先调用父类的赋值重载,赋值父类部分,再赋值子类自己的成员;
    若子类自定义了拷贝构造 / 赋值重载,必须显式处理父类部分,否则父类部分会用默认方式初始化
cpp 复制代码
class B : public A {
public:
    // 自定义拷贝构造:显式调用父类拷贝构造
    B(const B& other) : A(other), m_b(other.m_b) {}
    // 自定义赋值重载:显式调用父类赋值重载
    B& operator=(const B& other) {
        if (this != &other) {
            A::operator=(other); // 处理父类部分
            m_b = other.m_b;     // 处理子类部分
        }
        return *this;
    }
};

菱形继承

菱形继承的情况:

cpp 复制代码
class A
{
public:
	int _a;
};
class B :public A
{
public:
	int _b;
};
class C :public A
{
public:
	int _c;
};
class D :public B, public C
{
public:
	int _d;
};
int main()
{
	D d;
	d._a = 1;
	return 0;
}

解决方法是虚拟菱形继承:

cpp 复制代码
class A
{
public:
	int _a;
};
class B :virtual public A
{
public:
	int _b;
};
class C :virtual public A
{
public:
	int _c;
};
class D :public B, public C
{
public:
	int _d;
};
int main()
{
	D d;
	d._a = 1;
	d._b = 2;
	d._c = 4;
	d._d = 5;
	return 0;
}

这样就没有二义性了。具体是怎么实现的可以回顾我之前的文章。

简单来说D中的B,B里面的A部分换成了虚基表指针。同样D中的C,C里面的A部分也换成了虚基表指针。而A部分单独放在D,具体排列方式就是:

复制代码
虚基表指针
int _b
虚基表指针
int _c
int _d
int _a

虚基表里面放的是当前位置到公共部分的偏移量。

这样实现的目的是,可以让D赋值给B或C

继承和组合

  • public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
  • 组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
  • 优先使用对象组合,而不是类继承 。
  • 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语"白箱"是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高
  • 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以"黑箱"的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
  • 实际尽量多去用组合。组合的耦合度低 ,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。

多态

概念

多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。

实际上我们分成静态多态:函数重载。

动态多态就是接下来要讲的部分。

实现

在继承中构成多态要满足三个条件:

  1. 必须通过基类的指针或者引用调用虚函数
  2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
  3. 子类重写父类的虚函数时,函数名、参数以及返回值都需要相同

例外:协变

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。

cpp 复制代码
class A{};
class B : public A {};
class Person {
public:
virtual A* f() {return new A;}
};
class Student : public Person {
public:
virtual B* f() {return new B;}
};
int main() {
	Student s;
	Person p;
	cout << typeid(s.f()).name() << endl;
	cout << typeid(p.f()).name() << endl;
}

注意这里的返回值也必须是父子类的指针/引用/值,不一定要是自己的,可以是别的父子类。

但如果不是:

析构函数建议实现多态

自然是因为:

这样会发生内存泄漏。重写之后就不会了。

纯虚函数

在虚函数的后面写上 =0 ,则这个函数为纯虚函数 。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。

虽说无法实例化对象,但是类型还是有大小的。

override和final

override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。

final:修饰虚函数,表示该虚函数不能再被重写

此外,final修饰的类和函数都不可被继承。

重载、重写、隐藏的区别

特性维度 重载(Overload) 重写(Override,覆盖) 隐藏(Redefine,重定义)
核心定义 同一作用域内,同名函数不同参数(个数/类型/顺序) 不同作用域(基类→派生类),派生类重写基类虚函数(签名完全一致) 不同作用域(基类→派生类),子类定义同名成员(变量/函数),隐藏父类成员
作用域 同一类/全局作用域(无继承关系) 不同作用域(基类和派生类,有继承关系) 不同作用域(基类和派生类,有继承关系)
函数签名要求 函数名相同,参数列表(个数/类型/顺序)不同;返回值无要求 函数名、参数列表、cv限定(const/volatile)、返回值(协变除外)完全一致 函数名相同即可,参数/返回值可不同(只要同名就隐藏)
虚函数要求 无(重载和虚函数无关) 必须:基类函数是虚函数(virtual) 无(基类函数可虚可非虚)
调用绑定方式 静态绑定(编译期根据参数匹配) 动态绑定(运行期根据对象实际类型) 静态绑定(编译期根据指针/对象类型)
关键字 无专属关键字 派生类可加 override(C++11,校验重写合法性) 无专属关键字
成员变量支持 不支持(仅针对函数) 不支持(仅针对虚函数) 支持(成员变量/成员函数都可隐藏)

多态的原理

实现了虚函数的类,都有一个隐藏成员:虚表指针。虚表指针指向虚表,虚表中存储的是虚函数指针。

运行结果判断

cpp 复制代码
using namespace std;
class A {
public:
	A(const char* s) { cout << s << endl; }
	~A() {}
};
class B :virtual public A
{
public:
	B(const char* s1, const  char* s2) :A(s1) { cout << s2 << endl; }
};
class C :virtual public A
{
public:
	C(const char* s1, const  char* s2) :A(s1) { cout << s2 << endl; }
};
class D :public B, public C
{
public:
	D(const char* s1, const char* s2, const  char* s3, const  char* s4) :B(s1, s2), C(s1, s3), A(s1)
	{
		cout << s4 << endl;
	}
};
int main() {
	D* p = new D("class A", "class B", "class C", "class D");
	delete p;
	return 0;
}
复制代码
class A
class B
class C
class D
cpp 复制代码
class Base1 { public:  int _b1; };
class Base2 { public:  int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main() {
	Derive d;
	Base1* p1 = &d;
	Base2* p2 = &d;
	Derive* p3 = &d;
	cout << p1 << ' ' << p2 << ' ' << p3 << endl;
	return 0;
}	
复制代码
0019FB34 0019FB38 0019FB34
cpp 复制代码
class A
{
public:
	virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
	virtual void test() { func(); }
};
class B : public A
{
public:
	void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
int main(int argc, char* argv[])
{
	B* p = new B;
	p->test();
	return 0;
}
复制代码
B->1
  1. 虚函数的函数体是动态绑定(运行期按对象类型确定),但默认参数是静态绑定(编译期按指针 / 引用类型确定);
  2. 派生类重写虚函数时,不要修改默认参数值 ------ 因为默认参数始终由基类的声明决定,修改后会造成逻辑混乱;
  3. 本例中 test() 属于基类未重写的虚函数,内部调用虚函数时,默认参数沿用基类的定义,最终输出 B->1。

C++11

C++11部分我们需要重点掌握,右值引用和lambda表达式。

右值在上面已经讲过了。

lambda表达式

使用格式:

cpp 复制代码
[capture-list] (parameters) mutable -> return-type { statement }

如:

cpp 复制代码
int main()
{
	auto func1 = [](int x, int y)->int {return x + y; };
	auto func2 = [](int x, int y){return x + y; };
	auto func3 = [] {cout << "Love" << endl; };
	cout << func1(10, 2) << endl << func2(20, 10) << endl;
	func3();
	return 0;
}

lambda表达式实现原理

lambda 表达式的底层实现,是 C++ 编译器在编译期做的 "语法糖":

编译器会为每个 lambda 表达式生成一个匿名的、不可见的类(闭包类) ,并为这个类重载 operator()(函数调用运算符);lambda 表达式的 "捕获列表" 对应这个类的成员变量,lambda 的函数体对应 operator() 的实现。

异常

详细参见异常部分。

  • throw: 当问题出现时,程序会抛出一个异常。这是通过使用 throw 关键字来完成的。
  • catch: 在您想要处理问题的地方,通过异常处理程序捕获异常.catch 关键字用于捕获异常,可以有多个catch进行捕获。
  • try: try 块中的代码标识将被激活的特定异常,它后面通常跟着一个或多个catch 块。

函数调用链中异常栈展开匹配原则

1。首先检查throw本身是否在try块内部,如果是再查找匹配的catch语句。如果有匹配的,则调到catch的地方进行处理。

  1. 没有匹配的catch则退出当前函数栈,继续在调用函数的栈中进行查找匹配的catch。

  2. 如果到达main函数的栈,依旧没有匹配的,则终止程序。上述这个沿着调用链查找匹配的catch子句的过程称为栈展开。所以实际中我们最后都要加一个catch(...)捕获任意类型的异常,否则当有异常没捕获,程序就会直接终止。

  3. 找到匹配的catch子句并处理以后,会继续沿着catch子句后面继续执行。

异常的优缺点

优点:

  1. 异常对象定义好了,相比错误码的方式可以清晰准确的展示出错误的各种信息,甚至可以包含堆栈调用的信息,这样可以帮助更好的定位程序的bug。
  2. 返回错误码的传统方式有个很大的问题就是,在函数调用链中,深层的函数返回了错误,那么我们得层层返回错误,最外层才能拿到错误。
  3. 很多的第三方库都包含异常,比如boost、gtest、gmock等等常用的库,那么我们使用它们也需要使用异常。
  4. 部分函数使用异常更好处理,比如构造函数没有返回值,不方便使用错误码方式处理。比如T& operator这样的函数,如果pos越界了只能使用异常或者终止程序处理,没办法通过返回值表示错误。

缺点:

  1. 异常会导致程序的执行流乱跳,并且非常的混乱,并且是运行时出错抛异常就会乱跳。这会导致我们跟踪调试时以及分析程序时,比较困难。
  2. 异常会有一些性能的开销。当然在现代硬件速度很快的情况下,这个影响基本忽略不计。
  3. C++没有垃圾回收机制,资源需要自己管理。有了异常非常容易导致内存泄漏、死锁等异常安全问题。这个需要使用RAII来处理资源的管理问题。学习成本较高。
  4. C++标准库的异常体系定义得不好,导致大家各自定义各自的异常体系,非常的混乱。
  5. 异常尽量规范使用,否则后果不堪设想,随意抛异常,外层捕获的用户苦不堪言。所以异常规范有两点:一、抛出异常类型都继承自一个基类。二、函数是否抛异常、抛什么异常,都使用 func() throw();的方式规范化。

智能指针

智能指针是RAII技术使用的一种典范:

RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。

在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。

核心分类:

auto_ptr:弃用,被unique_ptr替代。

unique_ptr:独占,轻量,首选;

shared_ptr:共享,引用计数,需避免循环引用;

weak_ptr:弱引用,解决 shared_ptr 循环引用;

shared_ptr简单模拟实现

cpp 复制代码
// shared_ptr简化实现(仅核心逻辑)
template <typename T>
class shared_ptr {
private:
    T* ptr;
    size_t* ref_count; // 引用计数指针(所有共享对象共用)
public:
    // 构造:初始化指针和引用计数
    explicit shared_ptr(T* p = nullptr) : ptr(p) {
        ref_count = new size_t(1); // 初始计数1
    }
    // 拷贝构造:计数+1
    shared_ptr(const shared_ptr& other) {
        ptr = other.ptr;
        ref_count = other.ref_count;
        (*ref_count)++;
    }
    // 析构:计数-1,为0时释放内存
    ~shared_ptr() {
        (*ref_count)--;
        if (*ref_count == 0) {
            delete ptr;
            delete ref_count;
        }
    }
    // 重载->和*
    T* operator->() const { return ptr; }
    T& operator*() const { return *ptr; }
    // 获取引用计数
    size_t use_count() const { return *ref_count; }
};

常考问题

  1. shared_ptr的底层实现?引用计数是线程安全的吗?
    底层存两个指针(指向资源的裸指针 + 指向共享引用计数的指针),拷贝时计数 + 1,析构时计数 - 1,计数为 0 释放资源;引用计数的增减是原子操作(线程安全),但指向的对象本身非线程安全,多线程访问需手动加锁。
  2. shared_ptr为什么会出现循环引用?怎么解决?
    两个shared_ptr互相指向对方(如 A→B,B→A),导致各自引用计数无法归 0,内存泄漏;用weak_ptr替代一方的shared_ptr,weak_ptr不增加引用计数,打破循环。
  3. unique_ptr为什么不能拷贝?怎么转移所有权?
    设计初衷是独占资源,因此编译器显式删除了拷贝构造 / 赋值重载;通过移动语义(std::move) 转移所有权,转移后原unique_ptr置空,不再指向资源。
  4. 智能指针能管理数组吗?怎么处理?
    可以。unique_ptr有专门的数组版本unique_ptr<T[]>,自动调用delete[];shared_ptr无原生数组支持,需自定义删除器(shared_ptr(new T[N], [](T* p){delete[] p;}))
  5. weak_ptr怎么访问所指向的对象?为什么不能直接访问?
    通过lock()方法转为shared_ptr后访问,访问前可通过expired()检查对象是否已释放;因为weak_ptr是弱引用,不保证对象一定存在,直接访问可能导致空悬指针,转为shared_ptr后会增加计数,保证访问期间对象不被释放。

类型转化

C++有四种类型转化,分别是:

static_cast,reinterpret_cast,const_cast,dynamic_cast。

  1. static_cast(静态转换)
    核心定义
    编译期完成的 "静态类型转换",用于相关类型之间的合法转换,编译器会做基本的类型检查,但不保证运行时安全。
  2. reinterpret_cast(重解释转换)
    核心定义
    最 "暴力" 的转换,编译期直接重新解释二进制位,不做任何类型检查,仅改变对内存的解释方式,风险极高(几乎无类型安全保证)。
  3. const_cast(常量转换)
    核心定义
    专门用于添加 / 移除变量的 const/volatile 限定符,仅作用于指针 / 引用 / 指向对象成员的指针,不能用于基本类型直接转换。
cpp 复制代码
int main() {
    // 场景1:移除指针的const(变量本身非const,合法)
    int a = 10;
    const int* const_p = &a;
    int* p = const_cast<int*>(const_p);
    *p = 20;
    cout << "const_cast移除指针const:" << a << endl; // 输出20

    // 场景2:移除引用的const
    const int& const_ref = a;
    int& ref = const_cast<int&>(const_ref);
    ref = 30;
    cout << "const_cast移除引用const:" << a << endl; // 输出30

    // 场景3:修改原本的const变量(UB,禁止)
    const int b = 100;
    int* p_b = const_cast<int*>(&b);
    // *p_b = 200; // 未定义行为,可能崩溃/结果异常

    // 场景4:添加const限定
    int* p_a = &a;
    const int* const_p2 = const_cast<const int*>(p_a); // 合法

    return 0;
}
  1. dynamic_cast(动态转换)
    核心定义
    运行期完成的 "动态类型转换",仅用于继承体系中指针 / 引用的转换,会做运行时类型检查(RTTI),是四种转换中唯一 "安全的向下转型"。
cpp 复制代码
class Base {
public:
    virtual void func() {} // 必须有虚函数,否则dynamic_cast编译报错
};
class Derived1 : public Base {};
class Derived2 : public Base {};

int main() {
    // 1. 向上转型(派生类→基类,和static_cast一致,安全)
    Derived1* d1 = new Derived1();
    Base* b = dynamic_cast<Base*>(d1); // 合法

    // 2. 向下转型(基类→派生类,运行时检查)
    Base* b2 = new Derived1();
    Derived1* d1_cast = dynamic_cast<Derived1*>(b2);
    if (d1_cast != nullptr) {
        cout << "dynamic_cast向下转型成功(Derived1)" << endl;
    }

    Derived2* d2_cast = dynamic_cast<Derived2*>(b2);
    if (d2_cast == nullptr) {
        cout << "dynamic_cast向下转型失败(Derived2)" << endl;
    }

    // 3. 引用的dynamic_cast(失败抛异常)
    Base& ref = *b2;
    try {
        Derived2& ref_cast = dynamic_cast<Derived2&>(ref);
    } catch (const bad_cast& e) {
        cout << "引用dynamic_cast失败:" << e.what() << endl;
    }

    delete d1;
    delete b2;
    return 0;
}
复制代码
dynamic_cast向下转型成功(Derived1)
dynamic_cast向下转型失败(Derived2)
引用dynamic_cast失败:Bad dynamic_cast!

常考问题

  1. dynamic_cast 为什么需要虚函数?static_cast 和 dynamic_cast 的区别?
    dynamic_cast 依赖 RTTI(运行时类型信息),而 RTTI 的实现依赖虚函数表(有虚函数的类才会生成 RTTI 信息);区别:① 时机:static_cast 编译期,dynamic_cast 运行期;② 检查:static_cast 无类型检查,dynamic_cast 有;③ 场景:static_cast 适用于向上转型,dynamic_cast 适用于安全的向下转型;④ 依赖:dynamic_cast 需虚函数 / RTTI,static_cast 不需要。
  2. const_cast 的使用场景和风险?
    场景:临时移除指针 / 引用的 const 属性(变量本身非 const);风险:若修改 "原本就是 const 的变量",会导致未定义行为;且仅能修改指针 / 引用的 const 属性,不能修改变量本身。
  3. dynamic_cast 向下转型失败时,指针和引用的表现有什么不同?
    指针转换失败返回 nullptr;引用转换失败抛出std::bad_cast异常(因为引用不能为 null)。

IO流

来到C++重点语法最后一部分IO流

文件流(ifstream/ofstream/fstream)

用于读写文件,核心是 "打开模式" 和 "资源释放":

cpp 复制代码
#include <fstream>
#include <string>

int main() {
    // 1. 写文件(ofstream)
    ofstream ofs("test.txt", ios::out | ios::trunc); // 覆盖写
    if (!ofs.is_open()) { // 检查文件是否打开成功
        cerr << "文件打开失败" << endl;
        return 1;
    }
    ofs << "C++ IO流" << endl;
    ofs << "文件写入测试:" << 123 << endl;
    ofs.close(); // 手动关闭(也可依赖析构自动关闭)

    // 2. 读文件(ifstream)
    ifstream ifs("test.txt", ios::in);
    if (!ifs.is_open()) {
        cerr << "文件打开失败" << endl;
        return 1;
    }
    // 方式1:按行读取
    string line;
    while (getline(ifs, line)) {
        cout << "读取行:" << line << endl;
    }
    ifs.close();

    // 3. 读写文件(fstream)
    fstream fs("test.txt", ios::in | ios::out | ios::app); // 追加写
    if (fs.is_open()) {
        fs << "追加内容" << endl;
        // 移动文件指针到开头
        fs.seekg(0, ios::beg);
        string content;
        fs >> content;
        cout << "文件开头内容:" << content << endl;
    }
    fs.close();

    return 0;
}
复制代码
读取行:C++ IO流
读取行:文件写入测试:123
文件开头内容:C++

字符串流(sstream)

cpp 复制代码
#include <sstream>
#include <string>

int main() {
    // 1. ostringstream:拼接字符串
    ostringstream oss;
    oss << "姓名:" << "张三" << " 年龄:" << 20 << " 成绩:" << 95.5;
    string str = oss.str(); // 获取拼接后的字符串
    cout << "拼接结果:" << str << endl; // 输出:姓名:张三 年龄:20 成绩:95.5

    // 2. istringstream:解析字符串
    istringstream iss(str);
    string key1, key2, key3;
    string name;
    int age;
    double score;
    // 按格式解析
    iss >> key1 >> name >> key2 >> age >> key3 >> score;
    cout << "解析结果:" << endl;
    cout << "姓名:" << name << endl;
    cout << "年龄:" << age << endl;
    cout << "成绩:" << score << endl;

    return 0;
}
复制代码
拼接结果:姓名:张三 年龄:20 成绩:95.5
解析结果:
姓名:年龄:20
年龄:-858993460
成绩:-9.25596e+61

常见问题

  1. C++ 中 cin/cout 和 C 语言的 scanf/printf 有什么区别?
    ① 类型安全:cin/cout 是类型安全的(编译器检查类型),scanf/printf 需手动匹配格式符(易出错);② 扩展性:cin/cout 可重载(支持自定义类型),scanf/printf 不支持;③ 缓冲:cin/cout 有缓冲(效率略低),可通过sync_with_stdio(false)提速;④ 用法:cin/cout 更简洁(无需格式符),scanf/printf 对格式控制更精细(如小数位数)。
  2. cerr 和 clog 的区别?cout 为什么要刷新缓冲区?
    ① cerr:标准错误流,无缓冲(输出立即显示),用于紧急错误;clog:日志流,带缓冲(批量输出),用于非紧急日志;② cout 带缓冲,默认满缓冲区 / 换行(endl)/ 程序结束时刷新,刷新是为了让数据从内存缓冲区写入控制台(避免数据滞留)。
  3. stringstream 的优势?为什么替代 sprintf?
    优势:① 类型安全(无需格式符,避免类型不匹配);② 自动内存管理(无需手动分配缓冲区);③ 支持自定义类型(重载 <<);④ 拼接 / 解析更灵活;sprintf 易出现缓冲区溢出、类型错误,安全性低。
  4. IO 流的状态标志有哪些?读取失败后如何恢复?
    核心状态:good(正常)、eof(到达文件末尾)、fail(逻辑错误,如类型不匹配)、bad(底层错误,如文件损坏);恢复步骤:① clear() 重置状态标志;② ignore() 清空输入缓冲区的无效数据。
相关推荐
打工的小王3 小时前
Spring Boot(三)Spring Boot整合SpringMVC
java·spring boot·后端
毕设源码-赖学姐3 小时前
【开题答辩全过程】以 高校体育场馆管理系统为例,包含答辩的问题和答案
java·spring boot
我真会写代码3 小时前
SSM(指南一)---Maven项目管理从入门到精通|高质量实操指南
java·spring·tomcat·maven·ssm
vx_Biye_Design3 小时前
【关注可免费领取源码】房屋出租系统的设计与实现--毕设附源码40805
java·spring boot·spring·spring cloud·servlet·eclipse·课程设计
DN金猿3 小时前
接口路径正确,请求接口却提示404
java·tomcat
爱学习的阿磊4 小时前
使用Fabric自动化你的部署流程
jvm·数据库·python
Maynor9964 小时前
OpenClaw 玩家必备:用 AI 自动追踪社区最新动态
java·服务器·人工智能
堕2744 小时前
java数据结构当中的《排序》(一 )
java·数据结构·排序算法
m0_550024634 小时前
持续集成/持续部署(CI/CD) for Python
jvm·数据库·python