考虑这样一种程序:它从数据流中读取序列化的数据,并试图从中构造对象:
#include <fstream>
#include <cstdint>
#include <array>
#include <memory>
#include <string_view>
struct Point3D {
float x{}, y{}, z{};
Point3D() = default;
constexpr Point3D(float x, float y, float z)
: x{ x }, y{ y }, z{ z } {
}
};
// ...
// 从名为 file_name 的文件中最多读取 N 个字节,
// 并将这些字节写入 buf 中。返回实际读取的字节数
// (后置条件:返回值 <= N)
//
template <int N>
int read_from_stream(std::array<unsigned char, N> &buf,
std::string_view file_name) {
// ...
}
// ...
在这个例子中,我们有一个 Point3D 类。这种类型的对象表示一组 x,y,z 坐标。还有一个 read_from_stream<N>() 函数,它从文件中读取字节。该函数最多将 N 个字节存储到以引用方式传入的参数 buf 中,并返回实际读取的字节数(该数值可能是零,但绝不会超过 N)。
为了说明这个例子,假设要读取的文件中存储的是 Point3D 对象的二进制序列化形式,等价于以三个为一组连续存储的 float 类型对象的二进制格式。现在,考虑以下这个程序,它从名为 some_file.dat 的文件中读取最多四个 Point3D 类型对象的字节表示:
// ...
#include <print>
#include <cassert>
using namespace std::literals;
int main() {
static constexpr int NB_PTS = 4;
static constexpr int NB_BYTES =
NB_PTS * sizeof(Point3D);
alignas(Point3D)
std::array<unsigned char, NB_BYTES> buf{};
if (int n = read_from_stream<NB_BYTES>(buf, "some_file.dat"sv); n != 0) {
// 输出字节:左补0,宽度为2个字符,十六进制格式
for (int i = 0; i != n; ++i)
std::print("{:0<2x} ", buf[i]);
std::println();
// 如果我们想将这些字节视为 Point3D 对象,
// 我们需要启动这些 Point3D 对象的生命周期。
// 如果不这样做,就会进入未定义行为(UB)的领域
// (它可能能工作,也可能不能工作,即使现在能工作)
// 我们也不能依赖它)
const Point3D* pts =
std::start_lifetime_as_array(buf.data(), n);
assert(n % 3 == 0);
for (std::size_t i = 0; i != n / sizeof(Point3D); ++i)
std::print("{} {} {}\n", pts[i].x, pts[i].y, pts[i].z);
}
}
这个示例程序将文件中的字节读入一个 std::array 对象中,该数组足够大,可以容纳四个 Point3D 类型对象的字节。在读取之前,首先确保该数组在对齐方式上是合适的,以容纳这种类型的对象。这种对齐考虑至关重要,因为我们计划在读取这些字节之后,将其当作该类型的对象来使用。
这个示例的关键在于:当字节读取完成,开发者可以(最大程度上)确信这些字节对于某些假想的 Point3D 对象来说是正确的,但仍然无法直接使用这些对象,因为其生命周期尚未开始。
这类情况在传统上会让许多 C 开发者微笑,却让一些 C++ 开发者感到不安:C++ 对象模型对程序施加了一些限制,使得在对象生命周期之外,使用对象会构成未定义行为(UB)(参见第 2 章),即使所有字节都正确且对齐要求也得到了满足,而 C 语言在这方面的限制更少。为了使用我们刚刚读取到的那个缓冲区中的内容,通常有以下几种选择:
遍历字节数组,将适当大小的字节子集写入 float 类型的对象中,然后调用 Point3D 对象的构造函数,并将其放入另一个容器中。
使用 reinterpret_cast 将字节数组转换为 Point3D 对象数组,并寄希望于一切正常。这种做法可能导致代码有时能工作,有时不能;而且由于属于未定义行为(UB),其不可移植的(甚至在同一编译器的不同版本之间也可能行为不一致)。对于这个 Point3D 类型来说,可能会产生预期的结果,但如果将这些对象替换为标准库中的 std::complex
使用 std::memcpy() 将字节数组复制到自身中,并将返回值强制转换为 Point3D* 类型,然后像使用 Point3D 对象数组一样使用该指针。实际上,这种做法是合法的(std::memcpy 是少数几个可以启动对象生命周期的函数之一)。当然,这样做的风险是会实际复制字节内容(造成执行时间的浪费);据说某些标准库实现能够识别这种模式,并表现得像没有执行任何操作一样,但这种“无操作”实际上具有启动对象生命周期的副作用。
然而,这些选项似乎都不够理想,因此需要一种更清晰、不依赖于编译器特定优化的解决方案。为此,C++23 标准引入了一组 constexpr 函数(并附带多个重载版本),称为 std::start_lifetime_as_array<T>(p, n) 和 std::start_lifetime_as<T>(p)。这两个函数都是可移植的“魔法无操作”函数形式,用于告知编译器:这些字节是合法的,并应视为目标对象的生命周期已经启动。
如果由于某种原因,目标类型具有非简单的析构函数(non-trivial destructor),应当确保在适当时机调用这些析构函数。不过这种情况应当非常少见且不寻常。
由于是从某个数据源中读取原始字节并将其转换为对象,这些对象拥有资源的可能性相对较低。当然,在它们的生命周期开始后,这些对象可能会获取资源。亲爱的读者,让我们诚实一点:C++ 开发者若有什么特点,那就是富有创造力!
这套 std::start_lifetime_... 函数预计将特别有利于网络编程人员。这些人经常接收到格式良好的数据帧字节序列,并需要将它们转换为对象以便进一步处理。此外,这些函数对于从文件中读取字节并构造聚合类型(aggregate)的程序也非常有用。许多开发者认为,只需将字节读入字节数组,然后将该数组强制转换为预期类型(或其数组类型),就可以访问其中的(假想)对象(或多个对象),当 C++ 代码开始表现出未预期的行为时,往往会感到惊讶。C++ 是一门系统编程语言,而这组 std::start_lifetime_... 函数填补了一个它原本表现不足的空白。
由于涉及的风险,这些函数组成的是一组非常“锋利”的工具:以这种方式启动生命周期的具有非简单析构函数的对象尤其值得怀疑,并且必须对提供这些字节的数据源有极高的信任,才能手动、显式地启动对象的生命周期,所以这些功能应当极为谨慎地使用。
本节的最后说明:在撰写本书时(2025年初),尽管这些函数已标准化并成为 C++23 的一部分,但尚无主流编译器实现它们。也许当你读到这本书时,它们已经实现了,这谁又知道呢?