15面向对象程序设计
——————面向对象程序设计的核心思想是:数据抽象、继承和动态绑定。
15.1 OOP概述
面向对象程序设计的核心思想是数据抽象、继承、和动态绑定。
使用数据抽象,我们可以将类的接口与实现分离;使用继承可以定义相似的类型并对其相似关系建模;使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。
继承
对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数(virtual function)
class Quote{
public:
std::string isbn() const;
virtual double net_price(std::size_t n) const
}
派生类必须通过使用类派生列表指明它是从哪个基类继承而来的。
class Bulk_quote: public Quote{
public:
double net_price(std::size_t)const override; //指明它将使用哪个成员函数改写基类的虚函数
}
动态绑定
当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。
函数的运行版本由实参决定,即在运行时选择函数的版本,所以动态绑定又被称为运行时绑定。(run-time binding)
定义基类
作为继承关系中根节点的类通常都会定义一个虚析构函数,即使该函数不执行任何实际操作。 成员函数不被声明成虚函数,则其解析过程发生在编译时,而不是运行时。
访问控制和继承 protected成员:基类希望它的派生类有权访问该成员,同时禁止其他用户访问。派生类访问基类私有成员的方式和其他用户是一样的,都是通过成员函数访问。、
定义派生类
- 派生类必须通过类派生列表明确指出它是从哪个基类继承而来的。 类派生列表(class derivation list):冒号+基类列表+public/private/protected
- 派生类必须将其继承而来的成员函数中需要覆盖的那些重新说明。
- 派生列表中的访问说明符:控制派生类从基类继承而来的成员是否对派生类的用户可见。
- 大多数类都只继承自一个类,这种形式称为单继承。
- 如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通函数,派生类会直接继承其在基类中的版本。
- 在派生类对象中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到派生类对象的基类部分上。称为派生类到基类的类型转换。这种隐式特性意味着我们可以把派生类对象或者派生类对象的引用用在需要基类引用的地方,也意味着我们可以把派生类对象的指针用在需要基类指针的地方。
派生类构造函数
派生类必须使用基类的构造函数来初始化它的基类组成部分。
每个类控制它自己的成员初始化过程。
派生类的作用域嵌套在基类的作用域之内
每个类负责定义各自的接口。要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。
继承与静态成员
如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。
派生类的声明
声明中包含类名,但是不包含它的派生列表。派生类列表与定义有关的细节必须与类的主体一起出现。
class Bluk_quote;
被用作基类的类
如果想将某个类用作基类,则该类必须已经定义而非仅仅声明。
一个类不能派生它本身。
一个类是基类,同时它也可以是一个派生类。
class Base{ /* ... */};
class D1:public Base{/* ... */};
class D2:public D1{/* ... */};
//在这个继承关系中,Base是D1的直接基类,同时是D2的间接基类,直接基类出现在派生列表中,而间接基类是由派生类通过其直接基类继承而来。
防止继承发生
在类名后跟一个关键字final;
class NoDerived final;
类型转换与继承
可以将基类的指针或引用绑定到派生类对象上。当使用基类的引用或指针时,我们并不清楚该引用所绑定对象的真实类型。该对象可能是基类的对象也可能是派生类的对象。
智能指针也支持派生类向基类的类型转换,则我们可以将一个派生类对象的指针存储在一个基类的智能指针内。
静态类型与动态类型
表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型;动态类型则是变量或表达式表示的内存中的对象的类型,动态类型直到运行时才可知。
如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致。
不存在基类到派生类的隐式类型转换
Quote base;
Bluk_quote *bulkp = &Base; //错误,不能将基类转换成派生类
Bluk_quote &bulkRef = Base; //错误 不能将基类转换为派生类
另一种情况是即使一个基类指针绑定到一个派生类对象上,也不能执行从基类到派生类的转换。
Bluk_quote bulk;
Quote *itemP = &bulk;
Bulk_quote *bulkP = itemP; //错误 不能将基类准换为派生类
使用dynamic_cast请求一个类型转换,该转换的安全检查将在运行时进行。当已知某个基类到派生类的转换时安全时,则使用static_cast来强制覆盖掉编译器的检查工作。
在对象之间不存在类型转换,派生类向基类的自动类型转换只对指针或引用类型有效。
我们通常能够将一个派生类对象拷贝、移动或赋值给一个基类对象,这种操作只处理派生类对象的基类部分。
OOP的核心思想是多态性。引用或指针的静态类型与动态类型不同正是C++语言多态性的根本所在。 当且仅当通过指针或引用调用虚函数时,才会在引用时解析该引用,也只有在这种情况下对象的动态类型才有可能与静态类型不同。
15. 2 基类和派生类的关系
一、定义基类和派生类
- 定义基类
基类通常应该有一个虚析构函数,即使该函数不执行任何实际操作也是如此。
- 成员函数与继承
在C++中,基类必须将它的两种成员函数分开:一种是基类希望其派生类覆盖的函数;一种是基类希望其派生类直接继承的函数。对于前者我们希望将其定义为虚函数(virtual function),当我们使用指针或引用调用虚函数时,该调用将会动态绑定。根据引用或指针所绑定对象的不同,该调用可能执行基类的版本,也可能执行派生类的版本。
基类通过在成员函数前加上关键字virtual来使得该函数执行动态绑定,任何构造函数之外的非静态函数都可以是虚函数。关键字virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义。如果基类将一个函数声明为虚函数,则在派生类中该函数也隐式的是虚函数。
如果成员函数没有被声明为虚函数,则其解析过程发生在编译时而非运行时。
- 定义派生类
派生类必须使用类派生列表(class derived list)明确指出它是从哪个类中继承来的。类派生列表的形式是:
class Bulk_quote : public Quote{
}
访问说明符的作用是:控制派生类从基类继承而来的成员是否对派生类的用户可见。如果一个派生是public,则基类的公有成员也是派生类接口的组成部分。也可以将公有派生类型的对象绑定到基类的引用或指针上。
- 派生类中的虚函数
C++允许派生类显式的注定它的某个成员函数覆盖了继承的虚函数。做法是在形参列表后面、或在const成员函数的const关键字后面、或在引用成员函数的引用限定符后面添加一个关键字override.
可以将基类的指针或引用绑定到派生类对象中的基类部分上,这种转换叫做派生类到基类(derived-to-base)的类型转换。
- 派生类构造函数
每个类控制它自己的成员初始化过程,所以派生类也必须使用基类的构造函数来初始化它的基类部分。派生类对象的基类部分与派生类对象自己的数据成员都是在构造函数的初始化阶段执行初始化操作的。首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。
每个类负责定义自己的接口,要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。
- 静态成员的继承
若基类中定义了一个静态成员,则在整个体系中只存在该成员的唯一定义。不论从基类中派生出多少个类,每个静态成员都只存在唯一的实例。静态成员遵循通用的方位控制规则。
- 可以用作基类的类
可用作基类的类,必须被定义,而不是仅仅声明。即一个类不能派生它本身。
- 防止继承发生
若一个类不想其他类继承它,即在类名后跟一个关键字final.
1 class NoDerived final {/*...*/}
- 基类和派生类之间的类型转换
我们可以将基类的指针和引用绑定到派生类对象上,当使用基类的引用(或指针时),我们并不清楚该引用或指针所绑定对象的真实类型,也可能是基类对象,也可能是派生类对象。
- 静态类型与动态类型
静态类型在编译时是已知的,它是变量声明时的类型或表达式生成的类型。动态类型则是变量或表达式表示的内存中的对象的模型。动态类型直到运行时才能知道。如果表达式既不是指针也不是引用,则它的动态类型永远与静态一致。
- 不存在基类到派生类的隐式转换
因为一个基类的对象可能是派生类的一部分,也可能不是,所以不存在基类到派生类的自动类型转换。即使一个基类指针绑定在一个派生类上,也不能执行基类到派生类的转换。
- 在对象之间不存在类型转换
派生类到基类的类型转换只对指针或引用有效,在派生类类型和基类类型之间不存在这样的转换,有时我们确实希望派生类对象转化成它的基类类型。但是这种转换结果却与我们设想的有所差别。
当我们初始化或赋值一个类类型的对象时,如果是初始化,则是再调用拷贝构造函数;如果是赋值则是再调用赋值运算符。这些成员通常都含有一个参数,该参数是该类类型的引用。
因为这些成员接受引用作为参数,所以派生类想基类的转换允许我们给基类的拷贝/移动操作传递一个派生类的对象。但是只有该派生类中的基类部分才会被拷贝、移动、赋值,派生类的其他部分都会被忽略掉。
15. 3 虚函数
虚函数
我们必须为每一个虚函数都提供定义,而不管它是否被用到了。
动态绑定只有当我们通过指针或引用调用虚函数时才会发生。 一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与被它覆盖的基类完全一致。同样派生类中虚函数的返回类型也必须与基类函数匹配。
当类的虚函数返回类型是类的指针或引用时,上述规则无效。例如如果D由B派生得到,则基类的虚函数可以返回B而派生类的虚函数可以返回D,只不过这样的返回类型要求从D到B的转换时可访问的。
虚函数与默认实参
如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。
回避虚函数的机制
如果一个派生类虚函数需要调用它的基类版本,则使用作用域运算符来回避虚函数的机制。通常只有成员函数中的代码才需要使用。
double undiscounted = baseP -> Quote::net_price(42);//该代码强行调用Quote的net_price函数,而不管baseP实际指向的对象类型到底是什么。该调用将在编译时完成解析。
允许同时将一个成员函数同时声明为override和final, 使用override是告诉我们的编译器我们想要覆盖掉已存在的虚函数。而使用override则是告诉编译器不允许后续的派生类来覆盖这个函数。
当我们使用基类的指针或引用调用一个虚函数时会执行动态绑定,通常情况下,所有的虚函数都需要被定义,以为连编译器都不知道会使用哪个虚函数。
- 对虚函数的调用只有运行时才会被解析
当我们使用一个具有普通类型(非引用非指针)的表达式调用虚函数时,在编译时就会将调用的版本确定下来。所以只有通过指针或引用调用虚函数时才会发生动态绑定。要明确一点:引用和指针的静态类型和动态类型不一致,这正是C++支持多态的根本所在。当且仅当通过对指针或引用调用虚函数时,才会在运行时解析该调用,也只有在该情况下对象的动态类型与静态类型不相同。
- 派生类中的虚函数
一个函数一旦被声明为虚函数,则在所有派生类中,它都是虚函数。一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参必须与被它覆盖的基类函数的形参完全一致,同样派生类的返回类型必须与基类函数相匹配,但是存在一个例外:即当类的虚函数返回的是类本身的指针或引用时,上述规则失效。
即D由B派生得到,则基类的虚函数可以返回B*,派生类的虚函数可以返回D*,此时要求从D到B的类型转换时可以访问的。
- 让派生类的虚函数覆盖或不覆盖基类的虚函数
使用final(不允许覆盖)和override(必须覆盖)放在形参列表(包括任何const或引用修饰符)以及任何尾置返回类型之后。
1 class B {
2
3 virtual void f1(int) const;
4 virtual void f2();
5 virtual void f3() const final; //不允许后续的派生类函数覆盖
6 }
7
8 class D : B{
9 vodi f1(int) const override; //与基类B中的f1函数参数及返回类型都匹配,所以成功覆盖。
10 void f2(int) override; //参数不匹配,不能成功覆盖,报错。
11 void f3() const ; //参数及返回类型都相同,所以能覆盖,但是final不允许覆盖,会报错。
12 }
- 虚函数的默认实参
如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。
- 回避虚函数机制
在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本,使用作用域运算符可以做到。
1 double undiscounted = baseP->Quote::nete_price(42);
该代码强行调用Quote的net_price函数,而不管baseP实际指向的是什么。该调用将在编译时解析。通常是一个派生类的对象需要调用它覆盖的基类的虚函数版本时。
15. 4 抽象基类
一、纯虚函数:
和虚函数一样,一个纯虚函数无需定义。我们通过在函数体的位置即在声明语句的分号之前j加上 = 0就可以将一个虚函数声明为纯虚函数,其中 =0,只能出现在类内部的虚函数声明语句处。
如下所示:net_price是一个纯虚函数:
1 class Disc_quote : public Quote { 2 public: 3 Disc_quote() = default; 4 double net_price(std::size_t) const = 0; 5 protected: 6 std::size_t quantity = 0; 7 double discount = 0.0; 8 9 }
值得注意的是:我们也可以为一个纯虚函数提供定义,不过函数体必须定义在类的外部,也就是说我们不能在类的内部为一个 =0的函数提供函数体。
二、抽象基类:
含有纯虚函数的类是抽象基类(未经覆盖或是直接继承),抽象基类负责定义接口,而后续的其他类可以覆盖该接口,我们不能直接创建一个抽象基类的对象,因为它内部有纯虚函数。但是我们可以定义抽象基类的派生类,前提是派生类把这些虚函数给覆盖了。
15. 5访问控制与继承
访问控制与继承
每个类分别控制着自己的成员初始化过程,每个类还控制着其成员对于派生类来说是否可访问(accessible)
protected关键字来声明那些它希望与派生类分享但是不想被其他公共访问使用的成员。
- 和私有成员类似,受保护的成员对于类的用户来说是不可访问的。
- 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的。
- 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员,派生类对于一个基类对象中受保护成员没有任何访问特权。
派生类的成员和友元只能访问派生类对象中的基类部分的受保护成员;对于普通的基类对象中的成员不具有特殊的访问权限。
公有、私有和受保护继承
- 基类中该成员的访问说明符
- 在派生类的派生列表中的访问说明符
派生类访问说明符对派生类的成员(友元)能否访问其直接基类的成员没什么影响。对基类成员的访问权限只与基类中的访问说明符有关。
派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限:
Pub_Derv d1; //继承自Base的成员是public的
Priv_Derv d2; //继承自Base的成员是private的
d1.pub_men(); //正确 pub_men在派生类中是public的
d2.pub_men(); //错误 pub_men在派生类中是private的
如果继承是公有的,则成员将遵循其原有的访问说明符,此时d1可以调用pub_men.在Priv_Derv中,Base的成员是私有的,因此类的用户不能调用pub_men。
若是定义了一个继承自Base的受保护的类Prot_Derv的类,采用受保护继承。Prot_Derv的用户不能访问pub_men,但是Prot_Derv的成员和友元可以访问那些继承而来的成员。
派生类向基类转换的可访问性
- 只有当D公有地继承B时,用户代码才能使用派生类向基类的转换;如果D继承B的方式是受保护的或者私有的,则用户代码不能使用该转换。
Base *p = &d1; //d1的类型是Pub_Derv d1是公有继承, 存在转换
p = &d2; //d2的类型是Priv_Derv ,d2是私有继承,不存在派生类到基类的准换
p = &d3; //d3的类型是Prot_Derv,d3是受保护的继承,不存在派生类到基类的转换
p = &dd1; //dd1的类型是Derived_from_public D继承自B的方式是公有的,则D的派生类Derived_from_public是存在D向B的转换的。
p = &dd2; //dd2的类型是Derived_from_Private,D继承自B的方式是私有的,则D的派生类Derived_from_Private是不存在D向B的转换的。
p = &dd3; //dd3的类型是Derived_from_Protected,D继承自B的方式是受保护的,不存在D向B的类型转换。
- 不论D以什么方式继承B,D的成员函数和友元都能使用派生类向基类的转换;派生类向其基类的类型转换对于派生类的成员和友元来说永远是可访问的。
- 如果D继承自B的方式是公有的或受保护的,则D的派生类的成员函数和友元可以使用D向B的类型转换;反之,如果D继承B的方式是私有的,则不能使用。
B->D->G:
不论D以何种方式继承B,B的成员函数和友元都能使用子类到父类的转换;
如果D继承B的方式是public或protected的,则G的成员函数和友元能够使用G到B类型的转换,如果D继承B的方式是私有的,则G的成员函数和友元不能使用G到B的转换。
对于代码中的某个给定节点来说,如果基类的公有成员是可访问的,则派生类向基类的类型转换也是可访问的;反之则不行。
友元和继承
友元既不能传递也不能继承。
每个类负责控制自己的成员的访问权限。 当一个类将另一个类声明为友元时,这种友元关系只对做出声明的类有效。对于原来那个类来说,其友元的基类或者派生类不具有特殊的访问能力。
改变个别成员的可访问性
有时我们需要改变派生类继承的某个名字的访问级别,通过使用using声明可以达到这一目的。
class Base{
public:
std::size_t size() const {return n;}
protected:
std::size_t n;
};
class Derived : private Base{
public:
//保持对象尺寸相关的成员的访问级别
using Base:size;
protected:
using Base::n; //Derived使用了私有继承,所以继承而来的成员size和n是Derived的私有成员,使用using改变了这些成员的可访问性。 //Derived的用户可以使用size成员,而Derived的派生类将能使用n。
};
using声明语句中名字的访问权限由该using声明语句之前的访问说明符来决定。 即:
如果一条using声明语句出现在类的private部分,则该名字只能被类的成员或友元访问;如果using声明语句位于public部分,则类的所有用户都能访问它;如果using声明语句出现在protected部分,则该名字对于成员、友元、及派生类是可访问的。
默认继承保护机制
默认情况下,使用class关键字定义的派生类是私有继承的;而使用struct关键字定义的派生类是公有继承的;
struct D1:Base{ } //默认public继承
class D2:Base{ } //默认private继承
使用struct关键字和使用class关键字定义类之间的唯一差别就是默认成员访问说明符及默认派生访问说明符;除此之外,再无其他差别。
一个私有派生的类最好显式地将private声明出来,而不要仅仅依赖于默认的设置。
每个类分别控制着自己成员的初始化过程,也各自控制着其成员对于派生类来说是否可访问。
一、受保护的成员
类使用protected来声明那些希望与派生类分享但是不想被其他公共访问使用的成员。protected可以看做是private和public中和的产物。
- 和私有成员一样,受保护的成员对于类的用户来说是不可访问的。
- 和公有成员一样,受保护的成员对于derived classs的member和friend(友元)来说是可以访问的
- derived class的member和friend只能访问derived class object中基类部分的受保护成员,派生类对于一个base object中的受保护成员没有任何访问权限。
对于第三条规则,请看如下实例:
class Base {
protected:
int prot_men ; //受保护成员
};
class Sneaky :public Base {
friend void clobber(Sneaky&); //能够访问到 Sneaky::prot_mem
friend void clobber(Base& ); // 不能访问到Base::prot_men
int j;
}
//正确,clobber能够访问到Sneaky对象中的private和protected成员
void clobber(Sneaky &s){s.j = s.prot_mem = 0;}
//错误:clobber不能访问Base的protected成员
void clobber(Base &b) {b.prot_mem = 0;}
所以派生类的member 和 friend只能访问派生类对象中的基类部分的protected成员,对于普通基类对象中的受保护成员不具有特殊的访问权限。
上面的意思就是:当成员修饰符是public时,可以直接通过类名(class object)去访问public member;而当成员是private时,只能通过class的成员函数(成员函数是public的)来访问private member。就是说类是大门,public成员是堂屋门,然后private是房间门,想要通过大门直接到达房间门,是不可能的。只能通过堂屋门来访问。当类的成员是protected时,只能通过derived class的member 和friend访问derived class object中基类部分的protected member;(很重要!!!)
二、公有、私有和受保护继承:
某个类对于其继承而来的成员的访问权限受到两个因素的影响,一是在基类中该成员的访问说明符;二是派生类的派生列表中的访问说明符。
举个例子:
1 class Base {
2 public:
3 void pub_mem(); //public member
4 protected:
5 int prot_mem; //protected member
6 private:
7 char priv_mem; //private member
8 };
9
10 //Pub_Derv公有继承Base
11 struct Pub_Derv : public Base {
12 //derived class 可以访问 protected members(注意是derived class object中继承的base class的protectd members)
13 int f() { return prot_mem;}
14
15
16 // error: derived class不能访问private members
17 char g() {return priv_mem;} //不能直接访问私有成员
18 };
19
20 struct Priv_Derv : private Base{
21 //private 继承不影响derived class的访问权限
22 int f1() const {return prot_mem;} //即使Derived class 继承的方式private的,derived class的member 也能访问derived class中base class的protected members;
23 };
派生访问说明符对于派生类的成员(或友元)能否访问其直接base class的成员没有影响。对于base class member的访问权限只与base class的访问说明符有关。也就是说公有继承的类和私有继承的类都可以访问公有成员和受保护成员;
派生访问说明符的目的在于控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限,即通过派生类访问基类的成员。
如下所示:
1 Pub_Derv d1; //公有继承Base
2 Priv_Derv d2; //私有继承Base
3 d1.pub_mem(); //正确,pub_mem在派生类中是公有的
4 d2.pub_mem();//错误,pub_mem在派生类中是私有的
Pub_Derv和Priv_Derv都继承了pub_mem,如果继承是Public,那么继承的成员将遵循基类原有的访问说明符,此时d1可以调用pub_mem。但是在Priv_Derv中,Base的成员变成私有的了,此时类的用户就不能再调用pub_mem:
即当派生类是私有继承自基类时,则derived class中base class的成员就会变成私有的。此时类的用户就不能调用pub_mem;
派生访问说明符还可以控制继承自derived class的子类的访问权限:
1 struct Derived_from_public : public Pub_Derv{
2
3 int use_base() { return prot_mem;} //正确访问,因为在Pub_Derv中Base的成员遵循原有的访问说明符,即prot_mem是protected的,而此时Derived_from_Public又public继承了Pub_Derv,所以派生类的派生类的prot_mem仍然是受保护的,所以可以直接访问。
4 };
5
6 struct Derived_from_Private : public Priv_Derv {
7 //
8 int use_base() {return prot_mem;} //由于Priv_Derv是私有继承的Base,所以Priva_Derv中所有的成员都变成私有的,虽然Derived_from_Private是公有继承的Priv_Derv,但是还是不能直接访问它的直接基类Priv_Derv中的私有成员。
9
10 };
总结:
一,类的成员 :类的成员分为公有成员(public),私有成员(private)和保护成员(protected) 三种成员的区别在于什么时候可以被调用。
二,被调用情况
1:通过创建的对象来调用 比如有一个类A,实例为a,其中有成员为int val; cout<<a.val; 这即为通过创建的对象来调用,也是外部的调用。
2:第二种情况是通过写在类里面的函数,来调用类的成员。
第一种情况只能访问类的公有成员(public) 第二种可以访问类的所有成员
三,类的三种继承方法
类有三种继承方法,公有继承(public),私有继承(private)和保护继承(protected)
- 公有继承(public)
可以理解为父类的public成员和protected成员分别写到子类的public和protected成员中,而父类的private被分到了一个特殊的区域里面,这个特殊的区域只能用父类原有的函数来访问。因此,结合第二部分中所述调用成员的方法:外部调用:父类的public成员,子类新的public成员。类中的函数:除了父类的private外所有成员。
- 私有继承(private)
可以理解为父类的public成员和protected成员写到子类的private成员中,而父类的private仍然被分到了一个特殊的区域里面,这个特殊的区域只能用父类原有的函数来访问。因此,结合第二部分中所述调用成员的方法:外部调用:子类新的public成员。(父类的public在子类的private中,无法访问)类中的函数:除了父类的private外所有成员(同公有继承)
- 保护继承(protected)
可以理解为父类的public成员和protected成员写到子类的protected成员中,而父类的private仍然被分到了一个特殊的区域里面,这个特殊的区域只能用父类原有的函数来访问。因此,结合第二部分中所述调用成员的方法:外部调用:子类新的public成员。(父类的public在子类的protected中,无法访问)类中的函数:除了父类的private外所有成员(同公有继承和私有继承)
综上所述。
三种继承方式,对于父类的private成员,都只能用父类原有函数调用,公有继承public还是public,protected还是protected,私有继承中public和protected都变成了private,保护继承中public和protected都变成了protected。
不考虑继承的话,我们认为一个类有两种不同用户:普通用户和类的实现者。其中普通用户编写的代码使用类的对象,这部分代码只能访问类的公有接口成员;实现者则负责编写类的成员和友元的代码,成员和友元既能访问类的公有部分,也能访问类的私有部分;
基类应该将其接口声明为公有的,同时将属于实现的部分分为两种:一组给派生类访问,另一组只能让基类和基类的友元访问。对于前者,应该声明成受保护的,这样派生类在实现自己功能时使用基类的这些操作和数据;对于后者应该声明成私有的。
三、派生类向基类转换的可访问性
假如D继承自B:
1.只有当D public inheritance B时,user code 才能使用derived classs D 到base class B的转转。如果是private 或者protected的,则不能从D转换到B。
2.不论D以哪种方式继承自B,对D的member function 和friend 来说,派生类向基类的转换永远是可以访问的。
3.只有当D public或者protected继承自B时,D的派生类(D的子类)的成员和友元才能使用D向B的转换,如果D private 继承自B,则D的派生类的成员和友元不能使用D到B的转换。
前两个都是相对于D或者D的成员来说的,最后一个是对于D的子类来说的。
四、友元和继承:
友元既不能传递也不能继承,基类的友元在访问派生类的成员时,不具有特殊性;同样;派生类的友元在访问基类成员时也不具备特殊性。
class Base{
friend class Pal;
protected:
int prot_mem;
};
class Sneaky : public Base{
public:
int j;
};
class Pal {
public:
int f(Base b) {return b.prot_mem;}
int f2(Sneaky s) {return s.j;} //Pal不是Sneaky的friend,所以不能访问到Sneaky的成员j
int f3(Sneaky s) {return s.prot_mem;} //Pal是Base的友元,prot_mem是派生类中内嵌的基类对象
};
五、改变个别成员的可访问性:
1 class Base{
2 public:
3 std::size_t size() const {return n}
4 protected:
5 std::size_t n;
6 };
7
8 class Derived : private Base {
9 public:
10 using Base::size; //
11 protected:
12 using Base::n;
13
14 };
在上述代码中,Derived私有继承了Base,所以在Derived中,size()和n都变成了private的,也就是说无法通过外部调用直接访问size和n,当使用using以后,size在Derived就成了public成员,Derived的用户可以使用size成员,n成了protected成员,Derived的派生类将能使用n;
六、默认继承机制:
1 class Base{/*...*/};
2 struct D1 : Base {/*...*/}; //默认public继承
3 class D2:Base {/*...*/}; //默认private继承
15.6、继承中类的作用域:
继承中的类作用域
派生类的作用域嵌套在基类的作用域之内,若果一个名字在派生类的作用域之内无法正确解析,则编译器将继续在外城的基类作用域中寻找该名字的定义。
在编译时进行名字查找
一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。即使静态类型与动态类型可能不一致,但是我们仍然能使用哪些成员仍然是由静态类型决定的。
当我们给派生类新增一个成员后,我们只能通过Disc_quote及其派生类对象、引用或指针使用discount_policy;
Bulk_quote bulk;
Bulk_quote *bulkP = &bulk; //静态类型与动态类型一致
Quote *itemP = &bulk; // 静态类型与动态类型不一致
bulkP->discount_policy(); //正确:bulkP的类型是Bulk_quote*
itemP->discount_policy(); //错误:itemP的类型是Quote;
尽管在bulk中确实含有一个名为discount_policy的成员,但是该成员对于itemP却是不可见的。itemP的类型是Quote的指针,意味着对discount_policy的搜索将从Quote开始。
派生类的成员将隐藏隐藏同名的基类成员
//基类
struct Base{
Base ():men(0) { }
protected:
int men;
};
struct Derived: Base{
Derived(int i):men(i) { }
int get_men() {return men;}
protected:
int men;
};
//
Derived d(42);
cout << d.get_men()<<endl; //打印42
输出的结果是42
使用作用域运算符来使用隐藏的成员
struct Derived:Base{
int get_base_men() {return Base:men;}
//此时使用了作用域运算符Base:men
//将覆盖掉原有的查找规则,并指示编译器从Base类的作用域开始查找men
}
函数调用的解析过程,p->men
- 首先确定p的静态类型。因为我们调用的是一个成员,所以该类型必然是类类型
- 在p的静态类型对应的类中查找men,如果找不到,则依次在直接基类中不断查找,直至到达继承链的顶端。
- 一旦找到了men,就进行常规的类型检查以确认对于当前找到的men是否合法。
- 若调用合法:则编译器将根据不同的调用的是否是虚函数而产生不同的代码;
- 如果men是虚函数,且我们是通过引用或指针进行的调用,则编译器产生的代码将在运行时确定到底运行该虚函数的哪个版本,依据是对象的动态类型。
名字查找先于类型检查
定义在内层作用域的函数不会重载声明在外层作用域的函数。如果派生类的成员与基类的某个成员同名,则派生类将在其作用域内隐藏该基类成员,即使派生类与基类成员的形参列表不一致。
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
虚函数与作用域
基类与派生类中的虚函数必须要有相同的形参列表。假如基类与派生类虚函数接受的实参不同,则我们就无法通过基类的引用或指针调用派生类的虚函数。
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
};
通过基类调用隐藏的虚函数
当调用的是非虚函数时,不会发生静态绑定。实际调用的函数版本由指针的静态类型确定。
覆盖重载的函数
当只需要覆盖一部分函数时,只需要为重载的成员提供一条using声明语句,这样就无需覆盖基类中的每一个重载版本了。
using声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的using声明语句就可以把该函数的所有重载实例添加到派生类的作用域中。
此时派生类只需要定义其特有的函数就可以了,而无须为其继承而来的其他函数重新定义。
每个class定义自己的作用域,然后再作用域内定义自己的member,当存在inheritence时,derived class的作用域嵌套在base class的作用域之内。
如果一个名字在derived class的作用域内无法解析,则编译器将继续在外层的base class作用域内查找该名字的定义。
例如如下代码:
class Quote{
public:
void isbn();
};
class Disc_quote :public Quote{
};
class Bulk_quote :public Quote{
};
Bulk_quote bulk;
cout << bulk.isbn():
上述代码的解析过程如下所示:
- 我们通过Bulk_quote class object去调用isbn,所以首先在Bulk_quote中查找isbn。没找到
- 因为Bulk_quote是Disc_quote的派生类,所以接下来在Disc_quote的作用域中查找,依然没找到
- 因为Disc_quote是Quote的派生类,所以接着在Quote的作用域中查找,最终isbn被解析成Quote中的isbn。
二、编译时进行名字查找:
一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。即使静态成员可能与动态成员不一致,但是我们能使用哪些成员仍然是由静态类型决定的。
三、名字查找先于类型检验:
15.7 构造函数与拷贝控制
派生类的拷贝控制和构造函数:包括构造函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、析构函数。后面5个称为拷贝控制操作
一、先介绍下虚析构函数对继承体系中的类的操作影响:
基类通常需要定义一个虚析构函数,这样我们就可以动态分配继承体系中的对象。
1 1 //当我们delete一个Quote*类型的指针,则该指针有可能指向一个Bulk_quote的对象。
2 2 //我们通过在基类中将析构函数定义成虚函数以确保执行正确的析构函数版本。
3 3 class Quote{
4 4 public:
5 5 virtual ~Quote() = default; //动态绑定析构函数
6 6 };
和其他虚函数一样,析构函数的虚属性将会被继承。无论派生类使用的是合成的析构函数还是自己定义的析构函数,都是虚析构函数。但是基类的析构函数不遵循:如果一个类需要析构函数那么它也需要拷贝和赋值操作这一准则。
虚析构函数将阻止合成移动操作:如果一个基类定义了析构函数,即使它通过=default的形式使用了合成版本,编译器也不会为这个类合成移动操作。
二、再来讨论下合成拷贝控制与继承:
基类或派生类的合成拷贝控制成员也是对类本身的成员依次进行初始化、赋值或销毁操作。此外这些合成的成员还负责使用基类中的操作对对象的直接基类部分进行初始化、赋值和销毁操作。
所有类使用合成的析构函数,派生类隐式使用。基类将其虚析构函数定义成=default显式使用。派生类的析构函数除了负责销毁自己的成员外,还负责销毁派生类的直接基类。该直接基类又销毁它的直接基类,依次类推,直至达到继承链的顶端。
派生类定义了析构函数,所以不能拥有合成的移动操作,当我们移动派生类对象时实际使用的是合成的拷贝操作。另外当基类没有移动操作时,意味着它的派生类也没有。
类的构造函数与拷贝控制
创建、拷贝、移动、赋值、销毁、
虚析构函数
为了动态分配继承体系中的对象,继承关系对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数。
我们通过在基类中定义虚析构函数来确保执行正确的析构函数版本。
class Quote {
public:
//如果我们删除的是一个指向派生类对象的基类指针,则需要析构函数。
virtual ~Quote() = default; //动态绑定析构函数
};
和其他虚函数一样,析构函数的虚属性也会被继承。
Quote *itemP = new Quote;
delete itemP; //调用Quote的析构函数
itemP = new Bulk_quote;
delete itemP //调用Bulk_Quote的析构函数
若基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为。
以前的经验准则,如果一个类需要析构函数,那么它同样也需要拷贝和赋值操作。但是基类是一个例外,基类的析构函数并不遵循上述准则。
虚析构函数将阻止合成移动操作
如果一个类定义了析构函数,即使它通过=default的形式使用了合成的版本,编译器也不会为这个类合成移动操作。
析构函数的作用主要是清除本类中定义的数据成员,如果该类没有定义指针类成员,则使用合成版本即可;如果该类定义了指针成员,则一般需要自定义析构函数以对指针成员进行适当的清除。
类的合成拷贝控制与继承
它们对类本身的成员依次进行初始化、赋值或销毁的操作。此外,这些合成的成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁的操作。
- 合成的Bulk_quote默认构造函数进行Disc_quote的默认构造函数,后者又运行Quote的默认构造函数。
- Quote的默认构造函数将bookNo成员默认初始化为空字符串,同时使用类内初始化值将price初始化为0.
- Quote的构造函数完成后,继续执行Disc_quote的构造函数,它使用类内初始化的值初始化qty和discount。
- Disc_quote的构造函数完成以后,继续执行Bulk_quote的构造函数,但是它什么具体工作也不作。
在Quote继承体系中,所有的类都使用合成的析构函数。其中派生类隐式的使用而基类通过将其虚构函数定义成=default而显示的使用。
Quote因为定义了析构函数而不能拥有合成的移动操作,因此当我们移动Quote对象时实际使用的是合成的拷贝操作。
Quote没有移动操作意味着它的派生类也没有。
派生类中删除的拷贝控制与基类的关系
基类或派生类也能出于同样的原因将其合成的默认构造函数或者任何一个拷贝控制成员定义为被删除的函数。
- 基类中默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或不可访问,则派生类中对应的成员将是被删除的,原因是编译器不能使用基类成员来执行派生类对象基类部分的构造。
- 若基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法撤销派生类对象的基类部分。
- 编译器不会合成一个删除掉的移动操作。当我们使用=default请求一个操作时,如果基类中的对应操作时删除的或不可访问的,那么派生类中该函数将是被删除的,原因是派生类对象的基类部分不可移动。同样,如果基类的析构函数是删除的或不可访问的,则派生类的移动构造函数也将是被删除的。
在实际编程中,如果基类没有默认、拷贝或移动构造函数,则一般情况下派生类也不会定义相应的操作。
移动操作与继承
因为大多数基类会定义虚析构函数,所以默认情况下,基类不包含合成的移动操作,因而派生类中也没有合成的移动操作。当我们确实需要执行移动操作时,首先应该在基类中定义。
前提是Quote必须显式定义这些成员,一旦Quote定义了自己的移动操作,那么它必须同时显式的定义拷贝操作。
class Quote{
public:
Quote() = default; //对成员依次进行默认初始化
Quote(const Quote&) = default; //拷贝构造函数
Quote(Quote && ) = default; //移动构造函数
Quote& operator=(const Quote&) = default; //拷贝赋值
Quote& operator=(Quote &&) = default; //移动赋值
virtual ~Quote() = default;
//通过以上定义,我们就能对Quote的对象逐成员地分别进行拷贝、移动、赋值和销毁操作了。
};
派生类的拷贝控制成员
当派生类定义了拷贝、赋值、移动操作时,该操作负责拷贝、赋值、移动包括基类部分成员在内的整个对象。即拷贝、赋值、移动运算符,在执行自有成员操作的同时,也要执行基类部分的成员。
class Base{ /*...*/ };
class D:public Base{
public:
//要想使用拷贝或移动构造函数,我们必须在构造函数初始值列表中显示的调用该构造函数。
D(const D& d): Base(d) //拷贝基类成员
/* D的成员的初始值 */ { /* ... */ }
D(D&& d):Base(std::move(d)) //移动基类成员
/* D的成员的初始值 */ {/* ... */}
};
Base(d)一般会匹配Base的拷贝构造函数。D类型的对象d将被绑定到该构造函数的Base&形参上。Base的拷贝构造函数负责将d的基类部分拷贝给要创建的对象。
默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显式的使用基类的拷贝(或移动)构造函数。
派生类赋值运算符
与拷贝和移动构造函数一样,派生类的赋值运算符也必须显式的为其基类部分赋值:
//Base::operator = (const Base&) //不会被自动调用
D &D::operator = (const D &rhs){
Base::operator = (rhs); //为基类部分赋值
//按照过去的方式为派生类的成员赋值
//酌情处理自赋值及释放已有资源等情况
return *this;
}
派生类析构函数
派生类析构函数只负责销毁由派生类自己分配的资源
class D : public Base{
public:
//Base::~Base 被自动调用执行
~D(){ /*该处由用户定义清除派生类成员的操作*/ }
};
对象销毁的顺序与其创建的顺序相反:派生类析构函数先执行,然后是基类的析构函数,依次类推,沿着继承体系的反方向直至最后。
继承的构造函数
一个类只能初始化它的直接基类,同样,一个类也只能继承其直接基类的构造函数。类不能继承默认、拷贝和移动构造函数,如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们。
派生类继承基类构造函数的方式是提供了一条注明了直接基类的using声明语句。
class Bulk_quote : public Disc_quote{
public:
using Disc_quote :: Disc_quote; // 继承Disc_quote的构造函数
double net_price(std::size_t) const;
}
- 当作用于构造函数时,using声明语句将令编译器产生代码。对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数。
构造函数如下所示:
derived(parms) : base(args) { } //derived:派生类名字 base:基类名字 parms:构造函数的形参列表,args:将派生类构造函数的形参传递给基类构造函数。
Bulk_quote(const std::string& book,double price,std::size_t qty,double disc):
Disc_quote(book,price,qty,disc){ } //若派生类中含有自己的数据成员,则这些成员将被默认初始化
继承构造函数的特点
- 一个构造函数的using声明不改变该构造函数的访问级别。
- 一个using声明不能指定explicit或constexpr,若基类的构造函数是explicit或constexpr,则继承的构造函数也是如此。
- 当一个基类构造函数含有默认实参,这些实参并不会被继承。相反,派生类将获得多个继承的构造函数,其中每个构造函数分别省略掉一个含有默认实参的形参。 例如:如果基类有一个接受两个形参的构造函数,其中第二个形参含有默认实参,则派生类将获得两个构造函数:一个构造函数接受两个形参,另一个构造函数只接受一个形参,对应于基类中最左侧的没有默认值的那个形参。
- 如果基类含有几个构造函数,大多数情况下派生类会继承所有这些构造函数:
- 第一个例外是派生类继承一部分构造函数,如果派生类自己定义的构造函数与基类的构造函数具有相同的形参变量,则该基类构造函数将不会被继承;
- 另一个情况是默认、拷贝和移动构造函数不会被继承,这些构造函数按照正常规则被合成。
15.8 类的容器与继承
当我们使用容器存放继承体系中的对象时,通常采用间接存储的方式。因为不允许在容器中存放不同类型的元素,所以我们不能把具有继承关系的多种对象直接放在容器中。
在容器中存放(智能)指针而非对象
当我们希望在容器中存放具有继承关系的对象时,我们实际存放的通常是基类的指针(最好是智能指针)。和往常一样,这些指针所指对象的动态类型可能是基类类型也可能是派生类类型。
vector<shared_ptr<Quote>>basket;
basket.push_back(make_shared<Quote>("0-101-23423-1",50));
basket.push_back(make_shared<Bulk_quote>("0-101-23232-8",50,10,.25));
cout << basket.back() -> net_price(15)<<endl; //.back()返回c容器的最后一个元素的值,使用->以达到此目的。实际调用的net_price版本依赖指针所指对象的动态类型。
可以将派生类的普通指针转换为基类指针,同样派生类的智能指针也可以准换为基类的指针。
编写Basket类
对于C++面向对象的编程来说,一个悖论是我们无法直接使用对象进行面向对象编程。相反,我们必须使用指针和引用。
class Basket{
public:
//Basket使用合成的默认构造函数和拷贝控制成员
}