C++ object-oriented programming

概念

  1. 类的基本思想是数据抽象和封装。数据抽象是一种依赖于接口实现分离的编程技术。类的接口包括用户所能执行的操作;类的实现包括类的数据成员,接口的实现部分(函数体),类所需要的各种私有函数的实现。封装指的是用访问说明符(public, private, protected)实现对类的访问控制。
  2. 继承。有一个类作为基类,派生类从基类继承而来,具有基类的全部或者部分成员。
  3. 动态绑定,也叫运行时绑定。在运行时选择函数的版本。
  4. 虚函数。基类将希望派生类override的函数声明为虚函数。
  5. 类派生列表。冒号后面跟着以逗号分隔的基类列表,每个基类前面可以有三种访问说明符中的一个:public,private, protected

什么是面向对象

面向对象程序设计的核心思想是数据抽象继承动态绑定

  1. 数据抽象和封装。数据抽象依赖于接口和实现分离的编程技术。接口包括用户所能执行的操作,实现包括类的数据成员,接口实现的函数体,定义类所需要的各种私有函数。封装指的是用访问说明符(public, private, protected)实现对类的访问控制。
  2. 继承,定义相似的类型并对其相似关系建模。有一个类作为基类,派生类从基类继承而来,具有基类的全部或者部分成员。
  3. 动态绑定,也叫运行时绑定,在运行时选择函数的版本。动态绑定在一定程序上忽略相似类型的区别,用统一的方式使用它们的对象。

基类和派生类

定义基类

  1. 成员函数和继承。派生类可以继承基类的成员函数,也可以提供自己的函数对基类的函数进行覆盖(override)。基类需要将这两种成员函数分开:一种是基类希望派生类覆盖的函数,一种是基类希望派生类直接使用的函数。
    定义虚函数表明基类希望派生类覆盖的函数,在使用对象的指针或者引用调用虚函数时,这个调用会被动态绑定。而没有定义为虚函数的成员函数,解析过程发生在编译时而非运行时。
    除了构造函数之外的所有非静态函数都可以是虚函数。在基类中声明的虚函数,在派生类中无需声明为虚函数就隐式的是虚函数。
  2. 访问控制和继承。派生类可以继承基类的成员,但是派生类的成员函数不一定有权限访问继承来的成员。派生类可以访问基类的公用成员,但是无法访问基类的私有成员。protected访问运算符声明的成员表示派生类可以访问,但是其他用户无法访问的成员。

定义派生类

通过派生类列表,指出它是从哪个或者哪些基类中继承而来的。首先是一个冒号,后面跟着用逗号分隔的基类列表,每个基类前面可以有三种访问说明符中的一个:public、protected或者private,默认是private。

  1. 派生类中的虚函数。派生类可以不override他继承的虚函数,这样子派生类会直接继承基类中的版本。
    C++ 11中,不一定需要在要override的函数前加上virtual关键字,可以使用override关键字修饰,表明这个函数是override的函数。
  2. **派生类对象和派生类向基类的类型转换。可以把派生类对象当做基类对象使用,也可以将基类指针或引用绑定到派生类对象上,也可以把派生类指针用在需要基类指针的地方。**一个派生类对象包含多个组成部分:一个含有派生类自己定义的成员的子对象,以及一个与该派生类继承的基类对应的子对象,如果有多个基类,就会有多个这样的子对象。一个基类的对象可以独立存在,也可以作为派生类对象的一部分存在。如果基类对象不是派生类对象的一部分,那么它只含有基类定义的成员,而不含有派生类定义的对象。而一个基类对象可能是派生类对象的一部分,也可能不是,所以不存在从基类到派生类的自动类型转换。
  3. 派生类的构造函数。派生类的构造函数中,需要调用基类的构造函数对于基类部分进行初始化。即每隔类控制它自己的成员的初始化。
  4. 派生类使用基类的成员。派生类的对象不能直接初始化基类的成员,尽管语法上是正确的。与类的对象交互必须使用类的接口。
  5. 继承与静态成员。整个继承体系中只存在每个静态成员的一个定义。
  6. 派生类的声明。派生类的声明中不能包含它的派生列表。
  7. 用作基类的类。如果要将某个类用作基类,这个类必须声明而且定义过了。
  8. final关键字防止继承的发生。用final关键字修饰的类不能用作基类,但是可以作为子类,不过这个子类不能继续作为基类被继承。

类型转换和继承

  1. **在具有继承关系的类之间,可以将基类的指针或引用绑定到派生类对象。当使用基类的引用或者指针时,引用或者指针绑定的对象可能是基类的对象,也可能是派生类的对象。**因为每个派生类都包含一个基类部分,基类的引用或者指针可以绑定到基类部分。
  2. 表达式的静态类型是变量声明时的类型或表达式生成的类型,在编译时就是已知的;动态类型是变量或者表达式表示的内存中的对象的类型,在运行时才知道。
  3. 不存在从基类向派生类的隐式类型转换。
  4. 派生类向基类的类型转换只对指针或者引用类型有效,在派生类类型和基类类型之间不存在这样的转换。当用一个派生类对象为一个基类对象初始化(调用拷贝构造函数)或者赋值(调用拷贝赋值函数)时,只有该派生类中的基类部分会被拷贝,移动或者赋值,它的派生类部分将被忽略掉。

虚函数

  1. 每一个虚函数都必须被定义,因为连编译器也不知道哪个虚函数被使用了。
  2. 对虚函数的调用可能在运行时才被解析,编译器产生的代码知道运行时才能确定应该调用哪个版本的函数。
  3. 动态绑定只有在通过指针或者引用调用虚函数时才会发生。(P537页)
  4. C++ 的多态性的根本原因是引用或指针的静态类型可以和动态类型不同。
    当使用基类的引用或者指针调用基类中定义的一个函数时,如果这个函数是虚函数,直到运行时才会决定到底执行哪个版本,这个版本取决于引用或者指针绑定的对象的真实类型。
    对于非虚函数的调用在编译时进行绑定。同样,通过对象(不是引用也不是指针)进行的函数调用(包含虚函数和非虚函数)都在编译时确定。
  5. 派生中override的虚函数必须和基类中的函数形参,返回类型完全一致,除了类的虚函数返回类型是类本身的指针或引用时是一个例外。
  6. final和override关键字。final说明这个类不能被继承,override说明这个函数是对基类中虚函数的重写。它们出现在形参列表(包括const或引用修饰符)之后。final也可以用于修饰某个函数,表示这个函数不能被override。
  7. 虚函数和默认实参。如果虚函数调用使用了默认实参,实参值由调用的静态类型决定(也就是基类虚函数定义的默认实参,和派生类定义的默认实参无关)。
  8. 可以使用作用域运算符强制调用虚函数的某个特定版本。例如,当一个派生类的虚函数需要调用它覆盖的基类的虚函数版本时。

纯虚函数和抽象基类

  1. 纯虚函数。在函数体的位置书写=0就可以将一个函数声明为纯虚函数,纯虚函数无序定义。
  2. **含有纯虚函数的类是抽象基类。**不能定义抽象基类的对象。抽象基类负责定义接口,而派生类可以覆盖接口。

访问控制和继承

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

成员访问说明符

protected说明符:

  1. 对于类的用户来说是不可访问的,对派生类的成员和友元来说是可以访问的。
  2. 派生类的成员或者友元只能访问派生类对象的基类部分的protected成员,派生类对于基类对象中的protected成员没有任何访问特权。

某个类对它继承而来的成员的访问权限受到两个因素影响:一个是在基类中该成员的访问说明符,另一个是派生类的派生列表中的访问说明符(public, private, protected继承)。
**基类中的访问说明符决定基类的用户(包含派生类的成员和友元)是否能够访问它的直接基类成员,派生列表中的访问说明符决定派生类用户(包含派生类的派生类)对于基类的访问权限。**对于public继承,派生类用户对于基类的成员的访问遵循原来的访问说明符;私有继承,所有的基类成员都是private的;对于protected继承,基类的所有public成员都是protected的,派生类的成员和友元可以访问继承而来的成员,而派生类的用户不能访问。

派生类向基类转换的可访问性

一个类可能有三种用户:普通用户,类的实现者,派生类。普通用户编写的代码使用类的对象,这部分用户只能访问类的公有成员(接口),而实现者负责编写类的成员和友元代码,它们既能访问类的公有部分,也能访问类的私有部分。
派生类向基类的转换会受派生类的派生访问说明符的影响。

  1. 派生类public继承基类,用户代码可以使用派生类到基类的转换,否则不行。
  2. 派生类public, protected, private继承基类,派生类成员或者友元都可以使用派生类到基类的转换。
  3. 派生类public或者protected继承基类,派生类的派生类的成员或者友元可以使用派生类到基类的转换,否则不行。

基类的public成员,基类成员和友元可以访问,基类的普通用户可以访问,派生类的成员和友元可访问。
基类的protected成员,基类成员和友元可以访问,基类的普通用户不可以访问,派生类的成员和友元可以访问。
private,基类的成员和友元可以访问,基类的普通用户不可以访问,派生类的成员和友元不可以访问。
public继承,派生类的普通用户可以访问基类的public成员,派生类的派生类的成员和友元可以访问基类public和protected成员。
protected继承,派生类的普通用户不可以访问基类的任何成员,派生类的派生类的成员和友元可以访问基类的public和protected成员。
private继承,派生类的普通用户不可以访问基类的任何成员,和派生类的派生类成员和友元不可以访问基类的任何成员。

友元和继承

友元关系不能继承,每个类负责控制各自的访问权限。

改变个别成员的可访问性

可以使用using声明改变继承的某些成员的可访问性。

继承中的类作用域

  1. **派生类的作用域嵌套在基类的作用域之内。**所以派生类才能像使用自己的成员一样使用基类的成员。
  2. 一个对象,引用或者指针的静态类型决定了该对象的哪些成员是可见的。
  3. 和其他的作用域一样,派生类也能重用定义在它的直接基类或者间接基类的名字,定义在内层作用域的名字会隐藏在定义在外层作用域的名字,而不会重载声明在外部作用域的函数。派生类的成员会隐藏同名的类成员,可以通过作用域运算符使用隐藏的外层作用域成员。
    **为什么基类与派生类的虚函数必须有相同的形参列表?**如果它们的形参不同,就不会override了,而是隐藏了。
  4. **名字查找和继承。**当使用对象,指针或者引用调用某个函数时,可以分为四个步骤:
    首先确定静态类型。
    然后在这个静态类型中查找成员,如果没有找到,依次查找直接基类和间接基类,找到为止,如果一直到最后都没有找到,报错。
    找到了的话,就进行类型检验。名字查找优先于类型检查。
    如果调用合法,编译器根据调用的是否是虚函数产生不同的代码。如果是通过引用或者指针调用的虚函数,进行动态绑定;如果不是虚函数或者通过对象调用的函数,编译器产生一个常规函数调用。
  5. 派生类可以override重载的函数,如果派生类希望所有重载版本对它来说都是可见的,需要override所有的版本,或者一个也不override。可以使用using声明指定一个名字而不指定形参列表。这时,只需要定义派生类特有的函数就可以了。

构造函数和拷贝控制

虚析构函数

当存在继承关系时,需要在基类中将析构函数定义成虚函数。如果基类的析构函数不是虚函数,那么delete一个指向派生类对象的基类指针将会产生未定义的行为。
虚析构函数会阻止合成移动操作,即使是默认版本的虚析构函数。

合成拷贝控制和继承

  1. 合成的构造函数,赋值运算符和析构函数分别负责使用直接基类中对应的操作对一个对象中的直接基类部分进行初始化,赋值和销毁的操作。
  2. 删除的拷贝控制函数和基类的关系。
  3. 移动操作和基类的关系。大多数基类都会定义一个虚析构函数,虚析构函数是拷贝控制函数中的一个,所以基类通常不会含有合成的移动操作,而在在它的派生类中也没有合成的移动操作。

派生类的拷贝控制成员

  1. 派生类的拷贝或移动构造函数,需要调用基类的拷贝或者移动构造函数进行派生类的基类部分对象的拷贝或者移动。
  2. 派生类的赋值运算符,在派生类的赋值运算符中还要调用基类的赋值运算符。
  3. 派生类析构函数。析构函数中只需要进行派生类的析构就行了,基类的析构是自动执行的。
  4. 在构造函数和析构函数中调用虚函数时,分别调用与构造函数或者析构函数所属类型相对应的虚函数版本。因为派生类对象构造函数被调用时,首先调用基类的构造函数,这个时候派生类的对象还是未初始化的,所以就无法调用派生类的构造函数。而在析构过程中,同理。

继承的构造函数

  1. 派生类可以重用它的直接基类的构造函数,通过使用一条using声明:
    using Base::Base;
    就可以在派生类中使用基类的构造函数,并且在派生类中构造函数的访问级别和基类中相同。
  2. 当基类的构造函数含有默认实参的时候,派生类会获得多个继承的构造函数。
  3. 除了两个例外情况,派生类会继承所有基类的构造函数。一个是派生类中定义了和基类中参数列表相同的构造函数,另一个是基类的默认,拷贝和移动构造函数不会被继承。继承的构造函数也不会当做用户定义的构造函数使用,所以如果一个类如果只有一个继承的构造函数,它也会有一个合成的默认构造函数。

容器和继承

使用容器存放继承体系中的对象时,在容器中放置(智能)指针而非对象,这些指针的动态类型可能是基类类型,也可能是派生类类型。

参考文献

1.《C++ Primer》第五版