多态全解——虚函数、重写、虚表与多态原理

Ciallo(∠・ω< )⌒☆

系列专栏 直达链接
🔤 C 语言 进入专栏 →
🧱 数据结构 进入专栏 →
🔧 C++ 进入专栏 →
🐍 Python 进入专栏 →
🔌 STM32 进入专栏 →
🐧 Linux 进入专栏 →

🏠 博客主页:暖焰核心

📦 作者仓库:github | gitee

文章目录

  • 引言
    • 一、多态的概念与构成条件
      • [1.1 编译时多态与运行时多态](#1.1 编译时多态与运行时多态)
      • [1.2 构成多态的两个条件](#1.2 构成多态的两个条件)
      • [1.3 虚函数与重写](#1.3 虚函数与重写)
    • 二、重写里的四处细节
      • [2.1 题目一:缺省参数与虚函数的搭配](#2.1 题目一:缺省参数与虚函数的搭配)
      • [2.2 协变](#2.2 协变)
      • [2.3 析构函数的重写](#2.3 析构函数的重写)
      • [2.4 override 与 final](#2.4 override 与 final)
    • 三、重载、重写与隐藏的对比
    • 四、纯虚函数与抽象类
    • 五、多态的原理
      • [5.1 题目二:sizeof 与虚函数表指针](#5.1 题目二:sizeof 与虚函数表指针)
      • [5.2 虚表的查找过程](#5.2 虚表的查找过程)
      • [5.3 动态绑定与静态绑定](#5.3 动态绑定与静态绑定)
      • [5.4 虚表的内容与存放位置](#5.4 虚表的内容与存放位置)

引言

学完继承,代码复用的问题解决了,但有个很别扭的地方,同一句调用,为什么不能让它做的事随对象变?

买票就是这样。普通人全价,学生打折,军人优先,都是"买票"这一个行为,不同的人做,结果不同。假设有下面两个类,Student 继承 Person,各自有一个 BuyTicket:

cpp 复制代码
Person ps;
Student st;
ps.BuyTicket();   // 全价
st.BuyTicket();   // 打折

一旦 Student 对象交给一个收 Person& 的函数,就出了岔子。函数声明是 void Func(Person& ptr),函数体里只有一句 ptr.BuyTicket():传 ps 进去调 Person::BuyTicket,传 st 进去还是调 Person::BuyTicket。ptr 的静态类型是 Person&,编译器按这个类型去找函数,找到 Person::BuyTicket 就定下来了

多态要的效果是:Func(st) 也调 Student::BuyTicket。函数地址不能编译时就定死,得等到运行时看 ptr 到底指着谁

一、多态的概念与构成条件

1.1 编译时多态与运行时多态

多态(polymorphism)的字面意思就是多种形态。C++ 里的多态分两类,分界线是"什么时候决定调哪个函数"

一类在编译期就定好了。函数重载和函数模板都是:

cpp 复制代码
void swap(int& a, int& b);
void swap(double& a, double& b);

两个 swap 名字一样,编译器按实参类型挑一个,匹配在编译时完成。这种叫编译时多态,也叫静态多态

另一类要等到运行时才定,就是上面买票那回事。调哪个函数不看函数名和参数,看指针(或引用)指向的到底是哪个对象。这叫运行时多态,或者动态多态

本篇讲的是后一种

1.2 构成多态的两个条件

第一,必须由基类的指针或者引用去调用

只有基类的指针或引用能同时指向基类对象和派生类对象。Person* 既能接 Person 对象的地址,也能接 Student 对象的地址,因为派生类对象里本来就有一份基类部分;反过来 Student* 接不了 Person 对象的地址

第二,被调用的函数必须是虚函数,并且派生类完成了重写

两条缺一条都不构成多态。缺第一条,编译器按静态类型直接定函数地址;缺第二条,派生类里那个同名函数只是把基类的隐藏掉,跟多态没关系

代码如下:

cpp 复制代码
class Person
{
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};

class Student : public Person
{
public:
	virtual void BuyTicket() { cout << "买票-打折" << endl; }
};

void Func(Person* ptr)
{
	// 都是 Person 指针在调 BuyTicket
	// 但调哪个跟 ptr 的类型没关系,由 ptr 指向的对象决定
	ptr->BuyTicket();
}

int main()
{
	Person ps;
	Student st;

	Func(&ps);   // 买票-全价
	Func(&st);   // 买票-打折

	return 0;
}

ptr 两次都是 Person*,一次指 Person 对象、一次指 Student 对象,输出就分开了

多态不要求一端非得是基类对象。同一个基类下的多个派生类,各自重写虚函数之后,交给同一段代码也都能各调各的那一份

1.3 虚函数与重写

虚函数:类成员函数前面加 virtual。非成员函数不能加

重写(也叫覆盖):派生类里有一个跟基类完全相同的虚函数,返回值类型、函数名、参数列表全部相同,就叫派生类重写了基类的虚函数

这里有个爱考的点:派生类重写时不加 virtual 也能构成重写。基类的虚函数被继承下来之后,在派生类里仍然保持虚函数属性。写法不规范,但不影响构成多态,面试里经常拿这个设坑

反过来,"看起来像"不等于构成重写。函数名拼错一个字母、参数类型里的 const 对不上(比如 const int& 写成 int&),都会让重写不成立,而编译器不会报错,它只是悄悄当成两个不相干的函数。返回值不同是另一回事:同名同参、只改返回值又构不成协变,编译器会直接报错。真正难查的是前两种,等运行时结果不对再回头找就费劲了。解决方法后文 2.4 节的 override 有解释

二、重写里的四处细节

2.1 题目一:缺省参数与虚函数的搭配

先看一道题,代码如下:

cpp 复制代码
class A
{
public:
	virtual void func(int val = 1) { cout << "A->" << val << endl; }
	virtual void test() { func(); }
};

class B : public A
{
public:
	void func(int val = 0) { cout << "B->" << val << endl; }
};

int main(int argc, char* argv[])
{
	B* p = new B;
	p->test();
	p->func();
	return 0;
}

选项:

A. A->0 B. B->1 C. A->1 D. B->0 E. 编译出错 F. 以上都不正确

答案是 B,输出 B->1

很多人就会顺着"多态调派生类那份实现"的思维,看到 B::func 里写的是 val = 0,就选了 B->0。这道题卡人的地方在于:缺省参数和函数地址不是同一个时刻定下来的

先看 p->test()。B 没有重写 test,所以调的是从 A 继承来的 A::test。进到函数体里,那句 func() 等价于 this->func(),此时 this 的类型是 A*

func 是虚函数,B::func 也构成对它的重写,多态条件满足,于是函数地址走运行期:this 指向的是 B 对象,虚表里那一项是 B::func,调的是它。到这里都没问题

问题出在参数上。缺省参数是编译期 按静态类型填的:编译器在 A::test 里看到 func(),按 this 的静态类型 A* 找到 A::func,于是把 A::func 声明里的缺省值 1 填进去,这一句在编译后等价于 func(1)。运行时虚表把函数换成了 B::func,可实参 1 早就填好了

两个时刻一分开,结果就出来了:

环节 什么时候定 取谁的
缺省参数 val 编译期 静态类型 A 里的 1
函数地址 运行期 虚表里查到的 B::func

所以第一句输出 B->1

第二句 p->func() 不一样。p 是 B*,名字查找优先在 B 里找,找到 B::func,缺省值用 B 自己声明的 0,输出 B->0

输出分别是 B->1 和 B->0

结论落到实践上:重写虚函数时最好把缺省值保持一致。既然缺省值跟着静态类型走,声明里写两套值,调用方换一个指针类型就换一个结果,看代码的人很难顺着看出来

2.2 协变

派生类重写基类虚函数时,返回值类型可以不同,但这种"不同"有限定:基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或引用

代码如下:

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

class Person {
public:
	virtual A* BuyTicket()
	{
		cout << "买票-全价" << endl;
		return nullptr;
	}
};

class Student : public Person {
public:
	virtual B* BuyTicket()
	{
		cout << "买票-打折" << endl;
		return nullptr;
	}
};

A* 和 B* 是不同的类型,这也算重写,叫协变。A 和 B 必须有继承关系,否则不成立。实际意义不大,了解即可

2.3 析构函数的重写

基类的析构函数声明成虚函数时,派生类的析构函数只要定义出来,不管加不加 virtual,都构成对基类析构的重写

按字面看这不符合重写规则:基类叫 ~A,派生类叫 ~B,名字都不一样。原因是编译器对析构函数的名称做了特殊处理,编译后统一处理成 destructor

为什么基类的析构要设计成虚函数?看下面这段:

cpp 复制代码
class A
{
public:
	virtual ~A()
	{
		cout << "~A()" << endl;
	}
};

class B : public A {
public:
	~B()
	{
		cout << "~B()->delete:" << _p << endl;
		delete _p;
	}
protected:
	int* _p = new int[10];
};

int main()
{
	A* p1 = new A;
	A* p2 = new B;
	delete p1;
	delete p2;
	return 0;
}

运行结果:

复制代码
~A()
~B()->delete:0x22c5c48ea20
~A()

delete p2 的时候,p2 的类型是 A*,指向的却是 B 对象。析构函数是虚函数,构成多态,所以先调 B 的析构把那 10 个 int 释放掉,再自动调基类的 ~A()

把 ~A() 前面的 virtual 去掉,析构就不构成多态了。delete p2 只调 ~A(),~B() 里那句 delete _p 永远执行不到,_p 指的 10 个 int 就漏了

注意:这题面试问得很多。光答"基类析构要加 virtual"不够,得能讲出少调这一次析构漏掉的是什么

2.4 override 与 final

重写的要求严格,函数名、参数、返回值有一个对不上就不成立。名字或者参数对不上时编译器不会报错,只是悄悄变成另一个函数。C++11 给了 override 来治这个:在派生类函数后面写上它,编译器会检查这个函数是不是真的重写了基类的虚函数,不是就报错

代码如下(基类函数名故意拼成 Dirve):

cpp 复制代码
class Car {
public:
	virtual void Dirve()
	{}
};

class Benz :public Car {
public:
	virtual void Drive() override { cout << "Benz-舒适" << endl; }
};

编译报 error C3668: "Benz::Drive": 包含重写说明符"override"的方法没有重写任何基类方法。拼错一个字母,编译器当场就拦下来了

反过来,不想让派生类重写某个虚函数,用 final 修饰基类的那个函数:

cpp 复制代码
class Car
{
public:
	virtual void Drive() final {}
};

class Benz :public Car
{
public:
	virtual void Drive() { cout << "Benz-舒适" << endl; }
};

报 error C3248: "Car::Drive": 声明为"final"的函数无法被"Benz::Drive"重写

三、重载、重写与隐藏的对比

这三个概念名字像,条件和作用域都不一样,放一起最容易记混

先把判据列全:

概念 作用域 函数名 参数 返回值 虚函数
重载 同一作用域 相同 类型或个数不同 可同可不同 无要求
重写 父类与子类,不同作用域 相同 相同 相同(协变例外) 两个都必须是虚函数
隐藏 父类与子类,不同作用域 相同 不要求 不要求 无要求

三条可以压成一句话记:同一作用域看参数,不同作用域看虚函数

同一作用域里两个同名函数,参数不同就是重载,参数也相同就是重复定义,编译直接报错。不同作用域里两个同名函数,构成重写的条件全满足就是重写,只要有一条不满足就是隐藏

隐藏最容易出错的地方是它不挑参数 :派生类里只要出现一个和基类同名的函数,基类里所有同名函数不管参数全部被隐藏。想调基类那份,得写 b.A::fun() 显式指定作用域

代码如下:

cpp 复制代码
class A
{
public:
	void fun() { cout << "fun()" << endl; }
};

class B : public A
{
public:
	void fun(int i) { cout << "fun(int i):" << i << endl; }
};

int main()
{
	B b;
	b.fun(10);    // 正常
	b.fun();      // 编译报错
	b.A::fun();   // 显式指定作用域才能调
	return 0;
}

b.fun() 报错的原因是名字查找只认名字:从 B 的作用域开始找 fun,找到 fun(int) 就停了,参数对不上直接报错,不会回退到 A 里去找那个无参版本

四、纯虚函数与抽象类

在虚函数后面写上 = 0,这个函数就是纯虚函数:

cpp 复制代码
class Car
{
public:
	// 纯虚函数
	virtual void Drive() = 0;
};

纯虚函数只要声明,通常不需要写定义,它本来就等着派生类去重写(语法上允许给定义,只是一般用不到)

包含纯虚函数的类叫抽象类。抽象类不能实例化对象:

cpp 复制代码
Car car;   // 编译报错 error C2259: "Car": 无法实例化抽象类

派生类继承之后如果不重写纯虚函数,派生类也是抽象类,一样实例化不了。纯虚函数某种程度上强制了派生类必须重写

代码如下:

cpp 复制代码
class Benz :public Car
{
public:
	virtual void Drive()
	{
		cout << "Benz-舒适" << endl;
	}
};

class BMW :public Car
{
public:
	virtual void Drive()
	{
		cout << "BMW-操控" << endl;
	}
};

int main()
{
	Car* pBenz = new Benz;
	pBenz->Drive();

	Car* pBMW = new BMW;
	pBMW->Drive();

	return 0;
}

抽象类不能实例化,但可以定义指针和引用,Car* 照样能指向各个派生类对象。这也是多态最常见的用法:接口用抽象类定下来,具体实现交给派生类,调用方只认基类指针

五、多态的原理

5.1 题目二:sizeof 与虚函数表指针

再看一道题,代码如下:

cpp 复制代码
class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
protected:
	int _b = 1;
	char _ch = 'x';
};

int main()
{
	Base b;
	cout << sizeof(b) << endl;
	return 0;
}

编译成 32 位程序,运行结果是什么?

A. 编译报错 B. 运行报错 C. 8 D. 12

一个 int 加一个 char 是 5 字节,按边界对齐到 8 字节,正好对应选项 C。但答案是 D,12 字节

多出来的 4 字节不是一个成员变量,是一个指针:虚函数表指针。Base 里有虚函数 Func1,编译器就在对象里塞了一个指向虚函数表的指针,Func1 的地址存在那张表里。这个指针叫虚函数表指针(v 代表 virtual,f 代表 function),一般放在对象最前面:

复制代码
  32 位下 Base 对象的内存布局(共 12 字节)

  +--------------------------------+
  |  _vfptr   虚函数表指针   4B    |  --> 指向虚表,Func1 的地址存在表里
  +--------------------------------+
  |  _b                      4B    |
  +--------------------------------+
  |  _ch                     1B    |
  +--------------------------------+
  |  填充对齐                3B    |  按 4 字节边界对齐
  +--------------------------------+

注意:虚表指针一般放在对象最前面,C++ 标准没有规定它的位置,主流实现都放在开头。只要类里有虚函数,对象里就至少有一个虚表指针,因为一个类所有虚函数的地址都要放进这个类的虚表里

VS 里可以直接看到它。b 展开后是 {_b=1 _ch=120 'x'},_vfptr 单独占一项:

_ch 的值显示成 120 'x',是因为 char 在监视窗口里会同时给出 ASCII 码值和字符

同一段代码用 MinGW g++ 编译成 64 位跑,输出是 16 。64 位下指针占 8 字节,加上 int 的 4 字节、char 的 1 字节和 3 字节填充,正好 16。数值跟 32 位不一样,但多出来的那份是同一个东西

5.2 虚表的查找过程

回到买票那段。Func 里只有一句 ptr->BuyTicket(),ptr 是 Person*,它怎么知道该调哪个?

不满足多态条件时,这件事很简单:编译器按静态类型就能确定要调哪个函数,把地址直接写进指令。满足多态条件后这条路走不通了,编译器手里只有 ptr 的类型,不知道它运行时指向谁,于是改成运行时去查

查的过程分三步:先从对象里取出虚表指针,用它在虚表里定位到对应那一项,取出函数地址再调用。ptr 指向 Person 对象,取到的就是 Person 对象的虚表,BuyTicket 那一项是 Person::BuyTicket;指向 Student 对象,取到 Student 对象的虚表,那一项是 Student::BuyTicket

两句 Func 编译出来的是同一段查表指令,差别只在 ptr 运行时指向谁。指针指到哪个对象,就查到哪个对象的虚表,就调哪个函数

图里还能看出另一件事:Student 对象里那份 _vfptr 是从继承下来的基类部分带过来的,它指向的是 Student 自己的虚表,不是 Person 对象那张。虚表跟着对象的实际类型走,跟指针的静态类型没关系

5.3 动态绑定与静态绑定

多态的这层机制落到编译结果上,就是两种绑定方式

不满足多态条件(基类指针或引用 + 虚函数)的调用在编译时绑定,编译器直接确定函数地址写进指令,这叫静态绑定。满足多态条件的调用在运行时绑定,运行时到指向对象的虚表里取函数地址,这叫动态绑定

反汇编里两种写法对比很清楚:

复制代码
  // 满足多态:ptr 是指针,BuyTicket 是虚函数
  // 运行时到 ptr 指向对象的虚表里取地址
  00EF2001  mov  eax,dword ptr [ptr]           ; 取虚表指针
  00EF2004  mov  edx,dword ptr [eax]           ; 取虚表里 BuyTicket 那一项
  00EF2006  mov  esi,esp
  00EF2008  mov  ecx,dword ptr [ptr]           ; this
  00EF200B  mov  eax,dword ptr [edx]           ; 函数地址
  00EF200D  call eax                           ; 间接调用,地址运行时才有

  // 不满足多态:BuyTicket 不是虚函数
  // 编译期就把地址写死
  00EA2C91  mov  ecx,dword ptr [ptr]           ; this
  00EA2C94  call 0EA153Ch                      ; 编译器写死的地址,不查表

差异全在最后那句 call:动态绑定是 call eax,地址来自寄存器,运行时才有值;静态绑定是直接 call 一个写死的地址,编译完就定了

5.4 虚表的内容与存放位置

这一节换一组更完整的例子来看,代码如下:

cpp 复制代码
class Base {
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
	void func5() { cout << "Base::func5" << endl; }
protected:
	int a = 1;
};

class Derive : public Base
{
public:
	// 重写基类的 func1
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
	void func4() { cout << "Derive::func4" << endl; }
protected:
	int b = 2;
};

int main()
{
	Base b;
	Derive d;
	return 0;
}

Base 有两个虚函数 func1、func2 和一个普通函数 func5。Derive 重写了 func1,又添了一个虚函数 func3,另外有个普通函数 func4。func4 和 func5 不是虚函数,都进不了虚表

先把对象和虚表的关系摆清楚

同类对象共用一张虚表,基类和派生类各有各的表

一个类的所有对象,虚表指针都指向同一张表。Person 的对象共用 Person 的虚表,Student 的对象共用 Student 的虚表,两张表互相独立

派生类对象里的虚表指针是从基类那部分继承下来的,不新生成

派生类对象由继承下来的基类部分和派生类自己的成员两部分构成。基类部分里本来就带着虚表指针,派生类不会再生成一个。要分清的是,这个指针指向的虚表和基类对象那张虚表不是同一张,就像派生类对象里的基类成员和基类对象自己的成员是两份一样

派生类重写的虚函数,会覆盖虚表里对应的那一项

Student 重写了 BuyTicket,Student 虚表里 BuyTicket 那一项就从 Person::BuyTicket 的地址换成了 Student::BuyTicket 的地址。没重写的那项保持基类函数的地址不动

所以派生类的虚表分三部分:基类的虚函数地址、被重写项覆盖后的地址、派生类自己的虚函数地址

VS 监视窗口里能看到前两项:

b 的 _vfptr 展开是 [0] Base::func1、[1] Base::func2;d 展开后 [0] 换成了 Derive::func1,[1] 还是 Base::func2,地址没变。第三项 Derive::func3 在这张窗口里看不到

看不到 func3 是因为监视窗口按类型信息展开,它只认得出基类那几项。换内存窗口把原始字节摊开就能看全:

按小端读一下 Derive 那一组:

复制代码
   Derive 对象  0x0053FB28          虚表  0x000A9B74

   +----------------------+          +----------------------------+
   | 74 9b 0a 00          |--------->| 30 12 0a 00      func1     |
   +----------------------+          +----------------------------+
   | 01 00 00 00  a = 1   |          | 13 11 0a 00      func2     |
   +----------------------+          +----------------------------+
   | 02 00 00 00  b = 2   |          | 21 12 0a 00      func3     |
   +----------------------+          +----------------------------+
                                     | 00 00 00 00      end       |
                                     +----------------------------+

对象开头的 74 9b 0a 00 是虚表指针,按小端读出来就是虚表的地址 0x000A9B74。虚表里三行依次是 Derive::func1、Base::func2、Derive::func3 的地址,最后那个 00 00 00 00 是结束标记

Base::func2 那一项在两张虚表里地址相同,都是 0x000A1113。它没被重写,派生类虚表直接沿用了基类函数的地址

末尾的 00 00 00 00 是 VS 系列编译器在虚表后面放的结束标记。这一条 C++ 标准没有规定,各编译器自己定,g++ 就不放

虚函数放在哪、虚表放在哪,是两个问题

虚函数编译好后就是一段指令,跟普通函数一样放在代码段,只是它的地址又被存了一份到虚表里

虚表放在哪,C++ 标准同样没有规定。写一段程序把各个区域和虚表地址一起打出来对比:

cpp 复制代码
int main()
{
	int i = 0;
	static int j = 1;
	int* p1 = new int;
	const char* p2 = "xxxxxxxx";
	printf("栈:%p\n", &i);
	printf("静态区:%p\n", &j);
	printf("堆:%p\n", p1);
	printf("常量区:%p\n", p2);

	Base b;
	Derive d;
	Base* p3 = &b;
	Derive* p4 = &d;
	printf("Base虚表地址:%p\n", *(int*)p3);      // 64 位下写成 *(void**)p3
	printf("Derive虚表地址:%p\n", *(int*)p4);
	printf("虚函数地址:%p\n", &Base::func1);
	printf("普通函数地址:%p\n", &Base::func5);

	return 0;
}

*(int*)p3 是把对象开头 4 个字节当成 int 取出来,取到的就是虚表指针的值。VS 下编译成 32 位,输出是:

复制代码
栈:010FF954
静态区:0071D000
堆:0126D740
常量区:0071ABA4
Base虚表地址:0071AB44
Derive虚表地址:0071AB84
虚函数地址:00711488
普通函数地址:007114BF

虚表地址落在常量区那一带。也就是说,VS 下虚表放在只读数据段(平时说的常量区),跟函数指令所在的代码段不是同一处

同样的程序我用 g++ 编译成 64 位跑,位置关系一致:

复制代码
栈                     :0000000c151ffe34
静态区                 :00007ff63a6f8000
堆                     :0000016a82c0eb70
常量区                 :00007ff63a6f9040
Base 虚表地址          :00007ff63a6f9a50
Derive 虚表地址        :00007ff63a6f9a70
虚函数地址 &Base::func1:00007ff63a6f7d90
普通函数地址 &Base::func5:00007ff63a6f7e10

虚表地址与常量区在同一段,虚函数和普通函数落在更低的另一条地址带上

把 Base 虚表的第一项取出来是 00007ff63a6f7d90,和 &Base::func1 完全一样,虚表里存的就是虚函数的地址,这一点能直接对上:

复制代码
Base 虚表内容:
  [0] = 00007ff63a6f7d90   Base::func1
  [1] = 00007ff63a6f7dd0   Base::func2
  [2] = 0000000000000000

再取 Derive 的虚表:

复制代码
Derive 虚表内容:
  [0] = 00007ff63a6f7e50   Derive::func1
  [1] = 00007ff63a6f7dd0   Base::func2
  [2] = 00007ff63a6f7e90   Derive::func3
  [3] = 0000000000000000

[0] 换了地址,[1] 与 Base 虚表里那一项完全相同,[2] 是派生类追加的虚函数地址

相关推荐
白杨尚青1 小时前
C++入门篇(十八):模板进阶——非类型参数、特化全家桶与分离编译(收官篇)
开发语言·c++·笔记·模板·偏特化·分离编译
C++ 老炮儿的技术栈2 小时前
char 造成的数据溢出
linux·服务器·c语言·c++·算法·c
一拳一个呆瓜12 小时前
【STL】文件系统导航
c++·stl
All for pursuit.12 小时前
【动态规划-21】312.戳气球
数据结构·c++·算法·leetcode·动态规划
栈帧之外12 小时前
【C++】STL之set
开发语言·c++
zhanghaha131413 小时前
C++构造函数的一些其他应用(逐词逐句保姆讲解)
c++
jimy114 小时前
c++的 Type Traits (二、更详细版本)
开发语言·c++
半杯咖啡半行码16 小时前
C++11核心特性详解:从C++11到C++20 的现代C++指南
开发语言·c++
All for pursuit.17 小时前
【位运算-3】136.只出现一次的数字
数据结构·c++·算法·leetcode