跟我学c++中级篇——launder和start_lifetime_as

一、std::launder的回顾

在前面的文章中分析过std::launder(有兴趣可以翻一翻),最近看C++23的资料,才发现这玩意儿的由来,思路打开了,才解决了原来写文章时的困惑。总结成一句话:就是C++对内存生命周期加强管理的手段。
在c/c++编程中,哪个地方更让人难以捉摸,那么一定是指针莫属。指针其实就是内存的控制的一个入口,这个应该没有什么争论。那么,这里提出一种情况来看怎么处理内存?看一下代码:


template 
class coreoptional
{
private:
T payload;
public:
coreoptional(const T& t)
: payload(t) {
}
template
void emplace(Args&&... args) {
payload.~T();

::new (&payload) T(std::forward(args)...);
}
const T& operator * () const & {
return payload;
}
};
//If here T is a structure with constant or reference members:
struct X
{
const int _i;
X(int i) : _i(i) {}
friend std::ostream& operator<< (std::ostream& os, const X& x) {
return os << x._i;
}
};
then the following code results into undefined behavior:
coreoptional optStr{42};
optStr.emplace(77);
std::cout << * optStr; // undefined behavior (probably outputs 42 or 77)

这段代码很简单,主要关注点在emplace,它表明调用这个函数会在payload原地创建一个新T对象。而这里恰恰有问题,那就是当一个T 对象调用此函数后,其本身做为一个临时对象已经被释放,而此时如果调用返回其实例的重载运算符就可能会产生UB的事件。当然,这里有一个前提就是这个对象必须是const修饰或者如果其为类类型其部有const修饰时。这个很好理解,毕竟内存变了,但const又不能允许变,那么到底应该返回什么 呢?这就是语法上的冲突了。
所以要解决上面的问题,就需要增加一个辅助的变量T*ptr_,来拿到 placement new后的新的地址和新的值。请注意,不要认为一个地址生成的两个对象是一个东西 。举一个不恰当的例子,你的房子过户给了别人,地址还是那个地址,但还是你的么?
所以这个新的辅助变量看上去是必须的,可从情理上讲,同一个地址你反复搞两回,是不是有点过分?所以只要使用std::launder在原地把对象返回去即可即:

return std::launder(&payload);

其实也可以在emplace时使用,那么此处就可以不再使用了。
这其实就是在对glvalue使用时,标准要求对象必须已经存在,而上面的payload在调用函数emplace时已经销毁,这里只不过原地重建罢了。这和古建筑原地重建一样,即使完全的一模一样,人们也不会认为二者是同一个东西。所以C++标准要求placement new 后必须使用新的指针来返回对象。
那么标准为什么会这么要求呢?一个重要的原因就是,为了优化,看下面的例子:

struct A { const int i; };

void foo(A* p);

int main()
{
  A a{ 42 };
  foo(&a);
  return a.i;
}

转来转去,此处还得重点声明一下,在C++20后,又可以直接使用上面的不带std::launder的代码了,标准在前进嘛。
最后,再看一个简单的例子来加深印象:

struct X { const int n; };
union U { X x; float f; };
void tong() {
U u = {{ 1 }};
u.f = 5.f; // OK, creates new subobject of u
X * p = new (&u.x) X {2}; // OK, creates new subobject of u
assert(p->n == 2); // OK
...
assert(u.x.n == 2); // undefined behavior, u.x does not name new subobject
}

另外,在一些支持硬件 pointer provenance 的架构的机器上,std::launder可以得到一些相关的生命周期的信息(provenance主要还是为了内在安全和相关信息),这有点超纲了。有兴趣可以自己去看看。

二、std::start_lifetime_as

在分析start_lifetime_as之间,先看一个小例子来引入:

struct X { int a, b; };
X* make_x() {
X* p = (X*)malloc(sizeof(struct X)); // implicitly creates an object of type X
p->a = 1;
p->b = 2;
return p;
}

这段代码是不是看起来非常平常,估计很多人都写过类似代码,甚至在教科书上都看到过类似的代码。这里面有什么问题么?没问题,至少在不少的平台跑起来非常好。但和标准对比一下,就会发现有问题。
C++20前标准中只有程序给对象分配好了存储空间并且初始化完成了,一个对象的生命周期才算开始,而一个对象的生命周期开始而未结束才被认为对象的存在。那么上面的代码照着语法分析,就会发现不和谐了。怎么办?Fix啊,打补丁。先是声明类似malloc这些标准库以及编译器厂商的类似函数可以 实现隐式的对象创建(这纯粹就是垄断),但面对大量的自定义的非标准的库,标准委员会也不得不作出妥协,这就提出了std::start_lifetime_as。
先扯点远的,不一定非得搞一个新的东西出来啊?完全可以用前面的placement new啊?但是这里有一个问题,无论哪种内存分配,一般在分配完成后,内存中的原有数据就是没用的了。可有些情况下,比如从文件读出一些序列化数据流,从Socket中接到的一大批序列化数据流等等,都需要重建数据,这里如果原地搞一下new,估计接收者就疯了。
但是以往的经验又告诉开发者,其实这种在c/c++的直接序列化中(未使用第三方的序列化库)直接强转(或static_cast)更简单更容易理解。至少在以前看到的很多IO通信中的案例中,都是如此操作的。这就替代了std::launder和std::start_lifetime_as的部分应用。仁者见仁吧,总之标准是标准,实际情况又是实际情况。反正编译器厂商也不是标准的应声虫。
所以看一下上面的代码如何修正:

struct X { int a, b; };
X * make_x()
{
X * p = std::start_lifetime_as(myMalloc(sizeof(struct X));
p->a = 1;
p->b = 2;
return p;
}

流的数据例子也是如此。
另外需要提到的是std::bit_cast,它和std::start_lifetime_as其实有些类似,不过前者返回一个纯右值而不是如后者在原地创建,这个很关键:

template< class To, class From >
constexpr To bit_cast( const From& from ) noexcept;

三、二者的关系

可能大多数认为std::launder不涉及到生命周期的管理,而std::start_lifetime_as会涉及。但此事有商榷的余地,看从哪个角度看问题。之所以用std::launder就是因为生命周期不存在了,需要重建,那么从这个角度来看,它和生命周期紧相关,如果单纯从功能本身看,可能确实和生命周期没有什么 关系。而std::start_lifetime_as你看后面那个as,其实就是一种改头换面的意思(有xxx_cast的味道),如果合适原来的数据对象就改换过来了。所以它有重新开始一个新对象的生命周期的意思,也就是说其本身就有生命周期的内涵。
std::launder还可以 屏蔽掉继承的虚函数,也就是说通过其重建后的对象不能用父类和子类的对象实现多态来进行操作了(即父就是父,子就是子)。另外std::start_lifetime_as还支持内在对齐,这个还是比较有用的。在很多的应用 中,内在对齐是强制要求的。

更多的内容可以看一下相关的文档:
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0532r0.pdf
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p2590r2.pdf

四、总结

学习新知识,回顾老知识和老问题,这次真得是弄明白了很多东西。原来只是觉得可以,现在知道是为什么。
越往C++的底层学习,就会发现大量东西已经无法和标准断开了学习了。在上层应用,只要会它的定义适用范围,这基本就好了。但在底层,一些语法标准定义出来后,在实际应用中,会产生各种想不到的情况,就需要对标准完善和升级,这都是一个缓慢但又必须要做的事情。
而从这些标准看起来,就会发现,其它语言和C++之间的千丝万缕的联系,对于学习其它语言有着更容易理解和直达原理以及产生的原因。这也是学习较为底层的语言一个优势或者说长处。
生命周期是各个语言的一个重点的设计模块,它涉及到内在的安全和运行管理的效率,在当初学习内存垃圾回收时也重点阐述了这点,互相对照,就更容易加深印象。

你可能感兴趣的:(C++11,C++,c++,开发语言,算法)