C++ Pirmer第十五章②
C++ Primer
面向对象程序设计
类型转换与继承
理解基类和派生类之间的类型转换是理解C++语言面向对象编程的关键所在 我们把基类的指针绑定到派生类对象上,这一行为也是指针和引用规则的一个例外哦,它还有一层极为重要的含义:当我们使用基类的引用或指针时,我们并不清楚它所绑定的对象的真实类型,可能是基类对象,也可能是派生类对象
静态类型与动态类型
我们在使用存在继承关系的类型时,必须要区分两个概念了:
- 静态类型:编译时知道,是变量声明时的类型或表达式生成的类型
- 动态类型:运行时才知,是变量或表达式表示的内存中的对象的类型
举个例子:
double ret = item.net_price(n);
我们知道item的静态类型是Quote&,但它的动态类型依赖于item绑定的实参(可能是派生类),这个是在运行时调用该函数才会知道,是不是有动态绑定的地方很容易有动态类型啊
如果表达式不是引用也不是指针,那它的动态类型与静态类型一致,只要你理解了上面动态类型的定义,这个结论很容易得出
不存在从基类向派生类的隐式类型转换
感觉没什么好解释的,很自然嘛
在对象之间不存在类型转换
//我们不建议这样用,以下代码只做说明
Bulk_quote bulk;
Quote item = bulk; //只拷贝基类部分
虚函数
前面学习了:在C++语言中,当我们使用基类的引用或指针调用一个虚成员函数时会执行动态绑定
我们必须要为每个虚函数提供定义,不管你是不是要用到它,这一点与普通函数不同,普通函数可以只有一个声明,因为我们不会调用它;但是每个虚函数都可能被调用,运行时才确定,所以为了防止运行时无函数调用,索性要求所有的虚函数都要有定义。
对虚函数的调用可能在运行时才被解析
这个我们前面已经介绍了,需要注意的是,参数是指针或引用类型才会这样
我们要着重了解的是C++的多态性: OOP的核心思想就是多态性,我们把具有继承关系的多个类型称为多态类型,因为我们可以使用这些类型的多种形式而无须在意它们的差异。
而为什么我们能这样做呢?因为引用或指针的静态类型与动态类型不同,所以我们才能支持多态
再强调一遍,当且仅当通过指针或引用调用虚函数时,才会在运行时解析该调用;也只有在这种情况下,对象的动态类型才有可能和静态类型不同
派生类中的虚函数(一旦虚,一直虚)
一个派生类的函数如果覆盖了某个继承而来的虚函数,那么,它的形参类型必须与被它覆盖的基类函数完全一致,返回类型也要与基类匹配。
这个规则有一个例外:当类的虚函数返回类型是类本身的引用或指针
举个栗子:
D由B派生得到B的虚函数返回B,那么D的对应函数可以返回D,有一个要求,从D到B的类型转换是可以访问的(看不懂没关系,后面会继续介绍)
final和override说明符
如果派生类定义了一个与基类虚函数的名字相同但形参列表不同,这是合法的,是两个独立的函数,可以看成重载嘛
我们除了能把类用final定义成断子绝孙之外,函数也可以这么搞:
struct B
{
void f() const final;
}
struct D
{
void f() const; //错误,即使忽略了override也是覆盖基类,因为形参一样
}
虚函数与默认实参
与普通函数一样,有个需要注意的地方是:既然默认实参可以在基类和派生类中都有,那我们最好让它们一致。
回避虚函数的机制
在某些情况下,我们希望对虚函数的调用不要动态绑定,而是强迫其执行虚函数的某个特定版本,那我们就可以使用域作用域运算符来实现这个目的:
double undiscounted = baseP->Quote::net_price(24);
//强行调用Quote的net_price函数,编译时就完成了
什么时候我们需要回避函数的默认机制呢
通常是当一个派生类的虚函数调用它覆盖的基类的虚函数版本(我们还没碰到过这情况)
为毛呢?假设我们在派生类中没有使用域作用运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归
抽象基类
我们来假设一个新的书店程序:我们要拓展书店程序使其支持几种不同的折扣策略。除了购买量超过一定数量打折外,还有购买量不超过某个限额时享受折扣,但是一旦超过限额就要原价支付。又或者是(书店老板花样是多啊)购买量超过一定数量后购买的全部书籍都打折。
上面的每个策略都要求一个购买量的值和一个折扣值,我们定义一个Disc_quote来支持,其中Disc_quote负责保存购买量的值和折扣。其他的表示某种特定策略的类将继承自Disc_quote,每个派生类通过定义自己的net_price函数来实现各自的折扣策略
在定义Disc_quote类前,我们想想它的net_price函数完成什么工作:
我们的基类Disc_quote类与任何特定的折扣策略都无关,因此Disc_quote类中的net_price函数没有实际含义,先来看一下现在的继承关系:
Disc_quote-->Quote
其他特定价格策略派生类-->Disc_quote
我们当然可以在Disc_quote类中不定义新的net_price,此时Disc_quote将继承Quote中的net_price函数 这样做不妥,为毛呢:
我们创建了一个Disc_quote的对象,然后我们打印时调用的是Quote版本的net_price,这样就没有考虑我们在创建对象时提供的折扣值,因此上述操作不好
我们来用新东西解决这个问题
纯虚函数
仔细考虑下我们会发现,我们为什么要创建Disc_quote对象呢,我们踏马地根本不需要也不希望创建一个Disc_quote对象:Disc_quote类表示的是一本打折书籍的通用概念,而不是某种具体的折扣策略
我们可以将net_price定义为纯虚函数(pure virtual function)从而令程序实现我们的设计意图,这样做可以清晰明了地告诉用户当前这个net_price是没有实际意义的。在函数***置写上=0就可以将一个虚函数说明为纯虚函数,只能出现在类内部的虚函数声明语句处:
class Disc_quote : public Quote
{
public:
Disc_quote() = default;
Disc_quote(const string& book, double price, size_t qty, double disc) :
Quote(book, price), quantity(qty), discount(disc) {}
double net_price(size_t) const = 0; //虚函数后面加=0是纯虚函数
protected:
size_t quantity = 0; //折扣适用的购买量
double discount = 0; //表示折扣的小数值
};
还有啊,我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部(虽然一般也不用)
含有纯虚函数的类是抽象基类
- 含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类(abstract base class)
- 抽象基类负责定义接口,而后续的其他类可以覆盖该接口
- 我们不能创建抽象基类的对象,但是我们可以定义Disc_quote的派生类的对象,前提是这些派生类覆盖了纯虚函数net_price函数:
Disc_quote的派生类必须给出继承的纯虚函数的定义(net_price),否则它们仍将是抽象基类//Bulk_quote继承Disc_quote并覆盖了net_price函数 Bulk_quote bulk; //正确:Bulk_quote中没有纯虚函数
派生类构造函数只初始化它的直接基类
我们重新实现Bulk_quote,让它继承Disc_quote而不是直接继承Quote,于是我们在构造函数中只需要调用它的直接基类的构造函数就好了,至于再往上的类,自有它老子直接基类负责:
class Bulk_quote : public Disc_quote
{
public:
Bulk_quote() = default;
Bulk_quote(const string& book, double price, size_t qty, double disc) :
Disc_quote(book, price, qty, disc) {}
double net_price(size_t) const override;
};
访问控制与继承
每个类分别控制自己成员的初始化过程(自己初始化自己的成员,老子的调老子的构造函数,祖先的有老子在上面顶着,不用管),与之类似,每个类还分别控制着其成员对于派生类来说是否可访问
受保护的成员
protected:派生类可以访问基类的受保护成员,但是只能通过两种合法的方式:
- 在派生类中直接访问
- 通过派生类对象访问
class Base
{
protected:
int prot_mem;
};
class Sneaky : public Base
{
friend void clobber(Sneaky&); //这是友元函数,不是成员函数哦
firend void clobber(Base&);
int j;
};
void clobber (Sneaky &s) {s.j = s.prot_mem = 0;}
//正确:友元函数访问自己的private和protected
void clobber(Base &b) {b.prot_mem = 0;} //错误:派生类的友元函数无法访问基类对象的protected
为什么要这么规定呢?为什么第二个函数Base的对象不让它访问基类的protected,我们这么想,如果合法,那有人设计了一个Base类,里面有个protected的成员,我们可以简单地定义它的派生,然后就可以通过Base对象去访问Base的protected了,这显然非常不安全,于是就禁止了
公有、私有和受保护继承
某个类对其继承而来的成员的访问权限受到两个因素影响:
- 在基类中该成员的访问说明符(这个介绍了)
- 在派生类的派生列表中的访问说明符(之前一直是public的)
class Base
{
public:
void pub_mem();
protected:
int prot_mem;
private:
char priv_mem;
};
struct Pub_Derv : public Base //公有继承
{
int f() {return proteme;} //正确:派生类能访问基类的protected成员
char g() {return priv_mem;} //错误:不能访问private的
};
struct Priv_Derv : privaet Base
{
int f1() const {return prot_mem;} //正确:在类中,private是没有影响的
};
//在通过派生类对象访问的时候,派生访问说明符才有影响,也就是说
//我们在在派生类中直接访问只受成员访问说明符的影响
//通过派生类对象访问才会受派生访问说明符的影响
pub_Derv d1;
Priv_Derv d2;
d1.pub_mem(); //正确
d2.pub_mem(); //错误
//派生访问说明符对子孙也有影响
struct DfP : public Pub_Derv
{
int use_base(){return prot_mem;} //正确:一路public***正确
};
struct DfPr : public Priv_Derv
{
int use_base(){return prot_mem;} //错误:它老子是private的,就连累它了,
//然而它老子还是能访问
};
其实关于继承的方式,我们可以总结为两点:
- 在类内,这个没有什么卵用
- 在类外,包括派生类的派生类,基类的某个成员访问权限等于一路继承过来最低的那个权限,比如原来protected的,通过private继承后,对于接下来要用它的,它就是private的了
派生类向基类转换的可访问性
有三个规定,比较繁琐,但是跟上面介绍的可访问性还是很相似的,假定D继承自B:
- 只有当D公有地继承B时,用户代码才能使用派生类向基类的转换
- 不论D以什么方式继承B,D的成员函数和友元都能使用派生类向基类的转换
- 如果D继承B的方式是公有或者受保护的,则D的派生类的成员和友元可以使用D向B的类型转换
友元与继承:朋友不能继承或传递
基类的友元在访问派生类成员时不具有特殊性,类似的,派生类的友元也不能随意访问基类的成员:
class Base
{
//添加友元类,其他跟之前一样
firend class Pal; //友元类
};
class Pal
{
public:
int f(Base b) {return b.prot_mem;} //正确:Pal是Base的友元
int f2(Sneaky s) {return s.j;} //错误:Pal不是Sneaky的友元(没有继承朋友关系)
int f3(Sneaky s){return s.prot_mem;} //正确:访问的是基类的部分,能不能访问基类说了算
//基类Base和Pal是朋友,所以可以访问
};
不能继承友元关系;每个类负责控制各自成员的访问权限
改变个别成员的可访问性
这个只是改一下继承方式的权限:
class Base
{
public:
size_t size() const {return n;}
protected:
size_t n;
};
//好的,我们来改了
class Derived : private Base
{
public:
using Base::size; //保持对象尺寸相关的成员的访问级别
protected:
using Base::n;
};
本来是私有继承,那么对于以后的派生类和其他用户,成员应该都是private的了,但是通过using语句,我们让其采取using前的权限(size是public的,n是protected的)
派生类只能为那些它可以访问的名字提供using声明,也就是不能为private提供using了
默认的继承保护级别
class Base {};
struct D1 : Base {}; //默认public继承
class D2 : Base {}; //默认private继承
再次强调,struct与class的唯一区别就是默认成员访问说明符及默认派生访问说明符