OOP随手记-Lec.8:虚函数

本周主要讲述了动多态与虚函数,其实我自己还没完全明白。
但还是放上来一下。
因为现在博客上的东西真的太少了。TAT

Lec.8 虚函数

动多态及其实现方式

向上类型转换

  • 派生类对象/引用/指针转换成基类对象/引用/指针,称为向上类型转换。只对public继承有效,在继承图上是上升的;对private、protected继承无效。(因为违反安全原则)
  • 向上类型转换(派生类到基类)可以由编译器自动完成,是一种隐式类型转换
  • 凡是接受基类对象/引用/指针的地方(如函数参数),都可以使用派生类对象/引用/指针,编译器会自动将派生类对象转换为基类对象以便使用。

e.g.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;

class Base {
public:
void print() { cout << "Base::print()" << endl; }
};

class Derive : public Base {
public:
void print() { cout << "Derive::print()" << endl; }
};

void fun(Base obj) { obj.print(); }

int main()
{
Derive d;
d.print();
fun(d); /// 本意:希望对Drive::print的调用——被切片,故只有基类成员函数了
return 0;
}

1、对象的向上转换

image-20220411135538907

对象切片:派生类对象(指针or引用不会)被向上转换后,其被切片为对应基类

——导致数据丢失!

e.g.

1
2
3
4
5
6
7
8
9
10
int main() {
Pet p;
cout << "Pet size:" << sizeof(p) << endl;
Dog g;
cout << "Dog size:" << sizeof(g) << endl;
getSize(g); /// 对象切片(传参),数据丢失
p = g; /// 对象切片(赋值),数据丢失
cout << "Pet size:" << sizeof(p) << endl;
return 0;
}
1
2
3
4
5
6
7
8
9
10
int main() {
Pet p(1);
cout << p.att_i << endl;
Dog g(2,3);
cout << g.att_i << " " << g.att_j << endl;
p = g; /// 对象切片,只赋值基类数据
cout << p.att_i << endl;
//cout << p.att_j << endl; // 没有该参数,编译错误
return 0;
}

2、指针/引用的向上转换

image-20220411135549207

不创造新的对象,但只保留基类的接口

1
2
3
4
5
6
7
8
9
10
int main() {
Dog g(2,3);
cout << g.att_i << " " << g.att_j << endl;
Pet& p = g; /// 引用向上转换
cout << p.att_i << endl;
p.att_i = 1; /// 修改基类存在的数据
cout << p.att_i << endl;
cout << g.att_i << " " << g.att_j << endl; /// 影响派生类
return 0;
}

e.g.2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;

class Instrument {
public:
void play() { cout << "Instrument::play" << endl; }
};
class Wind : public Instrument {
public:
// Redefine interface function:
void play() { cout << "Wind::play" << endl; }
};

void tune(Instrument& i) {
i.play();//编译器已经把该函数入口绑定在了基类上了!
}
int main() {
Wind flute;
tune(flute); /// 引用的向上类型转换(传参),编译器早绑定,无对象切片产生
Instrument &inst = flute; /// 引用的向上类型转换(赋值)
inst.play();
return 0;
}

“早绑定”

函数调用捆绑

  • 函数体与函数调用相联系称为捆绑(binding)。
    即将函数体的具体实现代码,与调用的函数名绑定。执行到调用代码时进入直接进入捆绑好的函数体内部。
  • 捆绑在程序运行之前(由编译器和连接器)完成时,称为早捆绑(early binding)。
    运行之前已经决定了函数调用代码到底进入哪个函数。
    上面程序中的问题是早捆绑引起的,编译器将tune中的函数调用i.play()与Instrument::play()绑定
  • 当捆绑根据对象的实际类型(上例中即子类Wind而非Instrument),发生在程序运行时,称为晚捆绑(late binding),又称动态捆绑或运行时捆绑
    要求在运行时能确定对象的实际类型(思考:如何确定?),并绑定正确的函数。
    晚捆绑只对类中的虚函数起作用,使用 virtual 关键字声明虚函数

虚函数

1、对于被派生类重新定义的成员函数,若它在基类中被声明为虚函数(如下所示),则通过基类指针或引用调用该成员函数时,编译器将根据所指(或引用)对象的实际类型决定是调用基类中的函数,还是调用派生类重写的函数。

1
2
3
4
5
class Base {
public:
virtual ReturnType FuncName(argument); //虚函数
...
};

2、若某成员函数在基类中声明为虚函数,当派生类重写覆盖它时(同名,同参数函数) ,无论是否声明为虚函数,该成员函数都仍然是虚函数。

e.g. 对上一题的修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
using namespace std;

class Instrument {
public:
virtual void play() { cout << "Instrument::play" << endl; }
};
class Wind : public Instrument {
public:
void play() { cout << "Wind::play" << endl; }
/// 重写覆盖(稍后:重写隐藏和重写覆盖的区别)
};

void tune(Instrument& ins) {
ins.play(); /// 由于 Instrument::play 是虚函数,编译时不再直接绑定,运行时根据 ins 的实际类型调用。
}
int main() {
Wind flute;
tune(flute); /// 向上类型转换,输出wind::play
return 0;
}

3、

但应当注意:晚捆绑只对引用和指针有效。若形参是对象,则一定会发生切片,并早捆绑

1
2
3
4
5
6
7
8
9
void tune(Instrument ins) {
ins.play(); /// 晚绑定只对指针和引用有效,这里早绑定 Instrument::play
}

int main() {
Wind flute;
tune(flute); /// 向上类型转换,对象切片,输出instrument::play
return 0;
}

4、实现方法:虚函数表

关键是 如何确定函数入口的地址?

image-20220411141103244

e.g.

image-20220411141350827

对类型信息的存放会导致编译器隐式地存放了上述信息,如:

1
2
int main(){    cout<<"int: "<<sizeof(int)<<endl;    cout<<"NoVirtual: "<<sizeof(NoVirtual)<<endl;    cout<<"void* : "<<sizeof(void*)<<endl;    cout<<"OneVirtual: "<<sizeof(OneVirtual)<<endl;    cout<<"TwoVirtual: "<<sizeof(TwoVirtual)<<endl;
return 0; }

image-20220411142329187

虚函数与构造函数/析构函数

1、虚函数与构造函数

(1)

  • 当创建一个包含有虚函数的对象时,必须初始化它的VPTR以指向相应的VTABLE。设置VPTR的工作由构造函数完成。编译器在构造函数的开头秘密的插入能初始化VPTR的代码。

(2)构造函数不能也不必是虚函数。

  • 不能:如果构造函数是虚函数,则创建对象时需要先知道VPTR,而在构造函数调用前,VPTR未初始化。
  • 不必:构造函数的作用是提供类中成员初始化,调用时明确指定要创建对象的类型,没有必要是虚函数。

(3)构造函数调用虚函数

e.g.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
using namespace std;

class Base {
public:
virtual void foo(){cout<<"Base::foo"<<endl;}
Base(){foo();} ///在构造函数中调用虚函数foo
void bar(){foo();}; ///在普通函数中调用虚函数foo
};

class Derived : public Base {
public:
int _num;
void foo(){cout<<"Derived::foo"<<_num<<endl;} Derived(int j):Base(),_num(j){}
};
int main() { Derived d(0); Base &b = d; b.bar(); b.foo(); return 0;}

image-20220411142743142

Summary: 构造函数调用虚函数只能调用本地版本!因为派生类此时还没分配好内存,会导致非法的内存访问。

•在构造函数中调用一个虚函数,被**调用的只是这个函数的本地版本(即当前类的版本)**,即虚机制在构造函数中不工作。

•派生类对象初始化顺序:(与构造函数初始化列表顺序无关)

基类初始化

对象成员初始化

构造函数体

•原因:基类的构造函数比派生类先执行,调用基类构造函数时派生类中的数据成员还没有初始化(上例中 Derive中的数据成员i)。如果允许调用实际对象的虚函数(如b.foo()),则可能会用到未初始化的派生类成员。

2、虚函数与析构函数

  • 析构函数能是虚的,且常常是虚的。虚析构函数仍需定义函数体。
  • 虚析构函数的用途:当删除基类对象指针时,编译器将根据指针所指对象的实际类型,调用相应的析构函数
  • 若基类析构不是虚函数,则删除基类指针所指派生类对象时,编译器仅自动调用基类的析构函数,而不会考虑实际对象是不是基类的对象。这可能会导致内存泄漏
  • 同样,在析构函数中调用一个虚函数,被调用的只是这个函数的本地版本,即虚机制在析构函数不工作。 为什么?

e.g.

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
29
30
31
#include <iostream>
using namespace std;

class Base1 {
public:
~Base1() { cout << "~Base1()\n"; }
};

class Derived1 : public Base1 {
public:
~Derived1() { cout << "~Derived1()\n"; }
};

class Base2 {
public:
virtual ~Base2() { cout << "~Base2()\n"; }
};

class Derived2 : public Base2 {
public:
~Derived2() { cout << "~Derived2()\n"; }
};

int main() {
Base1* bp = new Derived1;
delete bp; /// 只调用了基类的虚析构函数,根据指针类型选择
Base2* b2p = new Derived2;
delete b2p; /// 派生类虚析构函数调用完后调用基类的虚析构函数
return 0;
}

image-20220411143558393将基类的析构函数设置为虚析构函数!

重载/重写覆盖/重写隐藏的区分

  • 重载(overload):
    函数名必须相同,函数参数必须不同,作用域相同(同一个类,或同为全局函数),返回值可以相同或不同。

  • 重写覆盖(override):
    派生类重新定义基类中的虚函数,函数名必须相同,函数参数必须相同,返回值一般情况应相同。
    派生类的虚函数表中原基类的虚函数指针会被派生类中重新定义的虚函数指针覆盖掉

  • 重写隐藏(redefining):
    派生类重新定义基类中的函数,函数名相同,但是参数不同或者基类的函数不是虚函数。(参数相同+虚函数->不是重写隐藏)
    重写隐藏中虚函数表不会发生覆盖。(保留基类继承而来的虚函数)

    image-20220411143935739

image-20220411144209931

image-20220411144216374

重要的是明白基类指针指向哪个函数!也就是虚函数表中各个入口地址是什么情况

辅助检查:override关键字

重写覆盖要满足的条件很多,很容易写错,可以使用override关键字辅助检查。

  • override关键字明确地告诉编译器一个函数是对基类中一个虚函数的重写覆盖,编译器将对重写覆盖要满足的条件进行检查,正确的重写覆盖才能通过编译。
  • 如果没有override关键字,但是满足了重写覆盖的各项条件,也能实现重写覆盖。它只是编译器的一个检查,正确实现override时,对编译结果没有影响。

禁止重写:final关键字

不想让使用者继承?-> final关键字!
在虚函数声明或定义中使用时,final确保函数为虚且不可被派生类重写。

可在继承关系链的“中途”进行设定,禁止后续派生类对指定虚函数重写
在类定义中使用时,final指定此类不可被继承。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Base{
virtual void foo(){};
};
class A: public Base {
void foo() final {}; /// 重写覆盖,且是最终覆盖
void bar() final {}; /// bar 非虚函数,编译错误
};
class B final : public A{
void foo() override {}; /// A::foo 已是最终覆盖,编译错误
};
class C : public B{ /// B 不能被继承,编译错误
};

OOP的核心思想及其实现方式

OOP的核心思想是数据抽象、继承与动态绑定

  • 数据抽象:类的接口与实现分离
    Animal\模板设计的例子

  • 继承:建立相关类型的层次关系(基类与派生类)
    Is-a、is-implementing-in-terms-of: 客观世界的认知关系

  • 动态绑定:统一使用基类指针,实现多态行为
    虚函数(动多态)
    类型转换,模板(静多态)


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!