reinterpret_cast、char*和未定义行为

20

在哪些情况下将 char*(或 char[N])进行 reinterpret_cast 是未定义行为,而何时是定义行为?我应该使用什么经验法则来回答这个问题?


正如我们从这个问题中学到的那样,以下是未定义行为:

alignas(int) char data[sizeof(int)];
int *myInt = new (data) int;           // OK
*myInt = 34;                           // OK
int i = *reinterpret_cast<int*>(data); // <== UB! have to use std::launder

但是,在哪个时刻我们可以对一个char数组进行reinterpret_cast而不会出现未定义行为呢?以下是一些简单的例子:

  1. 没有new,只有reinterpret_cast

alignas(int) char data[sizeof(int)];
*reinterpret_cast<int*>(data) = 42;    // is the first cast write UB?
int i = *reinterpret_cast<int*>(data); // how about a read?
*reinterpret_cast<int*>(data) = 4;     // how about the second write?
int j = *reinterpret_cast<int*>(data); // or the second read?

int的生命周期是从声明data开始的吗?如果是,那么data的生命周期何时结束?

  • 如果data是指针会怎样呢?

  • char* data_ptr = new char[sizeof(int)];
    *reinterpret_cast<int*>(data_ptr) = 4;     // is this UB?
    int i = *reinterpret_cast<int*>(data_ptr); // how about the read?
    
  • 如果我只是从网络中接收结构体,并想根据第一个字节有条件地将它们转换,该怎么办?

  • // bunch of handle functions that do stuff with the members of these types
    void handle(MsgType1 const& );
    void handle(MsgTypeF const& );
    
    char buffer[100]; 
    ::recv(some_socket, buffer, 100)
    
    switch (buffer[0]) {
    case '1':
        handle(*reinterpret_cast<MsgType1*>(buffer)); // is this UB?
        break;
    case 'F':
        handle(*reinterpret_cast<MsgTypeF*>(buffer));
        break;
    // ...
    }
    

    这些情况中是否有UB?全部都是吗?这个问题的答案在C++11到C++1z之间有变化吗?


    (1)在我看来是有效的。在这两个语句中,都会产生一个新的int对象并赋值。读取该值是开始变得棘手的地方。对于**(2)也是一样(假设sizeof(int)==4)。对于(3)**,在我看来是未定义行为。 - Igor Tandetnik
    @IgorTandetnik 加入了一些阅读材料,去掉了关于 sizeof(int) 的假设,谢谢。 - Barry
    1
    现在**(1)(2)**似乎表现出UB,和相关的问题一样。通过保存第一个转换的指针,并将其用于所有后续的写入和读取,可以轻松挽救。 - Igor Tandetnik
    大多数编译器表现得像你所期望的那样,即使它没有确切定义。在这里查看一些更多的信息:https://dev59.com/Q1kS5IYBdhLWcg3w25sM。 - user2296177
    3
    language-lawyer 标签无关。 ;-] - ildjarn
    使用P0137,[intro.object]/1清晰地表明了对象何时被创建。在前两个示例中,datadata_ptr上没有任何活动的int对象。 - T.C.
    1个回答

    13

    这里有两条规则:

    1. [basic.lval]/8,也就是严格别名规则:简单来说,你不能通过指向/引用错误类型的指针/引用来访问一个对象。

    2. [base.life]/8:简单来说,如果你将存储器用于不同类型的对象,那么在未清理之前不能使用旧对象的指针。

    这些规则是区分“内存位置”或“存储区域”和“对象”的重要部分。

    所有您的代码示例都存在相同的问题:它们不是您转换为的对象:

    alignas(int) char data[sizeof(int)];
    
    这将创建一个类型为char[sizeof(int)]的对象。这个对象不是int。因此,您不能像访问int一样访问它。无论是读取还是写入都无所谓;您仍然会引发UB。

    类似地:

    char* data_ptr = new char[sizeof(int)];
    
    那也会创建一个类型为 char[sizeof(int)] 的对象。
    char buffer[100];
    
    这将创建一个类型为char[100]的对象。该对象既不是MsgType1也不是MsgTypeF。因此,您不能像访问它们一样访问它。

    请注意,当您将缓冲区作为Msg*类型之一进行访问时,UB发生在此处,而不是在检查第一个字节时。如果您所有的Msg*类型都是平凡可复制的,那么读取第一个字节,然后将缓冲区复制到适当类型的对象中是完全可以接受的。

    switch (buffer[0]) {
    case '1':
        {
            MsgType1 msg;
            memcpy(&msg, buffer, sizeof(MsgType1));
            handle(msg);
        }
        break;
    case 'F':
        {
            MsgTypeF msg;
            memcpy(&msg, buffer, sizeof(MsgTypeF));
            handle(msg);
        }
        break;
    // ...
    }
    

    请注意,我们讨论的是语言规定为未定义行为的情况。很有可能编译器会对这些情况都处理得很好。

    C++11到C++1z之间对这个问题的回答是否有所改变?

    自C++11以来,一些重要的规则澄清已经发生(特别是关于[基本生命期])。但规则背后的意图没有改变。


    1
    @Barry: 这不是 std::launder 的作用。如果你在旧对象的存储中开始新对象的生命周期,它允许你从旧对象的指针获取新对象的指针。它不会启动任何东西的生命周期。 "声明我的 char 数组不可能构成获取某些尚未被虚无初始化的类型 T 的存储空间吗?" 按照这个逻辑,任何对象都可以是“尚未被虚无初始化的类型 T”。毕竟,一个对象有存储空间。char[X] 和任何其他对象一样。 - Nicol Bolas
    2
    @Barry:Placement new开始了一个对象的生命周期,即使在该存储中已经有了一个对象。第一条语句将一个char [4]放置在该存储中。第二条语句结束了char [4]的生命周期,并开始了int的生命周期。 - Nicol Bolas
    1
    @Barry:"placement new 获得存储空间吗?" 是的。放置新语法只是向分配函数提供额外参数。在这种情况下,void* 参数会引发对operator new重载的调用,该重载仅返回传递的参数。但它仍然是一个分配函数,仍然获取存储空间。从来没有说过有关new存储的内容。 - Nicol Bolas
    1
    C++20中是否有这种变化?(至少对某些类型是这样的吗?)我记得至少有一个提案来定义以前未定义的关于指向平凡类型/未初始化内存的行为。 - aij
    1
    @aij:是的,但问题特别标记为C++17,所以这与问题无关。您可以通过查找“隐式生命周期”或“隐式对象构造”或类似内容来了解更多信息。 - Nicol Bolas
    显示剩余3条评论

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接