跳转至

Classes and Objects⚓︎

11064 个字 726 行代码 预计阅读时间 64 分钟

设计建议:将设计看作是类型设计 (treat class design as type design)

建议至少阅读完本章笔记后再来看这里(我实在不知道要把这块放哪里,所以导致阅读顺序的混乱

当我们在定义一个新的(class) 时,实际上就像相当于定义了一个新的类型(type)(个人认为,如果不抱着这种想法的话,那么创建类的意义何在?可能只要一个函数就能解决问题。因此,要想设计一个高效的类,我们需要考虑以下问题:

  • 新类型的对象的创建和销毁:涉及到类的构造函数、析构函数、内存分配函数和释放函数(newdelete
  • 对象的初始化和赋值的区别
  • 按值传递新类型对象:用到了拷贝构造函数
  • 新类型的合法值:类需要维护一定的约束条件,并进行错误检查工作,还要考虑各种异常
  • 新类型的继承图:考虑虚函数或非虚函数
  • 新类型的转换
    • 隐式转换 -> 必须编写一个类型转换函数,或一个可被单一参数调用的构造函数
    • 如果只允许 explicit 构造函数存在,那上述方法行不通,只能写一个专门负责执行转换的函数
  • 对于新类型而言合理的运算符和函数
  • 禁用编译器提供的函数:使用private + 无实现,或者用 = delete 禁用
  • 访问新类型的成员(访问控制publicprotectedprivate
  • 新类型的“未声明接口”:在模板中对模板参数的隐式要求(C++20 后可以用概念来约束)
  • 新类型的泛化:如果实际上要定义一系列的类型,那么应该定义一个类模板
  • 新类型的需求:有时,一个或多个函数 / 模板就能解决问题,无需从头搞一个新类

Declaration and Definition⚓︎

C++ 程序设计中,仅靠变量和函数这种面向过程的范式很难表述清楚复杂的现实问题。为了解决这个问题,C++ 提供了 OOP 范式,其“核心出装”就是本篇笔记的主角(classes) 对象(objects)。

目前,为了便于理解,我们可以将类和对象的关系对应到类型变量的关系。其中,类定义了一系列的属性和方法,分别称作成员变量(member variables) 成员函数(member functions);而对象则是类的一个实例 (instance),具备独立于其他对象的属性。

一个基本的类应该包含:

  • 构造函数 (constructor)
  • 私有成员函数 / 变量
  • 公有成员函数(面向用户的接口)
  • 析构函数 (destructor)

  • 在实现类的成员函数时,需要在函数名前加上类名和解析符 (resolver) ::(将类名作为命名空间,构成形如 class_name::funct_name 的形式。
  • this 关键字指代一个指向对象自身的指针,在成员函数的实现中会经常用到(通常用于访问成员变量,在变量前加上 this-> 可以消除可能存在的歧义;当然如果没有歧义的话可以不用
    • 默认构造函数:T()
    • 析构函数:~T()
    • 拷贝构造函数:T(const T&)
    • 拷贝赋值运算符:T& operator=(const T&)
    • 移动构造函数:T(T&&)
    • 移动赋值运算符:T& operator(T&&)

    以下成员函数称之为特殊的成员函数(special member functions, SMF),调用它们的时候会自动生成代码(当然我们也可以自己定义它们

    后面 4 个关于拷贝和移动的部分放到后面介绍。

    • 如果这些函数的默认行为够用的话,我们就没必要去修改;否则的话就去构建自己的 SMF
      • 一个默认构造函数
      • 一个析构函数
      • 一个拷贝构造函数
      • 一个拷贝赋值运算符

      如果声明一个空类 (empty class)(花括号内几乎啥东西都没有)的话,编译器会为这个类自动声明:

      并且这些函数都是 publicinline 的。比如:

      class Empty {
      public:
      //  Empty() { ... }                              // 默认构造函数
      //  Empty(const Empty& rhs) { ... }              // 拷贝构造函数
      //  ~Empty( ) { ... }                            // 析构函数
      
      //  Empty& operator=(const Empty& rhs) { ... }   // 拷贝赋值运算符
      };
      
  • 如果希望某个函数的所有参数(包括成员函数中被隐藏起来的 this)都支持类型转换,那么这个函数就必须是非成员函数

Header Files and Source Files⚓︎

  • .h 头文件用于定义接口

    • 一般包括:函数原型、类声明、类型定义、宏、常量、外部变量等,可能为多个源文件所用
    • 建议一个头文件仅包含一个类的声明
    • 用到头文件的接口时,记得在代码开头用 #include 导入,它将被包含的文件插入到 .cpp 文件内这条语句的位置上
      • #include "xx.h":先搜索当前目录,然后寻找系统目录
      • #include <xx.h>:直接搜索系统目录
      • #include <xx>:与 #include <xx.h> 相同
    • 以下是一个标准的头文件结构,里面的声明仅出现一次,这样可以避免头文件内容被多次包含,从而导致编译失败的问题

      #ifndef HEADER_FLAG
      #define HEADER_FLAG
      // Type declaration here...
      #endif // HEADER_FLAG
      
  • .cpp 源文件一般起到实现函数或类的作用

    • 一般包括:函数实现、可执行代码
    • 如果是用于类的实现的话,建议与对应的头文件同名
    • 编译器一次只看一个 .cpp 文件,并将其编译为 .obj 文件
    • 链接器将所有的 .obj 文件链接为一个可执行文件
建议:将文件间的编译依赖降至最低

如果文件之间的编译依赖(compilation dependency) 过于紧密,那么改动其中一个文件就会导致这些相互关联的文件被重新编译,这显然会产生不小的成本。更糟糕的是,如果多个文件形成了一个链式的依赖关系,那么就会产生更麻烦的级联编译依赖(cascading compliation dependency)。

避免或缓解这类问题的大致思路是用声明依赖替代定义依赖:尽可能让头文件自我满足,实在不行的话就让它依赖其他文件中的声明而非定义(具体实现。具体可以有以下策略:

  • 如果对象引用或指针能够完成任务,那么就不要直接使用对象
  • 将声明和定义放在不同的文件中
  • 句柄类(handle classes)
  • 接口类(interface classes)

Access Control⚓︎

比较 structclass

在结构体中,里面的所有字段默认是公有的 (public),这意味着任何人都可以修改字段值,没有针对结构体的访问控制,这往往是我们不希望看到的。

C++ 的一大重要思想是封装(encapsulation),其中最重要的封装手段便是访问说明符(access specifiers),用于控制成员的访问权限。C++ 提供了以下几种访问说明符(从上到下封装性越来越强

  • public公有:能被任何对象访问
  • protected受保护:只能在类内部以及该类的派生类内被访问(涉及到继承的知识)
  • private私有:只能在类内部被访问
例子
class zjuID {
private:
    string name;            
    int idNumber;
public:
    void setName(string n);
    void setIdNumber(int id);
    void display();
};


zjuID myCard;
myCard.setName("NoughtQ");     // OK
myCard.setIDNumber(114514);    // OK
myCard.idNumber = 1919;        // Error! We cannot access private members directly

  • 访问说明符的作用范围为:从指定该说明符开始,到下一个说明符或者类定义结束为止。
  • 类默认采用 private,而结构体默认采用 public,除此之外二者并无什么区别。但由于 class 的名称更加明确,所以后面涉及到类的代码时,我们就不会用 struct 而用 class
  • 我们应该将所有成员变量声明为 private
    • 前面提到过的封装性问题:如果用户能够直接操纵成员变量,那么当类的成员变量发生变化时,就会破坏掉用到这个成员变量的代码,也就是说可维护性很差。所以用 private 阻止直接访问成员变量为开发者保留了日后变更实现的权利。
    • 还有一个比较直观的好处是语法一致性:外部用户只能通过调用方法来访问 / 操纵对象的成员,而调用这些方法都是要带圆括号的,所以用户就不用去想要不要加圆括号的问题了。

Initialization and Clean-Up⚓︎

  • 构造函数(constructor,简写为 ctor) 用于初始化新创建对象的状态

    • 构造函数的名称与类名相同,且无需指出返回类型
    • 构造函数有两类:

      • 参数化构造函数:

        • 函数体内还可以增加一些额外限制
        zjuID::zjuID(std::string name, int idNumber) {
            this->name = name;
            if (idNumber > 0)
                this->idNumber = idNumber;
        }
        
        • 成员初始化列表(member initialization list):

          zjuID::zjuID(std::string name, int idNumber): name(name), idNumber(idNumber) { }
          
          • 相比在函数体内为每个成员变量赋值,这种方法的效率更高,因为前者在赋值前还会调用默认构造函数进行初始化,而这个初始化马上就会被后面的赋值操作覆盖。
          • 也可以将初始化列表里的圆括号改为花括号,这样就用到了统一初始化的语法,相比一般方法能够防止缩窄转换,更加安全。

            zjuID::zjuID(std::string name, int idNumber): name{name}, idNumber{idNumber} { }
            
          • 初始化列表可以为空,此时编译器会进行零初始化(对于数值类型的话就会赋予 0

          • 成员初始化的顺序按照声明成员的次序,而和其在初始化列表中的顺序无关
      • 默认构造函数:没有参数的构造函数

        zjuID::zjuID() {
            name = "NoughtQ";
            idNumber = 114514;
        }
        
        • 如果在类里面没有声明构造函数,那么编译器会自动为该类创建一个这样的构造函数 - 这两类构造函数可以同时存在(函数重载) - C++ 11 引入了一种显式设置默认构造函数的方法:= default,此时编译器会自动生成一个空的实现,为基本类型成员变量进行默认初始化(不是零初始化。该关键字仅用于默认构造函数(也就是说不得有任何参数
    • 注意:初始化的顺序不是按照初始化列表的顺序,而是按照类里面变量声明的顺序

    • explicit 关键字修饰构造函数,表明该构造函数不允许隐式类型转换。这主要为了阻止那些意外的、不直观的隐式类型转换,从而提高代码的清晰度、可读性和安全性。
  • 析构函数(destructor,简写为 dtor) 在对象销毁(生命周期结束)前被调用

    • 函数名 = ~ + 类名,无需任何参数,且同样没有返回类型
    • 如果使用 new 关键字动态分配数据的话,那就一定要在析构函数里用 delete 释放内存空间

      struct Y {
          ~Y();
      };
      
    • 可以显式调用析构函数,但一般来说没这个必要

    • 析构的顺序和构造的顺序相反
    • 析构函数不应该将抛出的异常影响到外部,因为这可能会让程序过早结束或出现未定义的行为。所以一旦抛出异常,析构函数应该及时捕获,然后要么中止程序,要么就当作无事发生,让程序继续执行下去。
      • 还有一种选择是设计一个函数,让析构函数调用这个函数。如果这个函数在执行期间发生异常,用户就有机会决定是否处理这个异常以及如何处理这个异常。

Getters and Setters⚓︎

一般情况下,类里面的成员变量都是私有的,类外部的对象无法直接访问成员变量。因此要想访问这些变量的话,我们需要通过该类的成员函数间接访问。其中用于读取成员变量的函数被称为 getter,用于修改成员变量的函数被称为 setter

  • getter

    std::string zjuID::getName() {
        return this->name;
    }
    
    int zjuID::getIdNumber() {
        return this->idNumber;
    }
    
  • setter

    void zjuID::setName(std::string name) {
        this->name = name;
    }
    
    void zjuID::setIdNumber(int idNumber) {
        if (idNumber >= 0)
            this->idNumber = idNumber;
    }
    

Friends⚓︎

友元(friends) 是一种用关键字 friend 修饰的函数或类,它可以访问另一个类(就是在这个类内部进行 friend 声明)的私有或受保护的成员(这在原来是不行的,从而实现“越权访问”(开后门

例子
class Box {
private:
    int length;

public:
    Box(int l) : length(l) {}

    friend void printLength(const Box& b);
};

void printLength(const Box& b) {
    std::cout << "Length is: " << b.length << std::endl;
}
class Engine;

class Car {
private:
    int speed;

public:
    Car() : speed(200) {}
    friend class Engine;
};

class Engine {
public:
    void boost(Car& c) {
        c.speed += 100;  
    }
};
  • 友元类不具备:

    • 对称性:类 A 是类 B 的友元,不代表 B 一定是 A 的友元
    • 传递性:若类 A 是类 B 的友元,类 B 是类 C 的友元,那么 A 不一定是 C 的友元
    • 继承性:子类不会继承父类的友元关系
  • 友元函数不是成员函数,因为友元函数是在类外部实现的,不具备 this 指针。所以要想访问成员变量,还得将对象引用作为参数传递

不妨用非成员非友元函数替换成员函数

相比成员函数,与类紧密相关的非成员非友元函数(non-member non-friend functions) 可能在以下几个方面更具优势:

  • 封装性
    • 乍看之下有些反直觉:OOP 不是要求数据应尽可能被封装,而成员函数正是被封装的体现啊,怎么反而非成员函数更具封装性呢?
    • 我们得考虑封装的两个特性:封装的内容越多,改变这些内容的能力也越大;越多函数能够访问数据,这些数据的封装性就越低。
    • 非成员非友元函数无法直接访问成员变量,那么也就不会破坏封装性;反倒是成员函数能够随意访问成员变量,这倒让封装性有所减弱。
    • 当然,这些非成员非友元函数可以是别的类的成员函数。
  • 包装灵活性
  • 功能扩展性
    • 我们可以将不同功能的函数(称为实用函数 (utility functions) 放在不同的头文件下,但同属于一个命名空间。需要的时候就可以用 #include 将头文件包进来。
    • 因此我们可以轻松扩展这些函数;而要扩展类里面定义的成员函数就没这么方便了。

Scope and Lifetime⚓︎

Local Objects⚓︎

字段、参数和局部变量的作用域和生命周期:

  • 字段(fields)

    • 在类里面,但是在构造函数和方法外定义
    • 生命周期等同于对象的生命周期,因此可用来维护对象的当前状态等
    • 作用域为整个类,因此可被类里面的构造函数或方法使用(包括相同类的不同对象)
    • 不过用 private 字段定义字段后,该字段不得在类之外的区域被访问(其他对象不得访问这样的字段)
  • 形参(parameters):

    • 定义在构造函数或方法的签名 / 声明上,接收来自外部的值(实参值,该过程即为形参初始化的过程
    • 生命周期仅在构造函数或方法的调用内,函数调用结束后就消失了
    • 作用域限制在定义它们的构造函数或方法内
  • 局部变量(local variables):

    • 定义在构造函数或方法的主体 / 定义内,仅在构造函数或方法内来初始化和使用变量
    • 由于局部变量没有被赋予默认值,在表达式中使用局部变量时必须先初始化
    • 生命周期仅在构造函数或方法的调用内,函数调用结束后就消失了
    • 作用域限制在定义它们的块内,在块外面无法访问它们

Global Objects⚓︎

全局对象 (global objects)

  • 全局对象的构造函数(constructor) 需要在进入 main() 函数之前被调用,声明顺序即为代码书写顺序,因此 main() 不再是程序中第一个被调用的函数
  • 全局对象的析构函数(destructor) main() 退出或 exit() 调用时被调用

Static⚓︎

静态初始化依赖性(static initialization dependency):单个文件内的对象构造顺序是已知的,但是多个文件之间的构造顺序是未定的。那么当位于不同文件的非局部静态对象有依赖关系时,它们的初始化顺序是不确定的,这样就可能产生不符预期的后果。

满足以下情况的对象称为非局部静态对象(non-local static object):

  • 定义于全局命名空间作用域内
  • 中或文件作用域内声明为静态变量的

解决上述问题的方案:

  • 避免非局部静态变量的依赖关系
  • 按正确的顺序在单个文件内定义静态对象
  • 非局部静态对象 -> 局部静态对象,具体来说可以将非局部静态对象搬到对象所属类的函数内,该函数返回一个引用,指向其所含的对象。之后用户通过调用这些函数来访问对象,从而保证访问的对象是初始化过的。

static静态)关键字

  • 静态的基本含义:静态存储(持续、受限访问(隐藏)
  • 在固定的地址上仅分配一次空间:名称可见性、内部链接
  • 使用场景:

    • 全局变量:作用域被限制在当前文件,不得被其他文件访问
    • 局部变量:该变量的生命周期存在于整个程序的运行期间
    • 成员变量:此时成员变量属于本身,而不是属于某个特定的对象(对象属性 -> 类属性)

      • 在类的内部只能对其声明(此时可以为变量赋初值)而不能定义,所以记得要在类外面定义一下这种变量

        class CostEstimate {
        private:
            static const double cost;
            // ...
        };
        const double CostEstimate::cost = 1.35;
        
      • 对于整数类型的变量,如果某些老式编译器不支持在声明时赋初值的话,可以用枚举类型 enum 来替代

    • 成员函数:此时成员函数属于本身,而不是属于某个特定的对象(对象方法 -> 类方法)

  • 对于静态成员变量 / 函数:

    • 由于不属于任何一个对象,所以没法对其使用 this 指针
    • 访问静态成员的两种方法(推荐前者

      <class name>::<static member>
      <object variable>.<static member>
      

Constants⚓︎

C++ const 关键字是一个限定词,表示不可修改。常见的用法有:

  • 修饰变量const int x = y;,之后 x 就不得再被修改了

    • 不能将非 const 引用绑定在 const 变量上
    • 编译器尝试避免为 const 变量创建内存空间,而将其保存在符号表(symbol table)
    • 但如果在前面使用 extern 关键字的话,就会让编译器强制分配内存空间
    • 也可以修饰数组、结构体等复杂数据结构,但此时编译器会为它们分配一块无法被修改的内存空间。但是在编译时,无法使用内部的元素,因为编译器在编译时不会获取里面的信息
    • const 修饰的类成员变量必须在构造函数中被初始化,但由于这样的变量没有在编译时被确定,所以要当心!解决方法有:

      • 匿名枚举
      • const 前加上 static
      // compilation error
      class HasArray {
          const int size = 100;
          int array[size];
      }
      
      // anonymous enumeration -- OK!
      class HasArray {
          enum { size = 100 };
          int array[size]; // OK!
      };
      
      // use static keyword -- OK!
      class HasArray {
          static const int size = 100;
          int array[size];
      }
      
  • 修饰指针:分多种情况讨论:

    • const int* p1;const 修饰的是,所以 p1 指向的值不得被修改,但 p1 本身可以被修改
    • int* const p2;const 修饰的是指针,所以 p2 本身不得被修改,但可以修改其指向的值
    • const int* const p3;:无论是指针还是指向的值都不得变
    关于迭代器

    STL 迭代器本质上也是指针(可看作 T* 指针。所以为迭代器声明 const 修饰的事迭代器自身:它不可以指向其他值,但它所指的值可以修改。

    如果希望迭代器所指的值不得修改,那么要用 const_iterator 替代 iterator

  • 修饰函数参数void foo(const int& x);,防止函数修改传入的参数

  • 修饰返回值:如果返回的是指针或引用,为防止误修改,可以加 const
    • e.g. const std::string& getStr();
  • 修饰成员函数const 加在函数签名之后(花括号之前(声明和定义都要加)

    class A {
    public:
        int getVal() const {
            return val;
        }
    
    private:
        int val;
    };
    
    • 必要性:在 C++ 中,由于 const 对象只能使用其 const 成员函数,所以不为成员函数添加 const 的话,编译器就不知道这个成员函数是否会修改 const 对象的成员变量,从而编译失败
      • 一般我们不会刻意创建 const 对象;const 对象主要用于向函数传入被 const 修饰的参数(一般是指针或引用)
    • 此时该成员函数不能修改成员变量,并且它只能调用其他 const 成员函数;但它可以修改全局变量、传入的参数以及 static 成员变量(因为它不属于任何对象)
    • const 修饰的对象只能调用同样被 const 修饰的成员函数
    • 实际上,这个 const 修饰的是 this 指针
    • 另外,这里的 const 修饰可以让类的接口更容易被理解
    • 函数重载:可以为同名成员函数设置被 const 修饰和不被 const 修饰的两个版本,这两个版本可以分别被 const 对象和非 const 对象调用

      void f() const;
      void f();
      
      • 一般情况下这两个版本的实现代码是一样的,所以会带来代码重复。要想避免这一情况,我们可以先定义一个 const 版本的函数,然后再让非 #!cpp const 版本调用这个函数。在这一过程中涉及到两步转型(casting):
        • static_cast:将非 const 对喜庆转化为 const 对象
        • const_cast:移除 const
      例子
      template <typename T>
      const T& Vector<T>::findElement(const T& value) const {
          for (size_t i = 0; i < logical_size; i++) {
              if (elems[i] == value) return elems[i];
          }
          throw std::out_of_range("Element not found");
      }
      
      template <typename T>
      T& Vector<T>::findElement(const T& value) {
          return const_cast<T&>(static_cast<const Vector<T>&>(*this).findElement(value));
      }
      
      • 反过来做(即先定义非 const 版本,后让 const 版本调用这一函数)是不合理的,因为前者可能会对某些成员变量作修改,但后者显然不具备这种能力

Composition⚓︎

组合(composition):一个类将另一个类的对象作为其成员变量,组织和复用其代码,从而“拥有”另一个类的功能,体现的是一种 "has-a"(拥有)关系。

有以下组合方式:

  • 值组合 /嵌入对象(embedded objects)(完全包含

    • 假如 "A has aB ",那么在对象 A 创建或销毁时,被嵌入的对象 B 会自动调用构造函数和析构函数(生命周期强绑定)
    • 成员初始化通常通过构造函数初始化列表进行
      • 可以只初始化部分成员,未初始化的将调用默认构造函数
      • 构造顺序由成员声明顺序决定,与初始化列表顺序无关
    • 嵌入对象通常是私有的(private

      class A {
          int x;
      public:
          A(int val) : x(val) {}
      };
      class B {
          A a1, a2;
      public:
          B() : a1(10), a2(20) {}  // 初始化列表初始化子对象
      };
      
  • 引用 / 指针组合

    • 引用 / 指针绑定的对象不会自动调用构造函数和析构函数,需要手动初始化和销毁对象(生命周期独立)
    • 应用场景:逻辑关系并不是完全的、程序开始时还不清楚对象大小、需要在运行时分配 / 连接资源 ...
    • 其他 OOP 语言只能采用这种方式

组合并不一定总是代表 "has-a" 关系,它还可以表示“根据 ... 实现”(is-implemented-in-terms-of)(或者说实现复用)的关系。这两个关系十分相近,但区别在于作用领域的不同:前者用于应用域(application domain),后者用于实现域(implementation domain)。

例子

注:你可能需要先了解一下模板的知识。

假如现在要用 STL 内置的链表 list 来实现一个集合容器 Set。很自然地,我们用到了类的组合:

template<class T>
class Set {
public:
    bool member(const T& item) const;
    void insert(const T& item);
    void remove(const T& item);
    std::size_t size() const;
private:
    std::list<T> rep;
};

Set 成员函数可以很轻松地用 list 及标准库中提供的函数实现:

template<typename T>
bool Set<T>::member(const T& item) const {
    return std::find(rep.begin(), rep.end(), item) != rep.end();
}

template<typename T>
void Set<T>::insert(const T& item) {
    if (!member(item))
        rep.push_back(item);
}

template<typename T>
void Set<T>::remove(const T& item) {
    auto it = std::find(rep.begin(), rep.end(), item);
    if (it != rep.end())
        rep.erase(it);
}

template<typename T>
std::size_t Set<T>::size() const {
    return rep.size();
}

Inheritance⚓︎

继承(inheritance):基于已有的类(称为基类(base class) 超类(superclass))创建一个新类(称为派生类(derived class) 子类(subclass),派生类自动获得基类的属性和方法,并在此基础上进行添加或修改。

继承的好处有:

  • 代码复用:避免在多个类中重复编写相同的代码
  • 简化维护:修改基类可以影响所有派生类,便于统一管理
  • 扩展性:方便地在现有类的基础上进行功能扩展

能继承的内容有:

  • 成员变量
    • 包括 privateprotected 的成员变量,派生类拥有基类的全部变量
    • 但是派生类无法直接访问 private 成员变量,必须通过基类的 publicprotected 的成员函数来访问
  • publilcprotected成员函数:可被派生类直接访问和调用
  • 静态成员:作用域仍然在类范围内

不能继承的内容有:

  • 构造函数
    • 派生类不直接继承基类构造函数的内容
    • 创建派生类对象时,总是先调用基类的构造函数来初始化基类部分,然后再执行派生类的构造函数
    • 可以在派生类构造函数的初始化列表中显式调用指定的基类构造函数 ; 若不显式调用,则会自动调用基类的默认构造函数
    • 派生类的拷贝构造函数需要显式调用基类的拷贝构造函数,否则会调用基类的默认构造函数
  • 析构函数
    • 不直接继承
    • 析构函数的调用顺序与构造函数相反:先调用派生类的析构函数,再调用基类的析构函数
  • 赋值运算
    • 不直接继承
    • 派生类的赋值运算符需要显式调用基类的赋值运算符,以确保基类部分的正确赋值,否则可能导致资源管理问题(如浅拷贝)
    • 如果未显式定义,编译器会生成默认的赋值运算符,进行成员逐一赋值(可能会有问题)
  • 私有数据被隐藏起来,但仍然存在

访问保护:

  • 成员

    • 公有:对所有用户都是可见的
    • 受保护:对派生类和友元而言是可见的
    • 私有:仅对自己和友元可见
  • 继承:

    • 公有:class Derived : public Base ...
      • 公有继承是一种 "is-a" 的关系
    • 受保护:class Derived : protected Base ...
    • 私有(默认class Derived : private Base ...

继承对访问的影响:假设类 B 是类 A 的派生类,那么:

继承类型 public protected private
public A B 里是公有的 B 里是受保护的 被隐藏
private A B 里是私有的 B 里是私有的 被隐藏
protected A B 里是受保护的 B 里是受保护的 被隐藏

何时“受保护”失效:

  • 派生类的行为异常
  • 对所有派生类而言,受保护的成员是公有的
  • 因此,需要让成员函数是受保护的,而成员变量是私有的
  • 析构函数按照调用构造函数时的倒序被调用

名称隐藏

  • 如果在派生类中定义了一个和基类同名的成员函数,无论这个函数是否和基类的同名函数有着相同的函数签名,是否是虚函数,都会将基类中所有(重载)的同名函数给“隐藏”起来,也就是说我们无法直接调用基类的同名函数。
    • 在派生类中使用 using 声明基类同名的成员函数

      例子
      #include <iostream>
      
      class Base {
      public:
          void func(int x) {
              std::cout << "Base::func(int) called with " << x << std::endl;
          }
          void func(double d) {
              std::cout << "Base::func(double) called with " << d << std::endl;
          }
      };
      
      class Derived : public Base {
      public:
          // 使用 using 声明将基类的 func 函数引入到派生类作用域
          using Base::func; // 这样 Base::func(int) 和 Base::func(double) 就可以和 Derived::func(char) 一起重载
      
          void func(char c) {
              std::cout << "Derived::func(char) called with " << c << std::endl;
          }
      };
      
      int main() {
          Derived d;
          d.func('A');    // OK: 调用 Derived::func(char)
          d.func(10);     // OK: 调用 Base::func(int)
          d.func(3.14);   // OK: 调用 Base::func(double)
      
          return 0;
      }
      

    解决方法有:

    • 还可以用转发函数(forwarding functions) 解决。它是派生类中显式调用基类同名函数的成员函数。

      例子
      // 这里仅展示派生类的成员函数,基类见上面的例子
      class Derived : public Base {
      public:
          // 派生类自己的 func 版本,它隐藏了 Base 的所有 func
          void func(char c) {
              std::cout << "Derived::func(char) called with " << c << std::endl;
          }
      
          // 转发函数:显式地将 Base::func(int) 暴露出来
          void func(int x) {
              // 可以选择在调用基类函数前后添加额外逻辑
              std::cout << "Derived forwarding to Base::func(int)..." << std::endl;
              Base::func(x); // 显式调用基类的 func(int)
          }
      
          // 转发函数:显式地将 Base::func(double) 暴露出来
          void func(double d) {
              // 也可以不添加额外逻辑,直接转发
              Base::func(d); // 显式调用基类的 func(double)
          }
      };
      
  • 之后会介绍关键字 virtual,它能够影响函数重载的行为。

Polymorphism⚓︎

下面我们学习 OOP 中另一个重要思想——多态(polymorphism),它允许我们以统一的方式处理不同但相关的对象,其核心思想就是“一个接口,多种实现”。

Subclassing and Subtyping⚓︎

子类型 (subtypes)

子类型描述了类型之间的可替换性关系。如果类型 S 是类型 T 的子类型 ( 记作S <: T ),那么在任何需要类型 T 的地方,都可以安全地使用类型 S 的对象,而不改变程序的期望行为和正确性。

子类(subclassing) 子类型(subtyping) 是多态的基础,提供了多态所需要的 “多种形态”的对象。具体是通过以下方法实现的:

  • public 继承
    • C++ 中,public 继承是设计者用来表达子类型关系的主要工具。它在语法上建立了 "is-a" 关系,并允许从子类到父类的隐式类型转换(向上转型),这是实现多态的基础。
  • 此外里氏替换原则 (Liskov Substitution Principle, LSP)发挥重要作用,它保证子类还必须在行为上与父类兼容,结合 public 继承,构成真正安全的子类型关系。LSP 有以下要求:

    • 子类不能强化父类方法的前置条件(对输入要求更严格)
    • 子类不能弱化父类方法的后置条件(对输出保证更少)
    • 子类必须维持父类的不变性(类状态的约束)

    如果没有遵循 LSP,即便是 public 继承,在运行时也可能导致逻辑错误或程序崩溃。这种情况下,这样的子类就不能表示有效的子类型。

private 继承
  • private 继承意味着 "implemented-in-terms-of" 关系,也就是说基类和派生类没有什么从属关系,只是想让派生类采纳基类的某些特性而已。
  • 它也意味着只继承了基类的实现部分,没有继承接口部分,这就导致派生类无法直接访问继承而来的所有成员,因为对它而言这些成员都是 private 的。
  • 组合也能表示 "implemented-in-terms-of" 关系。因此这里给出使用建议:尽可能使用组合,必要时才使用 private 继承。
    • 必要的时机:要继承基类的 protected 成员或重新定义虚函数;以及空间优化很要紧的时候(private 继承涉及到空基类优化(empty base optimization),而组合可能因对齐(alignment) 导致的字节填充 (padding) 浪费一些空间

Upcasting⚓︎

向上转型(upcasting) 是指将派生类的指针或引用转换为基类的指针或引用。

例子
class Animal {};
class Dog : public Animal {};

Dog myDog;
Animal* pAnimal = &myDog; // Upcasting
Animal& rAnimal = myDog;  // Upcasting

向上转型具有以下特点:

  • 隐式且安全:public 继承保证了 "is-a" 关系
  • 信息丢失(静态角度:通过基类指针 / 引用,我们只能访问基类中定义的成员(包括虚函数,子类特有的成员在编译时是不可见的
  • 多态的关键:这是使用多态机制的前提;通过基类指针 / 引用调用虚函数时,才能触发动态绑定

Dynamic Binding⚓︎

动态绑定(dynamic binding)(也称为后期绑定 (late binding)运行时绑定 (runtime binding))是指在程序运行时,根据指针或引用实际指向的对象类型(而不是指针或引用的声明类型)来决定调用哪个成员函数版本的机制。

静态绑定 (static binding)

静态绑定在编译期间就已经根据指针或引用的声明类型(静态类型)确定了要调用的具体函数。非虚函数、静态成员函数、以及通过对象本身(而不是指针或引用)进行的函数调用都属于静态绑定。

动态绑定的发生条件为:

  • 调用必须通过基类的指针或引用进行
  • 被调用的成员函数必须在基类中声明为 virtual(即虚函数(virtual functions),下面马上介绍👇)

动态绑定是 C++ 实现运行时多态的基石,使得我们可以编写操作基类指针 / 引用的通用代码,而这些代码在运行时能够自动适应所指向的具体子类对象,调用该子类对象特有的行为实现(如果子类重写了该虚函数。正是这种运行时的决策能力,赋予了程序处理不同类型对象的高度灵活性和可扩展性。

Virtual Functions⚓︎

虚函数(virtual functions) 允许子类重写 (override) 基类的行为,并在运行时根据对象的实际类型(而不是指针 / 引用的静态类型)来决定调用哪个版本的函数。

例子
class Shape {
public:
    virtual void Draw() { cout << "Drawing a generic shape" << endl; }
    virtual ~Shape() {}
};
补充知识:具体实现
  • 虚函数表(VTable):每个包含虚函数的类都有一个静态的虚函数表,存储着该类所有虚函数的地址
  • 虚表指针(vptr):类的每个对象内部包含一个隐藏的指针 vptr,指向其所属类的 VTable

当通过基类指针 / 引用调用虚函数时:

  1. 通过对象的 vptr 找到对应的 VTable
  2. VTable 中查找被调用虚函数的地址。
  3. 跳转到该地址执行代码(可能是基类的实现,也可能是某个子类的实现

性能与内存开销:

  • 内存:每个对象增加一个 vptr 的大小
  • 时间:每次虚函数调用涉及一次额外的间接寻址(vptr -> VTable -> Function,可能影响 CPU 缓存效率,相比普通函数调用有轻微开销
非虚函数 (non-virtual functions)
  • 静态绑定:调用地址在编译时就确定了,基于指针 / 引用的静态类型
    • 所以不要想着在派生类重新定义非虚函数,因为一旦这么做,派生类既有调用基类同名非虚函数的可能,也有调用其自身非虚函数的可能。
  • 无运行时开销:调用快,但不具有多态性。如果子类定义了同名非虚函数,通过基类指针调用时,执行的永远是基类的版本。

我们可能会遇到这样一个问题:如果通过基类指针 delete 一个派生类对象,而基类的析构函数不是虚函数,那么程序只会调用基类的析构函数,而派生类的析构函数不会被调用,这样就会得到一个“局部销毁”的对象,这可能会带来资源泄漏等一系列问题。

例子
class Base { public: ~Base() { /* 非虚析构函数 */ } };
class Derived : public Base {
    Resource* res; // 假设 Derived 管理某种资源
public:
    Derived() : res(new Resource()) {}
    ~Derived() { delete res; /* 清理资源 */ } // 这个析构函数可能不会被调用!
};

Base* pB = new Derived();
delete pB; // !!! 未定义行为 (Undefined Behavior) !!!
           // 只调用了 ~Base(),Derived 的析构函数未调用,导致 res 内存泄漏!

因此,如果打算将一个类作为基类,并且之后可能会通过基类指针删除派生类对象,那么它的析构函数必须声明为 virtual

或者说,只要类中有任何一个虚函数,就应该提供一个虚析构函数。即使类不打算被继承,这样做也无大碍,并且还更安全。

  • 然而,如果类里面没有虚函数的话,那么就不要搞一个虚析构函数出来,因为虚函数有一个虚拟表指针,这个指针占据一定空间,导致空间浪费。
class Base {
public:
    virtual ~Base() = default;
};
警告
    • 以构造函数为例,假如这个构造函数所在的类有一个派生类,而这个派生类在实例化时,一定会调用构造函数,而且先调用的是基类的构造函数。然而,由于基类构造函数调用的虚函数还没有具体实现(派生类才刚刚建立,而基类的虚函数不可能会用到派生类的虚函数实现,所以这样做是不行的。
    • 更难发现的一种情况是:构造函数调用类里面的私有成员函数,而这个成员函数调用了类里的一个虚函数。在这种情况下,编译器和链接器就不会发出警告,因而这种错误更难被发现了。
    • 所以我们要不仅要确保构造函数和析构函数内没有虚函数的调用,而且要确保它们所调用的函数内也没有虚函数的影子。
    • 如果有时构造和析构函数“不得不”调用虚函数,一种替代方案是将原来的虚函数声明为非虚函数,然后让派生类向基类传递调用该函数所需的信息。

    绝对不要在构造函数和析构函数内调用虚函数

    例子
    class Transaction {
    public:
        Transaction() {
            // ...
            logTransaction();    // 错误地调用了虚函数
        }
        virtual void logTransaction() const = 0;
    
    };
    
    class BuyTransaction: public Transaction {
    public:
        virtual void logTransaction() const;
        // ...
    };
    
    class Transaction {
    public:
        // 派生类向基类传递了 logInfo 信息
        Transaction(const std::string& logInfo) {
            // ...
            logTransaction(logInfo);
        }
        void logTransaction(const std::string& logInfo) const;    // 非虚函数
    
    };
    
    class BuyTransaction: public Transaction {
    public:
        BuyTransaction(parameters) : Transcation(createLogString(parameters)) { ... }
        // ...
    private:
        static std::string createLogString(parameters);
    };
    
  • 绝对不要重新定义继承而来的缺省(默认)参数值。因为缺省参数值和非虚函数一样也是静态绑定的(statically bound),所以如果这么做的话,调用这个函数的时候很可能用到的是基类的缺省参数值,而不是在派生类中重新定义的那个(比如指针指向派生类对象,但实际类型为指向基类的指针

    • 之所以缺省参数值不是动态绑定的,是因为这样的话编译器就必须在运行时为虚函数决定合适的参数缺省值,这样做会影响程序执行速度,并且实现起来更复杂。
虚函数的替代 / 增强方案
    • 公共接口是非虚函数
    • 这些非虚函数调用私有或受保护的虚函数(因此这个非虚函数被称为虚函数的包装器(wrapper))
      • 在包装器内,可以在调用虚函数之前执行一些检查(前置条件,例如参数验证、加锁、日志记录等;在虚函数返回后,它可以执行一些清理、验证(后置条件)或触发事件。

    非虚接口(non-virtual interface, NVI) 方法:它是一种模板方法(template method)(此“模板”非 C++ 的模板语法)设计模式的简单实现,其核心思想是:

    这样做能够解决直接暴露虚函数可能导致的一些问题,并提供更健壮、更可控的接口。

    例子
    #include <iostream>
    #include <string>
    
    class DocumentProcessor {
    public:
        // 公共非虚接口:定义处理文档的通用流程
        void processDocument(const std::string& docContent) {
            std::cout << "--- Starting document processing ---" << std::endl;
            // 前置条件/通用逻辑
            if (docContent.empty()) {
                std::cerr << "Error: Document content is empty." << std::endl;
                return;
            }
            std::cout << "Validating document..." << std::endl;
    
            // 调用私有/保护的虚函数,由派生类实现具体逻辑
            doProcess(docContent);
    
            // 后置条件/通用逻辑
            std::cout << "Document processed successfully." << std::endl;
            std::cout << "--- Document processing finished ---" << std::endl;
        }
    
        // 虚析构函数是必要的,以确保正确释放派生类资源
        virtual ~DocumentProcessor() = default;
    
    protected:
        // 保护的虚函数:供派生类重写具体处理逻辑
        virtual void doProcess(const std::string& docContent) = 0;
    };
    
    // 派生类 A
    class TextDocumentProcessor : public DocumentProcessor {
    protected:
        void doProcess(const std::string& docContent) override {
            std::cout << "Processing text document: " << docContent.substr(0, 20) << "..." << std::endl;
            // 模拟文本处理
            std::cout << "Counting words..." << std::endl;
        }
    };
    
    // 派生类 B
    class XMLDocumentProcessor : public DocumentProcessor {
    protected:
        void doProcess(const std::string& docContent) override {
            std::cout << "Processing XML document: " << docContent.substr(0, 20) << "..." << std::endl;
            // 模拟 XML 解析
            std::cout << "Parsing XML structure..." << std::endl;
        }
    };
    
    int main() {
        TextDocumentProcessor textProcessor;
        XMLDocumentProcessor xmlProcessor;
    
        std::cout << "Using TextDocumentProcessor:" << std::endl;
        textProcessor.processDocument("This is a sample text document for processing.");
    
        std::cout << "\nUsing XMLDocumentProcessor:" << std::endl;
        xmlProcessor.processDocument("<root><data>Some XML data</data></root>");
    
        std::cout << "\nUsing TextDocumentProcessor with empty content:" << std::endl;
        textProcessor.processDocument(""); // 触发前置条件检查
    
        return 0;
    }
    

    运行结果:

    Using TextDocumentProcessor:
    --- Starting document processing ---
    Validating document...
    Processing text document: This is a sample tex...
    Counting words...
    Document processed successfully.
    --- Document processing finished ---
    
    Using XMLDocumentProcessor:
    --- Starting document processing ---
    Validating document...
    Processing XML document: <root><data>Some XML...
    Parsing XML structure...
    Document processed successfully.
    --- Document processing finished ---
    
    Using TextDocumentProcessor with empty content:
    --- Starting document processing ---
    Error: Document content is empty.
    
  • 策略 (strategy) 模式:核心在于将算法(策略)从使用它的上下文(Context)中解耦出来,并使其可替换。在 C++ 中,我们有以下几种实现方式:

    • 函数指针:将功能从成员函数移交给类外面的函数,不过这样的函数只能通过 public 接口访问类里的数据
    • std::functionC++11 引入:它是一个通用的多态函数包装器,可以存储、复制和调用任何签名匹配的可调用对象(包括函数指针、lambda 表达式、函数对象、类的成员函数指针等。此时上下文类不再需要一个虚基类指针,而是直接持有一个 std::function 对象,它封装了具体的算法逻辑。

      例子
      #include <iostream>
      #include <functional>
      
      // 上下文类 (Context)
      class OrderWithFunctionStrategy {
      private:
          std::function<void(double)> paymentFunction;
          double totalAmount;
      
      public:
          OrderWithFunctionStrategy(double amount) : totalAmount(amount) {}
      
          // 设置支付函数(策略)
          void setPaymentFunction(std::function<void(double)> func) {
              paymentFunction = func;
          }
      
          // 执行支付操作,委托给封装的函数
          void checkout() {
              if (paymentFunction) {
                  std::cout << "Order total: " << totalAmount << std::endl;
                  paymentFunction(totalAmount); // 调用封装的函数
              } else {
                  std::cout << "No payment function set for this order." << std::endl;
              }
          }
      };
      
      int main() {
          OrderWithFunctionStrategy order1(100.0);
          // 使用 Lambda 表达式作为策略
          order1.setPaymentFunction([](double amount) {
              std::cout << "Paying " << amount << " using Lambda Credit Card." << std::endl;
          });
          order1.checkout();
      
          std::cout << std::endl;
      
          OrderWithFunctionStrategy order2(250.50);
          // 使用另一个 Lambda 表达式作为策略
          order2.setPaymentFunction([](double amount) {
              std::cout << "Paying " << amount << " using Lambda PayPal." << std::endl;
          });
          order2.checkout();
      
          std::cout << std::endl;
      
          // 也可以封装自由函数
          auto bankTransferPay = [](double amount) {
              std::cout << "Paying " << amount << " using Lambda Bank Transfer." << std::endl;
          };
          OrderWithFunctionStrategy order3(50.0);
          order3.setPaymentFunction(bankTransferPay);
          order3.checkout();
      
          return 0;
      }
      
    • 依靠来自另一个继承体系的虚函数

Overriding⚓︎

重写(overriding) 的目的是为子类提供一个与基类虚函数签名完全相同的函数,以实现自己的特定行为。其规则如下:

  • 函数签名:名称、参数列表、const 修饰符必须严格一致
  • virtual 关键字:
    • 基类函数必须是 virtual
    • 子类重写时 virtual 关键字可选(但强烈推荐写上)
  • override 关键字(C++11 标准引入,强烈推荐使用:使用该关键字后,编译器会检查该函数是否真的重写了基类的虚函数,防止因签名错误导致的意外行为(变成隐藏而非重写)

    例子
    class Base {
    public:
        virtual void display(int x) const;
    };
    
    class Derived : public Base {
    public:
        // 正确重写,编译器会检查
        void display(int x) const override;
        // void display(int x) override; // 编译错误!const 不匹配
        // void display(double x) const override; // 编译错误!参数类型不匹配
    };
    
  • 返回类型协变 (covariant return types):如果基类虚函数返回 Base*Base&,子类重写时可以返回 Derived*Derived&(其中 Derived 继承自 Base(通常应用于克隆函数等场景中)

    例子
    class Document {
    public:
        virtual Document* clone() const = 0;
        virtual ~Document() = default;
    };
    class TextDocument : public Document {
    public:
        // 返回更具体的类型,是合法的协变
        TextDocument* clone() const override { return new TextDocument(*this); }
    };
    
  • 访问权限:子类重写函数的访问权限不能比基类更严格

    • 举例:如果基类是 public 的话 , 那么子类就不能是 protectedprivate

重写可能会带来名称隐藏 (name hiding)的问题:如果子类定义了一个与基类同名参数列表不同的函数(无论基类是否为 virtual,基类所有同名的重载版本都会被隐藏。该问题的解决方法是使用 using 声明将基类同名函数引入子类作用域。

例子
class Base {
public:
    virtual void func(int);
    void func(double); // overloading
};
class Derived : public Base {
public:
    // 只重写 int 版本
    void func(int) override;
    // 如果没有下一行, Base::func(double) 将被隐藏
    using Base::func; // 将 Base 的 func(double) 引入 Derived 作用域
};

Abstract Classes⚓︎

抽象类(abstract classes) 是一种包含至少一个纯虚函数(pure virtual function) 的类。

  • 纯虚函数:在声明末尾加上 = 0,表示该函数没有实现,必须由子类提供。
例子
class Shape { // Shape 是一个抽象类
public:
    // 纯虚函数:定义接口,强制子类实现
    virtual double calculateArea() const = 0;

    // 抽象类可以有普通成员函数和数据成员
    void setColor(const std::string& c) { color = c; }

    // 即使是抽象类,如果可能被继承,也需要虚析构函数!
    virtual ~Shape() = default;
private:
    std::string color;
};

抽象基类具备以下特点:

  • 不能实例化:不能创建抽象类的对象
  • 作为接口基类:主要用于定义一套接口规范,强制派生类实现这些接口

而抽象类的意义在于:它为一组具有共同特征的类提供统一的接口规范,通过包含纯虚函数强制子类实现特定行为,从而支持多态性、提高代码一致性与可扩展性,同时防止对未完全实现的类进行误用,是一种构建灵活、安全、可维护的面向对象系统的重要机制。

Inheritance of Interface and Implementation⚓︎

继承可分为接口继承实现继承

  • 派生类总是继承基类的接口
  • 声明纯虚函数(pure virtual functions) 的目的是为了让派生类只继承函数接口
    • 但我们可以为纯虚函数提供实现(但一定要在类声明外面提供。但要想让派生类用到基类的这个实现,就必须明确指出基类名称(+ 作用域解析符 ::
  • 声明简单的非纯虚函数(simple impure functions) 的目的是让派生类继承该函数的接口和缺省实现
    • 如果派生类不重写这一实现的话,那么通过基类指针调用时,仍然会调用基类的实现。
  • 声明非虚函数(non-virtual functions) 的目的是为了让派生类继承函数接口以及一份强制性实现
    • 非虚函数的不变性(invariant) 凌驾于其特异性 (specialization) 之上,也就是说无论派生类之间有多么不同,它的行为都不可以改变。

Multiple Inheritance⚓︎

多重继承(multiple inheritance) 是指一个类可以同时继承多个基类的机制。该机制允许新类结合多个已有类的功能,从而在设计上具有更高的灵活性和表达能力。

多重继承有以下应用场景:

  • 实现多个接口:一个类可以同时满足多个不同的契约。

    例子
    class Clickable { public: virtual void onClick() = 0; virtual ~Clickable() = default; };
    class Serializable { public: virtual void serialize(std::ostream& os) const = 0; virtual ~Serializable() = default; };
    
    // Button 同时实现了 Clickable 和 Serializable 接口
    class Button : public Clickable, public Serializable { /* ... 实现接口 ... */ };
    
    • 因为继承了多个基类,所以很可能会遇到继承相同名称的成员的情况,而编译器无法帮我们选出最佳匹配。此时就需要我们手动指出具体要用哪个基类的成员(类名 + 作用域解析符 ::
  • 混合继承:继承一个具体的基类,同时实现一个或多个接口。


在多重继承中,我们常遇到的最经典的问题便是菱形继承问题(diamond problem) 了:当一个类通过不同的路径继承自同一个间接基类时,该基类的成员会在派生类中存在多份拷贝,导致访问出现歧义。

例子
class Top { public: int value; };
class Left : public Top {};
class Right : public Top {};
class Bottom : public Left, public Right {};

Bottom b;
// b.value = 10; // 编译错误!编译器无法判断是 Left::Top::value 还是 Right::Top::value

该问题的解决方案是采用虚继承(virtual inheritance),即在中间类继承共同基类时使用 virtual 关键字。

  • 这样做的代价是:虚继承通常会引入额外的复杂性和轻微的性能开销
  • 初始化规则:虚基类的构造函数由最终派生类 (most-derived class) 的构造函数负责调用,即使中间类也尝试调用,也会被忽略,从而确保虚基类只被初始化一次
  • 使用建议:
    • 非必要不使用虚基类,平常只用非虚继承。
    • 如果要用到虚基类,应尽可能避免在里面放置数据,从而减小额外开销。
例子
class Top { public: int value; };
// 使用虚继承
class Left : virtual public Top {};
class Right : virtual public Top {};
// Bottom 现在只包含一份 Top 的子对象
class Bottom : public Left, public Right {};

Bottom b;
b.value = 10; // 正确!只有一份 value
#include <iostream>

// 公共接口类:模拟一个可打印的接口
class Printable {
public:
    virtual void print() const = 0;
    virtual ~Printable() = default;
};

// 私有实现辅助类:模拟一个内部计数器,不希望外部直接访问
class CounterHelper {
protected:
    int count_ = 0;
    void increment() { count_++; }
    int getCount() const { return count_; }
};

class MyPrinter : public Printable, private CounterHelper {
public:
    MyPrinter() {
        increment();
    }

    // 实现 Printable 接口
    void print() const override {
        std::cout << "MyPrinter: Hello! (Printed " << getCount() << " times)" << std::endl;
    }

    // 额外的方法,可能在内部使用计数器
    void doSomethingElse() {
        increment();
        std::cout << "MyPrinter: Doing something else. Current count: " << getCount() << std::endl;
    }
};

int main() {
    MyPrinter printer;
    printer.print(); // 通过 Printable 接口调用
    printer.doSomethingElse(); // 调用 MyPrinter 自身的方法
    printer.print(); // 再次打印,计数器已更新

    // 尝试访问私有继承的成员 (会编译错误)
    // printer.increment(); // 错误: 'increment' is a private member of 'CounterHelper'
    // std::cout << printer.count_ << std::endl; // 错误: 'count_' is a private member of 'CounterHelper'

    // 通过基类指针使用多态 (只能访问 Printable 接口)
    Printable* p = &printer;
    p->print(); // 正常调用

    return 0;
}
关于继承和多态的设计原则
  • 优先使用组合而非继承:继承是强耦合关系 ("is-a")。如果只需要复用代码或建立 "has-a" 关系,组合通常更灵活、耦合更低。
  • 谨慎使用多重继承:虽然这个功能很强大,但也容易引入复杂性(特别是菱形继承。所以我们优先考虑使用接口继承(继承多个纯抽象类)来组合能力。
  • 警惕虚函数中的默认参数:默认参数值是静态绑定的!调用时使用的是指针 / 引用静态类型所对应的默认值,而不是动态类型(对象实际类型)的,这可能导致违反直觉的行为。所以实践中最好避免在虚函数中使用默认参数,或者确保所有重写版本都使用相同的默认值(但前者更好
  • 不在构造 / 析构函数中调用虚函数:在基类构造函数执行期间,对象的动态类型仍然是基类(vptr 指向基类的 VTable。此时调用虚函数,执行的是基类版本,而不是派生类的版本(派生类部分尚未构造 / 已析构,所以需要当心。

Advanced Topics⚓︎

前置知识

Handle Classes⚓︎

句柄类(handle classes) 是一种包装了指向另一个对象的指针或引用的类。它的主要目的是管理被包装对象的生命周期、访问权限,并提供一种更安全、更方便的方式来操作底层对象。句柄类通常用于实现以下目标:

  • 资源管理:自动管理动态分配的内存或其他系统资源(如文件句柄、网络连接等,确保资源在不再需要时被正确释放,防止内存泄漏。
  • 隐藏实现细节:将类的私有实现细节从头文件中移除,只在源文件中包含,从而减少编译依赖,加快编译速度,并允许在不重新编译客户端代码的情况下更改实现。
  • 多态:句柄类可以持有基类指针,从而通过基类接口操作派生类对象,实现运行时多态。
  • 防止对象切片:当派生类对象按值传递或赋值给基类对象时,派生类特有的部分会被“切掉”。句柄类通过持有指针或引用来避免这种情况。
  • 提供值语义的引用行为:句柄类本身可以具有值语义(可复制、可赋值,但它所管理的底层对象却表现出引用语义。

句柄类的常见形式有:

  • 智能指针
  • 自定义包装类:可以创建自己的类来包装原始指针或引用,并提供特定的管理逻辑。
  • PIMPL 惯用法(pointer to implementation idiom):一种特殊的句柄类应用,用于将类的私有数据成员和私有函数实现从头文件中分离出来。

句柄类有以下特点:

  • 通常包含一个指向实际对象的指针(或智能指针
  • 提供对底层对象的间接访问(通过 operator->operator* 重载
  • 负责底层对象的创建和销毁(尤其是智能指针
  • 可以实现复制控制(拷贝构造函数、拷贝赋值运算符,决定是深拷贝底层对象还是共享底层对象。
例子
#include <iostream>
#include <memory>

class Resource {
public:
    Resource(int id) : id_(id) {
        std::cout << "Resource " << id_ << " created." << std::endl;
    }
    ~Resource() {
        std::cout << "Resource " << id_ << " destroyed." << std::endl;
    }
    void operation() const {
        std::cout << "Resource " << id_ << " performing operation." << std::endl;
    }
private:
    int id_;
};

// MyHandleClass 就是一个句柄类,它包装并管理 Resource 对象
class MyHandleClass {
public:
    MyHandleClass(int id) : resource_ptr_(std::make_unique<Resource>(id)) {
        std::cout << "MyHandleClass created for Resource " << id << std::endl;
    }

    // 拷贝构造函数和拷贝赋值运算符通常被禁用或自定义,
    // 因为 unique_ptr 不可拷贝,shared_ptr 可拷贝。
    // 这里为了简单,让 unique_ptr 自动处理移动语义。

    void doSomething() const {
        if (resource_ptr_) {
            resource_ptr_->operation();
        } else {
            std::cout << "No resource to operate on." << std::endl;
        }
    }

private:
    std::unique_ptr<Resource> resource_ptr_; // 句柄,管理 Resource 对象的生命周期
};

int main() {
    std::cout << "--- Start main ---" << std::endl;

    // MyHandleClass 实例创建时,Resource 对象也被创建
    MyHandleClass h1(1);
    h1.doSomething();

    // 当 h1 超出作用域时,它所管理的 Resource 对象会被自动销毁
    {
        MyHandleClass h2(2);
        h2.doSomething();
    } // h2 和其管理的 Resource 2 在这里被销毁

    std::cout << "--- End main ---" << std::endl;
    return 0;
} // h1 和其管理的 Resource 1 在这里被销毁
#include <iostream>
#include <string>
#include <memory>

class MyClass {
public:
    MyClass(const std::string& name);

    // 析构函数必须在 Impl 完整定义后才能被编译器正确生成
    // 所以这里只是声明,实际定义会放在 Impl 定义之后
    ~MyClass();

    // 禁用拷贝构造和拷贝赋值,因为 unique_ptr 不可拷贝
    MyClass(const MyClass&) = delete;
    MyClass& operator=(const MyClass&) = delete;

    // 启用移动构造和移动赋值,因为 unique_ptr 支持移动语义
    MyClass(MyClass&&) noexcept;
    MyClass& operator=(MyClass&&) noexcept;

    // 公共接口方法
    void sayHello() const;

private:
    // 内部实现类的前向声明
    // 注意:这里 Impl 是 MyClass 的嵌套类
    class Impl;
    // 句柄:指向 Impl 对象的智能指针
    std::unique_ptr<Impl> pImpl;
};

// 内部 Impl 类的完整定义
// 必须在 MyClass 的析构函数和方法实现之前定义
class MyClass::Impl {
public:
    Impl(const std::string& name) : name_(name) {
        std::cout << "  [Impl] Impl object created for: " << name_ << std::endl;
    }
    ~Impl() {
        std::cout << "  [Impl] Impl object destroyed for: " << name_ << std::endl;
    }
    void sayHello_impl() const {
        std::cout << "  [Impl] Hello from " << name_ << "!" << std::endl;
    }
private:
    std::string name_;
};

// 外部 MyClass 类的成员函数实现
// 现在 Impl 已经完整定义,可以安全地使用 pImpl
MyClass::MyClass(const std::string& name) : pImpl(std::make_unique<Impl>(name)) {
    std::cout << "[MyClass] MyClass object created for: " << name << std::endl;
}

MyClass::~MyClass() = default;

MyClass::MyClass(MyClass&& other) noexcept = default;
MyClass& MyClass::operator=(MyClass&& other) noexcept = default;

void MyClass::sayHello() const {
    pImpl->sayHello_impl(); // 通过句柄调用 Impl 的方法
}

int main() {
    std::cout << "--- Start main ---" << std::endl;

    MyClass obj1("World");
    obj1.sayHello();

    {
        MyClass obj2("C++");
        obj2.sayHello();
    }

    std::cout << "--- End main ---" << std::endl;
    return 0;
}

运行结果:

--- Start main ---
[MyClass] MyClass object created for: World
[Impl] Impl object created for: World
[Impl] Hello from World!
[MyClass] MyClass object created for: C++
[Impl] Impl object created for: C++
[Impl] Hello from C++!
[Impl] Impl object destroyed for: C++
[MyClass] MyClass object destroyed for: C++
--- End main ---
[Impl] Impl object destroyed for: World
[MyClass] MyClass object destroyed for: World

Interface Classes⚓︎

C++ 中,并没有一个所谓的 interface 语法,但我们仍然可以利用现有语法构建一个“接口类”(interface classes):它通常指的是一个抽象基类,主要声明一组纯虚函数,而不提供这些函数的具体实现,也不包含任何数据成员(或极少的数据成员。它更像是一份契约(contract),规定了任何实现该接口的派生类必须提供的行为。

例子
#include <iostream>
#include <vector>
#include <memory>

// 接口类
class ILogger {
public:
    // 纯虚函数
    virtual void log(const std::string& message) = 0;

    // 虚析构函数
    virtual ~ILogger() {
        std::cout << "ILogger destructor called." << std::endl;
    }
};

// ILogger 接口的一个具体实现
class ConsoleLogger : public ILogger {
public:
    void log(const std::string& message) override {
        std::cout << "[Console] " << message << std::endl;
    }
    ~ConsoleLogger() override {
        std::cout << "ConsoleLogger destructor called." << std::endl;
    }
};

// 另一个具体实现
class FileLogger : public ILogger {
public:
    void log(const std::string& message) override {
        std::cout << "[File] " << message << " (simulated file write)" << std::endl;
    }
    ~FileLogger() override {
        std::cout << "FileLogger destructor called." << std::endl;
    }
};

// 客户端代码,只依赖于 ILogger 接口
void process_data(ILogger& logger) {
    logger.log("Processing data step 1...");
    // ...
    logger.log("Processing data step 2 finished.");
}

int main() {
    ConsoleLogger console_logger;
    process_data(console_logger);

    std::cout << "--------------------" << std::endl;

    FileLogger file_logger;
    process_data(file_logger);

    std::cout << "--------------------" << std::endl;

    std::unique_ptr<ILogger> logger_ptr = std::make_unique<ConsoleLogger>();
    logger_ptr->log("Message from unique_ptr managed logger.");

    logger_ptr = std::make_unique<FileLogger>();
    logger_ptr->log("Another message from unique_ptr managed logger.");

    return 0;
}

运行结果:

[Console] Processing data step 1...
[Console] Processing data step 2 finished.
--------------------
[File] Processing data step 1... (simulated file write)
[File] Processing data step 2 finished. (simulated file write)
--------------------
[Console] Message from unique_ptr managed logger.
ConsoleLogger destructor called.
ILogger destructor called.
[File] Another message from unique_ptr managed logger. (simulated file write)
FileLogger destructor called.
ILogger destructor called.
FileLogger destructor called.
ILogger destructor called.
ConsoleLogger destructor called.
ILogger destructor called.

评论区

如果大家有什么问题或想法,欢迎在下方留言~