前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【笔记】《C++Primer》—— 第15章:面向对象程序设计

【笔记】《C++Primer》—— 第15章:面向对象程序设计

作者头像
ZifengHuang
发布2020-07-29 16:26:35
5160
发布2020-07-29 16:26:35
举报
文章被收录于专栏:未竟东方白

这一章介绍了面向对象编程中最重要的对于类的全面介绍,全部内容都很常用,特别是15.2-4对于继承,抽象类,虚函数的介绍,是面试的常考点。这篇内容较多慢慢看。

15.1 OOP:概述

  • 面向对象程序设计(Oject-Oriented Programming)的核心是数据抽象,继承和动态绑定。
  • 数据抽象让接口与实现分离,继承让我们可以根据类的相似关系来建模,动态绑定让我们可以忽略相似类型的区别,以统一的方法使用其抽象
  • OOP中最关键的就是通过继承和虚函数的动态绑定来实现多态,多态简单说就是让基类使用派生类的方法,使用多态的一大目的是防止基类出现大量的重载函数
  • 这里要强调下两个术语:
    • 覆盖(重写) override,指重新实现了一个名称和参数都一样的方法
    • 重载 overload,指对一个同名方法进行了几种不同参数的实现

15.2 定义基类和派生类

  • 派生类必须使用类派生列表说明它是哪个类继承来的。类派生列表就是在类名后面加一个单冒号和一个访问说明符,其中访问说明符有public,protected,private三种,如果不写则默认是private
  • 派生类会继承基类的成员数据和成员函数,其中对这些继承来的成员的访问权限由成员本身的说明符和派生列表里的访问说明符一同决定
  • 派生类在继承函数时,如果需要覆盖(override)继承的函数,那就要在派生类中将完全相同的函数声明出来
  • 我们可以将一个派生类对象转换为基类对象,此时派生类独有的部分将被截断,其基类部分被处理而派生类部分被忽略
  • 有些时候我们不希望派生类独有的部分被截断,则需要使用类指针来调用重载的函数或使用指针所指的成员。如果此时我们只是普通地重载了函数,那么我们根据指针所调用的函数是和指针的类型相同的,这称为静态绑定。我们很多时候希望的是我们通过将基类指针指向派生类,然后可以动态调用派生类的函数,这时我们可以将基类的对应函数写为虚(virtual)函数来实现,此时发生的称为动态绑定
  • 基类通过在声明语句前加上关键字virtual实现虚函数,virtual只能添加在类内的函数声明前。任何构造函数以外的非静态函数都可以是虚函数,如果基类声明了虚函数,那么派生类中的对应函数都隐式的是虚函数
  • 通过抽象,我们使用动态绑定可以实现接口与实现的分离,基类用虚函数声明出接口,然后指针指向不同的派生类实现来动态调用
  • 派生类经常会覆盖继承的虚函数,但如果派生类没有覆盖,则这个函数将会被直接继承,可以被普通地使用
  • 派生类可以继承多个基类,称为多继承。每次继承一个基类就会在内存中生成一个子对象,存放了基类的成员,也正是因为这个原因派生类可以转换为基类。如果是对象转换到对象,那多余的成员会被截断。如果是指针或引用的转换,则只是一个指向的改变
  • 派生类的构造函数需要负责所有成员的初始化,尽管派生类也可以初始化继承来的基类成员,但是这不符合通常的编码思路,派生类一般在构造函数开始的地方调用基类的构造函数,让基类来初始化自己的成员
  • 派生类可以使用基类的public成员和protected成员
  • 如果基类定义了一个静态成员,那整个继承体系中都只会有这成员的唯一定义,无论派生了多少类这个成员都是唯一实例的,静态成员也同样遵循访问控制原则
  • 派生类的派生列表和其他定义细节必须和类主体一起出现
  • 如果我们想要将某个类作为基类,则这个类必须已经定义,这也就导致了一个类不能派生它本身
  • 在继承中,直接继承的类称为直接基类,需要多层继承的是简介基类,每个类都会继承其直接基类的成员,然后转换为自己的成员继续派生下去,因此最后一层派生将包含所有成员
  • 有时候我们不希望其他类继承某个类,可以在类的声明后加上final表示无法继续派生
  • 智能指针也支持派生到基类的类型转换
  • 静态类型是变量本身代码中的类型,在编译时决定,动态类型是变量在内存中的对象的类型,在运行时才能决定。如果表达式不是引用也不是指针,则其动态类型永远与静态类型一致
  • 派生类可以往基类类型转换,基类不能隐式反向转换,这是因为基类不一定拥有足够生成派生类的成员,但是如果我们能确保安全性,则可以用static_cast来强制转换。这里有一个特别的,即便处理的是基类指针,此指针指向某派生类,我们也不能隐式转换到这个派生类,如果基类中含有虚函数,我们可用用dynamic_cast强制转换

15.3 虚函数

  • 通过对基类的指针或引用来调用虚函数时会出现动态绑定,这个绑定在运行时才会确定,因此我们必须对每个虚函数都进行定义因为对虚函数的调用在运行时才解析,编译器也不能确定是否会被用到
  • 运用这个动态绑定就是C++OOP的核心,多态性
  • 一个派生类的函数如果想要覆盖继承来的虚函数,那必须名称和形参都一致,否则编译器会认为这两个函数是独立的,而不会产生覆盖
  • 派生类的虚函数的返回类型也需要与基类一致,除非虚函数的返回类型是类本身的引用或指针时为了多态性会有特例
  • 如果想要保证基类中某个虚函数一定会被覆盖,则可以在想要用来覆盖的函数后面加上override关键字,此时如果编译器没有找到这个函数所覆盖的函数,就会报错
  • 我们还可以将某个函数后面加上final,表示不允许继续覆盖
  • 如果虚函数中使用了默认实参,则最好保持每一层覆盖的对应默认实参都是相同的,运行时具体调用的函数和实参要依据动态绑定而定,无法在编译阶段发现问题
  • 如果我们希望某个对虚函数的调用不要进行动态绑定,可以在调用前使用::作用域符指明所需要的函数出自哪个类

15.4 抽象基类

  • 我们有时候希望某个基类只用来被继承而不允许被实例化,这时候我们可以给这个基类加入纯虚函数,拥有纯虚函数的基类叫抽象基类,不能被直接创建
  • 纯虚函数的定义方法是在声明函数名的时候函数体类似显式默认构造的写法改写为=0,我们只能对虚函数使用这个写法
  • 我们也可以为纯虚函数写定义,但是定义必须写在类外

15.5 访问控制和继承

  • 访问控制中最难理解的是protected,这表示那些希望与派生类和友元分享使用但不希望被公共函数使用的成员,是private和public的中和产物
  • 前面说过友元是类A特殊给与类B的权限,当类A中friend了B,则B可以自由使用A的protected。派生类可以自己将某些函数写为friend,这样会获得其基类的protected权限
  • 友元只对被声明的类有效,友元的基类或派生类都不是友元
  • 派生类的成员和友元只能通过派生类对象来访问基类的protected成员,派生类对一个基类中的protected没有任何的访问特权。也就是当一个派生类与基类声明为友元,此时想要访问基类的保护成员时,必须通过派生类作为中介而不能直接用基类来访问,即使在成员函数中。详细如下
  • 某个类对其继承来的成员的访问权限受到两个因素的影响:
    • 基类中此成员的访问说明符,这是最重要的一部分,private的成员不管怎么样都无法被其他类使用,其他级别受到下一项的影响
    • 派生类的派生列表的访问说明符,这一项决定的是派生类对继承来的成员对外表现出的最高权限,也就是这一项并不会影响派生类内部的使用,但是一旦外部想调用派生类来使用成员:
      • 如果此时访问说明符为public,则成员对外表现如基类的权限
      • 如果此时为protected,则public级别的成员会降级到protected,也就是压低最高权限
      • private继承也同理,也就是全部级别都变成private
      • 当派生列表中没有写明访问说明符时,默认说明符与类符有关,class默认private,struct默认public
  • // 在外部通过派生类使用基类的实例 // 此时FOO是protected继承了BASE类 FOO f; // 此语句报错,因为BASE::pub此时降级为protected std::cout << f.pub << std::endl; // 此语句报错,因为protected权限无法在外部被调用 std::cout << f.pro << std::endl;
  • 但是有时候我们需要改变外部对派生类继承的某个名字的访问级别,特殊地改变其访问权限(但要确保在派生类对那个名称本身是可访问的),可以在自己的访问控制符处用using声明需要改变的名称(用::作用域符特指名称),这样可以改变外部对目标成员的访问权限。例如通过在FOO的public中加入using BASE::pub;可以使得上面的第一个语句不会报错
  • 派生类对基类的转换也与派生列表的访问说明符有关,本质上与类型转换函数的权限有关
    • public继承时用户代码才能使用派生到基类的转换,其余时候都不能转换
    • 无论是什么继承,派生类和友元都可以在成员中使用派生到类的转换
    • public和protected继承时,派生类自己的派生类和友元可以使用派生到基类的转换,private则不行
  • 总结起来,最好基类应该将其对外接口声明为public,任何将其实现部分分成两组,可供派生类和友元只有的半内部接口声明为protected,仅供类内自己使用的声明为private保护起来
  • class和struct没有实质的区别,唯一区别就是默认访问控制符中,class默认为private,struct默认为public。为了规范和可读性,最好不要利用默认控制符,显式说明比较清晰

15.6 继承中的类作用域

  • 类中的名字查找是从内到外查找的,当派生类中无法找到时,就会往直接基类查找,以此类推
  • 名称查找是根据编译时的目标的静态类型进行查找的,目标的静态类型决定了其是否可见
  • 和其他作用域一样,派生类可以重用基类中的名字,因此当名字重叠时派生类的名字会隐藏基类中的名字,类似函数中的情形。但我们依然可以通过::作用域符来指定外层的名字
  • 如果内层某个成员与外层成员同名,即使它们的形参列表可能不一致也会因为名称查找而被隐藏,因为一旦找到名称编译器便会停止查找。因此除了重载虚函数外最好不要让名称同名
  • 假如基类和派生类的虚函数形参不同,我们就无法使用基类的引用或指针来动态调用派生类的函数了,此时两个函数将被判断为独立的函数并将外层隐藏。因此基类和派生类的虚函数形参应该相同
  • 派生类可以覆盖基类重载的函数,但是如果派生类希望基类重载的几个函数都在派生类中可见,避免名称隐藏的话:一种方法是不覆盖任何一个重载函数或将所有重载函数都进行一次覆盖,这种方法繁琐费力;另一种方法是为需要重载的函数名使用using语句,using 函数名并不要指定形参列表,可以将所有重载函数都加入派生类的作用域中,这样派生类只要覆盖所需的几个函数即可
  • 同样using语句要求每个重载函数都要处于可访问状态。派生类对其自身没有定义的重载版本实际上是利用了using的声明点进行了访问

15.7 构造函数和拷贝控制

  • 继承体系中的类也需要控制类的几个基本操作,即构造,拷贝,移动,析构。和之前一样如果一个类没有自定义对应的操作,编译器会生成合成版本的
  • 但在继承体系中,最关键的是基类通常需要定义一个虚析构函数,这样我们才能动态分配体系中的对象,确保delete时能够执行正确的析构函数版本,对于实现的内容我们一样可以使用=default简化
  • 如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为
  • 如果定义了虚析构函数,则一样的合成移动操作将被阻止
  • 派生类的析构函数和以往一样是空函数,成员是由析构的隐式部分销毁的,当派生类销毁了自己的成员部分后,就会调用基类的析构函数销毁基类部分直到顶端
  • 如果基类中的基本操作函数不可访问或被删除,则派生类中的对应成员是被删除的因为我们无法使用基类来操作那些成员
  • 同理如果基类中的对应操作不可访问或被删除,则派生类中该函数也将被删除,此时派生类只能使用自己定义的函数版本
  • 由于这个特性,当我们需要某个操作时一定要在基类就开始定义,派生类自然也可以使用自己的合成版本但是需要显式调用
  • 拷贝构造派生类时,假如我们基类成员没有被我们初始化的话,那些成员将被默认初始化而非拷贝。因此通常我们定义派生类的拷贝构造函数时也会在列表里调用基类的拷贝构造函数。其他操作也有类似的设计
  • 对象销毁的顺序与构造的顺序正好相反,派生类的析构先执行然后是基类,派生类的构造先构造基类再构造派生。因此编译器在构造和析构的时候只往基类搜索来得到所需的成员
  • C11中,我们可以用using重用基类定义的构造函数,写法和15.6中指明重载的基类函数一样,效果与定义一个空的构造函数然后列表中调用基类构造函数一致。此时如果派生类有自己的成员,派生成员将被默认初始化
  • 和普通函数的using不同,对构造函数的using不会改变构造函数的访问级别,不论这个using出现在哪里,而且这个using不能指定explicit或constexpr,而是这个构造函数会继承基类中声明的属性
  • 当基类构造函数中有默认实参时,这些实参不会被继承,而是派生类会得到多个继承的构造函数,每个构造函数省略一个有默认实参的形参
  • 大多数时候派生类会继承基类的所有构造函数,除了:
    • 派生类通过定义相同的参数列表只继承没有定义的那部分,自定义其他的部分
    • 默认,拷贝和移动构造函数不会被继承,这些函数会被派生类默认合成

15.8 容器与继承

  • 当我们想要把继承体系的对象存放到容器中时,最好使用间接存储也就是存放基类指针(智能指针就更好了),这是因为之前说到的截断特性导致的
  • 我们常常定义额外的容器类来保存类的指针们
  • 有时我们需要写好几个虚函数来处理容器中的多态问题
  • 当我们继承一个类时,表达的是一种“是一种xxx”的关系,当我们存放一个成员类型时,表达的是一种“有一个xxx”的关系
  • 标准库有一个set_intersection函数可以合并两个set,接受五个迭代器,前四个迭代器表示两个序列,最后一个表示目标位置,将两个序列中都出现的元素写入到目标位置中
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2020-05-28,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 未竟东方白 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档