嵌入式面试题 - C++总结(三)
嵌入式面试题 - C++总结(三)
部分常用术语解释
- 字面量:编译时确定的常量,即代码中直接写出的常量值,所以也是常量值的一种表示方式
- 字面量 = 常量值
- 成员属性 = 成员变量
- 成员方法 = 成员函数
- bool类型:C语言里面是宏,C++里面是一个真正意义上的数据类型
- 派生类:子类
- 基类:父类
31.虚继承
-
虚继承:虚继承是 C++ 中解决菱形继承二义性问题的一种机制
-
虚继承目的:确保只有一个基类的实例会被派生类继承(解决菱形继承二义性问题)
-
◇菱形继承二义性问题
-
A / \ B C \ / D // A 是基类 // B 和 C 都从 A 继承,D 同时从 B 和 C 继承 // 如果没有虚继承,类D将从B和C各自继承一个A的副本,导致A被重复继承,即:重复的基类实例化问题
-
-
实现:
- 当一个类通过虚继承继承自基类时,基类的实例只会存在一个,即使它有多个直接或间接的派生类
- 通过在继承声明中使用
virtual关键字来实现虚继承
-
原理:虚继承是利用虚继承指针和虚继承表实现的
- 虚基类指针:在每个派生类对象中,都会有一个指向虚基类 A 的虚表指针,它指向唯一的 A 实例
- 每个派生类(如B和C)并没有直接拥有A的成员,而是通过一个虚基类指针来指向A的单一实例
- 这个指针保证了多个继承链中的所有派生类都指向同一个A实例
- 虚基类是被共享的,对象内存模型中均只会出现一个虚基类的子对象
- 虚继承表:在虚继承的类中,每个虚基类都有一个独立的虚表
- 派生类对象的虚表指针指向这些表,以确保派生类能够正确地通过虚基类指针访问共享的虚基类实例
- 人话:虚继承表就相当于目录
- 内存布局
- 由于虚继承确保只有一个基类实例,因此内存布局也会有所不同
- 每个派生类(如B和C)只保留一个指向A的指针,而不存储A的完整副本
- 虚继承的代价
- 虚继承会增加一些内存开销,因为每个派生类对象需要维护一个指向虚基类的指针
- 同时,由于虚基类的构造由最派生类来完成,虚继承会影响构造函数的调用顺序
- 虚基类指针:在每个派生类对象中,都会有一个指向虚基类 A 的虚表指针,它指向唯一的 A 实例
-
[!IMPORTANT]
- 为什么不推荐使用虚继承?
- 1.从内存的角度去思考,内存上它是有问题的,对其规则的影响,还有内存膨胀的影响
- 2.代码可读性不太好,可维护性低
- 为什么不推荐使用虚继承?
-
#include <iostream> using namespace std; // 这里有一个菱形继承举例(虽然说菱形继承狗都不用就是了) class A { public: A() { cout << "构造函数A" << endl; } void show() { cout << "Class A" << endl; } }; // 虚继承1 class B : virtual public A { public: B() { cout << "构造函数B" << endl; } }; // 虚继承2 class C : virtual public A { public: C() { cout << "构造函数C" << endl; } }; // 多继承 class D : public B, public C { public: D() { cout << "构造函数D" << endl; } }; int main() { D obj; // 创建 D 对象 obj.show(); // 调用 A 类的成员函数 return 0; }
32.向上转型类型与向下转型类型
-
向上转型类型和向下类型转型都是继承相关的类型转换操作
-
向上转型:将派生类指针或引用转换为基类指针或引用(子类转换为父类)
- 特点
- ⭐安全性:隐式安全
- 编译器自动完成,无需显式类型转换
- 因为派生类是基类的扩展,任何派生类对象都可以被安全地视为基类对象
- 多态的基础:通过基类指针/引用调用虚函数时,会触发动态绑定(运行时多态)
- ⭐安全性:隐式安全
- 特点
-
向下转型:将基类指针或引用转换为派生类指针或引用(父类转换为子类)
- 特点
- ⭐安全性:显式风险
- 向下转型需要显式地进行转换(使用
static_cast或dynamic_cast),** - 如果使用不当,可能会导致类型错误,尤其是父类对象并不是目标派生类的实例时
- 向下转型需要显式地进行转换(使用
- 需要类型检查:若基类指针实际指向的不是目标派生类对象,可能导致未定义行为
- ⭐安全性:显式风险
- 特点
-
// 向上转型 class Animal { public: virtual void speak() { std::cout << "动物叫了一声" << std::endl; } }; class Dog : public Animal { public: void speak() override { std::cout << "又是哪个狗托在狗叫!" << std::endl; } }; int main() { Dog dog; Animal* animal = &dog; // 向上转型:将子类指针赋给父类指针 animal->speak(); // 调用 Dog 类的 speak 方法 } // ----------------------------------------------------------------------- // 向下转型 class Animal { public: virtual void speak() { std::cout << "动物叫了一声" << std::endl; } }; class Dog : public Animal { public: void speak() override { std::cout << "又是哪个狗托在狗叫!" << std::endl; } }; int main() { Animal* animal = new Dog(); // 父类指针指向子类对象 Dog* dog = dynamic_cast<Dog*>(animal); // 向下转型:父类指针转回子类指针 if (dog) { dog->speak(); // 安全地调用 Dog 类的 speak 方法 } else { std::cout << "Failed to cast to Dog!" << std::endl; } delete animal; // 释放内存 }
33.C++的类型转换
-
以下介绍的都是一些显示转换,隐式转换就是编译器自动进行的类型转换,例如
int与char之间的隐式转换 -
⭐1.静态转换
static_cast:基础数据类型转换- 1)不能用于不同的指针转换
- 2)可以用于
void*和其他指针的转换 - 3)可以用于父子类指针的转换,不会进行转换的安全检查(要小心数据丢失)
- 基类转子类(向下转型是不安全的,但是不一定是错的,不安全也不代表一定不能转)
- 子类转基类(向上转型是安全的)
-
⭐2.动态转换
dynamic_cast:父子类之间的转换(安全的转换)- 运行期检查:检查指针是否能够完全转换
- 如果不能转换,返回
nullptr
- 如果不能转换,返回
- 运行期检查:检查指针是否能够完全转换
-
3.常量转换
const_cast:const 转换为非const(去除const属性) -
4.重定义转换
reinterpret_cast:相当于C语言的强制类型转换- 权限最高的转换,所以用的很少
-
// 1.静态转换:static_cast class Animal { public: virtual void speak() { std::cout << "动物叫" << std::endl; } }; class Dog : public Animal { public: void speak() override { std::cout << "狗托叫!" << std::endl; } }; int main() { Animal* animal = new Dog(); Dog* dog = static_cast<Dog*>(animal); // 向下转型(使用 static_cast) dog->speak(); delete animal; } // ------------------------------------------------------------ // 2.动态转换:dynamic_cast class Animal { public: virtual void speak() { std::cout << "动物叫" << std::endl; } }; class Dog : public Animal { public: void speak() override { std::cout << "狗托叫!" << std::endl; } }; int main() { Animal* animal = new Dog(); Dog* dog = dynamic_cast<Dog*>(animal); // 向下转型,检查是否成功 if (dog) { dog->speak(); // 转换成功,调用 Dog 的 speak } else { std::cout << "没能变成狗!" << std::endl; } delete animal; } // ------------------------------------------------------------ // 3.常量转换:const_cast void modifyData(const int* ptr) { int* non_const_ptr = const_cast<int*>(ptr); // 移除 const 限定符 *non_const_ptr = 20; // 修改数据(警告:需要确保原数据未声明为常量) } int main() { const int x = 10; modifyData(&x); // 使用 const_cast 去除 const 限定符 } // ------------------------------------------------------------ // 4.重定义转换:reinterpret_cast int x = 10; char* p = reinterpret_cast<char*>(&x); // 将 int* 转换为 char* std::cout << *p << std::endl; // 输出结果未定义
34.RTII机制
-
RTII:运行时类型信息
-
RTII机制:运行的时候需要知道变量的类型
-
RTII机制是 C++ 提供的一种机制,使得程序在运行时能够识别对象的实际类型,尤其在多态的环境下非常有用
-
通过 RTTI,程序能够进行类型检查,类型识别、安全的类型转换(如向下转型)等操作
-
RTTI 的实现主要依赖于两个机制:
-
1.
typeid操作符:可以获取对象的类型信息-
typeid 是 C++ 中的一个操作符,用于在运行时获取一个表达式的类型信息
-
它通常与
type_info类一起使用,后者定义在<typeinfo>头文件中 -
typeid主要用于 RTTI,在面向对象编程中特别有用,尤其是在处理多态和继承时 -
// 比较两个表达式的类型是否相同 if (typeid(a) == typeid(b)) { // 如果a 和 b 类型相同 }
-
-
2.动态转换
dynamic_cast:用于运行时安全的类型转换(尤其是向下转型)- 运行期检查:检查指针是否能够完全转换
- 如果不能转换,返回
nullptr
- 如果不能转换,返回
- 运行期检查:检查指针是否能够完全转换
-
-
RTII作用(使用场景)
-
1.类型识别:在运行时识别对象的真实类型
-
2.安全的向下转型:
dynamic_cast通过 RTTI 实现安全的类型转换,避免直接使用不安全的转换(如static_cast) -
3.多态环境下的类型检查:在多态环境中,程序可以通过 RTTI 判断基类指针实际指向的派生类类型
35.抽象类
-
[!IMPORTANT]
-
封装(保护) vs 抽象(简化)
-
封装的目标是隐藏实现细节,将数据和方法封装在对象内部,对外提供清晰的接口(保护数据)
- 封装强调的是对数据的保护和管理,通过公开的接口对数据进行访问和操作
-
抽象的目标是隐藏对象的复杂实现(简化复杂实现),让程序员只关注其功能而非实现细节
- 抽象通过接口或抽象类定义了对象的行为规范,但不关心具体细节
- 抽象让程序员只关心做什么,而不关心如何做
- 一切的抽象都是为了扩展
- 反过来说,当发现类使用不方便时,就可以抽象
- 越向上越抽象,越向下越具体,最上层抽象往往是一个空类
-
-
-
说到抽象类,必然会提到抽象,抽象不一定非要用抽象类实现,但是抽象类必然和抽象挂钩
-
抽象类
- 抽象类是一种不能实例化的类,它用于 定义接口 或 提供一个基础框架,让派生类继承并实现具体的行为
- 抽象类通常包含 纯虚函数,即没有实现的函数,派生类必须实现这些函数
-
目的:作为基类提供接口和框架,让派生类实现具体行为
-
什么时候使用抽象类?
-
提供统一的接口
- 当不同的子类有相同的功能但具体实现不同时,使用抽象类可以定义一个统一接口,并让子类去实现具体细节
-
避免重复代码(提供公共代码)
- 如果多个子类有相似的行为或属性,抽象类可以提供共享的实现,避免重复代码
-
强制子类实现特定方法
- 抽象类可以声明抽象方法,强制子类必须实现这些方法,从而确保所有子类都具备特定的功能
-
设计通用的类层次结构
- 当有一个层次结构需要在多个类之间共享基本行为时,抽象类能有效地组织和管理这类行为。
-
-
特点
- 不能实例化:你不能直接创建抽象类的对象
- 包含纯虚函数:至少有一个纯虚函数
- 派生类实现:派生类必须实现所有的纯虚函数,否则派生类也会成为抽象类
-
作用:
- 定义接口:抽象类定义了必须实现的方法,派生类根据需要提供具体实现
- 代码重用:可以包含已实现的函数和成员变量,派生类可以继承和重用这些功能
- 实现多态:通过抽象类指针或引用,派生类可以在运行时动态调用正确的方法
- 强制实现功能:抽象类强制派生类实现某些功能,确保所有子类遵循相同的接口
- 设计框架:提供一个框架,派生类只需实现细节部分,减少重复代码
- 解耦合:抽象类作为接口,派生类实现具体功能,减少不同部分之间的依赖
-
// 抽象类语法 // 抽象类至少有一个纯虚函数 class AbstractClass { public: virtual void pureVirtualFunction() = 0; // 纯虚函数 virtual ~AbstractClass() {} // 析构函数,可以是虚函数(虚析构函数),但不是必须的 }; // ------------------------------------------------------------------------------------- // 抽象类的使用 // 抽象类 class Shape { public: // 纯虚函数:定义接口,派生类必须实现 virtual void draw() = 0; virtual ~Shape() {} // 虚析构函数,允许多态删除派生类对象 }; // 派生类:Circle class Circle : public Shape { public: void draw() override { std::cout << "Drawing Circle" << std::endl; } }; // 派生类:Square class Square : public Shape { public: void draw() override { std::cout << "Drawing Square" << std::endl; } }; int main() { // Shape shape; // 错误:不能实例化抽象类 Shape* shape1 = new Circle(); // 使用抽象类指针 Shape* shape2 = new Square(); shape1->draw(); // 输出:Drawing Circle shape2->draw(); // 输出:Drawing Square delete shape1; delete shape2; return 0; }
36.抽象基类为什么不能创建对象?
-
抽象基类不能创建对象的原因是,它包含至少一个纯虚函数,而纯虚函数没有实现
- 纯虚函数:一种没有实现的虚函数,它在基类中声明,但在基类中没有提供任何功能
- 纯虚函数用于在基类中定义接口,强制派生类必须实现该函数
-
抽象基类本身就不完整,所以不能直接用于实例化
-
具体原因:
- 1.缺少实现
- 抽象类的 纯虚函数 没有实现,只是声明了函数签名,表示它必须由派生类来实现
- 由于纯虚函数没有实际功能,抽象类不能作为一个完整的类来创建对象
- 2.类不完整:
- 抽象类本身只是作为一种 接口定义 或 模板框架,它的目的是让派生类实现具体的功能
- 没有实现所有函数的类不被认为是“完整”的类,因此无法实例化对象
- 3.目的:
- 抽象类的目的是作为基类提供接口和框架,让派生类实现具体行为
- 实例化抽象类会违背这一设计原则,因为它没有实现具体的行为
- 1.缺少实现
-
class Shape { public: virtual void draw() = 0; // 纯虚函数,必须由派生类实现 virtual ~Shape() {} }; int main() { Shape s; // 错误:不能实例化抽象类 }
37.不使用抽象类如何实现抽象?
-
C++中通常通过抽象类和纯虚函数来实现抽象,但是抽象不一定需要通过抽象类和纯虚函数来实现
-
还可以通过接口,函数指针和模板实现抽象
-
1.接口
-
使用接口可以强制类实现特定方法
-
但是与抽象类不同的是,接口不提供任何实现,只定义方法的签名
-
所有实现接口的类都必须提供接口中声明的方法的具体实现
-
class IShape { public: virtual void draw() = 0; // 纯虚函数 }; class Circle : public IShape { public: void draw() override { // 圆形的具体绘制方法 } };
-
-
2.组合
-
通过组合其他类对象来模拟抽象类的功能
-
通过将公共行为抽象到一个独立的类中,并通过成员对象来实现抽象类的效果
-
class Drawer { public: virtual void draw() = 0; // 纯虚函数 }; class CircleDrawer : public Drawer { public: void draw() override { // 绘制圆形 } }; class Shape { private: Drawer* drawer; public: Shape(Drawer* d) : drawer(d) {} void draw() { drawer->draw(); } };
-
-
3.模板(泛型编程)
-
通过模板类来实现特定的功能
-
虽然模板在编译时确定类型,但它也可以实现一定程度的抽象
-
template <typename T> class Shape { public: void draw() { static_cast<T*>(this)->drawImpl(); // 利用模板特化或 CRTP 实现 } }; class Circle : public Shape<Circle> { public: void drawImpl() { // 绘制圆形 } };
-
-
4.*函数指针
-
通过使用函数指针,可以动态地决定某个函数的实现,从而达到类似抽象类的效果
-
原理
- 函数指针可以指向不同的函数,而函数的签名(即参数和返回类型)是固定的
- 通过在结构体中存储函数指针,可以在不同的实例中动态地调用不同的实现
-
#include <iostream> struct Shape { void (*draw)(Shape*); // 函数指针,指向具体的绘制函数 }; // Circle 类 void drawCircle(Shape* shape) { std::cout << "Drawing Circle\n"; } // Rectangle 类 void drawRectangle(Shape* shape) { std::cout << "Drawing Rectangle\n"; } // 创建圆形对象 Shape createCircle() { Shape s; s.draw = drawCircle; // 为圆形对象绑定绘制函数 return s; } // 创建矩形对象 Shape createRectangle() { Shape s; s.draw = drawRectangle; // 为矩形对象绑定绘制函数 return s; } int main() { Shape circle = createCircle(); Shape rectangle = createRectangle(); circle.draw(&circle); // 输出:Drawing Circle rectangle.draw(&rectangle); // 输出:Drawing Rectangle return 0; }
-
-
38.🍁🍀虚函数及其实现原理🍀🍁
-
虚函数
- 从语法上来讲,虚函数就是在基类内部被
virtual关键字声明的成员函数,允许在派生类中对其进行重写 - 虚函数用于实现运行时多态,通过基类指针或引用调用时,
- 能根据对象的实际类型(而非指针类型)来调用对应的函数(程序运行时根据对象类型选择函数)
- 从语法上来讲,虚函数就是在基类内部被
-
注意事项:
- 虚函数必须是非静态成员函数,因为静态成员函数需要在编译时确定
- 虚函数一旦声明,就只能是虚函数,哪怕是派生类也不能改变
- 构造函数不能是虚函数,因为虚函数是动态绑定的,而构造函数创建时需要确定对象类型
- 析构函数一般是虚函数,但可以不是虚函数
-
虚函数意义:⭐实现多态,让派生类可以重写(override)其基类的成员函数
- 即:在不同的派生类中实现相同接口的不同实现
-
特点:
- 1.通过基类指针或引用调用:虚函数的多态性只有通过基类的指针或引用才能体现
- 2.动态绑定: 虚函数调用是在运行时绑定的,而不是编译时静态绑定
- 3.重写: 派生类可以重写(覆盖)基类中的虚函数,提供自己独特的实现
-
原理:虚函数原理 = 虚函数表 + 虚函数指针
- 编译器在含有虚函数的类中创建了一个虚函数表(vtable),用于存放虚函数地址
- 另外,还隐式地设置了一个虚函数指针(vptr),指向该类对象的虚函数表
- 派生类在继承基类的同时,也会继承基类的虚函数表
- 当派生类重写(overr)基类的虚函数时,则会将重写后的虚函数地址替换掉由基类继承而来的虚函数表中对应虚函数地址
- 若派生类没有重写,则由基类继承而来的虚函数地址直接保存在派生类的虚函数表中
- 特别注意:每个类都只有一张虚函数表,该类的所有对象共享这个虚函数表,而不是每个实例化对象都分别有一张虚函数表
- 人话:
- 一个类,一张虚函数表,一个虚函数指针,
- 一个类一张虚函数表,父类和子类不会共享一张表
- 一个类不管有多少个虚函数,只需要一个虚函数指针
- 一张表只需要一个虚函数指针就可以了
- 虚函数表存放的是这个类中所有的虚函数地址,也可以说,虚函数表就是编译器为了包含虚函数的类生成的一张表
- 虚函数表就相当于一个索引,虚函数指针就是通过索引选择正确的虚函数,然后调用正确的函数
- 当子类(派生类)重写父类(基类)的虚函数时,会用重写后的虚函数地址覆盖原来的地址
- 反之,如果没有重写,父类(基类)的虚函数地址继续接着用
-
运行时多态:
- C++的多态性是通过虚函数实现的,如果基类通过引用或指针调用的是虚函数时,并不能知道执行该函数的对象是什么类型
- 只有在运行时才能确定调用的是基类的虚函数还是派生类的虚函数
-
#include <iostream> using namespace std; class Base { public: // 虚函数 virtual void show() { cout << "Base class show" << endl; } // 虚析构函数 virtual ~Base() { cout << "Base class destructor" << endl; } }; class Derived : public Base { public: // 重写虚函数 void show() override { cout << "Derived class show" << endl; } // 派生类析构函数 ~Derived() { cout << "Derived class destructor" << endl; } }; int main() { // 父类指针指向子类对象 // Base *basePtr = new Derived(); Base *basePtr; Derived derivedObj; basePtr = &derivedObj; // 多态调用:在main函数中,basePtr是基类Base类型的指针,但它指向的是派生类Derived的对象 // 当调用basePtr->show()时,会根据实际对象的类型(即Derived)来调用Derived类的show函数,而不是基类的 // 调用的是Derived类的show函数 basePtr->show(); // 删除时会先调用Derived的析构函数,再调用Base的析构函数 delete basePtr; return 0; }
参考:【C++面试100问】第十一问:虚函数是什么?工作机制是什么?_bilibili
39.为什么构造函数不能是虚函数?而析构函数一般写成虚函数?
-
构造函数存在的意义是为了创建对象,析构函数存在的意义是为了销毁对象
-
为什么构造函数不能是虚函数?
-
新手作答:
-
在对象创建时,对象类型是已知的,不需要通过动态绑定来确定调用哪个构造函数,所以构造函数不需要绑定为虚函数
-
如果构造函数是虚函数,就需要通过虚函数表来调用,但是对象还没有实例化(内存空间还没有),无法查找虚函数表,
-
构造函数无法访问虚函数表,但是要调用已经变成虚函数的构造函数必须要使用虚函数表,
-
所以构造函数不能是虚函数
-
糕手作答:
(开启瞎扯模式——指中国教材独有的能1句话说明白必须用100句话诠释这句话,嗯诠释这个词不错,很适合中国教材) -
1.从存储空间角度看:构造函数无法访问虚函数表
-
虚函数依赖于虚函数表(vtable),每个包含虚函数的类都有一张虚函数表
-
且每个对象都持有一个指向该虚函数表的虚函数指针(vptr)
- 虚函数指针存储在对象的内存空间的
-
如果构造函数是虚函数,那么在对象构造阶段,虚函数表还没有完全建立,
-
虚函数指针尚未指向正确的位置,导致无法在构造函数中进行动态绑定;
-
由于构造函数的执行顺序是在对象初始化阶段,且对象内存空间还没有完全分配,无法通过虚函数表查找正确的虚函数
-
因此,构造函数不能是虚函数
- 备用答法:
(说不定一紧张就忘记了,你看我多好,还准备了备用版本) - 如果构造函数是虚函数,就必须要通过虚函数表来调用构造函数,但是对象还没有实例化,
- 也就是内存空间还没有完全分配,此时无法通过虚函数表查找正确的虚函数,
- 构造函数无法访问虚函数表,但是要调用已经变成虚函数的构造函数必须要使用虚函数表,
- 所以构造函数不能是虚函数
- 备用答法:
-
-
2.从使用角度:构造函数不涉及多态,不需要虚函数提供的动态绑定机制
- 答法1:
- 虚函数的主要作用是实现运行时多态,即在调用基类指针或引用时,根据实际对象的类型动态选择调用的函数
- 构造函数的作用是初始化对象,而在对象创建时,它是由编译器直接调用的,并不涉及基类指针或引用的动态调用
- 构造函数的调用是直接明确的,而虚函数主要是用于延迟绑定(即运行时决定调用哪个正确的函数)
- 因此,构造函数的行为不需要虚函数提供的多态性机制,即:构造函数不需要,也不能够是虚函数
- 答法2:
- 虚函数主要是实现运行时多态,在不同的派生类中实现相同接口的不同实现
- 构造函数本身就是要初始化实例,那使用虚函数也没有实际意义,所以构造函数没有必要是虚函数
- 虚函数的作用在于通过父类的指针或者引用来调用它的时候可以变成调用子类的那个成员函数,
- 而构造函数是在创建对象时自己主动调用的,不可能通过父类的指针或者引用去调用,
- 因此也就规定构造函数不能是虚函数
-
3.从对象生命周期角度:构造函数在对象创建时直接调用,不涉及基类指针或引用调用,不需要虚函数的支持
- 构造函数的目的是初始化对象,且每个对象的构造函数只会在创建对象时调用一次,
- 因此构造函数没有必要也不能使用虚函数的机制,它不涉及到对象的动态行为,也不需要依赖虚函数的多态性
- 另外,尽管可以通过基类的指针或引用访问对象,
- 但构造函数总是由创建对象时主动调用,不能像虚函数一样通过基类指针来访问和调用
-
4.从实现上:虚函数的绑定依赖于虚函数表,而在构造函数调用时虚函数表尚未建立
- 答法1:
- 虚函数指针(vptr)是在对象构造完成后由编译器初始化的
- 虚函数的绑定依赖于虚函数表的正确设置,而在构造函数执行时,虚函数表还未初始化,虚函数指针指向的表尚不准确
- 因此,构造函数不可能成为虚函数
- 构造函数的调用顺序是固定的(首先调用基类构造函数,再调用派生类构造函数),
- 而虚函数的动态绑定是运行时发生的,这种机制在对象构造过程中无法正确生效
- 答法2:
- 虚函数指针(vptr)在构造函数调用后才建立,因而构造函数不可能成为虚函数
- 从实际含义上看,在调用构造函数时还不能确定对象的真实类型(由于子类会调父类的构造函数),
- 并且构造函数的作用是提供初始化,在对象生命期仅仅运行一次,不是对象的动态行为,也没有必要成为虚函数
- 为什么析构函数一般写成虚函数?如果析构函数不是虚函数会怎么样?
- 直接点说,虚析构函数就是为了防止内存泄漏
- 如果析构函数不是虚函数可能就会导致内存泄漏
- (教材式说法)
- 析构函数通常写成虚函数,主要是为了确保在通过基类指针或引用删除派生类对象时,
- 能够正确调用派生类的析构函数,避免资源泄漏和未释放的内存
- 答法1:
- 由于类的多态性,基类指针可以指向派生类的对象,如果删除该基类的指针,就会调用该指针指向的派生类析构函数,
- 而派生类的析构函数又自动调用基类的析构函数,这样整个派生类的对象就会完全被释放
- 如果析构函数不被声明成虚函数,则编译器实施静态绑定,在删除基类指针时,
- 只会调用基类的析构函数而不调用派生类析构函数,这样就会造成派生类对象释放不完全,造成内存泄漏
- 所以将析构函数一般声明为虚函数
- 答法2
- 如果派生类中申请了内存空间,并在其析构函数中对这些内存空间进行释放
- 假设基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时就不会 触发动态绑定,
- 因而只会调用基类的析构函数,而不会调用派生类的析构函数
- 那么在这种情况下,派生类中申请的空间就得不到释放从而产生内存泄漏
- 所以,为了防止这种情况的发生,C++中基类的析构函数应采用虚析构函数
- 答法3:
- 假设现在有一个基类和一个派生类,
- 如果我再写一个新的类,在派生类中通过指针调用这个类,也就是加入一个新的指针成员,
- 构造函数中有指针的创建,析构函数中销毁这个新的指针,
- 然后,我在主函数中使用父类指针指向子类对象,
- 调用构造函数时先调用父类再调用子类的构造函数,
- 调用析构函数时,只会调用基类的析构函数,于是就可能就会产生内存泄漏,
- 而解决内存泄漏的方法就是将析构函数变成虚析构函数
参考:其余问题-01-20 | 阿秀的学习笔记 && 所以为什么要虚析构?_bilibili
40.模板 = 函数模板 + 类模板
-
模板是C++中的一种特性,用于支持泛型编程,允许你编写独立于数据类型的代码,使同一函数或类可以处理不同类型的数据
- 换句话说,模板就是让数据类型参数化
- 现实生活中,模板就是模具,假设现在我要做蛋糕,模具固定了蛋糕的形状,但是我们可以自己选择不同的材料
- C++中也是如此,模板中的内容是固定的,但是我们选择不同的数据类型
-
模板作用:
- 1.泛型编程
- 2.代码复用
- 3.类型安全
- 4.性能优化
- 5.STL容器模板库
- 6.元编程
-
template <typename T> // templae:模板关键字 // <>里面表示参数的定义 // 1.函数模板:函数支持多个通用数据类型的参数 // template <typename T>:定义了一个模板,T是占位符,可以替换为任意数据类型 // T add(T a,T b):定义一个加法函数,T类型的参数a和b // 2个通用数据类型的函数模板 // 函数模板支持重载 template <typename T1, typename T2> void add(T1 a, T2 b) { std::cout << "下班了!!!" << std::endl; } template <typename T> T add(T a, T b) { return a + b; } int result1 = add(3, 4); // T被推导为int double result2 = add(2.5, 3.7); // T被推导为double // ================================================================================ // 2.类模板 template <typename T> class Box { private: T value; public: Box(T val) : value(val) {} T getValue() { return value; } }; Box<int> intBox(10); Box<double> doubleBox(3.14); -
模板 = 函数模板 + 类模板
-
🚩函数模板:通用的函数描述,使用任意类型(泛型)来描述函数
- 编译时,编译器推导实参的数据类型,根据实参的数据类型和函数模板,生成该类型的函数定义
- 生成函数定义的过程被叫做实例化
- 反过来说,实例化:用模板生成了一个具体的函数
-
注意事项:
-
1.模板的类型推导:编译器自动推导模板的数据类型
-
2.普通函数遇上模板函数,优先调用普通函数
- 人话:你写了一个普通的函数,又写了一个模板函数,优先调用你写的普通函数
-
3.如果函数模板有更好的匹配,优先于普通函数
- 人话:
- 你想要两个double函数相加,但是写的普通函数是两个int类型相加,
- 而模板函数更加匹配,就优先使用模板函数
- 人话:
-
4.可以为类的成员函数创建模板,但不能是虚函数和析构函数
- 成员函数模板不能是虚拟的
- 一个类的析构函数只有一个,而且根本没有参数,所以不需要模板
-
5.模板类型必须严格匹配,不能隐式转换
- 人话:
- 模板中a和b是一个类型, 不满足条件
- 你写的参数,a 是 int 类型,b 是 char 类型
- 但是模板函数中 a 和 b 都是 int 类型
- 所以不能使用,要用就等着段错误吧
- 人话:
-
6.空参数不给模板指定类型编译器可以推导成任意类型,所以编译器会报错
-
7.函数模板可以适应任意数据类型,但是,函数中的代码不一定适应任意数据类型
-
8.函数模板支持多个通用数据类型的参数
-
9.函数模板支持重载,可以有非通用数据类型的参数
- 函数重载:相同的函数名,不同的实现
-
10.函数模板具体化(也叫特化,全特化)
-
提供一个具体化的函数定义,当编译器找到与函数调用匹配的具体化定义时,将使用该定义,而不再寻找模板
-
使用优先度:普通函数 > 函数模板具体化 > 常规模板
- 如果希望使用函数模板,可以用空模板参数强制使用函数模板
-
// 模板具体化 // 由自己去进行具体的类型替换和定义 // 代替编译器去对模板生成具体的函数 // 然后具体问题具体分析 // 模板具体化,变量就没了(不使用typename) template<> void Swap<Student>(Student &s1, Student &s2) { int temp = s1.id; s1.id = s2.id; s2.id = temp; }
-
-
11.函数模板分文件编写
- 函数模板只是函数的描述,没有实体,创建函数模板的代码放在头文件中
- 函数模板的具体化有实体,编译的原理和普通函数一样,所以,声明放在头文件中,定义放在源文件中
- 头文件:函数模板,函数模板具体化的声明
- 源文件:函数模板具体化的定义
-
12.函数模板高级语法
-
1)
decltype关键字:用于查询表达式的数据类型decltype关键字分析表达式并得到它的数据类型,但是不会执行表达式- 函数调用也是一种表达式,使用
decltype关键字也不会执行函数 - 语法:
decltype(expression) var;-
decltype(表达式类型) 变量类型; -
(1)如果
expression是没有用括号括起来的标识符,则var的类型与该标识符的类型相同,包括const等限定符 -
(2)如果
expression是函数调用,则var的类型与函数的返回值类型相同- 函数不能返回
void,但可以返回void*:因为void不能声明变量,但是void*可以声明变量
- 函数不能返回
-
(3)如果
expression是左值(能取地址),或者用括号括起来的标识符,那么var的类型是expression的引用 -
(4)如果上面的条件都不满足,则
var的类型与expression的类型相同 -
结论:
decltype的结果要么和表达式的类型相同,要不就是和表达式类型的引用相同
-
-
2)函数后置返回类型
int func(int x, double y);- 等同于
auto func(int x, double) -> int; - 这里的auto是一个占位符,为函数返回值占了一个位置
-
3)C++14中auto的一个新功能
- 函数后置返回类型可以省略返回类型
(说直白点就是函数后置返回类型白学了) int func(int x, double y);- 等同于
auto func(int x, double) -> int; - 等同于
auto func(int x, double)
- 函数后置返回类型可以省略返回类型
-
-
🚩类模板:通用类的描述,使用任意类型(泛型)来描述类的定义
-
使用类模板时,指定具体的数据类型,让编译器生成该类型的类定义
-
// 类模板定义 tymplate <class T> class 类模板名 { 类的定义; } // 函数模板建议用 typenname描述通用数据类型 // 类模板建议用 class -
注意事项:
-
1.在创建对象时,必须指明具体的数据类型
-
2.使用类模板时,数据类型必须适应模板中的代码
-
3.类模板可以为通用参数指定缺省的数据类型(C++11标准的函数模板也可以)
-
4.类的成员函数可以在类外实现(说白了就相当于一般类的成员函数声明与实现)
-
5.可以用
new创建模板对象 -
6.在程序中,模板类的成员函数使用了才会创建
-
7.模板类最常用的就是作为容器类
- STL容器模板类
-
8.嵌套和递归使用模板类
- 嵌套:容器中有容器,例如数组的元素是栈,栈中的元素是数组
- 递归是嵌套的特殊情况
-
9.类模板的具体化:全特化和偏特化
- 类模板的全特化:把所有的模板变成了具体类型
- 类模板的偏特化:部分明确类型
-
-
-
参考:C++泛编程(自动推导、函数模板、类模板)_bilibili
41*.泛型编程,模板元编程
-
泛型编程:编写与数据类型无关的可重用代码,提升代码的通用性和复用性
- 关键技术:函数模板和类模板
- 特点:
- 类型抽象化:通过模板参数(如
typename T)将类型参数化,使同一段代码支持多种数据类型 - 运行时执行:生成的代码在运行时根据具体类型实例化,逻辑在运行时执行
- 类型抽象化:通过模板参数(如
- 应用场景:标准模板库(STL)中的容器(如
std::vector<T>)和算法(如std::sort)是其典型代表
-
模板元编程:在编译期间进行计算或类型操作,生成高效代码或优化运行时性能
- 关键技术:递归模板实例化,特化等
- 特点:
- 编译时计算:利用模板实例化机制在编译时完成计算(如数值计算、类型推导)
- 类型操作:通过特化和递归生成或转换类型(如
std::conditional,std::is_integral) - 零运行时开销:结果在编译时确定,无额外运行时负担
- 因为是在编译器进行处理的,所以时间复杂度为
O(0)
- 因为是在编译器进行处理的,所以时间复杂度为
42.STL六大组件
-
1.容器(数据结构):用于存储数据的类模板
- STL容器实际上就是各种数据结构的类模板
可以类比成一个碗,里面装着食材
-
2.算法:用于处理容器中的数据的函数模板
- 其实就是用来操作容器里数据的工具,例如常用的排序算法,查找算法什么的
可以类比为厨房里的厨具,可以用锅碗瓢盆,刀具什么的,切菜、炒菜、煮汤
-
3.迭代器:用于访问容器元素对象的指针
- 迭代器的本质:指针
- 迭代器是定义在容器内部的结构体
- 迭代器能够访问容器中的每一个元素
可以类比为用筷子或者勺子从碗里精确地夹住某个食物
-
4.仿函数(也叫函数对象,作用:让类的对象像函数一样可以被调用):行为类似于函数
- 作为算法的某种策略(指定算法的行为)
可以类比为做菜的特定调料或者菜谱
-
5.适配器:根据需求对现有容器或算法进行修改的工具
- 用来修改其他组件接口的STL组件,是带有一个参数的类模板(这个参数是操作的值的数据类型)
- STL定义了3种形式的适配器:容器适配器,迭代器适配器,函数适配器
-
6.空间配置器(配接器):用于控制内存分配的工具
- 负责空间配置与管理,从实作的角度看,配置器是一个实现了动态空间配置,空间管理,空间释放的类模板
可以类比为厨房里的食物柜或者冰箱,是存放食材的地方
43.🍁🍀容器的迭代器失效问题🍀🍁
-
这个问题基本参考了B站某个up的视频,同时,up也对这个问题可能的追问进行了补充
-
Q1:什么情况下迭代器会失效?
-
L1:
- a:
- C++中,迭代器失效是指当对一个容器进行修改时,原本指向容器某个元素的迭代器可能会变得无效
- 即:不再正确指向预期的元素,甚至可能导致未定义行为
- b:
- 迭代器失效问题通常发生在容器结构发生变化时,例如插入,删除,重新分配元素时
- c:
- 不同容器在不同操作下导致迭代器失效的情况也不同,面试官你想要具体问哪一个容器的迭代器失效问题?
- (追问面试官)
- a:
-
Q2:那么描述一下
vector在插入元素的时候,什么情况下已有的迭代器会失效,什么情况下已有的迭代器不会失效? -
L2:
- a:
vector容器 迭代器失效的情况:- 1.当插入操作触发内存重新分配时(如容量不足),所有的迭代器都会失效
- 2.当插入位置之后的元素发生了移动(如中间插),插入位置及其之后的迭代器会失效
- b:
vector容器 迭代器不失效的情况:- 1.如果插入操作没有触发内存重新分配(如
reserve后添加元素),迭代器不会失效 - 2.在末尾插入元素时,如果不触发重新分配,除了
end()之外的迭代器都不会失效
- 1.如果插入操作没有触发内存重新分配(如
- a:
-
代码验证:
-
#include <iostream> #include <vector> // 验证迭代器失效问题 #if 0 int main() { std::vector<int> vec = {1, 2, 3, 4, 5}; std::cout << "vec:size = " << vec.size() << std::endl; std::cout << "vec:capacity = " << vec.capacity() << std::endl; /* 当前的vec的size(当前大小) = 5 当前的vec的capacity(容量) = 5 认知:vec的空间被使用完了,如果下次再进行插入,就要进行扩容 */ /* 迭代器失效:不再指向预期的元素,甚至可能导致未定义行为(包括随机值) 迭代器失效问题: 1.当插入操作触发内存重新分配时(如容量不足),所有迭代器都会失效 2.当插入位置之后的元素发生了移动(如中间插入时),插入位置及其之后的迭代器会失效 vector容器迭代器不失效的情况: 1.如果插入操作没有触发内存重新分配(如reserve后添加元素)迭代器不会失效 2.在末尾插入元素时,如果不触发重新分配,除了end()之外的迭代器都不会失效 */ // 1.当插入操作触发内存重新分配时(如容量不足),所有迭代器都会失效 std::vector<int>::iterator it = vec.begin() + 2; std::cout << "it = " << *it << std::endl; // 插入新的元素,因为容量不够,所以vec要扩容 vec.push_back(6); std::cout << "插入后的it = " << *it << std::endl; // 为什么打印出来的是随机值? // 如何验证内存空间重新分配了? std::cout << "vec:size = " << vec.size() << std::endl; std::cout << "vec:capacity = " << vec.capacity() << std::endl; // 结果: // vec:size = 6 // vec:capacity = 10 // 说明已经扩容了,也就是说内存已经重新分配了,从而导致之前的迭代器全部失效 // 所以打印出来的it是随机值 // 如何避免这种情况? // (如果插入操作没有触发内存重新分配(如使用reserve后添加元素),迭代器不会失效) // 在插入操作之前就进行扩容 // vec.reserve(6); return 0; } #else int main() { #if 0 // 迭代器失效问题: // 2.当插入位置之后的元素发生了移动(如中间插时),插入位置及其之后的迭代器会失效 std::vector<int> vec = {1, 2, 3, 4, 5}; auto it = vec.begin() + 1; std::cout << "it = " << *it << std::endl; vec.insert(it, 10); // 1, 10, 2, 3, 4, 5 // 此时的迭代器还生效吗?不会 // 迭代器失效:不再指向预期的元素,甚至可能导致未定义行为(包括随机值) std::cout << "it = " << *it << std::endl; #else // 迭代器不失效的情况: // 2.在末尾插入元素时,如果不触发重新分配,除了end()之外的迭代器都不会失效 std::vector<int> vec = {1, 2, 3, 4, 5}; vec.reserve(10); auto it = vec.begin() + 1; std::cout << "it = " << *it << std::endl; auto it_end = vec.end(); // 1 2 3 4 5 end() std::cout << "it_end = " << *it_end << std::endl; auto it_end2 = vec.end() - 1; std::cout << "it_end2 = " << *it_end2 << std::endl; vec.push_back(6); // 1 2 3 4 5 6 end() std::cout << "it = " << *it << std::endl; std::cout << "it_end = " << *it_end << std::endl; std::cout << "it_end2 = " << *it_end2 << std::endl; #endif return 0; } #endif
参考:第一小节:迭代器失效大纲_bilibili && 【腾讯云】请你描述下什么情况下的迭代器会失效? - 飞书云文档
44.断言
-
断言是一种常用的编程手段,也是一种调试工具
- 通常用于程序开发阶段验证假设的正确性,也可以说用于排除程序中不应该出现的逻辑错误,
- 它检查程序中的某些条件是否成立,
- 如果条件为真,程序继续执行;
- 如果条件不成立,则触发断言,通常会输出错误信息并终止程序执行
-
断言分为静态断言和运行时断言
-
静态断言:
- 头文件:
<assert.h> - 编译时验证(编译时检查源代码),不会影响程序运行时的性能
- 无法在运行时进行条件判断,必须在编译时确定
- 第一个参数必须是常量表达式
- 常量表达式:编译时就能计算出确定值的表达式
- 例如:字面量,常量,函数调用
- 头文件:
-
运行时断言:
- 头文件:
<cassert> - 运行时验证(程序运行时才检查源代码),可能影响程序的性能
- 头文件:
-
语法:
-
// 1.静态断言 // condition:需要验证的条件 // "Error message":条件不成立时的错误信息 static_assert(condition, "Error message"); // 2.运行时断言 // expression:需要验证的布尔表达式 // 如果表达式的结果为 false,则触发断言 assert(expression); // =============================================== #include <cassert> int divide(int a, int b) { assert(b != 0); // 确保除数不为零 return a / b; } int main() { int result = divide(10, 2); // 正常情况 // int result = divide(10, 0); // 断言失败,程序会终止 return 0; }
-
45.C++异常处理机制
-
C++ 异常处理机制主要通过
try,catch和throw这三个关键字实现 -
当出现异常分支,返回值不能作为非法值判断的时候,抛出异常
-
抛出异常:
throw- 用于抛出异常,表示程序遇到错误或异常情况
-
异常检测(运行某个代码):
try- 包裹可能引发异常的代码,异常发生时,程序会跳到相应的 catch 块
-
捕获异常:
catch- 捕获并处理异常,可以根据异常类型进行匹配,捕获到不同类型的异常时执行相应的处理
-
terminate函数:终止程序
-
throw关键字:- 在程序进行不下去的时候终止函数,抛出异常
- 当抛出异常没有被捕获的时候,默认调用
terminate函数
-
noexcept关键字:声明当前函数不会抛出异常
-
-
#include <iostream> // 根据 b 的不同值,抛出不同类型的异常: // 如果 b == 0,抛出一个 int 类型的异常 // 如果 b == 2,抛出一个 const char* 类型的异常(字符串 "除数为2") // 如果 b == 3,抛出一个 double 类型的异常(3.14) int divide(int a, int b) { // 抛出0 if (b == 0) { throw 0; } if (b == 2) { throw "除数为2"; } if (b == 3) { throw 3.14; } return a / b; } int main() { // 尝试运行某些代码 try { std::cout << divide(3, 3) << std::endl; } catch(int a) // 捕获某类型的异常:根据异常的类型(严格匹配,不存在隐式转换)进行捕获 { // 异常处理 // 如果抛出 int 类型的异常,输出此消息 std::cout << "除0异常!\n"; } catch(char a) // 捕获char类型的 { // 如果抛出 char 类型的异常,输出此消息 std::cout << "char!\n"; } catch(char const *s) { std::cout << s << std::endl; } catch(...) //捕获所有其他的异常 { // 输出捕获到的其他异常 std::cout << "其他异常!\n"; } return 0; }
终于终于终于终于整理完了,再搞下去迟早得变成C++的形状
朋友:要不你去连续刷一周算法题吧
我:其实我觉得整理八股文也没有什么不好的对吧

浙公网安备 33010602011771号