《C++Primer》第十五章——面向对象程序设计

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

15.1 OOP:概述

1.面向对象程序设计(object-oriented programming)

  • 核心思想是数据抽象、继承、动态绑定
  • 通过使用数据抽象,可以将类的接口与实现分离
  • 通过使用继承,可以定义相似的类型并对其相似关系建模
  • 通过使用动态绑定,可以在一定程度上忽略相似类的区别,而以统一的方式使用它们的对象
    2.继承
    1)通过继承联系在一起的类构成一种层次关系
  • 在层次关系根部的为基类,基类负责定义在层次关系中所有类共同拥有的成员;
  • 其他类则直接或间接地从基类继承而来,每个派生类定义各自特有的成员
    2)虚函数:在C++中,基类将类型相关的函数与派生类不做改变直接继承的函数区分对待,其中对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明为虚函数
    3)类派生列表:派生类必须通过类派生列表明确指出它是从哪个(哪些)基类继承而来的;其形式为首先是一个冒号,后面紧跟以逗号分隔的基类列表,每个基类前面可以有访问说明符
    3.动态绑定
  • 直到运行时才能确定到底执行函数的哪个版本,因此也称为运行时绑定
  • 在C++语言中,通过使用基类的引用(或指针)调用一个虚函数时将发生动态绑定,根据引用或指针所绑定对象的实际类型来选择执行虚函数的哪个版本

15.2 定义基类和派生类

1.定义基类

  • 基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此
    1)成员函数与继承
    在C++语言中,基类必须将他的两种成员函数区分开
  • 一种是基类希望派生类直接继承而不要改变的函数
  • 一种是基类希望其派生类进行覆盖的函数,对于这种函数,基类通常将其定义为虚函数(virtual)
    关键字 virtual 只能出现在类内部的声明语句之前而不能用于类外部的函数定义
    2.定义派生类
  • 派生类必须通过使用类派生列表明确指出它是从哪个(哪些)基类继承而来的
  • 类派生列表的形式是:首先一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有三种访问说明符中的一个,public、protected、private,这些访问说明符的作用是空置派生类从基类继承而来的成员是否对派生类的用户可见
    1)派生类中的虚函数
    派生类经常(但不总是)覆盖它继承的虚函数,若派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本
    2)派生类对象及派生类向基类的类型转换
    由于在派生类对象中含有与其基类对应的组成部分,所以我们可以把派生类对象当成基类对象来使用而我们也能将基类的指针或引用绑定在派生类对象中的基类部分上
Quote item;			//基类对象
Bulk_quote bulk;	//派生类对象
Quote *p = item;	//p 指向 Quote 对象
p = &bulk;			//p 指向 bulk 的 Quote 部分
Quote &r = bulk;	//r 绑定到 bulk 的 Quote 部分

这种转换通常称为派生类到街垒的类型转换,编译器会隐式地执行派生类到基类的转换,这种隐式特性意味着我们可以把派生类对象或者派生类对象的引用用在基类引用的地方,也可以把派生类对象的指针用在需要基类指针的地方
3)派生类构造函数

  • 每个类控制它自己的成员的初始化过程,因此派生类必须使用基类的构造函数来初始化它的基类部分
  • 派生类构造函数同样是通过构造函数初始化列表来将实参传递个基类构造函数的
  • 首先初始化基类的部分,然后按照声明的顺序依次初始化派生列的成员
    4)继承与静态成员
  • 如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义,不论从基类派生出多少个派生类,对于每个静态成员来说都只存在唯一的实例
  • 静态成员遵循通用的访问控制规则,若基类中的成员是 private 的,则派生类无权访问它;若某静态成员是可访问的,则既能通过基类使用它也能通过派生类使用它
    5)派生类的声明
    派生类的声明中包含类名但不包含它的派生列表
class Bulk_quote : public Quoted;	//错误:派生列表不能出现在这里
class Bulk_quote;					//正确:声明派生类的正确方式

声明语句的目的是令程序知晓某个名字的存在以及该名字表示一个什么样的实体,比如一个类、函数或变量
派生列表以及定义有关的其他细节必须与类的主体一起出现
6)被用作基类的类

  • 若想将某个类用作基类,则该类必须已经定义而非仅仅声明,因此一个类不能派生它本身
  • 直接基类出现在派生列表中,而间接基类有派生类通过其直接基类继承而来
  • 每个类都会继承直接接力的所有成员,对于一个最终的派生类来说,它会包含直接基类的子对象以及每个间接基类的子对象
    7)防止继承的发生
    有时为了定义一个不被继承的类,或者不想考虑它是否适合作为一个基类,则在类名后面跟一个关键字 final,从而阻止其他类继承该类
    3.类型转换和继承
  • 可以将基类的指针或引用绑定到派生类对象上,这也意味着当使用基类的引用或指针时,实际上我们并不清楚该引用或指针所绑定对象的真实类型,该对象可能是基类的对象也可能是派生类的对象
  • 智能指针类也支持派生类向基类的类型转换,意味着我们可以将一个派生类对象的指针存储在一个基类的智能指针内
    1)静态类型和动态类型
    当使用存在继承关系的类型时,必须将一个变量或其他表达式的静态类型与该表达式表示对象的动态类型区分开
  • 静态类型:对象被定义的类型或表达式产生的类型,静态类型在编译时是已知的
  • 动态类型:对象在运行时的类型。引用所引对象或者指针所指对象的动态类型可能与该引用或指针的静态类型不同。基类的指针或引用可以指向一个派生类对象,在这种情况中,静态类型是基类的引用或指针,而动态类型是派生类的引用或指针
    2)不存在从基类向派生类的隐式类型转换
  • 存在派生类向基类的类型转换是因为每个派生类对象都包含一个基类部分,而基类的引用或指针可以绑定到该基类部分上
  • 一个基类对象既可以以独立形式存在,也可以作为派生类对象的一部分存在
  • 一个基类对象可能是派生类对象的一部分,也可能不是,因此不存在从基类向派生类的自动类型转换
  • 如果我们已知某个基类向派生类的转换是安全的,则可以使用 static_cast 来强制覆盖编译器的检查工作(显式强制转换)
    3)在对象之间不存在类型转换
  • 派生类向基类的自动类型转换只对指针或引用类型有效,而在派生类类型和基类类型之间不存在这样的转换
  • 但派生类向基类的转换允许我们给基类的拷贝/移动操作传递一个派生类的对象,此时实际运行的构造函数时基类中定义的那个,显然该构造函数只能处理类自己的成员;类似的如果我们将一个派生类对象赋值给一个基类对象,则实际运行的赋值运算符也是基类中定义的那个,该运算符只能处理基类自己的成员
  • 当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动、赋值,它的派生类部分会被忽略掉

15.3 虚函数

  • 在C++中,当使用基类的引用或指针调用一个虚成员函数时,会执行动态绑定
  • 必须为每一个虚函数都提供定义,而不管它是否被用到了,这是因为编译器也无法确定到底会使用哪个虚函数
    1.对虚函数的调用可能在运行时才被解析
  • 动态绑定只有当我们通过指针或引用调用虚函数时才会发生,因为仅当通过指针或引用调用虚函数时,才有可能存在动态类型和静态类型不一致的情况,而当我们通过一个具有普通类型(非引用非指针)的表达式调用虚函数时,在编译时就会将调用的版本确定下来
  • 引用或指针的静态类型不同这一事实正是C++语言支持多态性的根本所在
    2.派生类中的虚函数
  • 基类中的虚函数在派生类中隐含地也是一个虚函数,当派生类覆盖了某个虚函数时,该函数的形参必须与派生类中的形参严格匹配
  • 派生类中的返回类型也必须与基类函数匹配,但存在一个例外,当类的虚函数返回类型是类本身的指针或引用时,该规则无效
    3.final 和 override 说明符
  • 若派生类定义了一个函数与基类中虚函数的名字相同但形参列表不同仍然是合法行为,此时编译器会认为新定义的这个函数与聚类中原有的函数时相互独立的,即派生类中的函数没有覆盖掉基类中的版本
  • 然而上述的声明往往意味着错误,因为我们可能希望派生类能够覆盖掉基类中的虚函数,但是一不小心把形参列表弄错了,为了避免这样的错误,可以使用 override 关键字来说明派生类中的虚函数,如果我们使用 override 标记了某个函数,但该函数没有覆盖已存在的虚函数,此时编译器将会报错
  • 还可以把某个函数指定为 final,若将某个函数定义为 final 了,则之后任何尝试覆盖该函数的操作都将引发错误
struct D2 : B{
	//从B继承f()和f(),覆盖f1(int)
	void f1(int) const final;	//不允许后序的其他类覆盖f1(int)
};
struct D3 : D2{
	void f2();			//正确,覆盖从间接B类继承而来的f2
	void f1(int) const;	//错误,D2已经将f2声明为final
};

:final 和 override 说明符出现在形参列表(包括任何 const 或引用修饰符)以及尾置返回类型之后
4.虚函数与默认实参

  • 虚函数也可以拥有默认实参,若某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定,即如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此,此时传入派生类函数的将是基类函数定义的默认实参
  • 由于上述特点,如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致
    5.回避虚函数的机制
  • 有时,我们不希望对虚函数的调用进行动态绑定,而是强制其执行虚函数的某个特定版本,而通过使用作用域运算符可以实现这一目的
//强行调用基类中定义的函数版本而不管baseP的动态类型到底是什么
double undiscounted = baseP->Quote::net_price(42);
  • 通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制
  • 若一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限的递归

15.4 抽象基类

1.纯虚函数

  • 通过在函数体的位置(声明语句的分号之前)书写 =0 就可以将一个虚函数说明为纯虚函数,=0 只能出现在类内部的虚函数声明语句处
  • 一个纯虚函数无须定义
    2.含有纯虚函数的类是抽象基类
  • 含有(或未经覆盖直接继承)纯虚函数的类是抽象基类,抽象基类负责定义接口,而后续的其他类可以覆盖该接口
  • 不能创建抽象基类的对象,因为在抽象类的声明中至少定义了一个抽象方法,该方法没有实现的方法,需要抽象基类的子类提供方法实现
    3.派生类构造函数只初始化它的直接基类
  • 每个类各自控制其对象的初始化过程
    4.重构:重新设计程序以便将一些相关的部分搜集到一个单独的抽象中,然后使用新的抽象替换原来的代码,通常情况下重构类的方式是将数据成员和函数成员移动到继承体系的高级别结点中,从而避免代码冗余

15.5 访问控制与继承

1.受保护的成员:一个类使用 protected 关键字声明那些它希望与派生类分享不想被其他公共访问使用的成员,protected 说明符可以看做是 public 和 private 中和后的产物

  • 和私有成员类似,受保护的成员对于类的用户来说不可访问
  • 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的
  • 派生类的成员和友元只能通过派生类对象来访问基类受保护的成员,派生类对于一个基类对象中的受保护成员没有任何访问特权
class Base{
protected:
	int prot_mem;		//protected 成员
};
class Sneaky : public Base{
	friend void clobber(Sneaky&);
	friend void clobber(Base&);
	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; }

2.公有、私有和受保护继承

  • 某个类对其继承而来的成员的访问权限受两个因素影响,一是在基类中该成员的访问说明符,二十在派生类的派生列表中的访问说明符
  • 派生说明访问符对派生类的成员(及友元)能否访问其直接基类的成员没影响,对基类成员的访问权限只与基类中的访问说明符有关
  • 派生访问说明符的目的是控制派生类用户(包括派生列的派生类在内)对于基类成员的访问权限
    3.派生类向基类转换的可访问性(重点理解)
    假定 D 继承自 B
  • 只有当 D 公有的继承 B 时,用户代码才能使用派生类向基类的转换;如果 D 继承 B 的方式是受保护的或者私有的,则用户代码不能使用该转换
  • 不论 D 以什么方式继承 B,D 的成员函数和友元都能使用派生类向基类的转换;派生类向直接基类的类型转换对于派生类的成员和友元来说永远是可访问的
  • 如果 D 继承 B 的方式是公有的或者受保护的,则 D 的派生类的成员和友元可以使用 D 向 B 的类型转换;反之,如果 D 继承 B 的方式是私有的,则不能使用
    4.友元与继承
  • 就像友元关系不能传递一样,友元关系同样不能继承
  • 基类的友元在访问派生类成员时不具有特殊性,类似的,派生类的友元也不能随意访问基类的成员
class Base{
	friend class Pal;
protected:
	int prot_mem;		//protected 成员
}
class Sneaky : public Base{
	friend void clobber(Sneaky&);
	friend void clobber(Base&);
	int j;
};
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; }	//正确:Pal 是 Base 的友元
}

注:对于 f3,Pal 是 Base 的友元,所以 Pal 可以访问 Base 对象的成员,这种可访问性包括了 Base 对象内嵌在其派生类对象中的情况
5.改变个别成员的可访问性

  • 有时需要改变派生类继承的某个名字的访问级别,通过使用 using 声明可以达到这一目的
  • 派生类只能为那些可以访问的名字提供 using 声明
    6.默认的继承保护级别
  • 默认情况下,使用 class 关键字定义的派生类是私有继承的,而使用 struct 关键字定义的派生类是公有继承的
  • struct 和 class 的唯一差别就是默认成员访问说明符以及默认派生访问说明符,其他无差别

15.6 继承中的类作用域

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

  • 一个对象、指针、引用的静态类型决定了该对象的哪些成员是可见的,即便静态类型和动态类型可能不一致,能使用哪些成员依旧是由静态类型所决定的
    2.名字冲突与继承
  • 派生类的成员将隐藏同名的基类成员
    3.通过作用域运算符来使用隐藏的成员
  • 可以通过作用域运算符来使用一个被隐藏的基类成员
  • 除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字
    4.名字查找优于类型检查
  • 声明在内层作用域的函数并不会重载声明在外层作用域的函数
  • 若派生类(即内层作用域)的成员与基类(即外层作用域)的某个成员同名,则派生类将在其作用域内**隐藏(而非重载)**该基类成员,即使派生类和基类成员的形参列表不一致
    5.虚函数与作用域
  • 虚函数必须有相同的形参列表,否则就不是覆盖(override)而是隐藏了
    6.覆盖重载的函数
  • 如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个都不覆盖
  • 而有时我们仅需覆盖重载集合中的一些而非全部函数,那么久不得不覆盖基类中的每一个版本
  • 为了对上述情况进行优化,一个好的解决方案是为重载的成员提供一条 using 声明语句,using 声明语句指定一个名字而不指定形参列表,而一条基类成员函数的 using 声明语句可以把该函数的所有重载实例添加到派生类作用域中,这样就无须覆盖基类中的每一个重载版本,只需定义特有的函数就可以了,对派生类没有重新定义的重载版本的访问实际上是对 using 声明点的访问

15.7 继承中的类作用域

1.虚析构函数

  • 当我们 delete 一个动态分配的对象的指针时将执行析构函数,若该指针指向继承体系中的某个类型,则有可能出现指针的静态类型与被删除的动态类型不符的情况,因此需要在基类定义一个虚析构函数
  • 若基类的析构函数不是虚函数,则 delete 一个指向派生类对象的基类指针将产生未定义的行为
  • 在三/五法则中,若一个类需要析构函数,那么它同样需要拷贝和赋值操作,但基类的析构函数不遵循上述准则,它是一个重要例外,因为一个基类总是需要虚析构函数,该析构函数为了成为虚函数而令内容为空,此时显然无法判断该基类还需要赋值运算符或拷贝构造函数
  • 虚析构函数将阻止合成移动操作,即使通过 =default 的形式使用了合成的版本,编译器也不会为这个类合成移动操作
    2.合成拷贝控制与继承
    1)派生类中删除的拷贝控制与基类的关系
  • 若基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或者不可访问,则派生类对应的成员将是被删除的,因为此时编译器不能使用基类成员来执行派生类对象的基类部分的构造、赋值或销毁操作
  • 若基类中有一个不可访问或删除掉的析构函数,则派生类合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类的基类部分
  • 和过去一样,编译器不会合成一个删除掉的移动操作
  • 当我们使用 =default 请求一个移动操作时,若基类中的对应操作时删除的或不可访问,则派生类中的该函数是被删除的,因为派生类的基类部分不可移动
  • 若基类的析构函数是删除的,则派生类的移动构造函数也是被删除的
    注:在实际编程中,若基类中没有默认、拷贝或移动构造函数,则一般情况下派生类也不会定义相应的操作
    3.派生类的拷贝控制成员
  • 派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员;类似的,派生类赋值运算符也必须为基类部分的成员赋值
  • 和构造函数以赋值运算符不同,析构函数仅负责销毁派生类自己分配的资源
    1)定义派生类的拷贝或移动构造函数
  • 在默认情况下,基类默认构造函数初始化派生类对象的基类部分
  • 若我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显式地使用基类的拷贝(或移动)构造函数
    2)派生类赋值运算符
  • 与拷贝和移动构造函数一样,派生类的赋值运算符必须显式地为其基类部分赋值
D &D::operator=(const D &rhs)
{
	Base::operator=(rhs);	//为基类部分赋值
	//按照过去的方式为派生类的成员赋值
	return *this;
}

3)派生类析构函数

  • 派生类的析构函数只负责销毁由派生类自己分配的资源
  • 对象销毁顺序与创建顺序相反,派生类析构函数首先执行,然后是基类的析构函数,一次类推反方向直到最后
    4.继承的构造函数
  • 类不能继承默认、拷贝和移动构造函数,若派生类没有直接定义这些构造函数,则编译器负责按照正常规则为派生类合成它们
  • 派生类继承基类构造函数的方式是提供一条注明了(直接)基类名的 using 声明语句
class Bulk_quote : public Disc_quote{
public:
	using Disc_quote::Disc_quote;	//继承 Disc_quote 的构造函数
	double net_price(std::size_t) const;
};

//在上述程序中,继承的构造函数等价于
//若派生类有自己的数据成员,将被默认初始化
Bulk_quote(const std::string& book, double price, std:size_t qty, double disc):
		Disc_quote(book, price, qty, disc){ }

注:通常情况下,using 声明语句只是令某个名字在当前作用域内可见,但当作用于构造函数时,using 声明语句将令编译器产生代码,对于基类的每个构造函数,编译器都将生成一个与之对应的派生类构造函数,即对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数

  • 和普通成员的 using 声明不同,一个构造函数的 using 声明不会改变该构造函数的访问级别,不管 using 声明在哪,基类的私有构造函数在派生类中仍然是一个私有构造函数,受保护的构造函数和公有构造函数是同样的规则
  • 一个 using 声明不能指定 explicit 和 constexpr,但如果基类的构造函数时 explicit 或 constexpr,继承的构造函数将拥有同样的属性
  • 继承的构造函数不会作为用户定义的构造函数来使用,因此,若一个类只含有继承的构造函数,则它也将拥有一个合成的默认构造函数

15.8 容器与继承

15.9 文本查询程序再探

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值