今天在和群友聊天的时候看到了一道很坑的题目,分享给大家

1.看题!

先来看看题目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct Dad
{
public:
Dad(){ echo();}
~Dad(){ echo();}
virtual void echo() {
cout << "DAD ";
}
};

struct Son:Dad
{
public:
void echo() const override {
cout << "SON ";
}
};

Son ss;

请问这个的输出是什么?

1
2
3
4
5
6
A  "DAD DAD "
B "DAD SON "
C "SON DAD "
D "SON SON "
E 编译出错
F 运行出错

答案是E,编译出错!

2.涉及到的知识点

2.1 知识点

先来说说这道题目里面涉及到了什么知识点

  • 多态调用;
  • 多态重写函数需要满足什么条件;
  • 类内函数后加const的作用;
  • 类内函数后加override的作用;
  • 什么是早绑定和晚绑定

一个一个复习吧!

  • 多态调用是父类指针/引用指向子类时,调用虚函数会调用子类重写后的版本
  • 多态重写函数的条件:函数名/参数/返回值都必须相同(注意还有协变)
  • 类内函数后加const修饰的是这个对象的this指针,被修饰的函数中无法修改类内成员变量
  • 类内函数后加override是让编译器来严格检查是否构成重载
  • 早绑定:静态绑定;晚绑定:动态绑定(具体请看CPP多态的博客)

2.2 分析题目

注意看父类和子类中这两个echo()函数的区别

1
2
virtual void echo(){}//父类
void echo() const override {}//子类

首先需要说明的是,子类函数中virtual关键字是可以省略的,但即便省略了,这个函数依旧是个虚函数。

这里子类的函数中多了const修饰,而这个const修饰的就是函数中隐含的this指针,此时子类中echo()函数的参数就发生了变化!

1
2
virtual void echo(Son* this) { } // 不加const
virtual void echo(const Son* this) { } // 加const

正是因为这里的this指针出现了const的修饰,所以子类的echo和父类echo的参数类型不同,不构成虚函数重写!再加上override关键字的严格检查,会直接编译报错!

正确的写法是删除子类echo中的const或者给父类echo函数加上const

3.再来看题

好了,坑人的点看完了,再来看个「常规」的,就是把上面的题干改成能编译通过的。此时又应该选谁呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct Dad
{
public:
Dad(){ echo();}
~Dad(){ echo();}
virtual void echo() const{
cout << "DAD ";
}
};

struct Son:Dad
{
public:
void echo() const override {
cout << "SON ";
}
};

Son ss;
1
2
3
4
A  "DAD DAD "
B "DAD SON "
C "SON DAD "
D "SON SON "

image-20230822211806379

编译运行,可以看到,结果是DAD DAD,应该选A

3.1 分析

在给 Son 类定义构造函数和析构函数时,没有指定调用父类的对应构造函数和析构函数。因此,在创建 Son 对象 ss 时,会默认调用 Dad 类的构造函数和析构函数。

由于 Dad 类中的构造函数和析构函数调用了虚函数 echo(),而这个虚函数在子类 Son 中被重写,所以会根据对象类型调用相应的重写函数。然而,在构造函数和析构函数中,虚函数机制不会按照预期工作。

构造函数中调用虚函数时,会忽略动态绑定机制,直接调用父类的函数版本。因此,在 Dad 的构造函数中调用 echo(),实际上调用的是 Dad 类中的 echo() 函数,而不是 Son 类中的重写版本。

同样地,析构函数中也会忽略动态绑定机制,直接调用父类的函数版本。所以,在 Dad 的析构函数中调用 echo(),依然调用的是 Dad 类中的 echo() 函数。

因此,当创建 Son 对象 ss 并打印输出时,会先调用 Dad 类的构造函数并打印 "DAD ",然后调用 Dad 类的析构函数并再次打印 "DAD "

3.2 结论

在父类的构造和析构中,对象的版本都被确定为父类的版本,会采用早绑定来调用父类自己的函数,而不是子类的重写后的函数;

简单记忆:父类的构造和析构中如果出现虚函数,只会调用父类自己的函数!


这是因为编译器需要保证正确的构造和析构顺序,如果父类析构里调用子类的虚函数,可能会出现下面的场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct Dad
{
public:
Dad(){ echo();}
~Dad(){ echo();}
virtual void echo() const{
cout << "DAD ";
}
};

struct Son:Dad
{
public:
Son() {
_a = new int(3);
}
~Son() {
delete _a;
}
void echo() const override {
cout << "SON ";
delete _a;
}
private:
int _a;
};

Son ss;

如果父类中的析构echo()调用子类重写的函数,此时就会出现子类已经被销毁(子类的析构函数早于父类析构调用)的_a被二次delete,两次delete同一片空间是会报错的!

所以为了避免这种情况,父类的析构中采用早绑定,子类重写的虚函数不会生效!

这种行为是为了确保在对象的构造和析构过程中,按照正确的顺序调用各个类的构造和析构函数,避免在对象处于未完全初始化或已部分销毁状态时调用子类的函数。

包括父类的构造也可以这么理解,如果父类构造里面可以调用子类的虚函数,可能会出现两次对一个子类对象进行new空间,会产生内存泄露;

但构造函数还和虚函数表的初始化有关系,此时虚函数表还没有完全初始化,子类对象尚未构造完成,没有多态调用的条件,所以也不能调用到子类重写后的虚函数。