【C++基础】第八十九课:[面向对象程序设计]继承中的类作用域

继承中的类作用域

Posted by x-jeff on December 14, 2023

【C++基础】系列博客为参考《C++ Primer中文版(第5版)》C++11标准)一书,自己所做的读书笔记。
本文为原创文章,未经本人允许,禁止转载。转载请注明出处。

1.继承中的类作用域

每个类定义自己的作用域,在这个作用域内我们定义类的成员。当存在继承关系时,派生类的作用域嵌套在其基类的作用域之内。如果一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找该名字的定义。例如,当我们编写下面的代码时:

1
2
Bulk_quote bulk;
cout << bulk.isbn();

名字isbn的解析将按照下述过程所示:

  • 因为我们是通过Bulk_quote的对象调用isbn的,所以首先在Bulk_quote中查找,这一步没有找到名字isbn。
  • 因为Bulk_quote是Disc_quote的派生类,所以接下来在Disc_quote中查找,仍然找不到。
  • 因为Disc_quote是Quote的派生类,所以接着查找Quote;此时找到了名字isbn,所以我们使用的isbn最终被解析为Quote中的isbn。

1.1.在编译时进行名字查找

一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。即使静态类型与动态类型可能不一致(当使用基类的引用或指针时会发生这种情况),但是我们能使用哪些成员仍然是由静态类型决定的。举个例子,我们可以给Disc_quote添加一个新成员,该成员返回一个存有最小(或最大)数量及折扣价格的pair

1
2
3
4
5
6
class Disc_quote : public Quote {
public:
	std::pair<size_t, double> discount_policy() const
		{ return {quantity, discount}; }
	//其他成员与之前的版本一致
};

我们只能通过Disc_quote及其派生类的对象、引用或指针使用discount_policy:

1
2
3
4
5
Bulk_quote bulk;
Bulk_quote *bulkP = &bulk; //静态类型与动态类型一致
Quote *itemP = &bulk; //静态类型与动态类型不一致
bulkP->discount_policy(); //正确:bulkP的类型是Bulk_quote*
itemP->discount_policy(); //错误:itemP的类型是Quote*

1.2.名字冲突与继承

和其他作用域一样,派生类也能重用定义在其直接基类或间接基类中的名字,此时定义在内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字:

1
2
3
4
5
6
7
8
9
10
11
12
struct Base {
	Base() : mem(0) { }
protected:
	int mem;
};
struct Derived : Base {
	Derived(int i) : mem(i) { } //用i初始化Derived::mem
		                        //Base::mem进行默认初始化
	int get_mem() { return mem; } //返回Derived::mem
protected:
	int mem; //隐藏基类中的mem
};

get_mem中mem引用的解析结果是定义在Derived中的名字:

1
2
Derived d(42);
cout << d.get_mem() << endl; //打印42

派生类的成员将隐藏同名的基类成员。

1.3.通过作用域运算符来使用隐藏的成员

1
2
3
4
struct Derived : Base {
	int get_base_mem() { return Base::mem; }
	//...
};

如果使用最新的Derived版本运行上面的代码,则d.get_mem()的输出结果将是0。

除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。

1.4.一如往常,名字查找先于类型检查

如前所述,声明在内层作用域的函数并不会重载声明在外层作用域的函数(参见:重载与作用域)。因此,定义派生类中的函数也不会重载其基类中的成员。和其他作用域一样,如果派生类(即内层作用域)的成员与基类(即外层作用域)的某个成员同名,则派生类将在其作用域内隐藏该基类成员。即使派生类成员和基类成员的形参列表不一致,基类成员也仍然会被隐藏掉:

1
2
3
4
5
6
7
8
9
10
11
struct Base {
	int memfcn();
};
struct Derived : Base {
	int memfcn(int); //隐藏基类的memfcn
};
Derived d; Base b;
b.memfcn(); //调用Base::memfcn
d.memfcn(10); //调用Derived::memfcn
d.memfcn(); //错误:参数列表为空的memfcn被隐藏了
d.Base::memfcn(); //正确:调用Base::memfcn

1.5.虚函数与作用域

我们现在可以理解为什么基类与派生类中的虚函数必须有相同的形参列表了(参见:派生类中的虚函数)。假如基类与派生类的虚函数接受的实参不同,则我们就无法通过基类的引用或指针调用派生类的虚函数了。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Base {
public:
	virtual int fcn();
};
class D1 : public Base {
public:
	//隐藏基类的fcn,这个fcn不是虚函数
	//D1继承了Base::fcn()的定义
	int fcn(int); //形参列表与Base中的fcn不一致
	virtual void f2(); //是一个新的虚函数,在Base中不存在
};
class D2 : public D1 {
public:
	int fcn(int); //是一个非虚函数,隐藏了D1::fcn(int)
	int fcn(); //覆盖了Base的虚函数fcn
	void f2(); //覆盖了D1的虚函数f2
};

D1的fcn函数并没有覆盖Base的虚函数fcn,原因是它们的形参列表不同。实际上,D1的fcn将隐藏Base的fcn。此时拥有了两个名为fcn的函数:一个是D1从Base继承而来的虚函数fcn;另一个是D1自己定义的接受一个int参数的非虚函数fcn。

1.6.通过基类调用隐藏的虚函数

给定上面定义的这些类后,我们来看几种使用其函数的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Base bobj; D1 d1obj; D2 d2obj;

Base *bp1 = &bobj, *bp2 = &d1obj, *bp3 = &d2obj;
bp1->fcn(); //虚调用,将在运行时调用Base::fcn
bp2->fcn(); //虚调用,将在运行时调用Base::fcn
bp3->fcn(); //虚调用,将在运行时调用D2::fcn

D1 *d1p = &d1obj; D2 *d2p = &d2obj;
bp2->f2(); //错误:Base没有名为f2的成员
d1p->f2(); //虚调用,将在运行时调用D1::f2()
d2p->f2(); //虚调用,将在运行时调用D2::f2()

Base *p1 = &d2obj; D1 *p2 = &d2obj; D2 *p3 = &d2obj;
p1->fcn(42); //错误:Base中没有接受一个int的fcn
p2->fcn(42); //静态绑定,调用D1::fcn(int)
p3->fcn(42); //静态绑定,调用D2::fcn(int)

最后三条调用语句中,指针都指向了D2类型的对象,但是由于我们调用的是非虚函数,所以不会发生动态绑定。实际调用的函数版本由指针的静态类型决定。

1.7.覆盖重载的函数

和其他函数一样,成员函数无论是否是虚函数都能被重载。派生类可以覆盖重载函数的0个或多个实例。如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。

有时一个类仅需覆盖重载集合中的一些而非全部函数,此时,如果我们不得不覆盖基类中的每一个版本的话,显然操作将极其烦琐。

一种好的解决方案是为重载的成员提供一条using声明语句(参见:改变个别成员的可访问性),这样我们就无须覆盖基类中的每一个重载版本了。using声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的using声明语句就可以把该函数的所有重载实例添加到派生类作用域中。此时,派生类只需要定义其特有的函数就可以了,而无须为继承而来的其他函数重新定义。

类内using声明的一般规则同样适用于重载函数的名字;基类函数的每个实例在派生类中都必须是可访问的。对派生类没有重新定义的重载版本的访问实际上是对using声明点的访问。