实例分析: 动态数组 (dynamic array)#

动态数组的分配和释放#

内置数组 int array[size] 的长度 size 必须在编译时已知, 而不能等到程序运行时才确定.

1int array[100];  // 显然在编译时就知道是 100

这是因为 调用函数将会在栈上创建栈帧来记录数据, 栈帧存储了本次调用的参数、局部变量等, 按设计往往大小是固定的, 因此需要在编译时就能确定局部变量的大小.

但我们有根据运行情况来确定数组长度的需求. 所以我们使用 new[] 在堆空间上 (而非栈上) 分配空间, 栈帧上仅存储 new[] 所返回的指针, 用这个指针来操作整个数组:

 1int main() {
 2  int size = 0;
 3  std::cin >> size;
 4
 5  // ↓ 局部变量——也就是栈上——只是这一个指针
 6  int* array = new int[size];
 7  array[0]   = 0;
 8  array[1]   = 1;
 9  array[2]   = 2;
10}

我们可以在 new[] 时, 直接初始化这个数组:

 1int main() {
 2  int size = 0;
 3  std::cin >> size;
 4
 5  // 初始化为 {0, 1, 2, 之后的都为 0}
 6  int* array0 = new int[size]{0, 1, 2};
 7
 8  // 初始化所有元素为 0
 9  int* array1 = new int[size]{};
10}

由于我们不再将数组分配在栈帧内, 当函数调用结束、栈帧被释放时, 随栈帧释放的只有栈帧上的指针, 而数组依然存在, 所以我们需要手动调用 delete[] 释放它:

1int main() {
2  int size = 0;
3  std::cin >> size;
4
5  int* array = new int[size]{};
6
7  delete[] array;
8}

反过来说, 只要我们具有指向该数组的指针, 且该数组还没被释放, 就能直接使用该数组:

 1int* make_array(int size) {
 2  int* array = new int[size]{};
 3  return array;
 4}
 5
 6int main() {
 7  int* array0 = make_array(5);   // 获得一个长度为 5 的数组
 8  int* array1 = make_array(10);  // 获得一个长度为 10 的数组
 9
10  delete[] array0;
11  delete[] array1;
12}

动态数组的基本操作#

C 风格数组很容易隐式类型转换为首元素的指针, 而我们用 new[] 请求动态数组返回的也是指针, 所以动态数组的操作除了需要手动释放, 其他部分与正常数组其实并无差异: [在线代码 jzjnv1vrz]

填充#
 1void fill_n(int* array, int size, int value) {
 2  for (int i = 0; i < size; ++i) {
 3    array[i] = value;
 4  }
 5}
 6
 7int main() {
 8  int* array = new int[100]{};
 9  fill_n(array, 100, 5);
10}
输出#
 1void print(int const* array, int size) {
 2  for (int i = 0; i < size; ++i) {
 3    std::cout << array[i] << ' ';
 4  }
 5  std::cout << '\n';
 6}
 7
 8int main() {
 9  int* array = new int[100]{};
10  print(array, 100);
11}

让动态数组像 int 一样#

直接使用 new[]delete[] 的问题#

目前我们已经建立了动态数组, 但这真的好用吗?

我们需要手动调用 delete[] 来释放资源.
1int main() {
2  int* array = new int[1000];
3}  // 没有 delete[] 而内存泄露, 你可能导致发动机停止, 引发了一场空难!
我们拷贝所得新指针仍然指向同一数组.
 1int main() {
 2  int* array0 = new int[3];
 3  fill_n(array0, 3, 5);
 4  print(array0, 3);  // array0: {5, 5, 5}
 5
 6  int* array1 = array0;
 7  fill_n(array1, 3, 7);  // 对 array1 填充应该不影响 array0, 对、对吧?
 8
 9  print(array0, 3);  // array0: {7, 7, 7}😱
10}
长度和数组本身相分离.
1int main() {
2  int size   = 10;
3  int* array = new int[10];
4
5  print(array, size);  // 既要传首元素指针, 还要传长度!
6
7  size = 20;  // size == 20, 可是数组长度还是 10!
8}

接下来我们设计动态数组类来进行资源管理, 让动态数组的使用跟 int 差不多:

  • 析构时就释放资源, 不需要手动调用 delete[].

  • 拷贝得到的新对象与原对象相互独立, 对一个对象操作不会影响另一个对象.

  • 数组本身和其长度被存储为一个对象的成员, 它们之间的不变式即逻辑关系对外始终成立.

但在设计之前, 我们先分离一些细节, 并引入一些基本组件.

关注逻辑、忽略数据#

动态数组存储什么类型的值跟我们设计动态数组没多大关系, 所以我们首先对数据的存储进行抽象, 用类型别名来替代存储的数据:

类型别名#
1using value_type = int;  // value_type 是 int 的别名
2
3value_type value = 0;  // 相当于 int value = 0;
4value_type array[5];   // 相当于 int array[5];

因此, 我们的动态数组类应该写为:

 1using value_type = int;
 2
 3class Dynamic_array {
 4 public:
 5  /* ... */
 6
 7 private:
 8  int size_;
 9  value_type* data_;
10};

实现动态数组的重要组件#

标准库中有很多被教学忽略了的算法和算法基础组件, 实际上很多代码是没必要自己编写的, 自行在算法内编写不仅低效而且影响阅读.

std::swap#

一个重要的组件是, 将 lhs (left hand side) 和 rhs 变量的内容进行交换, 课上可能会写为:

1int lhs = 2;
2int rhs = 3;
3
4int temp = lhs;
5lhs      = rhs;
6rhs      = temp;
7
8std::cout << lhs;  // 输出 3
9std::cout << rhs;  // 输出 2

不, 你没有自己写的必要:

1#include <utility>  // for std::swap
2
3int lhs = 2;
4int rhs = 3;
5
6std::swap(lhs, rhs);
7
8std::cout << lhs;  // 输出 3
9std::cout << rhs;  // 输出 2

std::copy_n#

回忆一下, 你有多少次编写拷贝整个数组了?

1#include <cstring>  // for std::strcpy
2
3char const* input = "hello world";
4int output[100]   = {};
5
6std::strcpy(output, input);
7
8std::cout << output;  // 输出 "hello world"

<algorithm> 中有上百种算法, 而 std::copy_n 就是其中一个.

相比于 std::strcpy(output, input) 只能用于字符串拷贝, std::copy_n(input, size, output) 能用于任何类型:

1#include <algorithm>  // for std::copy_n
2
3int input[10]  = {5, 4, 2, 7};
4int output[20] = {};
5
6// 拷贝 [input, input + 10) 到 [output, output + 10)
7std::copy_n(input, 10, output);

你可以自己实现 std::copy_n 的简单版本: [在线代码 55d95cbo5]

 1using value_type = int;
 2
 3// 将 [input, input + size) 从左到右拷贝到 [output, output + size) 中
 4//
 5// 返回值::
 6//   返回 output + size, 即如果继续对 output 进行拷贝, 应该使用的指针.
 7value_type* copy_n(value_type const* input, int size, value_type* output) {
 8  for (int i = 0; i < size; ++i) {
 9    output[i] = input[i];
10  }
11  return output + size;
12}

请注意 copy_n 有一个返回值, 之后你将会看到它的魅力.

资源的获取与释放#

定义构造函数和析构函数#

相比于之前手动调用 new[]delete[], 我们在类中定义构造函数来获取资源, 定义析构函数来释放资源:

 1using value_type = int;
 2
 3class Dynamic_array {
 4 public:
 5  Dynamic_array() {
 6    size_ = 0;
 7    data_ = nullptr;
 8  }
 9
10  Dynamic_array(int size) {
11    size_ = size;
12    data_ = size == 0 ? nullptr : new value_type[size]{};
13  }
14
15  ~Dynamic_array() {
16    // ↓ delete[] 本身自带对空指针的检查, 不需要提前判断 data_ == nullptr
17    delete[] data_;
18  }
19
20 private:
21  int size_;
22  value_type* data_;
23};

用默认参数合并构造情况#

很明显, 默认构造 Dynamic_array() 不过是 Dynamic_array(int size)size == 0 的情况, 因此我们合并为 Dynamic_array(int size = 0), 即当不提供 size 参数时, 默认 size0:

 1class Dynamic_array {
 2 public:
 3  Dynamic_array(int size = 0) {
 4    size_ = size;
 5    data_ = size == 0 ? nullptr : new value_type[size];
 6  }
 7
 8  /* ... */
 9
10 private:
11  int size_;
12  value_type* data_;
13};

用成员初始化器对成员进行初始化而非赋值#

但运行到构造函数体内时, 所有成员其实已经构造好了, 我们 size_ = size 不是在构造时初始化成员, 只是在对已经构造的成员进行赋值: 如果把构造时初始化比喻为修建房屋, 赋值就是你已经住了一段时间后再对房子进行装修.

为了在成员构造进行初始化, 我们在函数体之前使用成员初始化器:

 1class Dynamic_array {
 2 public:
 3  Dynamic_array(int size = 0)
 4      : size_(size), data_(size == 0 ? nullptr : new value_type[size]{}) {}
 5
 6  /* ... */
 7
 8 private:
 9  int size_;
10  value_type* data_;
11};

explicit 避免隐式类型转换#

这样就够了吗? 让我们看两段代码:

奇怪的构造#
1int main() {
2  Dynamic_array array = 5;  // 5 通过 Dynamic_array(int) 转换为 Dynamic_array
3}
奇怪的传参#
1void function(Dynamic_array array) {
2  /* ... */
3}
4
5int main() {
6  int value = 0;
7  /* ... */
8  function(value);  // value 通过 Dynamic_array(int) 转换为 Dynamic_array
9}

我们的 Dynamic_array(int) 构造函数导致 int 类型能莫名其妙类型转换为 Dynamic_array! 这种行为可能在写代码时导致意外结果, 所以对于能被单个参数调用的构造函数, 我们应该用 explicit 要求它只能被显式调用:

 1class Dynamic_array {
 2 public:
 3  explicit Dynamic_array(int size = 0)
 4      : size_(size), data_(size == 0 ? nullptr : new value_type[size]{}) {}
 5
 6  /* ... */
 7
 8 private:
 9  int size_;
10  value_type* data_;
11};

最终得到: [在线代码 G78cPj8Pq]

1/* ... */
2
3int main() {
4  // Dynamic_array array0 = 5;  // 错误: 不能从 int 转换为 Dynamic_array
5  Dynamic_array array(5);
6}  // 析构时调用析构函数, 自动释放数组

访问数组相关信息#

我们接下来定义公用成员函数, 告知使用者可以如何访问这个数组. 具体地, 我们希望使用者能写出这样的代码:

 1void fill(Dynamic_array& array, int value) {
 2  for (int i = 0; i < array.size(); ++i) {
 3    array[i] = value;
 4  }
 5}
 6
 7void print(Dynamic_array const& array) {
 8  for (int i = 0; i < array.size(); ++i) {
 9    std::cout << array[i] << ' ';
10  }
11  std::cout << '\n';
12}

那么我们需要定义:

  • 如何进行下标访问, 即实现 operator[](int index) 成员函数 (运算符是 [], 参数是 int index).

  • 如何查询数组长度, 即实现 size() 成员函数.

支持 fill 函数#

size() 很容易实现, 而对于下标访问操作, 它除了函数名字变成了 operator[] 以外, 其余的与正常函数没有什么区别.

由于 fill 需要通过下标访问修改数组元素的内容, 下标访问应该返回对元素的引用: [在线代码 Pc6TMcf7z]

 1class Dynamic_array {
 2 public:
 3  /* ... */
 4
 5  value_type& operator[](int index) {
 6    return data_[index];
 7  }
 8
 9  int size() {
10    return size_;
11  }
12
13 private:
14  int size_;
15  value_type* data_;
16};

支持 print 函数#

print 函数只是用于输出, 因而不应该能修改 Dynamic_array 的内容——我们按 Dynamic_array const& 传递参数:

1void print(Dynamic_array const& array) {
2  for (int i = 0; i < array.size(); ++i) {  // 错误: size() 不是 const 函数
3    std::cout << array[i] << ' ';           // 错误: 下标访问不是 const 函数
4  }
5  std::cout << '\n';
6}

为什么报错了? 我们没有指出 size()operator[] 保证不会修改对象, 因而不能对 const 对象调用它们.

好吧, size() 只是获取长度, 必然不会修改对象, 我们来标识一下:

1class Dynamic_array {
2 public:
3  //         ↓ 我保证这个函数不会修改对象
4  int size() const {
5    return size_;
6  }
7
8  /* ... */
9}

但是下标访问呢?

  • 对于非 const 对象, 我们要允许通过下标访问修改数据;

  • 对于 const 对象, 我们要保证不能通过下标访问修改数据.

其实很简单, 我们写一个非 const 版本的函数和一个 const 版本的函数:

 1class Dynamic_array {
 2 public:
 3  value_type& operator[](int index) {
 4    return data_[index];
 5  }
 6
 7  // ↓ 返回 value const&, 自然不能进行修改了
 8  value_type const& operator[](int index) const {
 9    return data_[index];
10  }
11
12  /* ... */
13}

最终得到: [在线代码 EYqb9Tqhs]

 1class Dynamic_array {
 2 public:
 3  value_type& operator[](int index) {
 4    return data_[index];
 5  }
 6
 7  value_type const& operator[](int index) const {
 8    return data_[index];
 9  }
10
11  int size() const {
12    return size_;
13  }
14};

参见

拷贝函数#

注意到在之前的 fill(Dynamic_array&, int)print(Dynamic_array const&) 函数中, 我们都使用的是引用传参而非按值传参 (拷贝传参).

因为我们目前不能进行按值传参. 结构体和类的默认拷贝行为是逐一拷贝所有数据成员, 对于 Dynamic_array 而言就是拷贝 int size_value_type* data_. 但除了这两个成员外, Dynamic_array 还具有 new[] 所得数组的所有权, 所以我们才定义了析构函数, 从而指出析构时这个类要负责用 delete[] 释放数组.

这样对 new[] 所得数组的所有权是通过 value_type* data_ 所指向的对象来表达的, 但默认的拷贝行为只拷贝 int size_value_type* data_, 而指针 data_ 发生拷贝所得新指针与原指针存储同样的地址、指向同一个对象.

因此, 默认拷贝行为下, 拷贝后新得到的 Dynamic_array 对象与原对象具有同一个数组的所有权, 数组将会被释放两次:

 1void print(Dynamic_array parameter) {
 2  // 通过拷贝得到的形式参数 parameter 与实际参数具有同一个数组的所有权
 3  /* ... */
 4}  // 参数发生析构, 释放数组
 5
 6int main() {
 7  Dynamic_array array(5);
 8
 9  print(array);  // 传入实际参数, 相当于 Dynamic_array parameter = array;
10
11  array[0] = 1;  // 错误: 访问已经被释放的数组空间
12}  // 错误: 析构时调用 delete[] 释放数组, 但这个数组早就释放了

我们需要自定义拷贝函数, 在拷贝时不仅拷贝成员 size_data_, 也对 new[] 所得数组进行拷贝.

提示

当类自定义了拷贝函数或析构函数, 往往意味着我们在管理某种特殊资源; 而在管理特殊资源时, 默认的拷贝行为和析构行为往往都不合适, 我们应该定义它们全部. 这称为 rule of 3/5/0: 要么不定义任何特殊函数, 要么定义它们全部.

一种拷贝情况是, 我们用原对象构造一个新对象, 使得已有对象与原对象相等, 这称为拷贝构造.
1int value = 0;
2int copy  = value;  // 虽然是等号, 但这是构造而非赋值
一种拷贝情况是, 我们用原对象对已有对象赋值, 使得已有对象与原对象相等, 这称为拷贝赋值.
1int value = 0;
2int copy;
3copy = value;

注意到了吗? = 根据情况不同, 可能是构造或赋值. 这真的很烦, 所以我更倾向于用 {} 进行初始化, 只在赋值时使用 =:

1int value{0};  // 或 int value{};
2
3int copy{value};  // 构造
4copy = value;     // 赋值

本文之后部分我将采用 {} 语法进行构造.

拷贝构造函数#

针对用原对象构造新对象的情况, 我们定义拷贝构造函数. 拷贝构造函数以同一类型 Dynamic_array 为参数, 从中读取内容以得到内容的拷贝.

让我们看看这个参数应该写成什么样子:

Dynamic_array(Dynamic_array other)

按值传参? 不, 我们是在定义怎么对 Dynamic_array 进行拷贝, 可按值传参就是在对 Dynamic_array 进行拷贝……我们在请山里的老和尚讲山里有座庙的递归故事!

Dynamic_array(Dynamic_array& other)

不, 我们只是想读取 other 的内容, 而不想修改它的内容.

Dynamic_array(Dynamic_array* other)

不, 对对象进行拷贝的语法是 int value{other} 而不是 int value{&other}.

Dynamic_array(Dynamic_array const* other)

同上.

排除这些选项, 我们最终确定应该使用 Dynamic_array(Dynamic_array const& other).

动态数组的拷贝构造函数分为两部分:

  1. 根据 other 的长度, 为本数组也分配同样长度的数组.

  2. other 数组的内容拷贝到新数组中.

 1class Dynamic_array {
 2 public:
 3  // 拷贝构造函数虽然也是单个参数, 但按约定不用加 explicit
 4  Dynamic_array(Dynamic_array const& other)
 5      : size_{other.size_},
 6        data_{other.size_ == 0 ? nullptr : new value_type[other.size_]{}} {
 7    for (int i{0}; i < other.size_; ++i) {
 8      data_[i] = other.data_[i];
 9    }
10  }
11
12  /* ... */
13};

函数体内是在对数组进行拷贝, 对吧? 我们换用组件 copy_n:

 1class Dynamic_array {
 2 public:
 3  Dynamic_array(Dynamic_array const& other)
 4      : size_{other.size_},
 5        data_{other.size_ == 0 ? nullptr : new value_type[other.size_]{}} {
 6    copy_n(other.data_, other.size_, data_);
 7  }
 8
 9  /* ... */
10};

等等, 怎么感觉成员初始化器部分也有点眼熟? 我们来对比看看:

 1class Dynamic_array {
 2 public:
 3  explicit Dynamic_array(int size = 0)
 4      : size_{size},
 5        data_{size == 0 ? nullptr : new value_type[size]{}} {}
 6
 7  Dynamic_array(Dynamic_array const& other)
 8      : size_{other.size_},
 9        data_{other.size_ == 0 ? nullptr : new value_type[other.size_]{}} {
10    copy_n(other.data_, other.size_, data_);
11  }
12
13  /* ... */
14};

我们可以干脆委托 Dynamic_array(int size) 来分配合适长度的数组:

 1class Dynamic_array {
 2 public:
 3  explicit Dynamic_array(int size = 0)
 4      : size_{size},
 5        data_{size == 0 ? nullptr : new value_type[size]{}} {}
 6
 7  Dynamic_array(Dynamic_array const& other) : Dynamic_array{other.size_} {
 8    copy_n(other.data_, other.size_, data_);
 9  }
10
11  /* ... */
12};

最终得到: [在线代码 44964Wrah]

参见

拷贝赋值函数#

除了用原对象构造新对象的情况外, 我们还可能用原对象对已有对象进行赋值, 为此需要定义拷贝赋值函数.

但是我们已经定义了如何拷贝构造, 则通过旧对象 other 可以拷贝得到一个新对象, 那么如果我们能交换这个新对象和已有对象的内容, 岂不是说已有对象就是 other 的拷贝了?

1Dynamic_array other{/*...*/};
2
3Dynamic_array 已有对象;
4
5Dynamic_array copy{other};
6/* 交换已有对象和 copy 的内容 */
7
8// 此后, 已有对象就是 other 的拷贝

为此我们需要定义 Dynamic_array 如何进行交换. 显然只需要交换对象的所有成员即可:

 1class Dynamic_array {
 2 public:
 3  void swap(Dynamic_array& other) {
 4    using std::swap;           // 先 using std::swap;
 5    swap(size_, other.size_);  // 再使用没有任何限定的 swap
 6    swap(data_, other.data_);
 7  }
 8
 9  /* ... */
10};

交换的双方地位是对等的, 但 array.swap(other) 这样的语法则显得 array 是交换的主动方, 这就显得不太合理, 因此应该将 swap 作为非成员函数 swap(lhs, rhs). 而为了访问私用成员, 我们将 swap 函数设为友元函数:

 1class Dynamic_array {
 2 public:
 3  friend void swap(Dynamic_array& lhs, Dynamic_array& rhs) {
 4    using std::swap;             // 先 using std::swap;
 5    swap(lhs.size_, rhs.size_);  // 再使用没有任何限定的 swap
 6    swap(lhs.data_, rhs.data_);
 7  }
 8
 9  /* ... */
10};

我们于是将拷贝赋值函数定义为: [在线代码 qb3dqov4v]

 1class Dynamic_array {
 2 public:
 3  Dynamic_array& operator=(Dynamic_array const& other) {
 4    Dynamic_array temp{other};  // 用 other 拷贝一个新对象
 5    swap(*this, temp);          // 交换 *this 和 temp 的内容
 6    return *this;
 7  }  // temp 的析构函数将会清理交换来的内容
 8
 9  /* ... */
10};

你只需要写好拷贝构造函数和析构函数, 就能直接定义拷贝赋值函数, 这样的方法称为 copy-and-swap 惯用法.

重设动态数组的长度#

我们希望通过 array.resize(int new_size) 来重设数组的大小, 但保持数组的内容不变:

  • 如果长度变长或不变, 原来 Dynamic_array 中所有元素内容 (size_ 个) 全部保持, 新元素设为 0.

  • 如果长度变短, 原来 Dynamic_array 中前 new_size 个元素内容全部保持.

也就是说, 我们申请 new_size 长度的新数组, 再将原来数组中前 min(new_size, size_) 个元素拷贝到新数组中.

可以发现, 这依旧能沿用 copy-and-swap 的逻辑, 只是改成了 construct-and-swap: [在线代码 Ys143Kh79]

 1#include <algorithm>  // for std::min
 2
 3class Dynamic_array {
 4 public:
 5  void resize(int new_size) {
 6    Dynamic_array temp{new_size};
 7    copy_n(data_, std::min(size_, new_size), temp.data_);
 8
 9    swap(*this, temp);
10  }
11
12  /* ... */
13};

连接两个动态数组#

所谓连接两个动态数组, 即由数组 {0, 1, 2}{2, 3, 4} 得到 {0, 1, 2, 2, 3, 4}. 这和重设动态数组长度的逻辑十分类似, 你甚至可以复用 resize(int new_size) 来做到.

但此处我不打算复用 resize(int new_size), 而向你展示精心设计的 <algorithm> 算法的魅力.

operator+(lhs, rhs)#

int 行为, lhs + rhs 是创建一个新对象来保存相加后的结果, 而 lhsrhs 不变, 所以我们实现的 operator+ 也不应该修改 lhsrhs.

为此我们可以按值传参, 也可以按 const& 传参; 为了避免对大数组进行拷贝, 我选择按 const& 传参而非按值传参.

清楚了参数的选择, 我们再来看看函数的实际逻辑:

  1. 申请一个动态数组, 它的长度是 lhs.size() + rhs.size().

  2. lhsrhs 的元素依次拷贝到新数组中.

这次要拷贝两个数组, 总不能用 copy_n 了吧? ……还记得 copy_n 的返回值吗:

 1// 将 [input, input + size) 从左到右拷贝到 [output, output + size) 中
 2//
 3// 返回值::
 4//   返回 output + size, 即如果继续对 output 进行拷贝, 应该使用的指针.
 5value_type* copy_n(value_type const* input, int size, value_type* output) {
 6  for (int i{0}; i < size; ++i) {
 7    output[i] = input[i];
 8  }
 9  return output + size;
10}

"如果继续对 output 进行拷贝, 应该使用的指针": [在线代码 eE4Mjh45a]

 1class Dynamic_array {
 2 public:
 3  friend Dynamic_array operator+(Dynamic_array const& lhs,
 4                                 Dynamic_array const& rhs) {
 5    Dynamic_array result(lhs.size() + rhs.size());
 6
 7    value_type* output{&result.data_[0]};
 8    output = copy_n(lhs.data_, lhs.size_, output);
 9    output = copy_n(rhs.data_, rhs.size_, output);
10
11    return result;
12  }
13
14  /* ... */
15};

operator+=(other)#

self += other 与之前所见的 swapoperator+ 不同, 它的左右参数地位不是对等的: 当我们使用 operator+=, 是达到与 operator= 即赋值运算符类似的效果, 是将 other 的内容添加到 self 上. 因此应该将 operator+= 定义为成员函数.

但我们仍能复用 operator+(lhs, rhs) 来实现它: [在线代码 aKxr34esz]

 1class Dynamic_array {
 2 public:
 3  // ↓ 按赋值运算符的惯例, 返回 *this 引用
 4  Dynamic_array& operator+=(Dynamic_array const& other) {
 5    *this = *this + other;
 6    return *this;
 7  }
 8
 9  /* ... */
10};

参见

比较动态数组#

我们接下来定义动态数组的比较关系, 从而允许 Dynamic_array 进行 lhs == rhslhs < rhs 这样的比较.

swap 类似, 比较的双方地位是对等的, 应该定义为友元函数.

相等性: bool operator==(lhs, rhs)#

什么样的两个动态数组才是相等的呢? 首先长度要相等, 其次相同下标下的元素也要相等.

我们应该传递引用传参而非拷贝传参, 为什么呢? 想一想什么叫拷贝: 用原对象构造/赋值一个对象, 使得该对象与原对象 相等. 但我们现在就是在定义什么是相等, 没定义之前凭什么说拷贝的结果相等呢? 因此我们不能让相等比较依赖于拷贝行为.

所以相等比较应该是:

 1class Dynamic_array {
 2 public:
 3  friend bool operator==(Dynamic_array const& lhs, Dynamic_array const& rhs) {
 4    if (lhs.size() != rhs.size()) {  // 长度是否相等
 5      return false;
 6    }
 7
 8    for (int i{0}; i < lhs.size_; ++i) {
 9      if (lhs.data_[i] != rhs.data_[i]) {  // 同一下标下的元素是否相等
10        return false;
11      }
12    }
13    return true;
14  }
15
16  /* ... */
17};

至于 bool operator!=(lhs, rhs), 我们复用 operator==:

1class Dynamic_array {
2 public:
3  friend bool operator!=(Dynamic_array const& lhs, Dynamic_array const& rhs) {
4    return !(lhs == rhs);
5  }
6
7  /* ... */
8};

最终得到: [在线代码 drTPGdsfx]

等价性、偏序关系: bool operator<(lhs, rhs)#

动态数组的偏序关系通常定义为字典序, 即词典排列单词的顺序.

对于 lhs < rhs:

  1. 从左到右依次比较各元素

    • lhs[i] < rhs[i]: lhs 更小, 返回 true.

    • lhs[i] == rhs[i]: 比较下一对元素.

    • lhs[i] > rhs[i]: rhs 更小, 返回 false.

  2. 相同长度部分都比较完毕

    • lhs.size() < rhs.size(): 说明 lhsrhs 的前缀, 想想词典是怎么排列 a 和 ab 的, 返回 true.

    • lhs.size() == rhs.size(): 说明两个数组相等, 返回 false.

    • lhs.size() > rhs.size(): 说明 rhslhs 的前缀, 想想词典是怎么排列 ab 和 a 的, 返回 false.

所以小于比较应该是:

 1#include <algorithm>  // for std::min
 2
 3class Dynamic_array {
 4 public:
 5  friend bool operator<(Dynamic_array const& lhs, Dynamic_array const& rhs) {
 6    // 比较同长部分:
 7    //  - 小于: lhs 更小, 返回 true
 8    //  - 等于: 比较下一个字符
 9    //  - 大于: rhs 更小, 返回 false
10    int const min_size{std::min(lhs.size_, rhs.size_)};
11    for (int i{0}; i < min_size; ++i) {
12      if (lhs.data_[i] < rhs.data_[i]) {
13        return true;
14      }
15      if (lhs.data_[i] > rhs.data_[i]) {
16        return false;
17      }
18    }
19
20    // 同长部分已经比较完毕
21    //  - lhs.size_ <  rhs.size_: 说明 lhs 是 rhs 的前缀, 想想词典是怎么排列 a 和 at 的, 返回 true
22    //  - lhs.size_ == rhs.size_: 相同, 返回 false
23    //  - lhs.size_ >  rhs.size_: 说明 rhs 是 lhs 的前缀, 想想词典是怎么排列 at 和 a 的, 返回 false
24    return lhs.size_ < rhs.size_;
25  }
26
27  /* ... */
28};

至于 bool operator>(lhs, rhs) 等比较, 我们复用 operator<:

 1#include <algorithm>  // for std::min
 2
 3class Dynamic_array {
 4 public:
 5  friend bool operator>(Dynamic_array const& lhs, Dynamic_array const& rhs) {
 6    return rhs < lhs;
 7  }
 8  friend bool operator<=(Dynamic_array const& lhs, Dynamic_array const& rhs) {
 9    return !(rhs < lhs);
10  }
11  friend bool operator>=(Dynamic_array const& lhs, Dynamic_array const& rhs) {
12    return !(lhs < rhs);
13  }
14
15  /* ... */
16};

最终得到: [在线代码 MMv73G61f]

提示

当然 <algorithm> 里也有 equallexicographical_comparemismatch 等比较两范围的算法, 这里为了避免说得太多就没用了.

参见

输出动态数组#

调用 print 函数实在太麻烦了, 让我们的动态数组也支持 std::cout << value 吧.

这其实没什么大不了的, 只是重载 operator<<(std::ostream& ostream, Dynamic_array const& array) 罢了: [在线代码 5vGqjGsWx]

print 函数#
1// 按 [a0, a1, a2, ..., an] 的形式输出数组
2void print(Dynamic_array const& array) {
3  std::cout << '[';
4  for (int i{0}; i < array.size(); ++i) {
5    std::cout << (i == 0 ? "" : ", ") << array[i];
6  }
7  std::cout << ']';
8  return ostream;
9}
重载输出运算符#
 1#include <ostream>  // for std::ostream
 2
 3class Dynamic_array {
 4 public:
 5  //     ↓ 按约定返回 ostream 的引用
 6  friend std::ostream& operator<<(std::ostream& ostream,
 7                                  Dynamic_array const& array) {
 8    ostream << '[';
 9    for (int i{0}; i < array.size(); ++i) {
10      ostream << (i == 0 ? "" : ", ") << array[i];
11    }
12    ostream << ']';
13    return ostream;
14  }
15
16  /* ... */
17};

参见

让动态数组能包含其他类型的数据#

我们之前使用 using value_type = int;, 从而淡化了动态数组存储的数据而专注于动态数组的设计. 其实这样做还带来另一个好处: 我们只需更改 using value_type = ...; 为其他类型, 就能让动态数组包含其他类型的数据!

1using value_type = double;  // 现在, 动态数组存储的是 double 类型数据
2// ...

那么如果我想让动态数组存储学生类呢?

1struct Student {
2  std::string id;
3  std::string name;
4};
5
6using value_type = Student;

但当你编译生成会发现这样修改后会报错, 为什么呢? 因为我们对 value_type 进行了比较、输出等操作, 而 Student 并不支持这些操作. 你需要真正地去学习 运算符重载 并为 Student 重载比较运算符和输出操作等.

整个动态数组类的附解释代码#

[在线代码 Yc6e5Pf4M]

扩展: 让动态数组能包含任何类型的数据#

前置内容

那有没有一种方法, 让动态数组能包含任意类型的数据呢? 我们代码里也许同时需要 int 类型的动态数组和 double 类型的动态数组.

为此我们使用 模板, 它将类型作为参数, 基于提供的参数生成对应的代码.

 1template <typename T>
 2struct Dynamic_array {
 3 public:
 4  // 所有的 value_type 都换成 T, 或者:
 5  using value_type = T;
 6
 7  /* ... */
 8
 9 private:
10  int size_;
11  value_type* data_;
12};

扩展: 让动态数组支持上百种算法#

见于 范围、迭代器和算法 (range, iterator and algorithm).

扩展: 让动态数组能够列表初始化#

数组还有一个很便利的特性——在初始化时列表初始化各个元素:

1int main() {
2  int array[4]{0, 1, 2, 3};  // {0, 1, 2, 3}
3}

我们也能让 Dynamic_array 支持这种功能, 为此需要定义 Dynamic_array(std::initializer_list<value_type>):

 1class Dynamic_array {
 2 public:
 3  // 这个函数和拷贝构造函数一样, 也是不需要加 explicit 的特例
 4  Dynamic_array(std::initializer_list<value_type> init_list)
 5      : size_{init_list.size()}
 6        data_{init_list.size() == 0 ? nullptr : new value_type[init_list.size()]} {
 7    copy_n(init_list.data(), init_list.size(), data_);
 8  }
 9
10  /* ... */
11};

但要注意, 使用 {} 进行初始化时会优先考虑 Dynamic_array(std::initializer_list<value_type>) 而非其他同样可用的构造函数, 如果需要其他构造函数, 应该在合适时候使用 () 进行初始化: (这就是生活😭)

1int main() {
2  Dynamic_array array0{5};  // {5}
3  Dynamic_array array1(5);  // {0, 0, 0, 0, 0}
4}

知道了这一点, 我们就能再委托 Dynamic_array(int size) 分配数组:

 1class Dynamic_array {
 2 public:
 3  Dynamic_array(std::initializer_list<value_type> init_list)
 4      : Dynamic_array(init_list.size()) {
 5    copy_n(init_list.data(), init_list.size(), data_);
 6  }
 7
 8  Dynamic_array(Dynamic_array const& other) : Dynamic_array(other.size_) {
 9    copy_n(other.data_, other.size_, data_);
10  }
11
12  /* ... */
13};

扩展: 让动态数组支持高效的移动#

定义拷贝函数后, 我们不再能高效地返回动态数组:

未定义拷贝函数时#
1value_type* make_array() {
2  value_type* array{new value_type[1000]{}};
3  /* ... */
4  return array;  // 返回只需要拷贝指向数组首元素的指针
5}  // 局部变量 array 被析构, 这没什么, 它只是指向数组首元素的指针
定义拷贝函数时#
1Dynamic_array make_array() {
2  Dynamic_array array(1000);
3  /* ... */
4  return array;  // 返回时拷贝整个数组
5}  // 局部变量 array 被析构, 我们平白无故拷贝了它一份作为返回, 又析构它本身

发现了吗? 对于有的情况 (尤其是当我们在函数内构造了动态数组, 且想要返回这个数组时), 我们只想拷贝指针, 从而复用这个数组. C++11 为此添加了移动语义, 它表达对资源的转移而非对资源进行拷贝.

要想让类支持移动语义, 我们需要定义移动构造函数和移动赋值函数; 但在定义之前, 让我们学习另一个组件函数.

std::exchange 组件#

exchange(object, new_value)new_value 赋给 object, 并返回 object 的旧值:

1#include <utility>  // for std::exchange
2
3int value = 5;
4std::cout << std::exchange(value, 3);  // 输出 5
5std::cout << value;  // 输出 3

这让数据像是水流一样, 从右边流向左边:

1#include <utility>  // for std::exchange
2
3// value1 == 0, value2 == 1
4int value1 = 0;
5int value2 = 1;
6
7// 返回 0, value1 == 1, value2 == 2
8std::exchange(value1, std::exchange(value2, 2));

定义移动构造和移动赋值函数#

由此, 我们可以这样定义移动构造函数和移动赋值函数:

 1#include <utility>  // for std::move
 2
 3class Dynamic_array {
 4 public:
 5  // 移动构造函数将资源从 other 转移到本对象中
 6  //  因此将 other.data_ 的值赋给 data_, 并将 other.data_ 设为 nullptr
 7  //  而 other.size_ 同理
 8  Dynamic_array(Dynamic_array&& other)
 9      : size_{std::exchange(other.size_, 0)},
10        data_{std::exchange(other.data_, nullptr)} {}
11
12  // 用定义好的移动构造函数、析构函数来实现移动赋值函数
13  Dynamic_array& operator=(Dynamic_array&& other) {
14    Dynamic_array temp{std::move(other)};  // 移动构造
15    swap(*this, temp);
16    return *this;
17  }
18};

此后, 当我们将局部动态数组对象作为返回值时, 它将调用移动函数而非拷贝函数:

1Dynamic_array make_array() {
2  Dynamic_array array(1000);
3  /* ... */
4  return array;  // 返回时移动整个动态数组
5}  // 局部变量 array 被析构, 没事, 它的 array.data_ 已经是 nullptr 了