Skip to content

1.2 组织数据的新方式

在序言给出的学生成绩处理程序里我们使用固定长度数组保存学生:

cpp
#define MAXCOUNT 100

Student students[MAXCOUNT];

这会把「目前输入了多少人最多允许多少人」和「数组实际有多大」分成三个容易混淆的概念。数量超过限制会失败,数量很少时又会留下大量空位。因此,接下来我们介绍如何使用标准库容器管理这些细节。

标准库不是附加内容

C++ 标准不仅规定语言语法,也提供容器、算法、字符串、时间、文件系统、线程等库设施。

算法竞赛和课程练习有时要求亲手实现底层数据结构,这是很有价值的训练。不过在一般工程项目中,重复实现已有的基础设施通常会增加测试和维护成本。使用标准库类型通常能同时获得:

  • 明确的生命周期和所有权;
  • 已经处理好的复制与移动行为;
  • 与标准库算法统一的接口;
  • 跨编译器和平台的可移植性。

三种常见的连续数据

类型长度典型用途
std::array<T, N>编译期固定始终恰好有 N 个元素
std::vector<T>运行时可变元素数量随输入或处理过程变化
std::string运行时可变保存和处理文本

固定长度的 std::array

cpp
#include <array>

std::array<int, 4> scores{72, 85, 59, 91};

它与内置数组一样连续存储元素,但知道自己的长度,也能直接参与赋值和返回值传递。我们可以使用 C++11 引入的基于范围的 for 循环(range-based for loop)遍历它:

cpp
for (int score : scores) {
    std::cout << score << '\n';
}

std::array 的成员函数这里不再逐一列举,可以查阅 cppreference:std::array。也可以阅读 菜鸟教程:C++ 容器类 <array>

不过当长度来自运行时输入时,std::array 并不适合,此时考虑另外两种类型。

可变长度的 std::vector

cpp
#include <cstddef>
#include <vector>

std::vector<int> scores;
scores.push_back(72);
scores.push_back(85);

std::vector 会自动管理动态存储。对象销毁时,它拥有的元素和内存也会得到正确清理。元素数量通过 size() 获取:

cpp
for (std::size_t index{0}; index < scores.size(); ++index) {
    std::cout << index << ": " << scores[index] << '\n';
}

关于 std::size_t

std::size_t 是容器长度和下标常用的无符号类型,它是一个基本的、与机器相关的无符号整型。一般来说 sizeof 算符返回的结果类型就是它。在不同平台上,std::size_t 可能会有不同的长度。

不过,如果根本不需要下标,范围 for 更简单:

cpp
for (int score : scores) {
    std::cout << score << '\n';
}

std::vector 常会预留一些空间用于追加新元素,可以使用 capacity 方法获取当前分配的存储容量。

如果预留的空间不足,则会重新分配更大的内存空间,把原有的元素复制或移动到新的位置,并归还旧内存。一旦发生重新分配,所有指向原有元素的指针、引用和迭代器都会失效。这个过程需要处理已有元素,因此如果对未来会使用的空间有大致了解,可以使用 reserve 来预留空间而不创建元素,从而减少大量追加操作中的重新分配次数。

cpp
void print_size_cap(std::vector<int> &vec) {
    std::cout << "size=" << vec.size() << ", cap=" << vec.capacity()
              << ", addr=" << vec.data() << '\n';
}

std::vector<int> foo;
for (int i{0}; i < 10; i++) {
    foo.push_back(1);
    print_size_cap(foo);
}
foo.reserve(500);
print_size_cap(foo);
for (int i{0}; i < 400; i++) {
    foo.push_back(1);
}
print_size_cap(foo);

我这里得到的结果是:

txt
size=1, cap=1, addr=0x2240ae9f840
size=2, cap=2, addr=0x2240ae9f860
size=3, cap=4, addr=0x2240aea2eb0
size=4, cap=4, addr=0x2240aea2eb0
size=5, cap=8, addr=0x2240aea21d0
size=6, cap=8, addr=0x2240aea21d0
size=7, cap=8, addr=0x2240aea21d0
size=8, cap=8, addr=0x2240aea21d0
size=9, cap=16, addr=0x2240aea5970
size=10, cap=16, addr=0x2240aea5970
size=10, cap=500, addr=0x2240aebed30
size=410, cap=500, addr=0x2240aebed30

可以看到,在预留空间不足时,std::vector 会自动开辟新的内存空间,并把原本的值复制或移动到新位置。在手动预留了 500 个元素的空间后,此后的 400 次 push_back 不再触发重新分配。

可以前往 cppreference:std::vector 了解更多成员函数和迭代器失效规则。也可以阅读 菜鸟教程:C++ 容器类 <vector>

文本使用 std::string

cpp
#include <string>

std::string name{"Hello"};
name += " world!";

std::string 管理字符存储,并提供长度、查找、拼接和比较等操作,而且可以方便地与原始的 C 风格字符数组互转。

可以前往 cppreference:std::basic_string 了解更多成员函数。也可以阅读 菜鸟教程:C++ 标准库 <string>

借用连续数据

函数有时只想查看一段数据,并不需要拥有或复制容器。C++20 的 std::span 可以表达这种借用关系。

cpp
#include <span>

double average(std::span<const int> scores) {
    int sum{};
    for (int score : scores) {
        sum += score;
    }
    return scores.empty() ? 0.0 : static_cast<double>(sum) / scores.size();
}

这个函数可以接收 std::arraystd::vector 或内置数组中的连续整数。span 不拥有元素,也不会延长它们的生命周期。

类似地,std::string_view 可以只读地查看一段字符:

cpp
#include <iostream>
#include <string_view>

void greet(std::string_view name) {
    std::cout << "Hello, " << name << "!\n";
}

span 原来的名字叫做 array_view,在进入标准时改成了 span

可以把这两类视图直观地理解为「对一段连续数据的轻量描述实现中通常包含地址和长度等信息。它们要求数据连续,不拥有源数据,也不会延长源对象的生命周期;保存一个指向已经销毁对象的 spanstring_view 会产生悬空视图。因此,它们适合作为函数参数或用于短期观察。

其他表达意图的工具

带作用域的枚举

为了表达有限个状态,C++ 从 C 继承了传统枚举 enum。但它有一些问题:

  • 命名空间污染
  • 会被隐式转换为整型

考虑下面的代码:

cpp
enum Foo { bar, baz };

auto bar{1};
// 'auto bar' redeclared as different kind of entity

if (baz < 10)
    std::cout << "true"; // 输出 true

这里的 barbaz 直接进入了当前作用域,污染了命名空间,其他变量不能再使用这些名称。而且,baz < 10 直接发生了隐式类型转换。

为此,C++11 引入了带作用域的枚举(scoped enumerations

cpp
enum class Foo { bar, baz };

使用 Foo::bar 访问成员。这样避免了作用域污染。同时,enum class 声明的枚举成员不再进行隐式类型转换:

cpp
if (Foo::baz < 10)
    std::cout << "true";
// no match for 'operator<' (operand types are 'main()::Foo' and 'int')

空指针 nullptr

C 中常使用 0NULL 表示空指针,可能参与整数重载并造成歧义。

C++11 引入了 nullptr 用于表示空指针:

cpp
int *selected{nullptr};

小结

  • 使用标准库容器表达连续数据
    • 固定长度:std::array
    • 不固定长度:std::vector,可预留空间以避免频繁重新分配
    • 字符串:std::string
    • 借用连续数据:std::spanstd::string_view
  • 使用 enum class 取代传统 enum
  • 使用 nullptr 表示空指针,取代 0NULL