Skip to content

7 类与对象

类简介

在日常生活中,有一些事物拥有相同或类似的属性(例如不同品牌的汽车、不同班的学生等在程序中,与函数相似,也可能会有一些代码表现出相同或相似的特性与行为,我们可以将这些代码统一使用类来表达这些特性与行为,这些同时拥有特定特性与行为的类型的集合称为对象。类是用于描述对象特性与行为的代码,将一些表现出相同或相似的特性与行为的代码归为一类的过程被称为抽象。

在 C++ 中,通常用以下代码来创建类:

cpp
class Example {
  private:
    int a;
    float b; // 在这里出现的所有变量称为成员
  public:
    Example();  // 特殊:构造函数
    ~Example(); // 特殊:析构函数
    void doA();
    void doB(); // 在这里出现的所有函数称为方法
};

上面的例子创建了 C++ 中的一个简单类。类实例化后的产物称为对象。

实例化:由类初始化在代码中可以访问的变量的过程。

代码中的 private: 与 public: 称为访问控制符,其中 private、public、protected 为 C++ 关键字,描述了在这些标签下的数据的可访问性。其中:

  • private:表示标记为该等级的成员和方法仅能被此对象访问(友元类除外,将在后续说明
  • public:表示标记为该等级的成员和方法能被以任意方式访问;
  • protected:表示标记为该等级的成员和方法仅能被此对象及此对象派生的对象访问(将在后续讲解类的继承机制)

我们能够在类的内部定义类函数,也可以在类声明中先声明函数,再在类外进行定义。如果需要在类内部定义函数,只需要修改上面的示例,添加上函数定义即可。下面的例子说明了在类内定义函数的方式:

cpp
// 上面的代码与创建类的代码相同
doB() { printf("B done!\n"); }; // 在这里出现的所有函数称为方法

在类外部定义类内的函数时,需要说明函数定义的作用域。例如:

cpp
void Example::doB() { // 这里的Example::说明了我们需要定义Example类内的函数
    printf("B done!");
}

作用域:描述了变量或函数在整个程序中的作用范围。在上面的例子中,Example::doB 说明了我们需要定义 Example 类中的 doB 函数,如果不带 Example::,则 doB 函数与 Example 类无关,可以在所有包含了这些代码的范围内调用,同时调用 Example 类中的 doB 函数也不会调用全局内的 doB 函数。

接口与抽象

在程序开发中,与函数类似,我们希望有代码能在部分场合下提供一种通用的类,这些类被称为接口。例如:

cpp
class AudioPlayer {
  private:
    bool status; // 播放状态,true代表正在播放,false代表暂停或播放完毕
  public:
    playMP3(string path);
    playFLAC(string path);
    playWAV(
        string path); // 这里假设所有函数都在类外定义,且能够实现预期功能
}

这样,如果我们需要播放任何 MP3 音频,只需要先创建 AudioPlayer 对象,再调用对应的方法即可。例如我们在 /home/user 下有一个 bgm.mp3 文件,要在代码中播放它,只需要写:

cpp
AudioPlayer player;
player.playMP3("/home/user/bgm.mp3");

公共库中一般都有丰富的接口。例如在 TagLib(用于音频元数据读取)库中,就有能够读取各类音频文件标题、艺术家、专辑、年份等的接口。这些接口极大地降低了我们的开发成本,方便了我们的开发过程,这也是公共接口的存在意义。

最常被使用的公共接口是系统内核,系统内核提供了一系列函数用于操作硬件,例如读取鼠标与键盘的输入、将输出显示在屏幕上、根据优先级调度 CPU 完成任务等。同时由于内核直接操作硬件,编写能够良好运行、适配各种硬件的内核是一项十分困难的工作。

特殊函数:构造函数、析构函数

在上面的例子中,有两个函数被注释标记为特殊函数:

cpp
Example();  // 特殊:构造函数
~Example(); // 特殊:析构函数

这些函数有其特定的格式与调用时机,称为构造函数与析构函数。

构造函数在创建对象时调用。例如在上面的 Example 类中,我们将构造函数定义如下:

cpp
Example::Example() { // 不要忘记指定作用域
    a = 100;
    b = 200.0;
    printf("a=%d, b=%.1f\n", a, b);
}

那么每次 Example 被创建时,程序都会有如下输出:

text
a=100, b=200.0

并且如果在 Example 内部调用 a 与 b 时,会发现 a 的初始值为 100,b 的初始值为 200。

析构函数在销毁对象时调用。例如在上面的 Example 类中,我们将析构函数定义如下:

cpp
Example::~Example() { // 不要忘记指定作用域
    printf("队友呢 队友呢 救一下啊!\n");
}

再加上下面的代码:

cpp
if (true) {
    Example e;
}

上面的代码输出就应该是:

text
a=100, b=200.0
队友呢 队友呢 救一下啊!

这里 Example 被销毁的原因是:由于 Example 在 if 块创建,Example 类的生存周期就是 if 块内。当 if 块执行完毕时,Example 的生存周期结束,于是被销毁,占用的内存被回收。

this 指针

在类内访问类自己的成员不需要指定作用域。例如,我们有如下类定义:

cpp
class Number {
    int value; // 如果最开始没有指定范围限定,则直到指定限定为止的所有成员和方法都是private属性
  public:
    Number() =
        default; // C++11标准开始,构造函数/析构函数如果等于default,则使用对象的默认行为,这里的默认行为是什么也不做
    ~Number() = default; // 同上
    Number greater(Number num);
    int value(){return value};
    void setValue(int _value) {
        value = _value
    } // 我们能够直接在类内使用它自己的成员
}

这里,我们希望 greater 函数对传入的 num 对象和自身进行比较,以 Number 对象的形式返回其中的较大者。但是在函数定义中,我们遇到了一个问题:

cpp
Number Number::greater(Number num) {
    if (num.getValue() > value) {
        return num;
    } else {
        return ? ? ? ? ? // 这里我们应该让函数返回什么呢?
    }
}

在上面的代码块中,我们需要一种方法来访问类实例化后的对象自身,但是我们并不知道该如何表达这个自身。我们要发明一个这样的东西吗?其实不需要,因为 C++ 语言已经替我们想到了这种可能。

在 C++ 中,使用 this 指针来表示类实例化后的对象自身。这样,我们就能将上面的函数写成:

cpp
Number Number::greater(Number num) {
    if (num.getValue() > value) {
        return num;
    } else {
        return *this;
    }
}

这样我们就能够用 greater 函数比较两个对象,将较大的那个 Number 对象返回了。这项工作能够使用运算符重载更加优雅的完成(将在后续介绍运算符重载

类作用域

类内所有变量的作用域是类自身,也就是说,如果我们有上面的 Number 类定义,我们可以在 Number 类外定义 value 变量,并且不会报错,类外的 value 变量与类内的无关。

类内的常量

如果我们想在类内定义常量,该怎么做呢?例如,我们有一个公共的 Math 接口用来完成数学相关的计算工作,类定义如下:

cpp
class
    Math { // 通常情况下,如果C++有Math类,这个Math类不应该要求实例化,这里仅作为示例
    const float PI = 3.141592654;

  public:
    float sin(float val);
    float cos(float val);
}

这样的代码会在定义 float 常量时报错。这是因为声明只告诉了编译器该怎么做,创建对象是另外的代码。因此在创建对象前,没有内存空间用来存储 PI 的值。我们可以这样做:

cpp
class
    Math { // 通常情况下,如果C++有Math类,这个Math类不应该要求实例化,这里仅作为示例
    static const float PI = 3.141592654;

  public:
    float sin(float val);
    float cos(float val);
}

这里我们使用了 static 关键字定义了一个静态常量,这个常量会在程序初始化时创建,而且在所有 Math 对象中共享。

抽象数据类型

类的另一个非常有用的用途是创建抽象数据类型 (Abstract Data Type, ADT),这样的类逻辑在语言间通用。例如,我们需要创建一个栈的定义,该怎么做呢?

首先,我们需要知道栈的一些基本特征:

  • 栈的本质是线性表(即一般认为的数组
  • 对栈能够进行两种操作:入栈、出栈;
  • 栈是后进先出 (LIFO) 的;

这样我们就能够写出这样的栈定义:

cpp
#include <cassert>

class Stack {
  private:
    static const int MAX_SIZE = 100; // 常量定义最大栈大小
    int data[MAX_SIZE];              // 静态数组存储元素
    int topIndex;                    // 栈顶索引,-1 表示空栈

  public:
    // 构造函数:初始化为空栈
    IntStack() : topIndex(-1) {}

    // 入栈:成功返回 true,栈满返回 false
    bool push(int value) {
        if (isFull()) {
            return false;
        }
        data[++topIndex] = value;
        return true;
    }

    // 出栈:成功返回 true,栈空返回 false
    bool pop() {
        if (isEmpty()) {
            return false;
        }
        --topIndex;
        return true;
    }

    // 返回栈顶元素(调用前应确保栈非空)
    int top() const {
        assert(!isEmpty() && "Stack is empty, cannot get top.");
        return data[topIndex];
    }

    // 判断栈是否为空
    bool isEmpty() const { return topIndex == -1; }

    // 判断栈是否已满
    bool isFull() const { return topIndex == MAX_SIZE - 1; }

    // 返回当前栈中元素个数
    int size() const { return topIndex + 1; }
};

类还能够实现更多抽象数据类型,例如链表、字符串、图等。