@taqikema
2018-01-14T14:07:57.000000Z
字数 8857
阅读 1247
C++Primer
学习记录
继承
派生
虚函数
基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。
基类的成员函数可以分为两类:
如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本。
派生类可以在它覆盖的函数前使用 virtual关键字,但不是非得这么做。C++11新标准允许派生类显式地注明它使用某个成员函数覆盖了它继承的虚函数。具体做法是在形参列表后面、或在 const成员函数的 const关键字后面、或在引用成员函数的引用限定符后面添加一个关键字 override
。
一个派生类对象包含派生类自己定义的子对象和与该派生类继承的基类对应的子对象。如下图所示:
也正是因为在派生类对象中含有与其基类对应的组成部分,所以能把派生类的对象当成基类对象来使用,也因此能将基类的指针或引用绑定到派生类对象中的基类部分上。这种转换也叫做派生类到基类的类型转换。
Quote item; // 基类对象
Bulk_quote bulk; // 派生类对象
Quote *p = &item; // p指向 Quote对象
p = &bulk; // p指向 Bulk_quote对象的 Quote部分
Quote &r = bulk; // r绑定到 Bulk_quote对象的 Quote部分
每个类负责定义自己的接口,所以,派生类对象不能直接初始化基类的成员。派生类应该遵循基类的接口,通过调用基类的构造函数来初始化那些从基类中继承而来的成员。派生类的初始化过程大致为:基类初始化——>基类构造函数体——>派生类初始化——>派生类构造函数体。
对于基类中定义的静态成员,因为它属于基类类型,而不是基类对象,则在整个继承体系中只存在该成员的唯一定义。不论从基类中派生出来多少个派生类,对于每个静态成员来说都只存在唯一的实例。
作为基类的类必须已经定义而非仅仅声明,这也意味着一个类不能继承它本身。
使用 final关键字可以防止一个类被其它类继承。
class NoDerived final { /* */ }; // NoDerived不能被继承
class Bad : NoDerived { /* */ }; // 错误
动态类型:变量或表达式表示的内存中的对象的类型,直到运行时才可知。如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致。
一个基类的对象既可以以独立的形式存在,也可以作为派生类对象的一部分存在。因此基类不一定是派生类对象的一部分,但派生类中一定含有基类部分。所以,不存在从基类向派生类的隐式类型转换,但“存在”派生类向基类的转换(只对指针和引用有效、对象类型的话派生类部分会被切断)。
即使一个基类指针或引用已经绑定在一个派生类对象上,也不能执行从基类向派生类的转换。可以使用 dynamic_cast执行运行时安全检查或 static_cast来强制覆盖掉编译器的检查工作。
Quote base;
Bulk_quote *bulkP = &base; // 错误
Bulk_quote bulk;
Quote *itemP = &bulk; // 正确,动态类型是 Bulk_quote
Bulk_quote *bulkP = itemP; // 错误,不能将基类转换成派生类
通常情况下,如果不使用某个函数,则无需为该函数提供定义。但是由于虚函数是在运行时才被解析,所以必须为每个虚函数都提供定义,而不管它是否被用到了。
引用或指针的静态类型与动态类型不同这一事实是 C++语言支持多态性的根本所在。而对于非虚函数的调用是在编译时进行绑定。类似的,通过对象进行的函数(虚函数或非虚函数)调用也在编译时绑定。
一旦某个函数被声明为虚函数,则在所有派生类中它都是虚函数。而对于派生类中覆盖的虚函数,其形参必须相同,返回类型也要与基类匹配。而当虚函数的返回类型是类本身的指针或引用且可进行类型转换时,也是允许的。
在派生类中覆盖基类的虚函数,使用 override
标识符。而如果被其标识的函数被编译器认为并不能成功覆盖掉基类的虚函数时,则会报错。
struct B {
virtual void f1(int) const;
virtual void f2();
void f3();
};
struct D1 : B {
void f1(int) const override; // 正确
void f2(int) override; // 错误,B没有形如 f2(int)的函数
virtual void f2(int); // 正确,在 D1中声明 f2(int)的虚函数
void f3() override; // 错误,f3不是虚函数
void f4() override; // 错误,B中没有名为 f4的函数
};
将某个函数指定为 final
,则之后任何尝试覆盖该函数的操作都将引发错误。另外,final
和override
说明符要出现在形参列表(包括任何 const或引用修饰符)以及尾后返回类型之后。
struct D2 : B {
// 从 B继承 f2()和 f3(),覆盖 f1(int)
void f1(int) const final; // 不允许后续的其他类覆盖 f1(int)
};
struct D3 : D2 {
void f2(); // 正确
void f1(int) const; // 错误
};
虚函数可以拥有默认实参,其实参值由本次调用的静态类型决定。如果我们通过基类的引用或指针调用函数,即使实际运行的是派生类中的函数版本,其使用的也是基类中定义的默认实参。
有时候我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的。如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归。
// 强行调用基类中定义的函数版本而不管 baseP的动态类型到底是什么
double undiscounted = baseP->Quote::net_price(42);
纯虚函数,在声明语句的分号之前书写=0
,将一个虚函数说明为纯虚函数。其中,=0
只能出现在类内部的虚函数声明语句处。
class A {
public:
A() = default;
int get_size() const = 0;
};
值得注意的是,也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。
含有纯虚函数的类是抽象基类。抽象积累负责定义接口,而后续的其他类可以覆盖该接口。不能(直接)创建一个抽象基类的对象,但派生类构造函数可以使用抽象基类的构造函数来构建各个派生类对象的基类部分。
派生类的成员和友元只能访问派生类对象中的基类部分的受保护成员,对于普通的基类对象中的成员不具有特殊的访问权限,即在派生类中也不能通过基类对象来访问基类的 protected成员。
class Base {
protected:
int prot_men;
};
class Sneaky : public Base {
friend void clobber(Sneaky &s);
friend void clobber(Base &b);
int j; // 默认是 private
};
// 正确,clobber能访问 Sneaky对象的 private和 protected成员
void clobber(Sneaky &s) { s.j = s.prot_men = 0; }
// 错误,clobber不能访问 Base对象的 protected成员
void clobber(Base &b) { b.prot_men = 0; }
派生访问说明符对于派生类成员(及友元)能否访问其直接基类的成员没有什么影响,对基类成员的访问权限只与基类中的访问说明符有关。派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限。
派生类向基类的转换是否可访问由使用该转换的代码决定,同时派生类的派生访问说明符也会有影响。总而言之,是在某个给定节点上,如果基类的共有成员是可访问的,则派生类向基类的类型转换也是可访问的。假定 D继承自 B:
就像友元关系不能传递一样,友元关系同样也不能继承。基类的友元在访问派生类成员时不具特殊性,类似的,派生类的友元也不能随意访问基类的成员。但是基类的友元是可以访问内嵌在派生类对象中的基类成员。
class Base {
// 添加 friend声明,其他成员与之前的一致
friend class Pal;
};
class Pal {
public:
int f(Base b) { return b.prot_men; } // 正确
int f2(Sneaky s) { return s.j; } // 错误
// 对基类的访问权限由基类本身控制,即使对于派生类的基类部分也是如此
int f3(Sneaky s) { return s.prot_men; }
};
通过在类的内部使用 using
声明语句,我们可以将该类的直接或间接基类中的任何可访问成员(非私有成员)标记出来。using声明语句中名字的访问权限由该 using声明语句之前的访问说明符来决定。
class Base {
public:
std::size_t size() const { return n; }
protected:
std::size_t n;
};
// private继承,继承成员的访问权限发生了改变
class Derived : private Base {
public:
using Base::size;
protected:
using Base::n;
};
struct和 class都可以用来定义类,只是二者的默认成员访问说明符和默认派生访问说明符不同而已。
派生类的作用域嵌套在其基类的作用域之内,所使用的对象、引用或指针的静态类型决定了哪些成员能被使用。
派生类能够重用定义在其直接基类或间接基类中的名字,并且定义在内层作用域(派生类)的名字将隐藏定义在外层作用域(基类)的名字。可以通过作用域运算符来使用被隐藏的基类成员。
struct Base {
Base() : mem(0) { }
protected:
int mem;
};
struct Derived : Base {
// 用 i初始化 Derived::mem,Base::mem进行默认初始化
Derived(int i) : mem(i) { }
int get_mem() { return mem; } // 返回 Derived::mem
protected:
int mem; // 隐藏基类中的 mem
};
// 使用作用域运算符来使用被隐藏的基类成员
struct Derived : Base {
int get_base_mem() { return Base::mem; }
}
名字查找先于类型检查,对于派生类和基类中的某个同名成员,即使派生类和基类成员的形参列表不一致,派生类成员也还是会隐藏基类成员。
struct Base {
int memfcn();
};
struct Derived : Base {
int memfcn(int); // 隐藏基类的 memfcn
};
Derived d; Base b;
b.memfcn(); // 调用 Base::memfcn()
d.memfcn(10); // 调用 Derived::memfcn(int)
d.memfcn(); // 错误,参数列表为空的 memfcn被隐藏了
d.Base::memfcn(); // 正确,调用 Base::memfcn()
以 p—>mem()解析函数调用的过程,依次执行以下 4个步骤:
class Base {
public:
virtual void fcn() { cout << "Base::fcn()" << endl; }
};
class D1 : public Base {
public:
// 隐藏基类的 fcn,这个 fcn不是虚函数
// D1继承了 Base::fcn()的定义
void fcn(int); // 形参列表与 Base中的 fcn不一致
virtual void f2(); // 一个新的虚函数,在 Base中不存在
};
class D2 : public D1 {
public:
void fcn(int); // 非虚函数,隐藏了 D1::fcn(int)
void fcn(); // 覆盖了 Base中的虚函数 fcn
void f2(); // 覆盖了 D1中的虚函数 f2
};
Base bobj; D1 d1obj; D2 d2obj;
// 指向不同对象的基类指针都调用 fcn()
Base *bp1 = &bobj; Base *bp2 = &d1obj; Base *bp3 = &d2obj;
bp1->fcn(); // 虚调用,将在运行时调用 Base::fcn()
bp2->fcn(); // 虚调用,将在运行时调用 Base::fcn()
bp3->fcn(); // 虚调用,将在运行时调用 D2::fcn()
// 静态指针类型与其动态类型相同,都调用 f2()
D1 *d1p = &d1obj; D2 *d2p = &d2obj;
bp2->f2(); // 错误,Base中没有名为 f2的函数
d1p->f2(); // 虚调用,将在运行时调用 D1::f2()
d2p->f2(); // 虚调用,将在运行时调用 D2::f2()
// 不同指针类型都指向 D2对象,都调用 fcn(int)
Base *p1 = &d2obj; D1 *p2 = &d2obj; D2 *p3 = &d2obj;
p1->fcn(42); // 错误,Base中没有接受一个 int的 fcn
p2->fcn(42); // 静态绑定,调用 D1::fcn(int)
p3->fcn(42); // 静态绑定,调用 D1::fcn(int)
派生类可以覆盖重载函数的 0个或多个实例。而因为函数调用过程中,在查找到目标名字后就会停止查找,而不关心参数类型,所以如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。
using声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的 using声明语句就可以把该函数的所有重载实例添加到派生类作用域中了。注意,此时基类函数的每个实例在派生类中都必须是可访问的。
当需要 delete一个的基类指针时,该指既可以指向基类对象,也可以指向派生类对象,此时编译器必须明确执行基类或派生类的指针。析构函数的虚属性会被继承,无论派生类中使用合成的析构函数还是自定义的析构函数,都将是虚函数。这样,就能保证 delete基类指针时总能运行正确的析构函数版本。假如基类析构函数不是虚函数,且指针的静态类型与动态类型不一致,则此时只能调用基类的析构函数,那派生类对象的部分则无法完成析构,从而产生未定义行为。
如前所述,当一个类中存在拷贝控制成员时,编译器不会为这个类合成移动操作。对于需要定义虚析构函数的基类,也是如此。
派生类可能会将合成的拷贝控制成员定义为删除的,这与基类有关:
=default
请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的,那么派生类中该函数将是被删除的,原因是派生类对象的基类部分不可移动。同样,如果基类的析构函数是删除的或不可访问的,则派生类的移动构造函数也将是被删除的。
class B {
public:
B();
B(const B&) = default;
// 其他成员,不含有移动构造函数
};
class D {
// 没有声明任何构造函数
};
D d; // 正确,D的合成默认构造函数使用 B的默认构造函数
D d2(d); // 错误,D的合成拷贝构造函数是被删除的
D d3(std::move(d)); // 错误,隐式地使用 D的被删除的拷贝构造函数
因为大多数基类都会定义一个虚析构函数,所以默认情况下,基类通常不含有合成的移动操作,导致派生类中也不会有合成的移动操作。而当确实需要执行移动操作时,应该首先在基类中显式定义相应的移动操作。注意,因为在定义了自己的移动操作后,编译器会将合成的拷贝操作定义为删除的。所以大多数情况下,定义了移动操作后,还需要显式的定义拷贝操作。
在派生类中定义除析构函数之外的其他拷贝控制成员时,都需要显式的进行基类的相应操作。而析构函数则只用负责销毁派生类自己分配的资源,派生类对象的基类部分时自动销毁的。
在默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显式的使用基类的拷贝(或移动)构造函数。
class Base { /*...*/ };
class D : public Base {
public:
D(const D &d) : Base(d) // 拷贝基类成员
/* D的成员的初始值 */ { /*...*/ }
D(D &&d) : Base(std::move(d)) // 移动基类成员
/* D的成员的初始值 */ { /*...*/ }
~D(); // Base::~Base()被自动调用
};
如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型相对应的虚函数版本。
通过一条注明了(直接)基类名的 using
声明语句,派生类可以从基类继承构造函数。通常情况下,using声明语句只是令某个名字在当前作用域内可见,而当作用于构造函数时,using声明语句将令编译器产生代码。只对基类部分进行初始化,派生类自己的数据成员将会默认初始化。
class Bulk_quote : public Disc_quote {
public:
using Disc_quote::Disc_quote; // 继承 Disc_quote的构造函数
double net_price(std::size_t) const;
};
不允许在容器中保存不同类型的元素,基类对象不能转换成派生类对象,派生类对象转换成基类对象,派生类部分又会被“切掉”。因此,容器和存在继承关系的类型无法兼容。此时,更好的做法是在容器中存放基类的(智能)指针。
vector<shared_ptr<Quote>> basket;
对于 C++面向对象的编程来说,一个很有意思的悖论是,我们无法直接使用对象进行面向对象编程,相反,我们必须使用指针和引用。