C++primer第十五章面向对象程序设计笔记

目录

15.1 OOP:概述

面向对象程序设计的核心思想是:

  • 数据抽象,通过使用数据抽象,我们可以将类的接口与实现分离
  • 继承,使用继承,可以定义相似的类型对其相似关系建模
  • 动态绑定,使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象

在C++语言中,基类类型相关的函数派生类不做改变直接继承的函数区分对待。

  • 对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数
  • 派生类必须在其内部对所有重新定义的虚函数进行声明。派生类可以在这样的函数之前加上virtual关键字,但是并不是非得这么做。C++11新标准允许派生类显示地注明它将使用哪个成员函数改写基类的虚函数,具体措施是在函数的形参列表之后增加一个override关键字

通过使用动态绑定,我们能用同一段代码分别处理Quote和Bulk_quote的对象。

15.2 定义基类和派生类

作为继承关系中根节点的类通常都会定义一个虚析构函数

基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。

在C ++中基类必须将它的两种成员函数区分开来

  1. 一种是基类希望其派生类进行覆盖函数;对于此类函数,基类通常将其定义为虚函数
  2. 另一种是基类希望派生类直接继承而不要改变函数;

============================================================

  • 基类通过在其成员函数的声明语句之前加上关键字virtual使得该函数执行动态绑定
  • 任何构造函数之外的非静态函数都可以是虚函数
  • 关键字virtual只能出现在类内部的声明语句之前不能用于类外部的函数定义
  • 如果基类把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数

派生类可以继承定义在基类中的成员,但是派生类的成员函数不一定有权访问从基类继承而来的成员。和其他使用基类的代码一样,派生类能访问共有成员,而不能访问私有成员。不过在某些时候基类中还有这样一种成员,基类希望它的派生类有权访问该成员,同时禁止其他用户访问。我们用受保护访问运算符说明这样的成员。

派生类访问bookNo成员的方式与其他用户是一样的,都是通过共有的isbn()函数,因此bookNo被定义成私有的,即使是Quote派生出来的类也不能直接访问它。

派生访问说明符(public,private,protected)的作用是控制派生类从基类继承而来的成员是否对派生类的用户可见。如果一个派生是共有的,则基类的共有成员也是派生类接口的一部分

  • 派生类经常(但不总是)覆盖它继承的虚函数
  • 如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本
  • 派生类必须其继承而来的成员函数中需要覆盖的那些重新声明。(个人:也就是名正言顺的告诉编译器,我将覆盖这个虚函数,定义自己的版本)
  • 派生类可以在它覆盖的函数前使用virtual关键字,但不是非得这么做
  • C++11新标准允许派生类显示地注明它使用某个成员函数覆盖了它继承的虚函数。集体做法是在形参列表后面,或者在const成员函数的const关键字后面,或者在引用成员函数的引用限定符后面添加一个关键字override

因为在派生类对象中含有与基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或者引用绑定到派生类对象中的基类部分。这种转换通常称为派生类到基类的类型转换。和其他类型转换一样,编译器会隐式地执行派生类到基类的转换。这种隐式特性意味着我们可以把派生类对象或者派生类对象的引用用在需要基类引用的地方。同样的,我们也可以把派生类对象的指针用在需要基类指针的地方。在派生类对象中含有与基类对应的组成部分,这一事实是继承的关键所在

尽管在派生类对象中含有从基类继承而来的成员,但是派生类并不能直接初始化这些成员。和其他创建了基类对象的代码一样,派生类也必须使用基类的构造函数来初始化它的基类部分每个类控制它自己的成员初始化过程。派生类对象的基类部分与派生类对象自己的数据成员都是在构造函数的初始化阶段执行初始化操作的。类似于我们初始化成员的过程,派生类构造函数同样是通过构造函数初始化列表来将实参传递给基类构造函数的。除非我们特别指出,否则派生类对象的基类部分会像数据成员一样执行默认初始化。如果想使用其他的基类构造函数,我们需要以类名加圆括号内的实参列表的形式为构造函数提供初始值。这些实参将帮助编译器决定到底应该选用哪个构造函数来初始化派生类对象的基类部分。首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。

派生类可以访问基类的共有成员受保护成员派生类的作用域嵌套在基类的作用域之内。因此对于派生类的一个成员来说,它使用派生类成员的方式与使用基类成员的方式没什么不同。

必须明确一点:每个类负责定义各自的接口。要想与类的对象交互必须使用该类的接口即使这个对象是派生类的基类部分也是如此。因此派生类对象不能直接初始化基类的成员。尽管从语法上来说我们可以在派生类构造函数体内给它的公有或受保护的基类成员赋值,但是最好不要这么做和使用基类的其他场合一样,派生类应该遵循基类的接口,并且通过调用基类的构造函数来初始化那些从基类中继承而来的成员

如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。不论从基类中派生出多少个派生类,对于每个静态成员来说都只存在唯一的实例。静态成员遵循通用的访问控制规则,如果基类中的成员是private的,则派生类无权访问它,假设某静态成员是可访问的,则我们既能通过基类使用它也能通过派生类使用它

派生类声明与其他类差别不大,声明中包含类名但是不包含它的派生列表一条声明语句的目的是令程序知晓某个名字的存在以及该名字表示一个什么样的实体,如一个,一个函数,一个变量等。派生列表以及与定义有关的其他细节必须与类的主体一起出现如果我们想将某个类用作基类,则该必须已经定义而非仅仅声明。这一规定的原因显而易见:派生类中包含并且可以使用他从基类继承而来的成员,为了使用这些成员,派生类当然要知道他们是什么。因此该规定还有一隐含的意思,即一个类不能派生它本身

有时我们会定义这样一种类,我们不希望其他类继承他,或者不想考虑他是否适合作为一个基类,为了实现这一目的,C++11新标准提供了一种防止继承发生的办法,即在类名后跟一个关键字final

内置指针一样,智能指针类也支持派生类向基类的类型转换,这意味着我们可以将一个派生类对象的指针存储在一个基类的智能指针内。

理解基类与派生类之间的类型转换是理解C++语言面向对象编程的关键所在。

  • 表达式的静态类型在编译时总是已知的,它是变量声明时的类型表达式生成的类型
  • 动态类型则是变量或表达式表示的内存中的对象的类型动态类型直到运行时才可知
  • 如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致

之所以存在派生类到基类的类型转换是因为每个派生类对象都包含一个基类部分,而基类的引用或者指针可以绑定到该基类部分上。一个基类的对象既可以以独立的形式存在,也可以作为派生类的一部分而存在。如果基类对象不是派生类对象的一部分,则它只含有基类定义的成员,而不含有派生类定义的成员。因为一个基类对象可能是派生类的一部分,也可能不是,所以不存在从基类向派生类的自动类型转换即使一个基类指针或者引用绑定到一个派生类对象上,我们也不能执行从基类到派生类的转换编译器在编译时无法确定某个特定的转换在运行时是否安全,这是因为编译器只能通过检查指针或者引用的静态类型来推断该转换是否合法。如果在基类中含有一个或者多个虚函数(个人理解如果不是为了通过虚函数使用多态,如果没有虚函数,则这样的的转换是没有意义的),我们可以使用dynamic_cast请求一个类型转换,该转换的安全检查在运行时执行,同样,如果我们已知某个基类向派生类的转换是安全的,则我们可以使用static_cast强制覆盖掉编译器的检查工作。在对象之间不存在类型转换。派生类向基类的自动类型转换只对指针或者应用类型有效,在派生类类型和基类类型之间不存在这样的转换。很多时候,我们确实希望将派生类对象转换成它的基类类型,但是这种转换的实际发生过程往往与我们期望的有所差别

请注意当我们初始化或者赋值一个类类型的对象时实际上是在调用某个函数

  • 执行初始化时,我们调用构造函数
  • 而当执行赋值操作时,我们调用赋值运算符

这些成员通常都包含一个参数,该参数的类型是类类型的const版本的引用。因为这些成员接受引用作为参数,所以派生类向基类的转换允许我们给基类的拷贝,移动操作传递一个派生类对象,这些操作不是虚函数

  • 当我们给基类的构造函数传递一个派生类对象时,实际运行的构造函数是基类中定义的那个,显然该构造函数只能处理基类自己的成员
  • 类似的,如果我们将一个派生类对象赋值给一个基类对象时实际运行的赋值运算符也是基类中定义的那个,该运算符同样只能处理基类自己的成员

当我们用一个派生类对象为一个基类对象初始化或者赋值时,只有该派生类对象中的基类部分会被拷贝,移动,赋值,它的派生类部分将被忽略掉

要想理解在具有继承关系的类之间发生的类型转换,有三点非常重要:

  1. 从派生类向基类的类型转换只对指针或引用类型有效
  2. 基类向派生类不存在隐式类型转换
  3. 和任何其他成员一样,派生类向基类的类型转换也可能会由于访问受限而变得不可行

尽管自动类型转换只对指针或者引用类型有效,但是继承体系中的大多数类仍然(显式或者隐式地)定义了拷贝控制成员。因此我们通常能够将一个派生类对象拷贝移动或者赋值给一个基类对象,不过要注意的是,这种操作只处理派生类对象的基类部分

15.3:虚函数

因为我们直到运行时才知道到底调用了那个版本的虚函数,所以所有的虚函数都必须有定义

  • 通常情况下,如果我们不使用某个函数,则无须为该函数提供定义,
  • 但是我们必须为每一个虚函数都提供定义,而不管它是否被用到了,这是因为连编译器也无法确定到底会使用哪个虚函数

OOP的核心思想是多态性,多态性这个词源自希腊语,其含义是多种形式。我们把具有继承关系的多个类型称为多态类型,因为我们能使用这些类型的多种形式而无须在意他们的差异引用或者指针的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本所在

  • 当我们在派生类中覆盖了某个虚函数时,可以再一次使用virtual关键字指出该函数的性质。然而这么做并非必须,因为一旦某个函数被声明成虚函数,则在所有派生类中它都是虚函数
  • 一个派生类中的函数如果覆盖了某个继承而来的虚函数,它的形参类型必须与被覆盖基类函数完全一致。同样,派生类中的虚函数的返回类型也必须与基类函数匹配。该规则有一个例外,当类的虚函数返回类型是类本身的指针或者引用时,上述规则无效。也就是说,如果D由B派生得到,则基类的虚函数可以返回B*而派生类的对应函数可以返回D*,只不过这样的返回类型要求从 D到B的类型转换是可访问的
  • 基类中的虚函数在派生类中隐含地也是一个虚函数(也就是说,我们在派生类也可以不覆盖这个虚函数,派生类的这个虚函数版本还是它继承自基类的版本)。
  • 当派生类覆盖了某个虚函数时,该函数在基类中的形参必须与派生类中的形参严格匹配。派生类如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,这任然是合法的行为。编译器将认为新定义的这个函数与基类中原有的函数是相互独立的。这时派生类的函数并没有覆盖掉基类中的版本就实际的编程习惯而言,这种声明往往发生了错误,因为我们可能原本希望派生类能覆盖掉基类中的虚函数,但是一不小心把形参列表弄错了。要想调试并发现这样的错误显然非常困难。在C++11新标准中我们可以使用override关键字说明派生类中的虚函数,这么做的好处是在使得程序员的意图更加清晰的同时让编译器可以为我们发现一些错误,后者在编程实践中显得更加重要。如果我们使用override标记了某个函数,但是该函数并没有覆盖已存在的虚函数,此时编译器将报错我们使用override所表达的意思是我们希望能覆盖基类中的虚函数而实际上并未做到,所以编译器会报错。
  • 只有虚函数才能被覆盖
  • 我们还能把某个函数指定为final,如果我们已经把函数定义成final了,则之后任何尝试覆盖该函数的操作都将引发错误
  • 虚函数也可以拥有默认实参,如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。如果虚函数使用默认实参,则基类和派生类中定义的默认实参做好一致
  • 在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的(个人:使用作用域运算符,可以明确的指出我们使用的是哪个名字)
    //强制调用基类中定义的函数版本,而不管baseP的动态类型到底是什么
    double undiscounted = baseP->Quote::net_price(42);
    该调用将在编译时完成解析。通常情况下只有成员函数(或者友元)中的代码才需要使用作用域运算符来回避虚函数的机制。什么时候需要回避虚函数的默认机制呢?通常是当一个派生类的虚函数调用它覆盖的基类的虚函数版本时。在此情况下,基类的版本通常完成继承层次中所有类型都要做的共同任务,而派生类中定义的版本需要执行一些与派生类本身密切相关的操作

 

15.4:抽象基类

普通的虚函数不一样,一个纯虚函数无须定义(个人:也就是说,一个纯虚函数既可以不定义,也可以定义(函数体必须定义在类的外部))。我们通过在函数体的位置(即在声明语句的分号之前)书写=0就可以将一个虚函数说明为纯虚函数。其中,=0只能出现在类内部的虚函数声明处。值得注意的是,我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。也就是说,我们不能在类的内部为一个=0的函数提供函数体

含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类抽象基类负责定义接口,而后续的其他类可以覆盖该接口,我们不能直接创建一个抽象基类的对象

派生类构造函数只能初始化它的直接基类,每个类各自控制其对象的初始化过程

重构负责重新设计类的体系以便将操作数据从一个类移动到另一个类中。对于面向对象的应用程序来说,重构是一种很普遍的现象。一旦类重构(或者以其他方式被改变),就意味着我们必须重新编译含有这些类的代码了。

15.5:访问控制与继承

每个类分别控制自己的成员的初始化过程,与之类似,每个类还分别控制着其成员对于派生类来说是否是可访问的

基类的受保护成员:

  • 和私有成员类似受保护的成员对于类的用户来说是不可访问的
  • 和共有成员类似受保护的成员对于派生类的成员友元来说是可访问的
  • 此外,受保护成员还有另外一条重要的性质。派生类的成员或友元只能通过派生类对象(自己所在的派生类对象this,或者任意一个我们创建的派生类对象)来访问基类(派生类中的基类部分)的受保护成员,派生类对于一个基类对象(作为一个个体存在的基类对象)中的受保护成员没有任何访问特权。C++中的规定:派生类的成员友元只能访问派生类对象中的基类部分的受保护成员,对于普通的基类对象中的受保护成员不具有特殊的访问权限。(这一点和java中的基本一致)

派生访问说明符对于派生类成员(及友元)能否访问其直接基类的成员没什么影响。对基类成员的访问权限只与基类中的访问说明符有关。派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类的访问权限派生访问说明符还可以控制继承自派生类的新类的访问权限

  • 如果继承是共有的(java中没有派生访问说明符,按照C++的标准,java中应该都是共有继承的)则成员将遵循其原有的访问说明符
  • 在私有继承中base的成员都是私有的,类的用户不能调用
  • 受保护继承,则base的所有共有成员在新定义的类中都是受保护的,base的私有和受保护成员遵循原有的访问说明符。

派生类向基类的转换(指针或者引用绑定)是否可访问使用该转换的代码决定,同时派生类的派生访问说明符也会有影响。假定D继承自B:

  • 只有当D公有地继承B时,用户代码才能使用派生类向基类的转换:如果D继承B的方式是受保护的或者私有的,则用户代码不能使用该转换。
  • 不论D以什么方式继承B,D的成员函数和友元都能使用派生类向基类的转换。派生类向其直接基类的类型转换对于派生类的成员和友元来说永远都是可访问的
  • 如果D继承B的方式是公有的或者受保护的,则D的派生类的成员和友元可以使用D向B的类型转换。反之,如果D继承B的方式是私有的,则不能使用。

对于代码中的某个给定节点来说,如果基类的公有成员是可访问的,则派生类向基类的类型转换也是可访问的;反之则不行

就像友元关系不能传递一样,友元关系也不能继承。不能继承友元关系,每个类负责控制各自成员的访问权限对于基类的访问权限由基类本身控制,即使对于派生类的基类部分也是如此,pal是base的友元,所以pal能够访问base对象的成员,这种可访问性包括了base对象内嵌在其派生类对象中的情况

有时我们需要改变派生类继承的某个名字的访问级别,通过使用using声明可以达到这一点。

class Base{
public:
	std::size_t size() const {return n;}
protected:
	std::size_t n;
};

class Derived:private Base{//注意:private继承
public:
	using Base::size;
protected:
	using Base::n;
};

因为Derived使用了私有继承,所以继承而来的成员size和n(在默认情况下)是Derived的私有成员,然而,我们使用using声明语句改变了这些成员的可访问性。改变之后,Derived的用户将可以使用size成员,而Derived的派生类将能使用n。

Note派生类只能为那些它可以访问的名字提供using声明。

默认派生运算符也由定义派生类所用的关键字来决定,默认情况下,使用class关键字定义的派生类是私有继承的,而使用struct关键字定义的派生类是共有继承的:

class Base { /* ... */ };
struct D1 : Base { /* ... */ };//默认public继承
class D2 : Base { /* ... */ };//默认private继承

人们常常有一种错觉,认为在使用struct关键字class关键字定义的类之间还有更深层次的差别。事实上,唯一的差别就是默认成员访问说明符默认派生访问说明符;除此之外,再无其他不同之处。  

15.6:继承中的类作用域

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

不过恰恰因为类作用域有这种继承嵌套关系,所以派生类才能像使用自己的成员一样使用基类的成员

在编译时进行名字查找,我们能使用那些成员是由静态类型决定的

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

派生类的成员将隐藏同名的基类成员。我们可以通过作用域运算符来使用一个被隐藏的基类成员。作用域运算符将覆盖掉原有的查找规则

  • 声明在内层作用域中的函数并不会重载声明在外层作用域的函数(应该是重载的函数必须在同一个作用域中吧,个人理解),因此定义在派生类中的函数也不会重载其基类中的成员。
  • 和其他作用域一样,如果派生类(即内层作用域)的成员与基类(即外层作用域)的某个成员同名,则派生类将在其作用域内隐藏该基类成员,即使派生成员和基类成员的形参列表不一致,基类成员仍然会被隐藏掉。名字查找先于类型检查。一旦名字找到,编译器就不再继续查找了
  • 除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字

虚函数与作用域:

  • 基类与派生类中的虚函数必须有相同的形参列表假如基类与派生类的虚函数接受的实参不同,则我们就无法通过基类的引用或指针调用派生类的虚函数了
  • 和其他函数一样,成员函数无论是否是虚函数都能被重载,派生类可以覆盖重载函数的0个或者多个实例,如果派生类希望所有的重载版本对于他来说都是可见的
    • 那么他就需要覆盖所有的版本(此时所有的重载版本在内层作用域中都是可见的)
    • 或者一个也不覆盖(此时到外层作用域中去使用)

 

posted on 2022-05-23 22:34  朴素贝叶斯  阅读(93)  评论(0编辑  收藏  举报

导航