C++八股之虚函数(三)
一、虚函数可以是模板函数吗?⭐
在C++中,虚函数通常用于实现多态性,而模板函数则是一种泛型编程手段。虚函数和模板函数在设计上有不同的目的和使用场景,但它们并不是互斥的。然而,根据C++语言的规则,虚函数不能是模板函数。
由于虚函数的动态绑定特性依赖于对象的动态类型,虚函数需要在运行时根据对象的实际类型进行调用,而模板函数的参数类型是在编译时确定的,因此虚函数不能是模板函数。
- 对于含有虚函数的类,编译器需要为每个类生成一个虚函数表(vtable),以便在运行时进行动态绑定。虚函数表中存储着该类的虚函数的地址。
- 而模板函数的实例化个数是在整个程序被编译完成之后才确定的,编译器无法为模板函数生成固定数量的虚函数表。
二、请你说说虚函数的工作机制⭐⭐⭐
虚函数是C++中实现运行时多态的关键机制。它的工作机制涉及几个关键概念:虚函数表(vtable)、虚函数指针(vptr)、动态绑定(Dynamic Binding)。以下是虚函数工作机制的详细说明:
1. 虚函数表(vtable)
- 每个包含虚函数的类都有一个虚函数表,这是一个函数指针数组。
- 虚函数表中存储了类中所有虚函数的地址。
2. 虚函数指针(vptr)
- 每个包含虚函数的对象都有一个虚函数指针,这是一个指向虚函数表的指针。
- 虚函数指针通常存储在对象内存布局的最前面。
3. 动态绑定(Dynamic Binding)
- 当通过基类指针或引用调用虚函数时,程序运行到该调用点时,会使用对象的虚函数指针来查找正确的虚函数。
- 编译器在编译时生成必要的代码,确保在运行时能够根据对象的实际类型确定调用哪个函数。
4. 构造函数和析构函数
- 即使构造函数和析构函数被声明为虚函数,它们的调用仍然在编译时解析(静态绑定),以确保对象的正确构造和析构。
5. 纯虚函数和抽象类
- 纯虚函数是没有任何实现的虚函数,它使用 = 0 声明。
- 包含至少一个纯虚函数的类是抽象类,不能被实例化,但可以作为其他类的基类。
6. 虚函数的重写(Overriding)
- 当派生类提供了与基类中虚函数相同签名的函数时,这被视为重写。
- 派生类的虚函数会覆盖基类中的同名虚函数。
7. override
关键字
- C++11引入了 override 关键字,用于明确指出函数是重写基类中的虚函数。
- 使用 override 可以帮助编译器检查是否正确重写了基类中的虚函数。
工作流程示例:
class Base { public: virtual void show() { std::cout << "Base show" << std::endl; } }; class Derived : public Base { public: void show() override { // 重写基类虚函数 std::cout << "Derived show" << std::endl; } }; int main() { Base* basePtr = new Derived(); // 基类指针指向派生类对象 basePtr->show(); // 动态绑定调用Derived::show delete basePtr; return 0; }
在这个例子中,Base
类有一个虚函数 show
,Derived
类重写了这个虚函数。在 main
函数中,通过基类指针 basePtr
调用 show
函数时,由于动态绑定,实际调用的是 Derived::show
函数。
总结一下,虚函数的实现方式通常包括在对象中添加一个指向虚函数表的指针(vptr),虚函数表存储了虚函数的地址,子类继承并重写父类的虚函数时会替换相应的地址,通过 vptr 指针和虚函数表来实现动态绑定和多态。虚函数的实现会带来额外的内存访问开销。
三、虚函数表在什么时候创建?每个对象都有一份虚函数表吗?
虚函数表(vtable)的创建和管理是C++运行时系统中一个重要的组成部分,具体细节可能因编译器和平台而异,但以下是一些通用的规则和概念:
虚函数表的创建:
- 类定义时:当编译器遇到一个类定义,并且该类包含至少一个虚函数时,编译器会为这个类创建一个虚函数表。
- 编译时生成:虚函数表是在编译时生成的,并且作为程序的一部分存储在可执行文件的代码段或数据段中。
- 每个类一个:每个包含虚函数的类都有自己的虚函数表,即使派生类没有添加新的虚函数,也会有一个虚函数表来覆盖基类中的虚函数。
虚函数表的内容:
- 函数指针:虚函数表中包含类中所有虚函数的指针,这些指针指向相应的函数实现。
- 继承和覆盖:如果派生类重写了基类中的虚函数,那么派生类的虚函数表中的相应条目会指向派生类中的实现,而不是基类中的实现。
虚函数表的使用:
- 对象创建:当创建一个对象时,编译器会为该对象生成一个虚函数指针(vptr),这个指针指向类的虚函数表。
- 动态绑定:在运行时,通过基类指针或引用调用虚函数时,程序会使用对象的虚函数指针来查找并调用正确的函数。
每个对象的虚函数表:
- 不是每个对象都有:实际上,每个类只有一个虚函数表,而不是每个对象都有一个。对象通过虚函数指针(vptr)来共享这个表。
- 虚函数指针:每个对象都有一个虚函数指针,它指向类的虚函数表,而不是对象自己拥有一个独立的表。
- 内存布局:对象的内存布局中通常包含一个或多个指针,指向类的虚函数表,以及对象的实际数据成员。
示例:
class Base { public: virtual void show() { std::cout << "Base show" << std::endl; } virtual ~Base() {} // 虚析构函数 }; class Derived : public Base { public: void show() override { std::cout << "Derived show" << std::endl; } }; int main() { Base* basePtr = new Derived();//创建一个派生类对象,并将其地址赋给一个基类类型的指针 basePtr->show(); // 调用Derived::show,通过虚函数表实现动态绑定 delete basePtr; return 0; }
在这个例子中,Base
类和 Derived
类共享同一个虚函数表,但 Derived
类的 show
函数会覆盖 Base
类中的实现。Derived
类的对象通过虚函数指针与这个共享的虚函数表关联。
虚函数表和虚函数指针是C++实现多态性的关键机制,它们使得运行时确定函数调用成为可能,同时保持了内存使用的效率。
四、请说说操作符重载?哪些操作符不能重载?⭐⭐
操作符重载是一种特殊的函数重载,可以使得某些运算符在对特定对象进行操作时具有自定义的行为。通过重载操作符,可以为自定义的类类型创建与内置类型相似的语法和行为。
#include <iostream> class MyNumber { private: int value; public: MyNumber(int val) : value(val) {} MyNumber operator+(const MyNumber& other) { return MyNumber(value + other.value); } int getValue() const { return value; } }; int main() { MyNumber num1(5); MyNumber num2(10); MyNumber sum = num1 + num2; std::cout << "The sum is: " << sum.getValue() << std::endl; return 0; }
然而,并不是所有的操作符都可以被重载。以下操作符不能被重载:
- 成员选择操作符(.):无法改变点操作符的行为。
- 展开操作符(::):它用于指定作用域,不能被重载。
- 条件运算符(?:):无法改变条件运算符的行为。
- sizeof:它是一个关键字,无法重载。
- typeid:它是一个运算符,无法重载。
五、什么是纯虚函数?⭐
纯虚函数(Pure Virtual Function)是在基类中声明但没有提供实现的虚函数。它的声明形式为在函数原型后面加上= 0。纯虚函数在基类中起到以下作用:
- 提供接口定义:纯虚函数在基类中定义了一种接口,规定了派生类必须实现的函数。基类通过纯虚函数定义了一组可供派生类实现的操作,从而实现了接口的定义。
- 实现多态性:派生类可以根据自己的需要对纯虚函数进行重写,具体的实现可以根据派生类的特性和需求而自由定义,从而实现了多态性的特征。通过基类指针或引用调用纯虚函数,可以在运行时根据指针或引用所指向的实际对象类型来调用对应的派生类的实现。
#include <iostream> // 基类定义 class Base { public: // 纯虚函数 virtual void pureVirtualFunc() const = 0; }; // 派生类1实现纯虚函数 class Derived1 : public Base { public: void pureVirtualFunc() const override { std::cout << "Derived1: Implementing pure virtual function" << std::endl; } }; // 派生类2实现纯虚函数 class Derived2 : public Base { public: void pureVirtualFunc() const override { std::cout << "Derived2: Implementing pure virtual function" << std::endl; } }; int main() { Derived1 d1; Derived2 d2; // 通过基类指针调用派生类的实现 Base* basePtr1 = &d1; basePtr1->pureVirtualFunc(); Base* basePtr2 = &d2; basePtr2->pureVirtualFunc(); return 0; }
在以上示例代码中,Base是一个基类,其中的pureVirtualFunc是一个纯虚函数。Derived1和Derived2是两个派生类,它们都重写了基类中的纯虚函数,并提供了各自的实现。在main函数中,我们创建了Derived1和Derived2的对象,并通过基类指针调用它们的纯虚函数实现。这样就实现了多态性,不同的派生类对象根据实际类型执行不同的操作。
六、虚函数可以内联吗?⭐
虚函数不能直接被标记为内联函数,因为虚函数在运行时通过虚函数表进行调用,而内联函数需要在编译时进行替换,两者的机制是不相容的。
- 由于虚函数调用发生在运行时,需要经过动态绑定过程,编译器在编译期无法确定具体的实现。
- 而内联函数在编译期间进行替换,需要在编译时确定函数体的实际代码。
虚函数的调用是通过虚函数表(vtable)实现的,每个包含虚函数的类都有一个虚函数表,存储了每个虚函数的地址。在调用虚函数时,通过对象的虚函数表找到正确的函数地址进行调用。这个过程是在运行时发生的,无法在编译期间进行内联优化。
七、析构函数一般写成虚函数的原因?构造函数为什么一般不定义为虚函数?构造函数或者析构函数中调用虚函数会怎样?⭐⭐⭐(一定要理解)
析构函数一般写成虚函数的原因:
- 多态性:在C++中,如果一个类包含虚函数,那么析构函数应该被声明为虚函数。这是因为当通过基类的指针或引用删除一个派生类对象时,需要正确地调用派生类的析构函数来完成对象的销毁。
- 避免悬空指针:如果基类的析构函数不是虚函数,而派生类对象通过基类指针被删除,那么基类的析构函数将被调用,而派生类的析构函数将不会被调用。这将导致派生类特有的资源没有被释放,从而造成内存泄漏或其他资源泄漏。
- 资源管理:派生类可能拥有一些基类中没有的资源,如动态分配的内存。虚析构函数确保当对象被删除时,所有这些资源都能被适当地清理。
构造函数为什么一般不定义为虚函数:
- 初始化目的:构造函数的主要目的是初始化对象。它们不应该是虚函数,因为它们在创建对象时就被调用,而不是在对象的生命周期中被动态地选择。
- 对象标识:构造函数在对象创建时确定对象的类型。如果构造函数是虚函数,那么在对象创建过程中,编译器如何确定调用哪个虚构造函数就会变得不明确。
- 对象已存在:当调用虚函数时,对象必须已经存在,以便能够调用正确的函数实现。然而,在构造函数执行时,对象尚未完全构造,因此不能使用虚函数。
构造函数或者析构函数中调用虚函数会怎样:
- 构造函数中调用虚函数:这通常不是一个好的做法,因为对象尚未完全构造,派生类的成员可能还没有被初始化。如果构造函数中调用了虚函数,那么基类中的实现将被调用,这可能不是预期的行为。
- 析构函数中调用虚函数:析构函数中可以调用虚函数,但通常是为了执行一些基类中定义的行为。然而,由于析构函数的调用顺序是从派生类到基类,所以当基类的析构函数被调用时,派生类的对象已经被销毁,此时调用派生类中的虚函数实现可能会导致未定义行为。
- 对象销毁顺序:在析构过程中,首先调用派生类的析构函数,然后才是基类的析构函数。如果在派生类的析构函数中调用虚函数,将调用派生类中的实现;而在基类的析构函数中调用虚函数,将调用基类中的实现。
示例:
class Base { public: virtual ~Base() { std::cout << "Base destructor" << std::endl; } virtual void behavior() { std::cout << "Base behavior" << std::endl; } }; class Derived : public Base { public: ~Derived() override { behavior(); } // 在析构函数中调用虚函数 void behavior() override { std::cout << "Derived behavior" << std::endl; } }; int main() { Base* basePtr = new Derived; delete basePtr; // 正确调用Derived的析构函数和behavior return 0; }
在这个示例中,Derived
类的析构函数中调用了虚函数 behavior()
,这将展示多态性,调用 Derived
类中的 behavior()
实现。如果 behavior()
被调用在 Base
类的构造函数中,它将调用 Base
类的实现,这可能不是预期的行为,因为对象的派生部分尚未构造完成。
八、请说说多重继承的二义性⭐
多重继承是指一个类(称为派生类)可以同时继承多个基类的特性。虽然多重继承提供了更大的灵活性,但它也可能引起一些问题,其中最典型的是二义性(Ambiguity)。
二义性问题:
- 相同名称的成员:当多个基类中有相同名称的成员(如成员变量或成员函数)时,这些成员在派生类中的引用就会变得不明确。派生类对象不知道应该引用哪个基类的成员。
- 虚继承:虚继承是解决多重继承中二义性的一个机制。当使用虚继承时,每个基类只会在派生类的对象中存储一份其成员的副本。这样,即使多个基类中有相同名称的成员,它们也不会引起二义性。
- 钻石继承问题:钻石继承是指一个类有两个基类,而这些基类又有一个共同的基类。这种情况下,如果不使用虚继承,共同基类的成员将在派生类的对象中存储两次,造成数据冗余和二义性。
示例:
class Base1 { public: void func() { std::cout << "Base1 func" << std::endl; } int value; }; class Base2 : virtual public Base1 { // 使用虚继承 public: void func() { std::cout << "Base2 func" << std::endl; } }; class Derived : public Base1, public Base2 { public: // 如何调用func?存在二义性 };
在这个例子中,Derived
类从 Base1
和 Base2
继承,而 Base2
又虚继承自 Base1
。如果在 Derived
类中直接调用 func()
,将产生二义性,因为 Base1
和 Base2
中都有 func()
。
解决二义性的方法:
- 明确指定:使用作用域运算符(::)来明确指定要调用的基类中的成员。
- 虚函数重写:在派生类中重写所有基类中的同名虚函数,并提供具体的实现。
- 访问控制:将一些成员声明为私有(private),并在派生类中提供公共的访问接口。
- 名称隐藏:有意让一个基类的成员被另一个基类的同名成员隐藏,然后在派生类中使用。
- 设计重构:重新设计类层次结构,避免不必要的多重继承,减少二义性的可能性。
理解多重继承中的二义性问题对于编写清晰、可维护的面向对象代码至关重要。在实际编程中,应谨慎使用多重继承,并考虑使用接口、组合等其他设计模式来替代或减少多重继承的使用。
九、可以通过引用实现多态吗?⭐
可以通过引用实现多态。多态是面向对象编程的一个重要概念,它允许使用基类类型的引用或指针来引用派生类对象,并根据实际对象的类型来调用相应的成员函数。
#include <iostream> class Animal { public: virtual void makeSound() const { std::cout << "Animal makes a sound. "; } }; class Dog : public Animal { public: void makeSound() const override { std::cout << "Dog barks. "; } }; class Cat : public Animal { public: void makeSound() const override { std::cout << "Cat meows. "; } }; int main() { Dog dog; Cat cat; // 使用基类类型的引用,实现多态 Animal& animal1 = dog; //基类引用绑定到派生类对象 Animal& animal2 = cat; animal1.makeSound(); // 输出:Dog barks. animal2.makeSound(); // 输出:Cat meows. return 0; }
在上面的示例中,Animal 是基类,Dog 和 Cat 是派生类。Animal 类中有一个虚函数 makeSound(),并在派生类中进行了重写。在 main 函数中,我们先创建了 Dog 和 Cat 的实例,然后使用 Animal 类型的引用 animal1 和 animal2 分别引用这些对象。通过调用引用的 makeSound() 函数,实际调用的是各个对象对应的派生类的函数,实现了多态。
#自动驾驶##机器人##C++##八股#在自动驾驶和机器人领域,C++因其高性能、内存管理高效和跨平台兼容性等特性,被广泛应用。本专栏整理了C++面试中常遇到的八股问题,可私信作者要飞书文档,不论是嵌入式软开、算法、软件开发都可以阅读,包括了C++的虚函数、C++11新特性、C++的STL库、Linux常见命令......