C++ 面试常见问题(C++面向对象)
面向对象三大特性
面试高频指数:★★★★☆
C++ 面向对象编程 (OOP) 的三大特性包括:封装、继承和多态。
封装(Encapsulation)
封装是将数据(属性)和操作这些数据的函数(方法)组合在一个类(Class)中的过程。
封装的主要目的是隐藏类的内部实现细节,仅暴露必要的接口给外部。
通过封装,我们可以控制类成员的访问级别(例如:public、protected 和 private),限制对类内部数据的直接访问,确保数据的完整性和安全性。
#继承(Inheritance)
继承是一个类(派生类,Derived Class)从另一个类(基类,Base Class)那里获得其属性和方法的过程。
继承允许我们创建具有共享代码的类层次结构,减少重复代码,提高代码复用性和可维护性。
在 C++ 中,访问修饰符(如 public、protected、private)控制了派生类对基类成员的访问权限。
#多态(Polymorphism)
多态是允许不同类的对象使用相同的接口名字,但具有不同实现的特性。
在 C++ 中,多态主要通过虚函数(Virtual Function)和抽象基类(Abstract Base Class)来实现。
虚函数允许在派生类中重写基类的方法,而抽象基类包含至少一个纯虚函数(Pure Virtual Function),不能被实例化,只能作为其他派生类的基类。
通过多态,我们可以编写更加通用、可扩展的代码,提高代码的灵活性。
总结:封装、继承和多态是面向对象编程的三大核心特性,能够帮助我们编写更加模块化、可重用和可维护的代码。
C++ 类成员访问权限
面试高频指数:★★☆☆☆
在 C++ 中,类成员的访问权限是通过访问修饰符来控制的。
有三种访问修饰符:public、private 和 protected,分别定义了类成员的访问级别,控制类成员的可见性和可访问性。
#public(公共)
公共成员在任何地方都是可访问的。
调用方可以直接访问和修改公共成员,公共访问修饰符通常用于类的外部接口。
但是一般情况下,不建议将类的成员变量设置为 public,因为这不符合封装的原则。
class MyClass {
public:
int x;
};
x 是一个公共成员,可以在类的对象中被访问。
#private(私有)
private(私有)
私有成员只能在类的内部访问,即仅在类的成员函数中可以访问。
私有成员用于实现类的内部实现细节,这些细节对于类的用户来说是隐藏的。
class MyClass {
private:
int x;
};
上面的x 是一个私有成员,不能在类的外部被直接访问,要想访问 x,必须由 MyClass 封装一些对外的 public 函数。
#protected(受保护)
受保护成员类似于私有成员,但它们可以被派生类访问。
受保护成员通常用于继承和多态等场景,这样子类也可以访问父类的成员变量。
class MyBaseClass {
protected:
int x;
};
class MyDerivedClass : public MyBaseClass {
public:
void setX(int a) {
x = a;
}
};
重载、重写、隐藏的区别
面试高频指数:★★★☆☆
在 C++ 中,重载(Overloading)、重写(Overriding)和隐藏(Hiding)这几个概念很容易搞混,接下来解释一下它们之间的区别:
#一、 重载(Overloading):
重载是指相同作用域(比如命名空间或者同一个类)内拥有相同的方法名,但具有不同的参数类型和/或参数数量的方法。 重载允许根据所提供的参数不同来调用不同的函数。它主要在以下情况下使用:
- 方法具有相同的名称。
- 方法具有不同的参数类型或参数数量。
- 返回类型可以相同或不同。
- 同一作用域,比如都是一个类的成员函数,或者都是全局函数
例如:
class OverloadingExample {
void display(int a) {
System.out.println("Display method with integer: " + a);
}
void display(String b) {
System.out.println("Display method with string: " + b);
}
}
二、重写(Overriding)
重写是指在派生类中重新定义基类中的方法。
当派生类需要改变或扩展基类方法的功能时,就需要用到重写。
重写的条件包括:
-
方法具有相同的名称。
-
方法具有相同的参数类型和数量。
-
方法具有相同的返回类型。
-
重写的基类中被重写的函数必须有virtual修饰。
-
重写主要在继承关系的类之间发生。
例如:
#include <iostream>
class BaseClass {
public:
virtual void display() {
std::cout << "Display method in base class" << std::endl;
}
};
class DerivedClass : public BaseClass {
public:
void display() override {
std::cout << "Display method in derived class" << std::endl;
}
};
int main() {
DerivedClass derived;
derived.display();
return 0;
}
三、隐藏(Hiding)
隐藏是指派生类的函数屏蔽了与其同名的基类函数。注意只要同名函数,不管参数列表是否相同,基类函数都会被隐藏。 示例:
#include<iostream>
using namespace std;
classA{
public:
void fun1(int i, int j){
cout <<"A::fun1() : " << i <<" " << j << endl;
}
};
classB : public A{
public:
//隐藏
void fun1(double i){
cout <<"B::fun1() : " << i << endl;
}
};
int main(){
B b;
b.fun1(5);//调用B类中的函数
b.fun1(1, 2);//出错,因为基类函数被隐藏
system("pause");
return 0;
}
四、区别
#4.1 重载和重写的区别:
- 范围区别:重写和被重写的函数在不同的类中,重载和被重载的函数在同一类中(同一作用域)。
- 参数区别:重写与被重写的函数参数列表一定相同,重载和被重载的函数参数列表一定不同。
- virtual的区别:重写的基类必须要有virtual修饰,重载函数和被重载函数可以被virtual修饰,也可以没有。
#4.2 隐藏和重写,重载的区别:
- 与重载范围不同:隐藏函数和被隐藏函数在不同类中。
- 参数的区别:隐藏函数和被隐藏函数参数列表可以相同,也可以不同,但函数名一定同;当参数不同时,无论基类中的函数是否被virtual修饰,基类函数都是被隐藏,而不是被重写。
#4.3 示例代码:
#include<iostream>
using namespace std;
class A{
public:
void fun1(int i, int j){
cout <<"A::fun1() : " << i <<" " << j << endl;
}
void fun2(int i){
cout <<"A::fun2() : " << i << endl;
}
virtual void fun3(int i){
cout <<"A::fun3(int) : " << i << endl;
}
};
class B : public A{
public:
//隐藏
void fun1(double i){
cout <<"B::fun1() : " << i << endl;
}
//重写
void fun3(int i){
cout <<"B::fun3(int) : " << i << endl;
}
//隐藏
void fun3(double i){
cout <<"B::fun3(double) : " << i << endl;
}
};
int main(){
B b;
A * pa = &b;
B * pb = &b;
pa->fun3(3); // 重写,多态性,调用B的函数
b.fun3(10); // 根据参数选择调用哪个函数,可能重写也可能隐藏,调用B的函数
pb->fun3(20); //根据参数选择调用哪个函数,可能重写也可能隐藏,调用B的函数
system("pause");
return 0;
}
输出结果:
B::fun3(int) : 3
B::fun3(int) : 10
B::fun3(int) : 20
请按任意键继续. . .
C++ 类对象的初始化和析构顺序
面试高频指数:★★★★☆
类的初始化顺序
整体上来说,在C++中,类对象的初始化顺序遵循以下规则:
#1. 基类初始化顺序
如果当前类继承自一个或多个基类,它们将按照声明顺序进行初始化,但是在有虚继承和一般继承存在的情况下,优先虚继承。
比如虚继承:class MyClass : public Base1, public virtual Base2,此时应当先调用 Base2 的构造函数,再调用 Base1 的构造函数。
#2. 成员变量初始化顺序
类的成员变量按照它们在类定义中的声明顺序进行初始化(这里一定要注意,成员变量的初始化顺序只与声明的顺序有关!!)。
#3. 执行构造函数
在基类和成员变量初始化完成后,执行类的构造函数。
#举个例子:
#include <iostream>
class Base {
public:
Base() { std::cout << "Base constructor" << std::endl; }
~Base() {
std::cout << "Base destructor" << std::endl;
}
};
class Base1 {
public:
Base1() { std::cout << "Base1 constructor" << std::endl; }
~Base1() {
std::cout << "Base1 destructor" << std::endl;
}
};
class Base2 {
public:
Base2() { std::cout << "Base2 constructor" << std::endl; }
~Base2() {
std::cout << "Base2 destructor" << std::endl;
}
};
class Base3 {
public:
Base3() { std::cout << "Base3 constructor" << std::endl; }
~Base3() {
std::cout << "Base3 destructor" << std::endl;
}
};
class MyClass : public virtual Base3, public Base1, public virtual Base2 {
public:
MyClass() : num1(1), num2(2) {
std::cout << "MyClass constructor" << std::endl;
}
~MyClass() {
std::cout << "MyClass destructor" << std::endl;
}
private:
int num1;
int num2;
// 这个是为了看成员变量的初始化顺序
Base base;
};
int main() {
MyClass obj;
return 0;
}
Base3 constructor // 虚继承排第一
Base2 constructor // 虚继承优先
Base1 constructor // 普通基类
Base constructor // 成员函数构造
MyClass constructor // 构造函数
类的析构顺序
记住一点即可,类的析构顺序和构造顺序完全相反
示例:
#include <iostream>
class Base {
public:
Base() { std::cout << "Base constructor" << std::endl; }
~Base() {
std::cout << "Base destructor" << std::endl;
}
};
class Base1 {
public:
Base1() { std::cout << "Base1 constructor" << std::endl; }
~Base1() {
std::cout << "Base1 destructor" << std::endl;
}
};
class Base2 {
public:
Base2() { std::cout << "Base2 constructor" << std::endl; }
~Base2() {
std::cout << "Base2 destructor" << std::endl;
}
};
class Base3 {
public:
Base3() { std::cout << "Base3 constructor" << std::endl; }
~Base3() {
std::cout << "Base3 destructor" << std::endl;
}
};
class MyClass : public virtual Base3, public Base1, public virtual Base2 {
public:
MyClass() : num1(1), num2(2) {
std::cout << "MyClass constructor" << std::endl;
}
~MyClass() {
std::cout << "MyClass destructor" << std::endl;
}
private:
int num1;
int num2;
Base base;
};
int main() {
MyClass obj;
return 0;
}
C++ 析构函数可以抛出异常吗?
面试高频指数:★★☆☆☆
首先,从语法层面 C++ 并没有禁止析构函数抛出异常,但是实践中建议不要这样做。
#Effective C++ 条款08:别让异常逃离析构函数
由于析构函数常常被自动调用,在析构函数中抛出的异常往往会难以捕获,引发程序非正常退出或未定义行为。
另外,我们都知道在容器析构时,会逐个调用容器中的对象析构函数,而某个对象析构时抛出异常还会引起后续的对象无法被析构,导致资源泄漏。
这里的资源可以是内存,也可以是数据库连接,或者其他类型的计算机资源。
析构函数是由C++来调用的,源代码中不包含对它的调用,因此它抛出的异常不可被捕获。
对于栈中的对象而言,在它离开作用域时会被析构;对于堆中的对象而言,在它被delete时析构。
如:
class C{
public:
~C(){ throw 1;}
};
void main(){
try{
C c;
}
catch(...){}
}
析构的异常并不会被捕获,因为try{}代码块中只有一行代码C c,它并未抛出异常。运行时会产生这样的错误输出:
libC++abi.dylib: terminating with uncaught exception of type int
如果在析构函数中真的可能存在异常,该如何处理呢?
看一个例子,假设你使用一个 Class 负责数据库连接:
class DBConnection
{
public:
...
static DBConnection create(); //返回DBConnection对象;为求简化暂略参数
void close(); //关闭联机;失败则抛出异常。
};
为确保客户不忘记在 DBConnection 对象身上调用 close(),一个合理的想法是创建一个用来管理 DBConection 资源的 class,并在其析构函数中调用 close 。
这就是著名的以对象管理资源即 RAII
//这个class用来管理DBConnection对象
class DBConn
{
public:
...
DBConn(const DBConnection& db)
{
this->db = db;
}
~DBConn() //确保数据库连接总是会被关闭
{
db.close();
}
private:
DBConnection db;
};
如果调用close成功,没有任何问题。但如果该调用导致异常,DBConn析构函数会传播该异常,如果离开析构函数,那会造成问题,解决办法如下:
#1. 如果close抛出异常就结束程序,通常调用abort完成:
DBConn::~DBconn()
{
try
{
db.close();
}
catch(...)
{
abort();
}
}
DBConn::~DBConn
{
try{ db.close();}
catch(...)
{
//制作运转记录,记下对close的调用失败!
}
}
一般而言,将异常吞掉不太好,然而有时候吞下异常比“草率结束程序”或“不明确行为带来的风险”好。
能够这么做的一个前提就是程序必须能够继续可靠的执行。
#3. 重新设计 DBConn 接口,使客户有机会对出现的异常作出反应
我们可以给 DBConn 添加一个close函数,赋予客户一个机会可以处理“因该操作而发生的异常”。
把调用close的责任从 DBConn 析构函数手上移到 DBConn 客户手中,你也许会认为它违反了“让接口容易被正确使用”的忠告。
实际上这污名并不成立。如果某个操作可能在失败的时候抛出异常,而又存在某种需要必须处理该异常,那么这个异常必须来自析构函数以外的某个函数。
因为析构函数吐出异常就是危险,总会带来“过早结束程序”或“发生不明确行为”的风险。
总结
析构函数可以抛出异常,但是这种做法是非常危险的,通常不推荐。 因为析构函数具有一种清理资源的特性,如果析构函数本身抛出异常,可能导致以下问题:
-
资源泄露:当一个对象被析构时,析构函数负责释放该对象持有的资源。如果析构函数抛出异常,这个过程可能会中断,导致资源泄露。
-
叠加异常:如果析构函数在处理另一个异常时抛出异常,会导致异常叠加。这种情况下,程序将无法处理两个异常,从而可能导致未定义行为或程序崩溃。
为了避免这些问题,通常建议在析构函数中处理异常或者避免执行会抛出异常的函数,可以在析构函数中使用 try-catch 块来捕获和处理潜在的异常,确保资源得到正确释放和清理。 保证程序的稳定性和健壮性。
C++ 中深拷贝和浅拷贝
面试高频指数:★★★★☆
C++中的深拷贝和浅拷贝涉及到对象的复制。
当对象包含指针成员时,这两种拷贝方式的区别变得尤为重要。
浅拷贝(Shallow Copy)
浅拷贝是一种简单的拷贝方式,它仅复制对象的基本类型成员和指针成员的值,而不复制指针所指向的内存。
这可能导致两个对象共享相同的资源,从而引发潜在的问题,如内存泄漏、意外修改共享资源等。
一般来说编译器默认帮我们实现的拷贝构造函数就是一种浅拷贝。
POD(plain old data) 类型的数据就适合浅拷贝,对于 POD的理解可以看下这篇class和struct的区别(opens new window)
简单来说,浅拷贝也可以理解为直接按 bit 位复制,基本等价于 memcpy() 函数。
#深拷贝(Deep Copy)
深拷贝不仅复制对象的基本类型成员和指针成员的值,还复制指针所指向的内存。
因此,两个对象不会共享相同的资源,避免了潜在问题。
深拷贝通常需要显式实现拷贝构造函数和赋值运算符重载。
举个例子:
#include <iostream>
#include <cstring>
class MyClass {
public:
MyClass(const char* str) {
data = new char[strlen(str) + 1];
strcpy(data, str);
}
// 深拷贝的拷贝构造函数
MyClass(const MyClass& other) {
data = new char[strlen(other.data) + 1];
strcpy(data, other.data);
}
// 深拷贝的赋值运算符重载
MyClass& operator=(const MyClass& other) {
if (this == &other) {
return *this;
}
delete[] data;
data = new char[strlen(other.data) + 1];
strcpy(data, other.data);
return *this;
}
void SetString(const char* str) {
if (data != NULL) {
delete[] data;
}
data = new char[strlen(str) + 1];
strcpy(data, str);
}
~MyClass() {
delete[] data;
}
void print() {
std::cout << data << std::endl;
}
private:
char* data;
};
int main() {
MyClass obj1("Hello, World!");
MyClass obj2 = obj1; // 深拷贝
obj1.print(); // 输出:Hello, World!
obj2.print(); // 输出:Hello, World!
// 修改obj2中的数据,不会影响obj1
obj1.SetString("Test");
obj2.print(); // 输出:Hello, World!
return 0;
}
上面例子中,实现了一个简单的 MyClass 类,其中包含一个指向动态分配内存的指针。
在拷贝构造函数和赋值运算符重载中,重新动态分配了内存,实现了深拷贝。
这样,当创建一个新对象并从另一个对象拷贝数据时,两个对象不会共享相同的资源。
C++多态的实现方式
面试高频指数:★★★★☆
C++中的多态是指同一个函数或者操作在不同的对象上有不同的表现形式。
C++实现多态的方法主要包括虚函数、纯虚函数和模板函数
其中虚函数、纯虚函数实现的多态叫动态多态,模板函数、重载等实现的叫静态多态。
区分静态多态和动态多态的一个方法就是看决定所调用的具体方法是在编译期还是运行时,运行时就叫动态多态。
#虚函数、纯虚函数实现多态
在 C++ 中,可以使用虚函数来实现多态性。
虚函数是指在基类中声明的函数,它在派生类中可以被重写。
当我们使用基类指针或引用指向派生类对象时,通过虚函数的机制,可以调用到派生类中重写的函数,从而实现多态。
C++ 的多态必须满足两个条件:
-
必须通过基类的指针或者引用调用虚函数
-
被调用的函数是虚函数,且必须完成对基类虚函数的重写
举例说明:
class Shape {
public:
virtual int area() = 0;
};
class Rectangle: public Shape {
public:
int area () {
cout << "Rectangle class area :";
return (width * height);
}
};
class Triangle: public Shape{
public:
int area () {
cout << "Triangle class area :";
return (width * height / 2);
}
};
int main() {
Shape *shape;
Rectangle rec(10,7);
Triangle tri(10,5);
shape = &rec;
shape->area();
shape = &tri;
shape->area();
return 0;
}
模板函数多态
模板函数可以根据传递参数的不同类型,自动生成相应类型的函数代码。模板函数可以用来实现多态。 举例说明:
template <class T>
T GetMax (T a, T b) {
return (a>b?a:b);
}
int main () {
int i=5, j=6, k;
long l=10, m=5, n;
k=GetMax<int>(i,j);
n=GetMax<long>(l,m);
cout << k << endl;
cout << n << endl;
return 0;
}
在上面这个例子用,编译器会生成两个 GetMax 函数实例,参数类型分别是 int 和 long 类型,这种调用的函数在编译期就能确定下来的叫静态多态。
#函数重载多态
静态多态还包括了函数重载。
this 指针
面试高频指数:★★★★☆
#关于 this 指针
this 是一个指向当前对象的指针。
其实在面向对象的编程语言中,都存在this指针这种机制, Java、C++ 叫 this,而 Python 中叫 self。
在类的成员函数中访问类的成员变量或调用成员函数时,编译器会隐式地将当前对象的地址作为 this 指针传递给成员函数。
因此,this 指针可以用来访问类的成员变量和成员函数,以及在成员函数中引用当前对象。
#例子:
#include <iostream>
class MyClass {
public:
MyClass(int value) : value_(value) {}
// 成员函数中使用 this 指针访问成员变量
void printValue() const {
std::cout << "Value: " << this->value_ << std::endl;
}
// 使用 this 指针实现链式调用
MyClass& setValue(int value) {
this->value_ = value;
return *this; // 返回当前对象的引用
}
private:
int value_;
};
int main() {
MyClass obj(10);
obj.printValue(); // 输出 "Value: 10"
// 链式调用 setValue 函数
obj.setValue(20).setValue(30);
obj.printValue(); // 输出 "Value: 30"
return 0;
}
static 函数不能访问成员变量
在C++中,static 函数是一种静态成员函数,它与类本身相关,而不是与类的对象相关。
大家可以将 static 函数视为在类作用域下的全局函数,而非成员函数。
因为静态函数没有 this 指针,所以它不能访问任何非静态成员变量。
如果在静态函数中尝试访问非静态成员变量,编译器会报错。
下面这个示例代码,说明了静态函数不能访问非静态成员变量:
class MyClass {
public:
static void myStaticFunction() {
// 以下代码会导致编译错误
// cout << this->myMemberVariable << endl;
}
private:
int myMemberVariable;
};
在上面的示例中,myStaticFunction是一个静态函数,尝试使用this指针来访问非静态成员变量myMemberVariable,但这会导致编译错误。
#总结:
this 实际上是成员函数的一个形参,在调用成员函数时将对象的地址作为实参传递给 this。
不过 this 这个形参是隐式的,它并不出现在代码中,而是在编译阶段由编译器默默地将它添加到参数列表中。
this 作为隐式形参,本质上是成员函数的局部变量,所以只能用在成员函数的内部,并且只有在通过对象调用成员函数时才给 this 赋值。
成员函数最终被编译成与对象无关的普通函数,除了成员变量,会丢失所有信息,所以编译时要在成员函数中添加一个额外的参数,把当前对象的首地址传入,以此来关联成员函数和成员变量。
这个额外的参数,实际上就是 this,它是成员函数和成员变量关联的桥梁。
C++ 纯虚函数是什么?
面试高频指数:★★★☆☆
#定义
纯虚函数是一种在基类中声明但没有实现的虚函数。
它的作用是定义了一种接口,这个接口需要由派生类来实现。(PS: C++ 中没有接口,纯虚函数可以提供类似的功能
包含纯虚函数的类称为抽象类(Abstract Class)。
抽象类仅仅提供了一些接口,但是没有实现具体的功能。
作用就是制定各种接口,通过派生类来实现不同的功能,从而实现代码的复用和可扩展性。
另外,抽象类无法实例化,也就是无法创建对象。
原因很简单,纯虚函数没有函数体,不是完整的函数,无法调用,也无法为其分配内存空间。
#例子
#include <iostream>
using namespace std;
class Shape {
public:
// 纯虚函数
virtual void draw() = 0;
};
class Circle : public Shape {
public:
void draw() {
cout << "画一个圆形" << endl;
}
};
class Square : public Shape {
public:
void draw() {
cout << "画一个正方形" << endl;
}
};
int main() {
Circle circle;
Square square;
Shape *pShape1 = &circle;
Shape *pShape2 = □
pShape1->draw();
pShape2->draw();
return 0;
}
在上面的代码中,定义了一个抽象类 Shape,它包含了一个纯虚函数 draw()。
Circle 和 Square 是 Shape 的两个派生类,它们必须实现 draw() 函数,否则它们也会是一个抽象类。
在 main() 函数中,创建了 Circle 和 Square 的实例,并且使用指向基类 Shape 的指针来调用 draw() 函数。
由于 Shape 是一个抽象类,能创建 Shape 的实例,但是可以使用 Shape 类型指针来指向派生类,从而实现多态。
为什么 C++ 构造函数不能是虚函数?
面试高频指数:★★★☆☆
#1. 从语法层面来说
虚函数的主要目的是实现多态,即允许在派生类中覆盖基类的成员函数。
但是,构造函数负责初始化类的对象,每个类都应该有自己的构造函数。
在派生类中,基类的构造函数会被自动调用,用于初始化基类的成员。因此,构造函数没有被覆盖的必要,不需要使用虚函数来实现多态。
#2. 从虚函数表机制回答
虚函数使用了一种称为虚函数表(vtable)的机制。然而,在调用构造函数时,对象还没有完全创建和初始化,所以虚函数表可能尚未设置。
这意味着在构造函数中使用虚函数表会导致未定义的行为。
只有执行完了对象的构造,虚函数表才会被正确的初始化。
为什么 C++ 基类析构函数需要是虚函数?
面试高频指数:★★★☆☆
#析构函数作用
首先我们需要知道析构函数的作用是什么。
析构函数是进行类的清理工作,比如释放内存、关闭DB链接、关闭Socket等等,
前面我们在介绍虚函数的时候就说到,为实现多态性(C++多态 (opens new window)),可以通过基类的指针或引用访问派生类的成员。
也就是说,声明一个基类指针,这个基类指针可以指向派生类对象。
#举例
#include <iostream>
class Base {
public:
// 注意,这里的析构函数没有定义为虚函数
~Base() {
std::cout << "Base destructor called." << std::endl;
}
};
class Derived : public Base {
public:
Derived() {
resource = new int[100]; // 分配资源
}
~Derived() {
std::cout << "Derived destructor called." << std::endl;
delete[] resource; // 释放资源
}
private:
int* resource; // 存储资源的指针
};
int main() {
Base* ptr = new Derived();
delete ptr; // 只会调用Base的析构函数,Derived的析构函数不会被调用
return 0;
}
这个例子执行的输出结果为:
Base destructor called.
由于基类Base的析构函数没有定义为虚函数,当创建一个派生类Derived的对象,并通过基类指针ptr删除它时,只有基类Base的析构函数被调用(因为这里没有多态,构造多态的必要条件就是虚函数)。
派生类Derived的析构函数不会被调用,导致资源(这里是resource)没有被释放,从而产生资源泄漏。
再来看一个将基类定义为虚函数的例子:
class Base {
public:
virtual ~Base() {
std::cout << "Base destructor called." << std::endl;
}
};
class Derived : public Base {
public:
~Derived() {
std::cout << "Derived destructor called." << std::endl;
}
};
int main() {
Base* ptr = new Derived();
delete ptr; // 调用Derived的析构函数,然后调用Base的析构函数
return 0;
}
输出结果为:
Derived destructor called.
Base destructor called.
在这个例子中,基类Base的析构函数是虚函数,所以当删除ptr时,会首先调用派生类Derived的析构函数,然后调用基类Base的析构函数,这样可以确保对象被正确销毁。
#为什么默认的析构函数不是虚函数?
那么既然基类的析构函数如此有必要被定义成虚函数,为何类的默认析构函数却是非虚函数呢?
首先一点,语言设计者如此设计,肯定是有道理的。
原来是因为,虚函数不同于普通成员函数,当类中有虚成员函数时,类会自动进行一些额外工作。
这些额外的工作包括生成虚函数表和虚表指针,虚表指针指向虚函数表。
每个类都有自己的虚函数表,虚函数表的作用就是保存本类中虚函数的地址,我们可以把虚函数表形象地看成一个数组,这个数组的每个元素存放的就是各个虚函数的地址。
这样一来,就会占用额外的内存,当们定义的类不被其他类继承时,这种内存开销无疑是浪费的。
这样一说,问题就不言而喻了。当我们创建一个类时,系统默认我们不会将该类作为基类,所以就将默认的析构函数定义成非虚函数,这样就不会占用额外的内存空间。
同时,系统也相信程序开发者在定义一个基类时,会显示地将基类的析构函数定义成虚函数,此时该类才会维护虚函数表和虚表指针。
#零成本抽象原则
不需要为没有使用到的语言特性付出代价。
使用某种语言特性,不会带来运行时的代价。
放在这个地方就是,如果我们知道一个类不会被其它类继承,那么也就没必要将析构函数设置为虚函数,因为一旦引入虚函数就会引入虚表机制,这会造成额外的开销。
#总结
在C++中,基类的析构函数需要定义为虚函数,以确保在通过基类指针或引用删除派生类对象时,能够正确地调用派生类的析构函数,否则派生类的析构函数不会被调用,这部分资源也就并无法被释放。
将基类的析构函数定义为虚函数后,C++运行时会自动识别指向的对象的实际类型,并确保调用正确的派生类析构函数。
当派生类析构函数执行完毕后,基类的析构函数也会自动调用,以确保对象完全销毁。
为什么C++的成员模板函数不能是 virtual 的
面试高频指数:★★☆☆☆
这个题目在面试时问得倒不是很多(一些面试官其实也答不上来),但是却值得了解,背后的原理会涉及到 C++ 的一些语法机制实现。
#问题含义
问题的意思是,为什么在C++里面,一个类的成员函数不能既是 template 又是 virtual 的。比如,下面的代码编译会报错
class Animal{
public:
template<typename T>
virtual void make_sound(){
//...
}
};
这个问题涉及到一点 C++ 的实现机制(C++中模板是如何工作的、虚拟函数是如何实现的、编译器和链接器如何从源代码生成可执行文件),所以很少人能一下子答上来。
具体理由如下:
因为C++的编译与链接模型是"分离"的(至少是部分原因吧)。
从Unix/C开始,一个C/C++程序就可以被分开编译,然后用一个linker链接起来。这种模型有一个问题,就是各个编译单元可能对另一个编译单元一无所知。
一个 function template最后到底会被 instantiate 为多少个函数,要等整个程序(所有的编译单元)全部被编译完成才知道。
同时,virtual function的实现大多利用了一个"虚函数表"(参考: 虚函数机制 (opens new window))的东西,这种实现中,一个类的内存布局(或者说虚函数表的内存布局)需要在这个类编译完成的时候就被完全确定。
所以当一个虚拟函数是模板函数时,编译器在编译时无法为其生成一个确定的虚函数表条目,因为模板函数可以有无数个实例。但是编译时无法确定需要调用哪个特定的模板实例。因此,C++标准规定member function 不能既是 template 又是 virtual 的。
sizeof 一个空类大小是多大
面试高频指数:★★☆☆☆
也就是下面这个输出多少:
class Empty {};
int main() {
Empty e1;
Empty e2;
std::cout << "Size of Empty class: " << sizeof(Empty) << std::endl;
}
大多数情况下 sizeof(Empty) = 1
原因
这是因为C++标准要求每个对象都必须具有独一无二的内存地址。
为了满足这一要求,编译器会给每个空类分配一定的空间,通常是1字节。
这样,即使是空类,也能保证每个实例都有不同的地址。
#C++之父的解释
为了确保两个不同对象的地址不同。出于相同的原因,“new”总是返回指向不同对象的指针。
考虑以下示例:
class Empty { };
void f()
{
Empty a, b;
if (&a == &b) cout << "impossible: report error to compiler supplier";
Empty* p1 = new Empty;
Empty* p2 = new Empty;
if (p1 == p2) cout << "impossible: report error to compiler supplier";
}
有一个有趣的规则是:如果一个空类做基类,那么在派生类中不需要用一个单独的字节来表示,例如
struct X : Empty {
int a;
// ...
};
void f(X* p)
{
void* p1 = p;
void* p2 = &p->a;
if (p1 == p2) cout << "nice: good optimizer";
}
上面说明了 p1 和 p2(成员变量a的地址)所指向相同的地方,也就是父类没有占空间。
这种优化允许使用空类来表示一些简单的概念,而且无需额外开销。
大多数编译器都提供了这种“空基类优化”。