第十五章 面向对象程序设计

版权声明:转载请注明出处 https://blog.csdn.net/weixin_39918693/article/details/86570502


面向对象程序设计基于三个基本概念:数据抽象、继承和动态绑定

继承和动态绑定对程序的编写有两方面的影响:一是我们可以更容易的定义与其他类相似但不完全相同的新类;二是在使用这些彼此相似的类编写程序时,我们可以在一定程度上忽略掉他们的区别


一、OOP:概述

通过继承联系在一起的类构成一种层次关系。通常在层次关系的根部有一个基类,其他类则直接或间接的从基类继承而来,这些继承得到的类称为派生类。基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自特有的成员

在C++语言中,基类将类型相关的函数与派生类不做改变直接继承的函数区分对待。对于某些函数,基类希望它的派生类各自定义合适自身的版本,此时基类就将这些函数声明为虚函数

派生类必须通过使用派生类列表明确指出它是从哪个(哪些)基类继承而来的。类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有派生访问说明符

派生类必须在其内部对所有重新定义(也可以不重新定义,如果要重新定义必须在类内进行声明)的虚函数进行声明。派生类可以在这样的函数之前加上virtual关键字,但是并不是非得这么做。C++新标准允许派生类显式的注明它将使用哪个成员函数改写基类的虚函数,具体措施是在该函数的形参列表之后增加一个override关键字(该关键字的放置位置有什么讲究吗???????)

动态绑定:函数的运行版本在程序运行时才能决定

在C++中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定


二、定义基类和派生类

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

派生类可以继承其基类的成员,然而当遇到虚函数的时候,派生类必须(不是必须,建议一定要进行重定义)对其进行重新定义。换句话说,派生类需要对这些操作提供自己的新定义以覆盖从基类继承而来的旧定义

动态绑定:根据引用或指针所绑定对象类型不同,会执行不同版本的虚函数

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

成员函数如果没有被声明为虚函数,则其解析过程发生在编译时而非运行时

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

类的用户的概念非常重要

派生类必须通过使用派生类列表明确指出它是从哪个(哪些)基类继承而来的。派生访问说明符可以有public、protected、private(建议显式派生,不要隐式派生)

派生类必须将其继承而来的成员函数中需要覆盖的那些重新声明

对于类的成员,尽量内部声明,外部定义

派生类经常(但不总是)覆盖它继承的虚函数。如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本

顺序:const 引用限定符 override

C++11允许派生类显式的注明它使用某个成员函数覆盖它继承的虚函数

一个派生类对象包含多个组成部分:一个含有派生类自己定义的(非静态)成员的子对象,以及一个(或几个)与该派生类继承的基类对应的子对象。如果由多个基类,那么这样的子对象有多个

因为在派生类对象中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到派生类对象中的基类部分上,这叫做派生类到基类的类型转换(编译器会隐式执行),“这意味着我们可以把派生类对象或派生类对象的引用用在需要基类引用的地方;同样,我们也可以把派生类对象的指针用在需要基类指针的地方”

在派生类对象中含有与其基类对应的组成部分,这一事实是继承的关键所在

派生类不能直接初始化从基类继承而来的成员,派生类必须使用基类的构造函数来初始化它的基类部分。每个类控制它自己的成员初始化过程

通过派生类构造函数的初始化列表来将实参传递给基类的构造函数,如非特别指出,派生类对象的基类部分执行默认初始化。首先初始化基类的部分,然后执行基类构造函数的函数体,再然后按照内部声明顺序依次初始化派生类的成员

派生类(其的成员和友元)可以访问基类的公有成员和受保护成员

派生类的作用域嵌套在基类的作用域内,对于派生类的一个成员来说,它使用派生类成员的方式和使用基类成员的方式没什么不同

要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。遵循基类的接口

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

派生类的声明中不包含它的派生列表

如果我们想将某个类用作基类,则该类必须已经定义而非仅仅声明。一个类不能派生它本身

直接基类、间接基类。注意理解其中的意思

每个类都会继承直接基类的所有成员,继承具有传递性,你懂的

如果不希望一个类被继承,C++11提供了一种防止继承发生的方法,在类定义时在类名后跟一个关键字final

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

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

表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型;动态类型则是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知

如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致

之所以存在派生类向基类的类型转换是因为每个派生类对象都包含一个基类部分,而基类的引用或指针可以绑定到该基类部分上。不存在从基类向派生类的自动类型转换,即使一个基类指针或引用绑定在一个派生类对象上,我们也不能执行从基类向派生类的转换

编译器在编译时无法确定某个特定的转换在运行时是否安全,这是因为编译器只能通过检查指针或引用的静态类型来推断该转换是否合法。如果在基类中含有一个或多个虚函数,我们可以使用dynamic_cast请求一个类型转换,该转换的安全检查将在运行时执行。同样,如果我们已知某个基类向派生类的转换是安全的,则我们可以使用static_cast来强制覆盖掉编译器的检查工作

派生类向基类的自动类型转换只对指针或引用类型有效,在派生类类型和基类类型之间不存在这样的转换

如果我们将一个派生类对象赋值给一个基类对象,则实际运行的赋值运算符也是基类中定义的那个,该运算符同样只能处理基类自己的成员(造成了切割的作用)

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

尽管自动类型转换只对指针或引用类型有效,但是继承体系中的大多数类仍然(显式或隐式)定义了拷贝控制成员。因此,我们通常能够将一个派生类对象拷贝、移动或赋值给一个基类对象。这样会造成切割


三、虚函数

我们必须为每个虚函数都提供定义,而不管它是否被用到了。一般的函数用不到,就无需提供定义

对虚函数的调用在运行时才被解析

动态绑定只有当我们通过指针或引用调用虚函数时才会发生,也只有在这种情况下其的动态类型才有可能与静态类型不同

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

注意理解,通过不同方式调用函数,函数是在不同的时期完成绑定的

当我们在派生类中覆盖了某个虚函数时,可以再一次使用virtual关键字指出该函数的性质。然而这么做并非必须,因为一旦某个函数被声明成虚函数,则在所有派生类中它都是虚函数

一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须于被它覆盖的基类函数严格保持一致。同样,派生类中虚函数的返回类型也必须于基类函数匹配。该规则存在一个例外,当类的虚函数返回类型是类本身的指针或引用时,上述规则无效,不过这样的返回类型要求类型转换是可访问的

在派生类中定义一个名字与基类中虚函数相同,但形参列表不同,这是合法的,该函数被当作新的函数

用override关键字来标记进行显式覆盖已存在的虚函数

我们还能把某个虚函数指定为final,如果我们已经把虚函数定义为final,则之后任何尝试覆盖该函数的操作都将引发错误

final和override关键字出现在形参列表(包括任何const或引用限定符)以及尾置返回类型之后

虚函数可以拥有默认实参。如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。基类和派生类中定义的默认实参最好一致

通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制p539


四、抽象基类

一个纯虚函数无需定义。我们通过在函数声明之后书写=0就可以将一个虚函数说明为纯虚函数。其中,=0只能出现在类内部的虚函数声明语句处

我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。

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

派生类构造函数只初始化它的直接基类(层层向下分配任务)

重构负责重新设计类的体系以便将操作和/或数据从一个类移动到另一个类中。对于面向对象的应用程序来说,重构是一种很普遍的现象。继承体系重构以后,用户代码无需改变,但含有这些类的代码必须重新编译


五、访问控制与继承

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

受保护的成员:其对于类的用户(类的对象)来说是不可访问的;其对于派生类的成员和友元来说是可访问的;派生类的成员和友元只能通过派生类对象来访问基类的受保护成员(其属于派生类对象)。派生类对于一个基类对象中的受保护成员没有任何访问特权

规定:派生类的成员和友元只能访问派生类对象中的基类部分的受保护成员;对于普通的基类对象中的成员不具有特殊的访问权限

某个类对其继承而来的成员的访问权限受到两个因素影响:一是在基类中该成员的访问说明符,二是在派生类的派生列表中的访问说明符

派生访问说明符对于派生类的成员(及友元)能否访问其直接基类的成员没什么影响。对基类成员的访问权限只与基类中的访问说明符有关。

派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限

公有、私有和受保护继承

派生类向基类的转换是否可访问由使用该转换的代码决定,同时派生类的派生访问说明符也会有影响

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

类的用户有三种:类的对象、类的设计者(其成员和友元)、类的派生类(其成员和友元)p544

友元关系不能传递,友元关系同样也不能继承。基类的友元在访问派生类成员时不具有特殊性,类似的,派生类的友元也不能随意访问基类的成员

不能继承友元关系,每个类负责控制各自成员的访问权限(继承稍微有些例外)p545

不要打擦边球

有时我们需要改变派生类继承的某个名字的访问级别,通过使用using声明

通过在类的内部使用using声明语句,我们可以将该类的直接或间接基类中的任何可访问成员(非私有成员)标记出来。using声明语句中名字的访问权限由该using声明语句之前的访问说明符来决定

派生类只能为那些它可以访问的名字提供using声明,可访问(不要想得太简单)

struct和class唯一的差别就是默认成员访问说明符及默认派生访问说明符


六、继承中的类作用域

当存在继承关系时,派生类的作用域嵌套在其基类的作用域之内。如果一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找该名字的定义

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

在编译时进行名字查找,一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。静态类型决定了名字查找开始的位置(虚函数也受该规则制约,但是动态绑定会自动调用动态类型对应的虚函数版本)

派生类的数据成员将隐藏同名的基类成员(函数之间的关系没那么简单)

我们可以通过作用域运算符来使用隐藏的成员

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

理解在继承体系中函数调用的解析过程p549

名字查找先于类型检查,声明在内层作用域的函数并不会重载声明在外层作用域的函数,而是进行了屏蔽,即使只是名字相同,形参列表不一致也会进行屏蔽。一旦名字找到,编译器就不再继续查找了,然后进行常规类型检查

虚函数必须有相同的形参列表是有原因的,自己理解

虚函数也存在被隐藏的风险,我们可以用很多方法调用隐藏的虚函数,关键是要彻底理解

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

我们可以在派生类中使用using语句指定一个名字而不指定形参列表,所以一条基类成员函数的using声明语句就可以把该函数的所有重载实例添加到派生类作用域中,此时,派生类只需要定义其特有的函数就可以了,而无需为继承而来的其他函数重新定义


七、构造函数与拷贝控制

继承关系对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数,这样我们就能动态分配继承体系中的对象了

只要基类的析构函数是虚函数,就能确保当我们delete基类指针时将运行正确的析构函数版本

如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为

一个基类总是需要析构函数,而且它能将析构函数设定为虚函数,此时,该析构函数为了成为虚函数而令内容为空,我们显然无法由此推断该基类还需要拷贝赋值运算符或拷贝构造函数(是之前一条准则的例外)

如果一个类定义了(虚)析构函数,即使它通过=default的形式使用了合成的版本,编译器也不会为这个类合成移动操作

基类或派生类的合成拷贝控制成员的行为与其他合成的构造函数、赋值运算符或析构函数类似:他们对类本身的成员依次进行初始化、赋值或销毁的操作。此外,这些合成的成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁的操作(向下层调用)

基类中的对应成员是合成的还是自定义的没有太大影响,唯一的要求是对应成员是可访问的并且不是一个被删除的函数

一个类没有移动操作意味着它的派生类也没有

派生类中删除的拷贝控制与基类的关系:底层不支持,上层无法完成相应工作。实际编程过程中,如果在基类中没有默认、拷贝或移动构造函数,则一般情况下派生类也不会定义相应的操作

基类缺少移动操作会阻止派生类拥有自己的合成移动操作,所以当我们确实需要执行移动操作时应该首先在基类中进行定义,这样的话,除非派生类中含有排斥移动的成员,否则它将自动获得合成的移动操作

派生类的构造函数和除析构函数之外的拷贝控制成员都是调用下层对应的成员来完成下层的任务的。而析构函数只负责销毁派生类自己分配的资源,派生类对象的基类部分也是自动销毁的?????不用手动调用下层操作吗?一切都是自动完成的吗??内部自动调用下层析构函数吗??完全自动调用

在默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显式的使用基类的拷贝(或移动)构造函数

对象的销毁的顺序正好与其创建的顺序相反;派生类析构函数首先执行,然后是基类的析构函数,以此类推,沿着继承体系的反方向直至最后

如果构造函数或析构函数调用了某个虚函数,则我们应该执行于构造函数或析构函数所属类型相对应的虚函数版本p556

在C++11中,派生类能够重用其直接基类定义的构造函数(在其自身构造函数初始化列表中)

类不能继承默认、拷贝和移动构造函数,但是派生类可以隐式使用基类的默认构造函数,可以显式使用基类的移动和拷贝构造函数,这是为什么

派生类继承基类构造函数的方式是提供一条注明了(直接)基类名的using声明语句。通常情况下,using声明语句只是令某个名字在当前作用域内可见

和普通的成员的using声明不一样,一个构造函数的using声明不会改变该构造函数的访问级别(感觉没什么意义),我不继承也能用,为什么要多此一举的继承呢??????难道是默认完成了继承。一个using声明语句不能指定explicit或constexpr,不能改变这两种属性

构造函数的继承,有些可笑??????p558


八、容器与继承

当我们使用容器存放继承体系中的对象时,通常必须采取间接存储的方式。因为不允许在容器中保存不同类型的元素,所以我们不能把具有继承关系的多种类型的对象直接存放在容器当中

当我们希望在容器中存放具有继承关系的对象时,我们实际上存放的通常是基类的指针(更好的选择是智能指针)

正如我们可以将一个派生类的普通指针转换成基类的指针一样,我们也能把一个派生类的智能指针转换成基类的智能指针。指针还可以转换呢??????

对于C++面向对象的编程来说,一个悖论是我们无法直接使用对象进行面向对象编程。相反,我们必须使用指针和引用。因为指针会增加程序的复杂性,所以我们经常定义一些辅助的类来处理这种复杂情况

实际设计类时,避免让用户(程序员)直接接触指针,我们可以隐藏指针

模拟虚拷贝p561


九、文本查询程序再探

猜你喜欢

转载自blog.csdn.net/weixin_39918693/article/details/86570502
今日推荐