# 定义抽象数据类型

类的基本思想是数据抽象(data abstraction)和封装(encapsulation)

数据抽象是一种依赖于接口(interface)和实现(implementation)分离的编程(以及设计)技术

  • 类的接口:包括用户所能执行的操作
  • 类的实现:包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数

封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分

类要想实现数据抽象和封装,需要首先定义一个抽象数据类型(abstract data type)

  • 在抽象数据类型中,由类的设计者负责考虑类的实现过程
  • 使用该类的程序员则只需要抽象地思考类型做了什么,而无须了解类型的工作细节

本节将以 Sales_data 类为例,介绍类的定义与使用

# 设计 Sales_data 类

我们希望 Sales_item 类有一个名为 isbn 的成员函数(member function),并且具有一些等效于 + 、= 、+= 、<<和>> 运算符的函数

于是,Sales_data 的接口应该包含以下操作:

  • 一个 isbn 成员函数,用于返回对象的 ISBN 编号
  • 一个 combine 成员函数,用于将一个 Sales_data 对象加到另一个对象上(等效于 += 运算符)
  • 一个名为 add 的普通函数,执行两个 Sales_data 对象的加法(等效于 + 运算符)
  • 一个名为 read 的普通函数,将数据从 istream 读入到 Sales_data 对象中(等效于 >> 运算符)
  • 一个名为 print 的函数,将 Sales_data 对象的值输出到 ostream (等效于 << 运算符)

定义 Sales_data 类如下所示:

struct Sales_data {
    // 数据成员
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
    // 成员函数:关于 Sales_data 对象的操作
    std::string isbn() const { return bookNo; }
    Sales_data& combine(const Sales_data&);
    double avg_price() const;
};
// 非成员的普通函数
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);

其中,Sales_data 类的数据成员包括:bookNo 、units_sold 、revenue ,分别表示书本的 ISBN 编号、销量、总销售额;Sales_data 类的成员函数包括 isbn 和 combine ;add、read 和 print 则是普通函数,并未作为 Sales_data 类的成员

Sales_data 类的使用示例:

Sales_data total;
if (read(cin, total))  {
    Sales_data trans;
    while(read(cin, trans)) {
        if (total.isbn() == trans.isbn())
            total.combine(trans);
        else {
            print(cout, total) << endl;
            total = trans;
        }
    }
    print(cout, total) << endl;
} else {
    cerr << "No data?!" << endl;
}

# 定义成员函数

成员函数的声明必须在类的内部,它的定义既可以在类的内部也可以在类的外部

例如,上述的 isbn 函数定义在 Sales_data 类的内部,combine 和 avg_price 定义在类的外部

和其他函数一样,类的成员函数也由函数类型、函数名、形参列表和函数体组成。以 isbn 函数为例,isbn 函数用于返回 Sales_data 对象的 bookNo 数据成员,其定义为

std::string isbn() const { return bookNo; }

然而,isbn 函数是如何获得 bookNo 成员所依赖的对象的呢?

# this

不妨先观察 isbn 成员函数的调用:使用点运算符访问 trans 对象的 isbn 成员,由此来调用该函数

Sales_data trans;
trans.isbn();

成员函数通过一个名为 this 的隐式参数来访问调用它的那个对象。具体来说,当我们调用一个成员函数时,请求该函数的对象地址 将会被用于初始化 this

例如,如果调用 trans.isbn() ,编译器将会把 trans 的地址传递给 isbn 函数的隐式形参 this 。可以等价地认为,编译器将 trans.isbn() 这一调用重写成了如下形式:

// 伪代码,用于说明调用成员函数的实际执行过程
Sales_data::isbn(&trans)

其中,调用 Sales_data 的 isbn 成员时传入了 trans 的地址

在成员函数内部,可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符来做到这一点,因为 this 所指的就是当前对象。任何对类成员的直接访问都被看作 this 的隐式引用,也就是说,当 isbn 使用 bookNo 时,它隐式地使用 this 指向的成员,就像我们书写了 this->bookNo 一样

this 形参是隐式定义的。任何自定义名为 this 的参数或变量都是非法的

我们可以在成员函数体内部使用 this (尽管没有必要)

std::string isbn() const { return this->bookNo; }

需要注意, this 是一个常量指针,不允许改变 this 中保存的地址

# const 成员函数

isbn 函数的另一个关键之处:参数列表后的 const 关键字,用于修改隐式 this 指针的类型

默认情况下, this 的类型是指向类类型非常量版本的常量指针。例如,在 Sales_data 的成员函数中,this 的类型是 Sales_data *const

尽管 this 是隐式的,也依然需要遵循初始化规则,这意味着:(在默认情况下)我们不能把 this 绑定到一个常量对象上,即,我们不能在一个常量对象上调用普通的成员函数(不加 const 的成员函数)

于是,我们不禁会想到:如果想要在常量对象上调用普通成员函数,就应该将 this 声明成指向常量的指针(即,const Sales_data *const )。然而, this 是隐式的,并不会出现在参数列表中。那我们应该在哪里将 this 声明成指向常量的指针呢?

C++ 语言的做法是:允许把 const 关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的 const 表示 this 是一个指向常量的指针

像这样使用 const 的成员函数被称作 常量成员函数(const member function)

因为 this 是指向常量的指针,常量成员函数不能改变调用它的对象的内容

常量对象,以及常量对象的引用或指针都只能调用常量成员函数

# 类作用域和成员函数

类本身就是一个作用域,类成员函数的定义嵌套在类的作用域之内

在上例中,isbn 中用到的名字 bookNo 是定义在 Sales_data 内的数据成员,并且,即使 bookNo 定义在 isbn 之后,isbn 也还是能够使用 bookNo

这是因为,编译器分两步处理类:首先编译成员的声明,然后才编译成员函数体(如果有的话)

因此,成员函数体可以随意使用类中的其他成员,无须在意这些成员出现的次序

# 在类的外部定义

当我们在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配

  • 返回类型、参数列表和函数名都得与类内部的声明保持一致
  • 如果成员被声明成常量成员函数,那么它的定义也必须在参数列表后明确指定 const 属性

并且,类外部定义的成员名字必须包含它所属的类名

double Sales_data::avg_price() const {
    if (units_sold)
        return revenue/units_sold;
    else
        return 0;
}

其中,函数名 Sales_data::avg_price 使用作用域运算符来说明 avg_price 函数被声明在类 Sales_data 的作用域内。因此,当 avg_price 使用 revenue 和 units_sold 时,它实际上是使用了 Sales_data 的成员

# 定义一个返回 this 对象的函数

函数 combine 的设计初衷类似于复合赋值运算符 += 。调用该函数的对象代表的是赋值运算符左侧的运算对象,右侧运算对象则通过显式的实参被传入函数

Sales_data& Sales_data::combine(const Sales_data &rhs) {
    units_sold += rhs.units_sold; // 把 rhs 的成员加到 this 对象的成员上
    revenue += rhs.revenue;
    return *this;                 // 返回调用该函数的对象
}

当我们的程序调用如下函数时,

total.combine(trans);             // 更新变量 total 的值

total 的地址被绑定到隐式的 this 参数上,而 rhs 绑定到了 trans 上

因此,当 combine 执行下面的语句时,

units_sold += rhs.units_sold;

等效于 total.units_sold += trans.unit_sold

一般来说,当我们定义的函数类似于某个内置运算符时,应该令该函数的行为尽量模仿这个运算符

  • 内置的赋值运算符把它的左侧运算对象当成左值返回
  • 为了与它保持一致,combine 函数必须返回引用类型。因为此时的左侧运算对象是一个 Sales_data 的对象,所以返回类型应该是 Sales_data&

如前所述,我们无须使用隐式的 this 指针访问函数调用者的某个具体成员,但是,我们需要使用 this 来把调用函数的对象当成一个整体访问。因此,需要使用

return *this; // 返回调用该函数的对象

以获得执行该函数的对象,其中,解引用 this 指针就是得到了 total 的地址

# 定义类相关的非成员函数

作为接口组成部分的非成员函数,它们的定义和声明都在类的外部,例如 add、read 和 print 等

一般来说,如果非成员函数是类接口的组成部分,则这些函数的声明应该与类在同一个头文件内。在这种方式下,用户使用接口的任何部分都只需要引入一个文件

// 定义 read 函数
istream &read(istream &is, Sales_data &item) {
    double price = 0;
    is >> item.bookNo >> item.units_sold >> price;
    item.revenue = price * item.units_sold;
    return is;
}
// 定义 print 函数
ostream &print(ostream &os, const Sales_data &item) {
    os << item.isbn() << " " << item.units_sold << " "
       << item.revenue << " " << item.avg_price();
    return os;
}
// 定义 add 函数
Sales_data add(const Sales_data &lhs, const Sales_data &rhs) {
    Sales_data sum = lhs;  // copy data members from lhs into sum
    sum.combine(rhs);      // add data members from rhs into sum
    return sum;
}

read 函数从给定流中将数据读到给定的对象里,print 函数则负责将给定对象的内容打印到给定的流中。其中,read 和 print 分别接受一个各自 IO 类型的引用作为其参数,这是因为 IO 类属于不能被拷贝的类型,因此我们只能通过引用来传递它们。而且,因为读取和写入的操作会改变流的内容,两个函数接受的都是普通引用,而非对常量的引用

# 构造函数

每个类都分别定义了它的对象被初始化的方式

类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做 构造函数(constructor)

  • 构造函数的名字和类名相同

  • 类似于其他函数,构造函数有一个参数列表(可能为空)和一个函数体(可能为空)

  • 构造函数没有返回类型

构造函数的任务是初始化类对象的数据成员。无论何时,只要类的对象被创建,就会执行构造函数

类可以包含多个构造函数,但是,不同的构造函数之间必须在参数数量或参数类型上有所区别(类似于函数重载)

不同于其他成员函数,构造函数不能被声明成 const 。当我们创建类的一个 const 对象时,直到构造函数完成初始化过程,对象才能真正取得其 “常量” 属性。因此,构造函数在构造 const 对象的过程中可以向其写值

# 合成的默认构造函数

我们的 Sales_data 类并没有定义任何构造函数,但是之前使用了 Sales_data 对象的程序仍然可以正确编译和运行,因此可以说明,Sales_data 对象执行了默认初始化

Sales_data total; // 没有为 total 提供初始值,执行默认初始化
Sales_data trans; // 没有为 trans 提供初始值,执行默认初始化

类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做 默认构造函数(default constructor)

  • 默认构造函数无须任何实参

  • 如果我们的类没有显式地定义构造函数,编译器将会为我们隐式地定义一个默认构造函数

编译器自动生成的构造函数被称为合成的默认构造函数(synthesized default constructor)

对于大多数类来说,合成的默认构造函数将按照如下规则初始化类的数据成员:

  • 如果存在类内的初始值,用类内初始值来初始化成员
  • 否则,默认初始化该成员
struct Sales_data {
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
}

例如,对上面的类而言,因为 Sales_data 为 units_sold 和 revenue 提供了初始值,所以合成的默认构造函数将使用这些值来初始化对应的成员,而 bookNo 并未提供初始值,所以合成的默认构造函数把 bookNo 默认初始化成一个空字符串

然而,合成的默认构造函数只适合非常简单的类,比如现在定义的这个 Sales_data 版本

通常来说,一个类必须定义它自己的默认构造函数,而不能仅依赖于合成的默认构造函数 。这是因为:

  • 只有当类没有声明任何构造函数时,编译器才会自动地生成默认构造函数。一旦我们定义了一些其他的构造函数,类将没有默认构造函数(除非我们自己定义一个默认构造函数)

  • 对于某些类来说,合成的默认构造函数可能执行错误的操作。如果类包含有内置类型或者复合类型的成员,则只有当这些成员全都被赋予了类内的初始值时,这个类才适合于使用合成的默认构造函数

  • 有的时候编译器不能为某些类合成默认的构造函数。例如,如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。此外,还有其他一些情况也会导致编译器无法生成一个正确的默认构造函数,我们将在以后讨论

# 定义构造函数

对于我们的 Sales_data 类来说,我们可根据实际需要而定义不同的构造函数,例如:

struct Sales_data {
    // 数据成员及成员函数
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
    std::string isbn() const { return bookNo; }
    Sales_data& combine(const Sales_data&);
    double avg_price() const;
    // 构造函数
    Sales_data() = default;
    Sales_data(const std::string &s): bookNo(s) { }
    Sales_data(const std::string &s, unsigned n, double p):
               bookNo(s), units_sold(n), revenue(p*n) { }
    Sales_data(std::istream &);
};

# = default

在 C++ 11 标准中,如果我们需要默认的行为,可以通过在参数列表后面写上 = default 来要求编译器生成默认构造函数,其作用完全等同于之前使用的合成默认构造函数

其中, = default 既可以与声明一起出现在类的内部,也可以作为定义出现在类的外部

  • 如果 = default 在类的内部,则默认构造函数是内联的
  • 如果它在类的外部,则该成员默认情况下不是内联的

If the = default appears inside the class body, the default constructor will be inlined; if it appears on the definition outside the class, the member will not be inlined by default.

例如:

Sales_data() = default;

需注意, = default 生成的默认构造函数之所以对 Sales_data 类有效,是因为我们已经为内置类型的数据成员提供初始值。如果编译器不支持类内初始值,默认构造函数就应该使用构造函数初始值列表来初始化类的每个成员

# 构造函数初始值列表

对于以下两个构造函数,其定义中出现了新的部分,即,冒号 以及 冒号与花括号之间的代码。我们把新出现的部分称为 构造函数初始值列表(constructor initialize list)

Sales_data(const std::string &s): bookNo(s) { }
Sales_data(const std::string &s, unsigned n, double p):
           bookNo(s), units_sold(n), revenue(p*n) { }

构造函数初始值列表负责为新创建对象的一个或几个数据成员赋初值

构造函数初始值列表的组成:

  • 成员名字
  • 成员名字后面的、括号(或者花括号)括起来的成员初始值
  • 不同成员之间的逗号分隔符

Sales_data(const std::string &s, unsigned n, double p): bookNo(s), units_sold(n), revenue(p*n) { } 为例,该函数使用 sn 来分别初始化成员 bookNo 和 units_sold ,并用 pn 的乘积来初始化 revenue

Sales_data(const std::string &s): bookNo(s) { } ,其只使用 s 来显式初始化 bookNo 。对于 units_sold 和 revenue 这两个成员而言,将利用类内初始值进行隐式初始化(类似于合成默认构造函数的方式)。因此,该构造函数等价于

Sales_data(const std::string &s):
           bookNo(s), units_sold(0), revenue(0){ }

当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化

通常情况下,构造函数使用类内初始值不失为一种好的选择,因为只要这样的初始值存在我们就能确保为成员赋予了一个正确的值。不过,如果你的编译器不支持类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员

另外,我们需要注意, Sales_data(const std::string &s): bookNo(s) { } 中的 { } 实际是构造函数的函数体。因为我们定义这些构造函数的目的是为数据成员赋初值,其并不需要通过函数体实现,所以将函数体定义成空的

# 在类的外部定义构造函数

与其他几个构造函数不同,以 istream 对象为参数的构造函数 Sales_data(std::istream &) 需要执行一些实际的操作,在它的函数体内调用了 read 函数来给数据成员赋以初值

Sales_data::Sales_data(std::istream &is) {
    read(is, *this); // 从 is 中读取一条信息然后存入 this 对象中
}

和其他成员函数一样,当我们在类的外部定义构造函数时,必须指明该构造函数是哪个类的成员

因此, Sales_data::Sales_data 指出,我们定义了 Sales_data 类的成员 Sales_data 。因为该成员的名字和类名相同,所以它是一个构造函数(构造函数没有返回类型)

由于 Sales_data(std::istream &) 函数定义了函数体,在执行该构造函数时,对象的成员是可以被初始化的(尽管这个构造函数初始值列表是空的)

# 拷贝、赋值和析构

除了定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时发生的行为

如果我们不主动定义这些操作,编译器将替我们合成它们。一般来说,编译器生成的版本将对对象的每个成员执行拷贝、赋值和销毁操作

例如:

total = trans;

实际上等价于

// Sales_data 的默认赋值操作等价于
total.bookNo = trans.bookNo;
total.units_sold = trans.units_sold;
total.revenue = trans.revenue;

尽管编译器能替我们合成拷贝、赋值和销毁的操作,但是必须要清楚的一点是,对于某些类来说合成的版本无法正常工作。特别是,当类需要分配类对象之外的资源时,合成的版本常常会失效。例如,管理动态内存的类通常不能依赖于上述操作的合成版本

  • 很多需要动态内存的类能够并且应该使用 vector 对象或者 string 对象来管理必要的存储空间
  • 如果类包含 vector 或者 string 成员,则其拷贝、赋值和销毁的合成版本能够正常工作

# 访问控制与封装

在 C++ 语言中,我们使用 访问说明符(access specifiers)加强类的封装性:

  • 定义在 public 说明符之后的成员可以在整个程序内被访问。 public 成员定义类的接口

  • 定义在 private 说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问。 private 封装(即,隐藏)类的实现细节

一个类可以包含 0 个或多个访问说明符,而且,某个访问说明符可以出现多次

每个访问说明符指定了接下来的成员的访问级别,其有效范围直到出现下一个访问说明符或者到达类的结尾处为止

再一次定义 Sales_data 类,其新形式如下所示:

class Sales_data {
public:            // 添加了访问说明符
    Sales_data() = default;
    Sales_data(const std::string &s, unsigned n, double p):
               bookNo(s), units_sold(n), revenue(p*n) { }
    Sales_data(const std::string &s): bookNo(s) { }
    Sales_data(std::istream&);
    std::string isbn() const { return bookNo; }
    Sales_data &combine(const Sales_data&);
private:            // 添加了访问说明符
    double avg_price() const
        { return units_sold ? revenue/units_sold : 0; }
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};

作为接口的一部分,构造函数和部分成员函数(即 isbn 和 combine )紧跟在 public 说明符之后;而数据成员和作为实现部分的函数则跟在 private 说明符后面

在上面的定义中我们还做了一个微妙的变化,我们使用了 class 关键字而非 struct 开始类的定义。这种变化仅仅是形式上有所不同,实际上我们可以使用这两个关键字中的任何一个定义类。唯一的一点区别是, structclass 的默认访问权限不太一样

类可以在它的第一个访问说明符之前定义成员,这种成员的访问权限依赖于类定义的方式

  • 如果我们使用 struct 关键字,则定义在第一个访问说明符之前的成员是 public

  • 如果我们使用 class 关键字,则这些成员是 private

出于统一编程风格的考虑,如果我们希望定义的类的所有成员是 public 的,使用 struct ;反之,如果希望成员是 private 的,使用 class

# 友元

类可以将其他类或者函数声明成为它的 友元(friend),从而允许其他类或者函数访问它的非公有成员

如果类想把一个函数作为它的友元,需要增加一条以 friend 关键字开始的函数声明语句

例如,此前 定义抽象数据类型 时的 read、print 和 add 函数并不是类的成员,但却需要访问 Sales_data 类中 private 的数据成员,因此,我们需要将 read、print 和 add 函数作为 Sales_data 类的友元

class Sales_data {
    // 为 Sales_data 的非成员函数所做的友元声明
    friend Sales_data add(const Sales_data&, const Sales_data&);
    friend std::istream &read(std::istream&, Sales_data&);
    friend std::ostream &print(std::ostream&, const Sales_data&);
    // 其他成员及访问说明符与之前一致
public:
    Sales_data() = default;
    Sales_data(const std::string &s, unsigned n, double p):
            bookNo(s), units_sold(n), revenue(p*n) { }
    Sales_data(const std::string &s): bookNo(s) { }
    Sales_data(std::istream&);
    std::string isbn() const { return bookNo; }
    Sales_data &combine(const Sales_data&);
private:
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};
// Sales_data 接口的非成员组成部分的声明
Sales_data add(const Sales_data&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);

友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束

一般来说,最好在类定义开始或结束前的位置集中声明友元

# 友元的声明

友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明

如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明

为了使友元对类的用户可见,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部)

因此,我们的 Sales_data 头文件应该为 read 、print 和 add 提供独立的声明(除了类内部的友元声明之外)

许多编译器并未强制限定友元函数必须在使用之前在类的外部声明

一些编译器允许在尚无友元函数的初始声明的情况下就调用它,不过最好还是提供一个独立的函数声明

# 封装

封装有两个重要的优点:

  • 确保用户代码不会无意间破坏封装对象的状态
  • 被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码

一旦把数据成员定义成 private 的,类的作者就可以比较自由地修改数据了

  • 当实现部分改变时,我们只需要检查类的代码本身以确认这次改变有什么影响。换句话说,只要类的接口不变,用户代码就无须改变
  • 如果数据是 public 的,所有使用了原来数据成员的代码都可能失效,这时我们必须定位并重写所有依赖于老版本实现的代码,之后才能重新使用该程序

把数据成员的访问权限设成 private 还有另外一个好处:防止由于用户的原因造成数据被破坏。如果我们发现有程序缺陷破坏了对象的状态,则可以在有限的范围内定位缺陷

尽管当类的定义发生改变时无须更改用户代码,但是使用了该类的源文件必须重新编译

参考:C++ Primer 中文版(第 5 版)