Cpp 面向对象——重定义与重写、虚函数
在一个作用域中,同名函数但是形参列表不同的函数构成重载的关系, 在考虑面向对象时,则会产生一系列新的问题,因为基类和派生类的作用域是一种非常微妙的关系,既不能说它们是简单的两个独立的作用域,也不能说是一个作用域,而且这种关系是天然不对称的。 在面向对象的语法中对基类和派生类的同名函数(相同或者不同的形参列表)都有着特殊的设计,在本文中我们主要讨论的是不涉及虚函数的部分。
为了简化讨论,我们不关注public
,private
,protected
修饰的区别,无论是对类的方法还是继承关系的修饰,统一使用public
。
重定义(隐藏)
例子
假设基类Base
定义了一个方法hello
,有好几个版本,相互之间构成重载关系。
派生类Derived
如果没有实现hello
方法,那么派生类对象仍然是可以直接调用hello
方法的,包括基类所实现的各种版本,通过重载决议调用,这是继承所赋予的特点。
例如 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Base {
void hello() { std::cout << "hello from base\n"; }
void hello(int i) { std::cout << "hello from base " << i << "\n"; }
};
struct Derived : Base {};
int main() {
Derived d;
d.hello(); // hello from base
d.hello(2); // hello from base 2
return 0;
}
但是一旦派生类也实现了一种hello
方法(无论形参列表是否不同),它和基类的同名方法都不会构成重载关系,派生类实现的版本有绝对的优先级,它的出现会在当前作用域中屏蔽对基类中所有同名方法的直接调用。
1 |
|
但是派生类对象仍然是可以调用基类版本的,只是必须加上基类名称的前缀,例如
1 | d.Base::hello(2); // hello from base 2 |
这种操作是有实际意义的,例如我们可以让派生类在基类已经实现的行为之后加上额外行为
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct Base {
void hello() { std::cout << "hello from base\n"; }
void hello(int i) { std::cout << "hello from base " << i << "\n"; }
};
struct Derived : Base {
void hello() {
Base::hello();
std::cout << "hello from derived\n";
}
};
int main() {
Derived d;
d.hello();
// hello from base
// hello from derived
return 0;
}
解释
可以这样理解这些现象的实质:
- 每一个类的所有方法实际都带有类名前缀,例如
Base
类的Base::hello
方法,但是在绝大多数情况下,调用方法时都可以省略这个前缀。 - 如果派生类没有实现
hello
方法,那么派生类调用Base::hello
方法时就可以缺省Base::
,因为根本没有Derived::hello
可供选择。 - 如果派生类也实现了
hello
方法(无论形参列表是否不同),那么就会对Base::hello
进行隐藏,派生类调用在缺省时就会被解释为Derived::hello
,因此不会找到Base::
的任何版本。
因此,对于一个基类的对象(包括指针和引用形式)或者基类方法内部,只能调用自己实现的方法。
1 |
|
对于一个派生类的对象(包括指针和引用形式)或者派生类方法内部,在权限允许的情况下,可以调用基类和自己实现的所有方法,只是不同情况下允许的调用方式不同:
- 如果对于基类实现的方法,在派生类中没有定义同名的方法,那么可以直接按照方法名称调用;
- 如果派生类定义了与基类中方法同名的方法,那么:
- 缺省类名前缀的调用只能调用到派生类实现的方法,不会调用到基类实现的方法。
- 使用类名前缀时可以成功调用到基类实现的方法。
没有发生重定义时的验证示例如下 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
32
33
34
struct Base {
void hello() { std::cout << "hello from base\n"; }
void hello(int i) { std::cout << "hello from base " << i << "\n"; }
};
struct Derived : Base {};
int main() {
Derived d;
Derived *pd = &d;
Derived &d2 = d;
d.hello(); // hello from base
pd->hello(); // hello from base
d2.hello(); // hello from base
d.hello(2); // hello from base 2
pd->hello(2); // hello from base 2
d2.hello(2); // hello from base 2
Base *pb = &d;
Base &b2 = d;
pb->hello(); // hello from base
b2.hello(); // hello from base
pb->hello(2); // hello from base 2
b2.hello(2); // hello from base 2
return 0;
}
发生重定义时的示例如下,第一个验证例子是派生类实现了与基类同名方法的形参列表相同的版本
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
struct Base {
void hello() { std::cout << "hello from base\n"; }
};
struct Derived : Base {
void hello() { std::cout << "hello from derived\n"; }
};
int main() {
Derived d;
Derived *pd = &d;
Derived &d2 = d;
d.hello(); // hello from derived
pd->hello(); // hello from derived
d2.hello(); // hello from derived
Base *pb = &d;
Base &b2 = d;
pb->hello(); // hello from base
b2.hello(); // hello from base
return 0;
}
第二个验证例子是派生类实现了与基类同名方法的形参列表相同的版本
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
32
33
34
struct Base {
void hello() { std::cout << "hello from base\n"; }
};
struct Derived : Base {
void hello(int i) { std::cout << "hello from derived " << i << "\n"; }
};
int main() {
Derived d;
Derived *pd = &d;
Derived &d2 = d;
d.hello(2); // hello from derived 2
pd->hello(2); // hello from derived 2
d2.hello(2); // hello from derived 2
// d.hello(); // compile error
// pd->hello(); // compile error
// d2.hello(); // compile error
Base *pb = &d;
Base &b2 = d;
pb->hello(); // hello from base
b2.hello(); // hello from base
// pb->hello(2); // compile error
// b2.hello(2); // compile error
return 0;
}
由于C++允许基类指针(和引用)指向派生类对象,这里也加上了对应的调用验证。 对应的结论是简单的:它们的表现就像直接通过基类对象来调用一样,只能调用基类实现的版本。 但是基类指针(和引用)指向派生类对象是下面的重写以及虚函数所关注的核心情景。
重写和虚函数
C++的重写概念其实就是对虚函数的重写,但是在这里我们主要关注的是与重定义和重载的区别,而不去关注虚函数的实现细节,动态绑定和静态绑定的区别等。
虚函数
C++
利用虚函数的机制来实现动态多态的效果,那么什么是虚函数?在代码中使用关键词virtual
修饰的,特殊的非静态成员函数就是虚函数。
下面这些函数显然不能被定义为虚函数:
- 友元函数,它不是类的成员函数
- 全局函数
static
静态成员函数,它没有this指针- 构造函数,拷贝构造函数,以及赋值运算符重载(可以但是一般不建议作为虚函数)
对于基类来说,在声明虚函数时(或在类中直接定义时)必须使用virtual
前缀修饰,例如
1
2
3
4
5struct Base {
virtual void show() const;
};
void Base::show() const { std::cout << "show, from Base class\n"; }
对于派生类,有如下几种选择:
- 不提供任何同名方法,此时派生类会直接继承基类中的实现;
- 定义一个与虚函数同名的方法:
- 如果这个函数的形参列表与基类对应的虚函数一致,并且返回值类型一致(或者后者的返回值类型也是前者的派生类),那么这就是对虚函数的重写;
- 在其它情况下,仍然会产生前面提到的重定义的情况。
对虚函数的重写可以视作重定义之外的豁免情形。
在下面的例子中,派生类的第一个实现是对虚函数的重写,第二个实现则是重定义。
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
32
33
34
35
36
37
38
39
40
struct Base {
virtual void show() const;
};
void Base::show() const { std::cout << "show, from Base class\n"; }
struct Derived : Base {
void show() const { std::cout << "show, from Derived class\n"; }
void show(int i) const {
std::cout << "show, from Derived class " << i << "\n";
}
};
int main() {
Derived d;
Derived *pd = &d;
Derived &d2 = d;
d.show(); // show, from Derived class
pd->show(); // show, from Derived class
d2.show(); // show, from Derived class
d.show(2); // show, from Derived class 2
pd->show(2); // show, from Derived class 2
d2.show(2); // show, from Derived class 2
Base *pb = &d;
Base &b2 = d;
pb->show(); // show, from Derived class
b2.show(); // show, from Derived class
// pb->show(2); // compile error
// b2.show(2); // compile error
return 0;
}
在派生类对虚函数进行重写时,支持添加很多修饰词,例如
1
2
3virtual void show() const;
void show() const override;
void show() const final;
这些修饰词的效果略有区别:
- 可以加上
virtual
修饰,也可以省略virtual
,两者效果是一样的。(加不加都可能存在低级错误的隐患,例如我们提供的实现并没有满足重写要求,仍然可能编译通过,但是运行结果就会产生异常) - 可以加上
override
后缀修饰,这会让编译器进行自动检查,确保当前正在对基类中的某个虚函数重写;(主流做法,也是最建议的用法) - 如果使用
final
修饰词,可以禁止当前类的派生类继续对此虚函数进一步重写。
因此,在声明派生类中重写的虚函数时(或在类中直接定义时),总是建议使用override
修饰
1
2
3
4
5struct Derived : Base {
void show() const override;
};
void Derived::show() const { std::cout << "show, from Derived class\n"; }
实例
下面用例子的对比来体现虚函数和非虚函数的区别,虚函数所实现的效果就是所谓的动态多态。
1 |
|
运行结果如下 1
2
3
4
5
6
7
8
9test:
hello, from Base class
show, from Base class
test:
hello, from Base class
show, from Derived1 class
test:
hello, from Base class
show, from Derived2 class
我们重点关注下面的函数 1
2
3
4
5void test(Base *base) {
std::cout << "test:\n";
base->hello();
base->show();
}
在这个示例中三次调用的hello()
函数都是一样的,因为三次传入的都是基类指针,对象指针的类型直接决定了调用哪一个类型的hello()
函数,也就是说三次都调用了基类的hello()
函数,具体调用关系在编译期即可确定。
但是与之不同的是,三次调用的show()
函数是不一样的,分别调用了基类的show()
函数和两个派生类的show()
函数,
这是因为三次的基类指针指向的实际对象类型是不一样的(C++允许使用基类指针指向派生类对象),对于虚函数的实际调用取决于指针指向的实际对象类型,实际对象类型只能在运行期确定,这导致实际调用关系在运行期才确定,这就是对函数调用关系的动态绑定,以此实现了动态多态的效果。