3.4. 对象与视图

自创建以来,C++ 就不仅限于使用拥有型指针:对象都可以拥有资源,表达独占所有权最简单的方法就是在栈上创建一个局部变量。这类对象中的一个也可以由指针(独占或共享)所拥有,而当需要非拥有式访问时,通常通过原始指针或引用来访问这些对象。然而,在 C++17 和 C++20 中,出现了一种不同的模式,值得我们去探索。

3.4.1 拥有资源的对象

每位 C++ 开发者都熟悉资源拥有型对象,最常见的例子就是 std::string —— 拥有字符数组的对象。当然,还拥有很多用于操作字符串的专用成员函数,但从内存所有权的角度来看,std::string 本质上就是一个拥有型的 char* 指针,std::vector 是任意类型对象数组的拥有型对象。

创建此类对象最常见的方法是,作为局部变量或作为类的数据成员。后一种情况下,整个类的拥有者问题由其他地方管理,但在类内部,所有数据成员都由对象本身独占拥有。考虑这个简单的例子:

class C {
  std::string s_;
};

...
std::vector<int> v = ... ; // v 拥有这个 int 数组
C c; //  c 拥有字符串 s_

与本章前面几页关于独占所有权的章节相比,我们并没有提出新的观点。然而,我们已经悄然将焦点从拥有型指针转移到了拥有型对象上。只要专注于所有权这一方面,这些对象本质上就是特化的拥有型(独占)指针。但这里有一个重要的区别:大多数此类对象都携带了额外的信息,例如 std::string 的字符串长度,或 std::vector 的数组大小。当讨论 C++17/20 带来的变化时,会再次出现。

尽管资源拥有型对象自 C++ 诞生之初就已存在,但它们本身却常常通过指针来拥有。这或许有两个主要原因;而这两个原因都已被 C++ 的发展所淘汰。第一个原因是需要转移所有权。例如,通过一个拥有型指针来拥有一个字符串,因为栈对象在作用域结束时会销毁,类的数据成员在对象销毁时也会销毁。这两种情况下,都无法将对象本身(例如 std::string)的所有权转移。然而,如果专注于所有权,字符串对象本身只是一个(带装饰的)拥有型指针,目标是将底层资源(对 std::string 而言是字符数组)的所有权转移给另一个所有者。当我们这样表述时,答案就显而易见了:自 C++11 起,字符串具有了移动语义,移动一个字符串的开销几乎不比移动一个指针大(字符串是一个知道长度的拥有型指针,因此长度信息也需要移动)。

如果仅仅是为了所有权转移,就没有理由通过指针来拥有一个易于移动的拥有型对象。例如,考虑这个字符串构建器类:

class Builder {
  std::string* str_;
public:
  Builder(...) : str_(new std::string){
    ... 构建字符串 str_ ...
  }

  std::string* get(){
    std::string* tmp = str_;
    str_ = nullptr;
    return tmp;
  }
};

虽然这种方式能够完成任务,但编写这个类更好的方法是直接移动字符串:

// Example 07
class Builder {
  std::string str_;
public:
  Builder(...){ ... 构建字符串 str_ ... }
  std::string get(){ return std::move(str_); }
};

std::string my_string = Builder(...).get();

对于构造易于移动的拥有型对象的工厂函数来说,同样适用。工厂函数可以直接返回对象本身,而无需通过 std::unique_ptr 返回:

std::string MakeString(...) {
  std::string str;
  ... 构建字符串 ...
  return str;
}
std::string my_string = MakeString(...);

返回值可能受益于返回值优化(RVO),编译器会直接在为最终对象(如 my_string)分配的内存中构造返回值。但即使没有这种优化,也可以保证这里不会发生字符串的复制,只会发生移动(如果这个移动也优化掉,这种优化有时会称为移动省略,类似于更广为人知的复制省略,它能优化掉复制构造函数)。

使用拥有型指针来管理资源拥有型对象的第二个原因是,对象本身也可能存在条件:

std::unique_ptr<std::string*> str;
if (need_string) str.reset(new std::string(...args...));

许多情况下,可以使用一个“空”对象来代替,例如一个长度为零的字符串。对于许多拥有型对象,特别是所有易于移动的 STL 容器来说,构造这样一个对象的代价微不足道。但空字符串和根本没有字符串之间可能存在有意义的区别(空字符串可能是一个有效结果,而没有字符串的存在则向程序的其余部分表示某种特定含义)。在 C++17 中,可以使用 std::optional 来直接表达这种行为:

std::optional<std::string> str;
if (need_string) str.emplace(...args...);

std::optional<std::string> 类型的对象可能包含一个字符串,也可能为空。非空的 std::optional 拥有其包含的对象(删除 std::optional 也会删除其中的字符串)。与 std::unique_ptr 不同,这里没有堆内存分配:std::optional 对象内部包含了足够的空间来存储一个 std::string 对象。

std::optional 和字符串本身一样,也可以移动,因此这种模式可以与前一种模式结合使用。在现代 C++ 中,没有理由通过间接方式(如指针)来拥有 std::string 这类轻量级的拥有型对象。

然而,如何表达对这类对象的非拥有式访问才得到足够的关注。

3.4.2 对拥有资源对象的非拥有式访问

std::string 对象在大多数情况下可以替代指向 char*(或 std::string)的拥有型指针,该如何表达非拥有式访问呢?

假设将一个字符串传递给一个函数,该函数会操作这个字符串,但不拥有(不会销毁它)。这是一个很简单的练习:

void work_on_string(const std::string& str);
std::string my_string = ...;
work_on_string(my_string);

这是我们自 C++ 诞生以来一直在做的事情。但这种简单性掩盖了一个深刻的差异:只要不关心 std::string 提供的所有方法和功能,其本质上就是一个知道自身长度的、指向字符数组的拥有型指针。

如果使用拥有型指针而不是 std::string,该如何处理同样的情况呢?对应的指针是 std::unique_ptr<char[]>:

void work_on_string(const char* str);
std::unique_ptr<char[]> my_string(new char[...length...]);
... initialize the string ...
work_on_string(my_string.get());

遵循之前的准则,向函数传递了一个非拥有的原始指针,但绝对不应该这样声明函数:

void work_on_string(const std::unique_ptr<char[]>& str);

当同一个字符数组由 std::string 对象拥有时,我们却毫不犹豫地这样做了。为什么对这些非常相似的问题采取了如此不同的处理方式?现在是时候回想一下,为什么字符串不仅仅是指向字符数组的拥有型指针;它包含的信息比指针本身更多,还知道字符串的长度。在 C++ 中,除了通过引用传递整个指针对象外,没有很好的方法来授予对这种“丰富”拥有型指针的非拥有式访问。相比之下,一个独占指针(或其他拥有型指针)包含的信息与基本指针相同,当不需要所有权时,拥有型指针自然可以简化为原始指针,而不会丢失信息。

这种差异不仅仅关乎对称性,通过 const 引用传递字符串可以防止 work_on_string 函数更改字符串内容。另一方面,一个非 const 引用允许函数清空字符串(释放其拥有的内存),这涉及所有权问题。需要将两种不相关的访问类型混在一起,从而模糊了意图的清晰性:一种是更改数据内容的能力,另一种是数据的所有权。

C++17 在一个非常有限的上下文中解决了这个问题:对于字符串,其引入了一个新类型 std::string_view。字符串视图是一个指向字符串的(const)非拥有型指针,同时也存储了字符串的长度,但它是 std::string 的完美非拥有型等价物:字符串视图之于字符串,正如 const 原始指针之于独占指针。现在,为了授予对 std::string 对象的非拥有式访问,可以这样写:

// Example 09
void work_on_string(std::string_view str);
std::string my_string = ...;
work_on_string(my_string);

相比之下,一个需要获取 std::string 对象所有权的函数仍然必须通过引用接收它,应使用右值引用来转移所有权:

// Example 09
void consume_string(std::string&& str);
std::string my_string = ...;
consume_string(std::move(my_string));
// 此后不要再使用 my_string!

使用非常量左值引用,可以让函数修改字符串内容;在 C++17 中,尚无与非常量原始指针功能对等的“丰富”非拥有型指针。除非现有接口要求,否则可能没有必要再使用 const std::string&,因为 std::string_view 提供了等效的功能。

使用 std::string_view 还有其他诸多好处和优势(特别是它极大地简化了处理 C 风格字符串和 C++ 字符串的通用代码的编写),但本章我们聚焦于所有权方面,字符串视图仅限于字符字符串。我们完全可以就另一个拥有型类(例如 std::vector<int>)展开完全相同的讨论。

现在看到了一种新模式的出现:对于一个“丰富的”拥有型指针,它除了拥有内存外,还包含一些关于其所拥有数据的信息,则对应的非拥有型对象(相当于原始指针)应该是一个视图对象,该对象包含相同的信息但不拥有资源。

可以在 C++20 中找到了这种视图对象,即 std::span。在此之前,授予对整数vector非拥有式访问的唯一好方法是通过引用传递:

void work_on_data(std::vector<int>& data);
std::vector<int> my_data = ...;
work_on_data(my_data);

在 C++20 中,可以使用 span 来清晰地区分非拥有式视图(相当于原始指针)和拥有式对象(相当于独占指针):

// Example 10
void ModifyData(std::span<int> data);
std::vector<int> my_data = ...;
ModifyData(my_data); // 可以修改 my_data

std::span<int> 是 int* 的"丰富指针"等价物 —— 包含一个非常量指针和大小信息,可以廉价地复制,并且不拥有其所指向的资源。与 std::string_view 不同,可以通过 span 修改所访问的对象。

但如果想要相当于 const 指针的类型,可以使用 std::span<const int>:

// Example 10
void UseData(std::span<const int> data);
std::vector<int> my_data = ...;
UseData(my_data); // 无法修改 my_data

由于 std::string 包含一个连续的字符数组,也可以与 span 一起使用,具体来说就是 std::span<char> 或 std::span<const char>。后者本质上与 std::string_view 相同,包括可以从字符串字面量构造的选项。前者则等同于指向 char 的非常量指针。

span 与 vector 或 string 配合得很好,它们为数组提供了非拥有式的视图。但它不适用于其他 STL 容器,这些容器都在多个非连续的内存块中进行分配。对于这类容器,需要使用 C++20 的 范围库。例如,将前面针对 vector 的非拥有式访问推广到任意容器:

// Example 11
void ModifyData(std::ranges::view auto data) { ... }
std::list<int> my_data = ...;
ModifyData(std::views::all(my_data));

如果从未见过 C++20 的模板,这可能需要一点时间来适应。第一行是一个模板函数:auto 参数使得“普通”函数即使没有 template 关键字也能成为模板。auto 前面的 std::ranges::view 这一写法将模板参数限制为满足 view 概念的类型。视图是一种类似容器的对象,具有 begin() 和 end() 成员函数,并且此外,必须易于移动,且要么易于复制,要么不可复制(当然,这是对标准中列举的精确要求的一种粗略转述)。可以使用 template 和 requires 关键字来编写相同的函数,但这种紧凑的语法在 C++20 中是一种惯用法。

在这种基于概念的编码风格中,函数参数的限制由概念要求来指定。可以编写相同的模板函数,但要求是范围,而非视图:

void ModifyData(std::ranges::range auto data) { ... }
std::list<int> my_data = ...;
ModifyData(my_data);

范围本质上是具有 begin() 和 end() 的任意对象,因此 std::list 是一个范围(但不是视图,可以复制,但代价不菲)。按上述方式编写,该函数通过值传递参数,因此会进行一次复制。除非有意为之(而在本例中并非如此),否则编写此函数的正确方式应如下所示:

void ModifyData(std::ranges::range auto&& data) { ... }

使用常量引用也可以,如果想表达非修改性访问的话,但不必对视图做同样的处理:通过将 work_on_data 函数限制为仅接受视图,将其限制在类似于 std::string_view(或原始指针)这样易于复制的类型上。事实上,按引用传递一个范围就相当于直接传递字符串或数组本身:这会赋予调用者访问所有权的权限。如果想要编写一个明确不获取范围所有权的函数,使用视图才是表达此意图的正确方式。

现在讨论 C++20 范围的模式还为时过早:它们出现的时间还不够长,尚未形成广泛认可和接受的通用实践(这是形成一种模式的必要条件),并且该库本身还不完整。C++23 预计将包含几项重大增强(例如,C++20 的范围库中目前没有与 std::span<const char> 对等的良好类型 —— 这将在 C++23 中添加)。

我们可以有把握地谈论一种正在 C++ 中确立的更普遍的模式:资源所有权(包括内存)应由拥有型对象处理,而非拥有式访问则应通过视图来授予。拥有型对象可以是智能指针,也可以是更复杂和专用的容器对象。这些容器除了以更复杂的方式管理内存外,还嵌入了关于其所包含数据的更多信息。对于每个容器,都应该有一个对应的视图,该视图在保留所有附加信息的同时,提供非拥有式访问。对于智能指针,其视图是原始指针或引用;对于 std::string,其视图是 std::string_view;对于 std::vector、数组以及其他拥有连续内存的容器,应该使用 std::span;对于任意容器,相应的视图可以在 C++20 的 范围 库中找到;而对于自定义容器,还需要自己编写视图对象(只需确保它们满足相关的视图概念即可)。