Loading

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

OOP概述

  • 面向对象程序设计的核心思想是数据抽象、继承和动态绑定。
  • 通过继承联系在一起的类构成一种层次关系。通常在层次关系的根部有一个基类,其他类则直接或间接地从基类继承而来,这些继承得到的类称为派生类。基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自特有的成员。
  • 在C++语言中,基类必须将它的两种成员函数区分开来:一种是基类希望其派生类进行覆盖的函数;另一种是基类希望派生类直接继承而不要改变的函数。对于前者,基类通常将其定义为虚函数。派生类需要对这些操作提供自己的新定义以覆盖从基类继承而来的旧定义。
  • 在C++中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定:函数的运行版本由实参决定,即在运行时选择函数的版本。所以动态绑定有时又被称为运行时绑定

基类和派生类

定义基类

  • 基类通过在其成员函数的声明语句之前加上关键字virtual使得该函数执行动态绑定。关键字virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义

    class Quote
    {
    public:
    	std::string isbn() const;
    	virtual double net_price(std::size_t n) const;
    };
    
  • 基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。

  • 任何构造函数之外的非静态函数都可以是虚函数。

  • 如果基类把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数。

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

  • 我们可以用受保护的访问运算符来说明基类希望它的派生类有权访问,而同时禁止其他用户访问的成员。

  • 派生类能访问基类的公有成员和受保护成员,而不能访问私有成员。

定义派生类

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

    class Bulk_quote : public Quote {
    public:
    	double net_price(std::size_t) const override;
    };
    
  • 访问说明符的作用是控制派生类从基类继承而来的成员是否对派生类的用户可见。如果一个派生是公有的,则基类的公有成员也是派生类接口的组成部分。此外,我们能将公有派生类型的对象绑定到基类的引用或指针上。

派生类中的虚函数

  • 派生类经常(但不总是)覆盖它继承的虚函数。如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本。
  • 派生类可以在它覆盖的函数前使用virtual关键字,但是并不是非得这么做。
  • C++允许派生类显式地注明它使用某个成员函数覆盖了它继承的虚函数。具体做法是在形参列表后面、或者在const成员函数的const关键字后面、或者在引用成员函数的引用限定符后面添加一个关键字override。

派生类与基类的类型关系

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

  • 因为在派生类对象中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到派生类对象中的基类部分上。这种转换通常称为派生类到基类的类型转换。和其它类型转换一样,编译器会隐式地执行派生类到基类的转换。

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

  • 尽管在派生类对象中含有从基类继承而来的成员,但是派生类并不能直接初始化这些成员。和其他创建了基类对象的代码一样,派生类也必须使用基类的构造函数来初始化它的基类部分。

  • 每个类控制它自己的成员初始化过程。

  • 派生类对象的基类部分与派生类对象自己的数据成员都是在构造函数的初始化阶段执行初始化操作的。类似于我们初始化成员的过程,派生类构造函数同样是通过构造函数初始化列表来将实参传递给基类构造函数的。

    Bulk_quote(const std::string& book, double p, std::size_t qty, double disc) :
    		Quote(book, p), min_qty(qty), discount(disc) { }
    	// 与之前一致
    };	
    
  • 首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。

派生类使用基类成员

  • 每个类负责定义各自的接口。要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。派生类应该遵循基类的接口,并且通过调用基类的构造函数来初始化那些从基类中继承而来的成员。

静态成员的继承

  • 如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。静态成员遵循通用的访问控制规则,如果基类中的成员是private的,则派生类无权访问它。假设某静态成员是可访问的,则我们既能通过基类使用它也能通过派生类使用它:

    void Derived::f(const Derived &derived_obj)
    {
    	Base::statmem(); // 正确:base定义了statmem
    	Derived::statmem(); // 正确:Derived继承了statmem
    	// 正确:派生类的对象能访问基类的静态成员
    	derived_obj.statmem(); // 通过Derived对象访问
    	statmem(); // 通过this对象访问
    }
    

派生类的声明

  • 派生类的声明与其他类差别不大,声明中包含类名但是不包含它的派生列表:

    class Bulk_quote : public Quote; // 错误:派生列表不能出现在这里
    class Bulk_quote; // 正确:声明派生类的正确方式
    
  • 一条声明语句的目的是令程序知晓某个名字的存在以及该名字表示一个什么样的实体,如一个类、一个函数或一个变量等。派生列表以及与定义有关的其他细节必须与类的主体一起出现。

成为基类的条件

  • 如果我们想将某个类用作基类,则该类必须已经定义而非仅仅声明:

    class Quote; // 声明但未定义
    // 错误:Quote必须被定义
    class Bulk_quote : public Quote { ... };
    

    因为派生类中包含并且可以使用它从基类继承而来的成员,为了使用这些成员,派生类当然要知道它们是什么。因此该规定还有一层隐含的意思,即一个类不能派生它本身。

  • 一个类是基类,同时它也可以是一个派生类:

    class Base { /* ... */ };
    class D1: public Base { /* ... */ };
    class D2: public D1 { /* ... */ };
    

    在这个继承关系中,Base是D1的直接基类,同时是D2的间接基类。直接基类出现在派生列表中,而间接基类由派生类通过其直接基类继承而来。

  • 每个类都会继承直接基类的所有成员。最终的派生类将包含它的直接基类的子对象以及每个间接基类的子对象。

防止继承的发生

  • C++提供了一种防止继承发生的方法,即在类名后跟一个关键字final

    class NoDerived final { /* */ }; // NoDerived不能作为基类
    class Base { /* */ };
    // Last是final的;我们不能继承Last
    class Last final : Base { /* */ }; // Last不能作为基类
    class Bad : NoDerived { /* */ }; // 错误:NoDerived是final的
    class Bad2 : Last { /* */ }; // 错误:Last是final的
    

类型转换与继承

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

  • 通常情况下,如果我们想把引用或指针绑定到一个对象上,则引用或指针的类型应与对象的类型一致,或者对象的类型含有一个可接受的const类型转换规则。存在继承关系的类是一个重要的例外:我们可以将基类的指针或引用绑定到派生类对象上

  • 可以将基类的指针或引用绑定到派生类对象上有一层极为重要的含义:当使用基类的引用(或指针)时,实际上我们并不清楚该引用(或指针)所绑定对象的真实类型。该对象可能是基类的对象,也可能是派生类的对象。

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

静态类型与动态类型

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

不存在从基类向派生类的隐式类型转换

  • 因为一个基类的对象可能是派生类对象的一部分,也可能不是,所以不存在从基类向派生类的自动类型转换。

  • 即使一个基类指针或引用绑定在一个派生类对象上,我们也不能执行从基类向派生类的转换:

    Bulk_quote bulk;
    Quote *itemP = &bulk; // 正确:动态类型是Bulk_quote
    Bulk_quote *bulkP = itemP; // 错误:不能将基类转换成派生类
    
  • 编译器在编译时无法确定某个特定的转换在运行时是否安全,这是因为编译器只能通过检查指针或引用的静态类型来推断该转换是否合法。

在对象之间不存在类型转换

  • 派生类向基类的自动类型转换只对指针或引用类型有效,在派生类类型和基类类型之间不存在这样的转换。
  • 当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分将被忽略掉。
  • 要想理解在具有继承关系的类之间发生的类型转换,有三点非常重要:
    • 从派生类向基类的类型转换只对指针或引用类型有效。
    • 基类向派生类不存在隐式类型转换。
    • 和任何其他成员一样,派生类向基类的类型转换也可能会由于访问受限而变得不可行。
  • 尽管自动类型转换只对指针或引用有效,但是继承体系中的大多数类仍然(显式或隐式地)定义了拷贝控制成员。因此,我们通常能够将一个派生类对象拷贝、移动或赋值给一个基类对象。不过需要注意的是,这种操作只处理派生类对象的基类部分。

虚函数

  • 因为我们直到运行时才能知道到底调用了哪个版本的虚函数,所以所有虚函数都必须有定义。
  • 引用或指针的静态类型与动态类型不同这一事实正式C++语言支持多态性的根本所在。
  • 当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型才有可能与静态类型不同。
  • 基类中的虚函数在派生类中隐含地也是一个虚函数。当派生类覆盖了某个虚函数时,该函数在基类中的形参必须与派生类中的形参严格匹配。
  • 派生类如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,这仍然是合法的行为。编译器将认为新定义的这个函数与基类中原有的函数是相互独立的,派生类的函数并没有覆盖掉基类中的版本。

override和final

  • 使用override关键字来说明派生类中的虚函数。如果我们使用override标记了某个函数,但该函数并没有覆盖已存在的虚函数,此时编译器将报错:

    struct B
    {
        virtual void f1(int) const;
        virtual void f2();
        void f3();
    };
    
    struct D1 : B
    {
        void f1(int) const override; // 正确:f1与基类中的f1匹配
        void f2(int) override; // 错误:B没有形如f2(int)的函数
        void f3() override; // 错误:f3不是虚函数
        void f4() override; // 错误:B没有名为f4的函数
    };
    
  • 如果我们已经把函数定义成final了,则之后任何尝试覆盖该函数的操作都将引发错误:

    struct D2 : B
    {
    	// 从B继承f2()和f3(),覆盖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或引用修饰符)以及尾置返回类型之后。

带默认实参的虚函数

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

回避虚函数机制

  • 在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的:

    // 强行调用基类中定义的函数版本而不管baseP的动态类型到底是什么
    double undiscounted = baseP->Quote::net_price(42);
    

    该调用将在编译时完成解析。

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

  • 什么时候我们需要回避虚函数的默认机制呢?通常是当一个派生类的虚函数调用它覆盖的基类的虚函数版本时。在此情况下,基类的版本通常完成继承层次中所有类型都要做的共同任务,而派生类中定义的版本需要执行一些与派生类本身密切相关的操作。

  • 如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归。

抽象基类

  • 和普通的虚函数不一样,一个纯虚函数无须定义。我们通过在函数体的位置(即在声明语句的分号之前)书写=0就可以将一个虚函数说明为纯虚函数(仅虚函数允许纯说明符=0)。其中,=0只能出现在类内部的虚函数声明语句处。
  • 值得注意的是,我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。也就是说,我们不能在类的内部为一个=0的函数提供函数体。
  • 含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类。抽象基类负责定义接口,而后续的其他类可以覆盖该接口。我们不能(直接)创建一个抽象基类的对象。(派生类必须给出自己的覆盖纯虚函数的虚函数定义,否则它们仍将是抽象基类)

访问控制与继承

派生类说明符

  • 一个类使用protected关键字来声明那些它希望与派生类分享但是不想被其他公共访问使用的成员。protected说明符可以看作是public和private中和后的产物:

    • 和私有成员类似,受保护的成员对类的用户来说是不可访问的。

    • 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的。

    • 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。(即派生类的成员和友元只能访问派生类对象中的基类部分的受保护成员;对于普通的基类对象当中的成员不具有特殊的访问权限。)

      class Base
      {
      protected:
      	int prot_mem; // protected成员
      };
      
      class Sneaky : public Base
      {
      	friend void clobber(Sneaky&); // 能访问Sneaky::prot_mem
      	friend void clobber(Base&); // 不能访问Base::prot_mem
      	int j; // j默认是private
      };
      
      // 正确: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; }
      
      /*
      如果派生类(及其友元)能访问基类对象的受保护成员,则上面的第二个clobber(接受一个Base&)将是合法的。该函数不是Base的友元,但是它仍然能够改变一个Base对象的内容。如果按照这样的思路,则我们只要定义一个形如Sneaky的新类就能非常简单地规避掉protected提供的访问保护了。
      */
      
  • 派生类说明符对于派生类的成员(及友元)能否访问其直接基类的成员没什么影响。对基类成员的访问权限只与基类中的访问说明符有关。

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

派生类向基类转换

  • 派生类向基类的转换是否可访问由使用该转换的代码决定,同时派生类的派生访问说明符也会有影响。假定D继承自B:

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

友元

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

    class Base
    {
    	// 添加friend声明,其他成员与之前的版本一致
    	friend class Pal; // Pal在访问Base的派生类时不具有特殊性
    };
    
    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的友元
    };
    
    // D2对Base的protected和private成员不具有特殊的访问能力
    class D2 : public Pal
    {
    public:
        int mem(Base b) { return b.prot_mem; } // 错误:友元关系不能继承
    };
    
  • 不能继承友元关系;每个类负责控制各自成员的访问权限(所以上述Pal能访问派生类的基类部分)

改变个别成员的可访问性

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

  • 我们可以将该类的直接或间接基类中的任何可访问成员(例如,非私有成员)标记出来。

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

  • using声明语句中名字的访问权限由该using声明语句之前的访问说明符来决定。如果一条using声明语句出现在类的private部分,则该名字只能被类的成员和友元访问;如果using声明语句位于public部分,则类的所有用户都能访问它;如果using声明语句位于protected部分,则该名字对于成员、友元和派生类是可访问的。

  • 本质上,这个可访问性修改的是该类的用户(包括该类的派生类)的使用权限,而该类的成员和友元不受影响,但这个修改的前提仍是该类能访问的名字。

    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;
    };
    

默认的继承保护级别

  • 默认情况下,使用class关键字定义的派生类是私有继承的;而使用struct关键字定义的派生类是公有继承的。
  • struct关键字和class关键字的唯一的差别就是默认成员访问说明符默认派生访问说明符。除此之外,再无其他不同之处。
  • 一个私有派生的类最好显式地将private声明出来,而不要仅仅依赖于默认的设置。显式声明的好处是可以令私有继承关系清晰明了,不至于产生误会。

继承中的类作用域

名字查找

  • 当存在继承关系时,派生类的作用域嵌套在其他基类的作用域之内。如果一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找该名字的定义。
  • 在编译时进行名字查找:一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。即使静态类型与动态类型可能不一致(当使用基类的引用或指针时会发生这种情况),但是我们能使用哪些成员仍然是由静态类型决定的。
  • 名字冲突与继承:派生类的成员将隐藏同名的基类成员。
  • 通过作用域运算符来使用隐藏的成员:我们可以通过作用域运算符来使用一个被隐藏的基类成员。
  • 除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。
  • 一如既往,名字查找先于类型检查:如声明在内层作用域的函数并不会重载声明在外层作用域的函数,定义派生类中的函数也不会重载其基类中的成员。如果派生类(即内层作用域)的成员与基类(即外层作用域)的某个成员同名,则派生类将在其作用域内隐藏该基类成员。即使派生类成员和基类成员的形参列表不一致,基类成员也仍然会被隐藏掉。(名字查找优先于类型检查;一旦名字找到,编译器就不再继续查找)

虚函数与作用域

class Base
{
public:
    virtual int fcn();
};

class D1 : public Base
{
public:
    // 隐藏基类的fcn,这个fcn不是虚函数
    // D1继承了Base::fcn()的定义
    int fcn(int); // 形参列表与Base中的fcn不一致
    virtual void f2(); // 是一个新的虚函数,在Base中不存在
};

class D2 : public D1
{
public:
    int fcn(int); // 是一个非虚函数,隐藏了D1::fcn(int)
    int fcn(); // 覆盖了Base的虚函数fcn
    void f2(); // 覆盖了D1的虚函数f2
};

通过基类调用隐藏的虚函数

Base bobj; D1 d1obj; D2 d2obj;
Base *bp1 = &bobj, *bp2 = &d1obj, *bp3 = &d2obj;
bp1->fcn(); // 虚调用,将在运行时调用Base::fcn
bp2->fcn(); // 虚调用,将在运行时调用Base::fcn
bp3->fcn(); // 虚调用,将在运行时调用D2::fcn

D1 *d1p = &d1obj; D2 *d2p = &d2obj;
bp2->f2(); // 错误:Base没有名为f2的成员 // 指针是Base类型,只在Base中查找
d1p->f2(); // 虚调用,将在运行时调用D1::f2()
d2p->f2(); // 虚调用,将在运行时调用D2::f2()

Base *p1 = &d2obj; D1 *p2 = &d2obj; D2 *p3 = &d2obj;
p1->fcn(42); // 错误:Base中没有接受一个int的fcn
p2->fcn(42); // 静态绑定,调用D1::fcn(int)
p3->fcn(42); // 静态绑定,调用D2::fcn(int)

覆盖重载的函数

  • 如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。(因为作用域是嵌套的,同名函数会被隐藏)
  • 一条基类成员函数的using声明语句就可以把该函数的所有重载实例添加到派生类作用域中。此时,派生类只需要定义其特有的函数就可以了,而无须为继承而来的其他函数重新定义。
  • 类内using声明的一般规则同样适用于重载函数的名字;基类函数的每个实例在派生类中都必须是可访问的。对派生类没有重新定义的重载版本的访问实际上是对using声明点的访问。
class Base
{
protected:
    void func();
    void func(int a);
};

class D : public Base
{
public:
    using Base::func;
    // 1. 将基类的func所有重载实例到包含到D中
    // 2. 将原本是protected属性改为public
    void func(double d);
};

int main(void)
{
    D d;

    d.func();
    d.func(2);

    return 0;
}

构造函数与拷贝控制

虚析构函数

  • 只要基类的析构函数是虚函数,就能确保当我们delete基类指针时将运行正确的析构函数版本。如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为。
  • 之前我们曾介绍过一条经验准则,即如果一个类需要析构函数,那么它也同样需要拷贝和赋值操作。基类的析构函数并不遵循上述准则,它是一个重要的例外。一个基类总是需要析构函数,而且它能将析构函数设定为虚函数。此时,该析构函数为了成为虚函数而令内容为空,我们显然无法由此推断该基类还需要赋值运算符或拷贝构造函数。
  • 虚析构函数将阻止合成移动操作:基类需要一个虚析构函数这一事实还会对基类和派生类的定义产生另外一个间接的影响:如果一个类定义了析构函数,即使它通过=default的形式使用了合成的版本,编译器也不会为这个类合成移动操作。

合成拷贝控制与继承

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

  • 无论基类成员是合成的版本还是自定义的版本对派生类的拷贝控制都无太大影响。唯一的要求是相应的成员应该可访问并且不是一个被删除的函数。

  • 派生类隐式地使用而基类通过将其虚析构函数定义成=default而显式地使用。对于派生类的析构函数来说,它除了销毁派生类自己的成员外,还负责销毁派生类的直接基类。

  • 就像其他任何类的情况一样,基类或派生类也能出于同样的原因将其合成的默认构造函数或者任何一个拷贝控制成员定义成被删除的函数(参见第13章,拷贝控制)。此外,某些定义基类的方式也可能导致有的派生类成员成为被删除的函数:

    • 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或者不可访问,则派生类中对应的成员将是被删除的,原因是编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或销毁操作。
    • 如果在基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类对象的基类部分。
    • 和过去一样,编译器将不会合成一个删除掉的移动操作。当我们使用=default请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的,那么派生类中该函数将是被删除的,原因是派生类对象的基类部分不可移动。同样,如果基类的析构函数是删除的或不可访问的,则派生类的移动构造函数也将是被删除的。
  • 在实际编程过程中,如果在基类中没有默认、拷贝或移动构造函数,则一般情况下派生类也不会定义相应的操作。

  • 如前所述,大多数基类都会定义一个虚析构函数。因此在默认情况下,基类通常不含有合成的移动操作,而且在它的派生类中也没有合成的移动操作。

  • 因为基类缺少移动操作会阻止派生类拥有自己的合成移动操作,所以当我们确实需要执行移动操作时应该首先在基类中进行定义:

    class Quote
    {
    public:
    	Quote() = default; // 对成员依次进行默认初始化
    	Quote(const Quote&) = default; // 队成员依次拷贝
    	Quote(Quote&&) = default; // 对成员依次移动
    	Quote& operator=(const Quote&) = default; // 拷贝赋值
    	Quote& operator=(Quote&&) = default; // 移动赋值
        virtual ~Quote() = default;
    	// 其他成员与之前的版本一致
    };
    

    除非Quote的派生类中含有排斥移动的成员,否则它将自动获得合成的移动操作。

派生类的拷贝控制成员

  • 派生类构造函数在其初始化阶段不但要初始化派生类自己的成员,还负责初始化派生类对象的基类部分。因此,派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员。类似的,派生类赋值运算符也必须为基类部分的成员赋值。但是析构函数只负责销毁派生类自己分配的资源,派生类对象的基类部分是自动销毁的。

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

    class Base { /* ... */ };
    class D : public Base
    {
    public:
    	// 默认情况下,基类的默认构造函数初始化对象的基类部分
    	// 要想使用拷贝或移动构造函数,我们必须在构造函数初始值列表中
    	// 显式地调用该构造函数
    	D(const D& d): Base(d) // 拷贝基类成员
    		/* D的成员的初始值 */ { /* ... */ }
    	D(D&& d): Base(std::move(d)) // 移动基类成员
    		/* D的成员的初始值 */ { /* ... */ }
    };
    
  • 与拷贝和移动构造函数一样,派生类的赋值运算符也必须显式地为其基类部分赋值:

    // Base::operator=(const Base&) // 不会被自动调用
    D &D::operator=(const D &rhs)
    {
    	Base::operator=(rhs); // 为基类部分赋值
    	// 按照过去的方式为派生类的成员赋值
    	// 酌情处理自赋值及释放已有资源等情况
    	return *this;
    }
    
  • 值得注意的是,无论基类的构造函数或赋值运算符是自定义的版本还是合成的版本,派生类的对应操作都能使用它们。例如,对于Base::operator=的调用语句将执行Base的拷贝赋值运算符,至于该运算符是由Base显式定义的还是由编译器合成的无关紧要。

派生类析构函数

  • 在析构函数体执行完成后,对象的成员会被隐式销毁。类似的,对象的基类部分也是隐式销毁的。
  • 派生类析构函数只负责销毁由派生类自己分配的资源。
  • 对象销毁顺序正好与其创建的顺序相反。

在构造函数或析构函数中调用虚函数

  • 如我们所知,派生类对象的基类部分将首先被构建。当执行基类的构造函数时,该对象的派生类部分是未被初始化的状态。类似的,销毁派生类对象的次序正好相反,因此当执行基类的析构函数时,派生类部分已经被销毁掉了。由此可知,当我们执行上述基类成员的时候,该对象处于未完成的状态
  • 为了能够正确地处理这种未完成的状态,编译器认为对象的类型在构造或析构的过程中仿佛发生了改变一样。也就是说,当我们构建一个对象时,需要把对象的类和构造函数的类看作是同一个;对虚函数的调用绑定正好符合这种把对象的类和构造函数的类看成同一个的要求;对于析构函数也是同样的道理。上述的绑定不但对直接调用虚函数有效,对间接调用也是有效的,这里的间接调用是指通过构造函数(或析构函数)调用另一个函数。
  • 如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型对应的虚函数版本。(可以理解为该期间不会呈现出多态)

继承的构造函数

  • 一个类只初始化它的直接基类,出于同样的原因,一个类也只继承其直接基类的构造函数。类不能继承默认、拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们。

  • 通常情况下,using声明语句只是令某个名字在当前作用域内可见。而当作用于构造函数时,using声明语句将令编译器产生代码。对于基类的每个构造函数,编译器都生成一个与之对应的派生类构造函数。

    class Bulk_quote : public Disc_quote
    {
    public:
    	using Disc_quote::Disc_quote; // 继承Disc_quote的构造函数
    	double net_price(std::size_t) const;
    };
    
    // 在我们的Bulk_quote类中,继承的构造函数等价于:
    Bulk_quote(const std::string& book, double price, std::size_t qty, double disc):
    	Disc_quote(book, price, qty, disc) { }
    // 注意,名字变了
    

    这种编译器生成的构造函数形如(如果派生类含有自己的数据成员,则这些成员将被默认初始化):

    derived(parms) : base(args) { }
    // 其中:
    // derived是派生类的名字
    // base是基类的名字
    // parms是构造函数的形参列表
    // args将派生类构造函数的形参传递给基类的构造函数
    
  • 一个构造函数的using声明不会改变该构造函数的访问级别。例如,不管using声明出现在哪儿,基类的私有构造函数在派生类中还是一个私有构造函数;受保护的构造函数和公有构造函数也是同样的规则。

  • 一个using声明语句不能指定explicitconstexpr。如果基类的构造函数是explicit或者constexpr,则继承的构造函数也拥有相同的属性。

  • 当一个基类构造函数含有默认实参时,这些实参并不会被继承。相反,派生类将获得多个继承的构造函数,其中每个构造函数分别省略掉一个含有默认实参的形参(相当于枚举出了所有的情况)。

  • 如果派生类定义的构造函数与基类的构造函数具有相同的参数列表,则该构造函数将不会被继承。定义在派生类中的构造函数将替换继承而来的构造函数。第二个例外是默认、拷贝和移动构造函数不会被继承。这些构造函数按照正常规则被合成。继承的构造函数不会被作为用户定义的构造函数来使用,因此,如果一个类只含有继承的构造函数,则它也将拥有一个合成的默认构造函数

容器与继承

  • 当派生类对象被赋值给基类对象时,其中的派生类部分将被“切掉”,因此容器和存在继承关系的类型无法兼容。

在容器中放置(智能)指针而非对象

  • 当我们希望在容器中存放具有继承关系的对象时,我们实际上存放的通常是基类的指针(更好的选择是智能指针)。
  • 正如我们可以将一个派生类的普通指针转换成基类指针一样,我们也能把一个派生类的智能指针转换成基类的智能指针。
posted @ 2021-08-20 15:35  橘崽崽啊  阅读(129)  评论(0编辑  收藏  举报