如果 Derived 没有向 Base 添加新成员(并且是 POD),则可以安全地完成哪种指针强制转换和取消引用

If Derived adds no new members to Base (and is POD), then what kind of pointer casts, and dereferencing, can be safely done?

本文关键字:安全 指针 取消 引用 转换 Base 添加 Derived 新成员 POD 如果      更新时间:2023-10-16

(这是关于未定义行为(UB)的另一个问题。 如果这段代码在某个编译器上"工作",那么这在UB的土地上没有任何意义。 这是可以理解的。 但是我们究竟在下面的哪条线上进入 UB?

关于 SO 已经有许多非常相似的问题,例如 (1) 但我很好奇在取消引用指针之前可以安全地使用这些指针。

从一个非常简单的基类开始。 没有virtual方法。没有继承。 (也许这可以扩展到任何 POD ?

struct Base {
        int first;
        double second;
};

然后是一个简单的扩展,它添加(非virtual)方法并且不添加任何成员。 没有virtual继承。

struct Derived : public Base {
        int foo() { return first; }
        int bar() { return second; }
};

然后,考虑以下几行。 如果与定义的行为有一些偏差,我很想知道到底是哪条线。 我的猜测是,我们可以安全地对指针执行大部分计算。 这些指针计算中的一些,如果没有完全定义,是否有可能至少给我们某种并非完全无用的"不确定/未指定/实现定义"的值?

void foo () {
    Base b;
    void * vp = &b;     // (1) Defined behaviour?
    cout << vp << endl; // (2) I hope this isn't a 'trap value'
    cout << &b << endl; // (3a) Prints the same as the last line?
                        // (3b) It has the 'same value' in some sense?
    Derived *dp = (Derived*)(vp);
                        // (4) Maybe this is an 'indeterminate value',
                        // but not fully UB?
    cout << dp << endl; // (5)  Defined behaviour also?  Should print the same value as &b

编辑:如果程序到此结束,它会是UB吗? 请注意,在此阶段,除了将指针本身打印到输出之外,我没有尝试对dp执行任何操作。 如果简单地铸造是 UB,那么我想问题到此结束。

                        // I hope the dp pointer still has a value,
                        // even if we can't dereference it
    if(dp == &b) {      // (6) True?
            cout << "They have the same value. (Whatever that means!)" << endl;
    }
    cout << &(b.second) << endl; (7) this is definitely OK
    cout << &(dp->second) << endl; // (8)  Just taking the address. Is this OK?
    if( &(dp->second) == &(b.second) ) {      // (9) True?
            cout << "The members are stored in the same place?" << endl;
    }
}

我对上面的(4)有点紧张。 但我认为在空指针之间投射总是安全的。 也许可以讨论这种指针的值。 但是,它是否定义为进行强制转换,并打印指向cout的指针?

(6)也很重要。 这会评估为真吗?

(8) 中,我们第一次取消引用此指针(正确的术语? 但请注意,此行不会从dp->second读取。 它仍然只是一个左值,我们采用它的地址。 我认为,这种地址计算是由我们从 C 语言中获得的简单指针算术规则定义的?

如果以上所有内容都没问题,也许我们可以证明static_cast<Derived&>(b)是可以的,并且将导致一个完全可用的对象。

  1. 从数据指针到void *的转换始终保证有效,并且指针保证在往返Base * -> void * -> Base * (C++11 §5.2.9 ¶13
  2. );
  3. vp是一个有效的指针,所以应该没有任何问题。
  4. A. 尽管打印指针是实现定义的1,但打印的值应该是相同的:事实上,默认情况下operator<<只对const void *重载,所以当你写cout<<&b时,你无论如何都要转换为const void *,即operator<<看到的在这两种情况下都&b转换为const void *

    b. 是的,如果我们采用"具有相同值"的唯一合理定义 - 即它与==运算符相等;事实上,如果您将vp&b==进行比较,则结果是 true ,如果您将vp转换为Base *(由于我们在 1 中所说的), 如果您将&b转换为void *.

    这两个结论都来自§4.10 ¶2,其中指定任何指针都可以转换为void *(模化通常符合cv标准的东西),并且结果"指向对象[...]的存储位置的开始[...]驻留»1

  5. 这很棘手; C 样式的强制转换等同于static_cast,这将愉快地允许铸造一个 «"指向 cv1 B 的指针 [...]到 [...]"指向 *cv2 D 的指针",其中 D 是从 B » 派生的类 » (§5.2.9, ¶11;还有一些额外的约束,但在这里得到满足);但是

    如果 prvalue 的类型 "指针指向 cv1 B " 指向一个实际上是类型 D 对象的子对象的子对象B,则生成的指针指向类型 D 的封闭对象。否则,转换的结果是未定义的。

    (着重号后加)

    所以,这里允许你的演员表,但结果是不确定的......

  6. 。这导致我们打印它的价值;由于强制转换的结果是未定义的,因此您可能会得到任何东西。由于指针可能被允许具有陷阱表示(至少在 C99 中,我只能在 C++11 标准中找到对"陷阱"的稀疏引用,但我认为这种行为可能应该已经继承自 C89)您甚至可能通过读取此指针通过 operator<< 打印它而崩溃。

如果遵循,则 6、8 和 9 也没有意义,因为您使用的是未定义的结果。

此外,即使强制转换有效,严格的混叠(§3.10,¶10)也会阻止你对指向的对象做任何有意义的事情,因为只有当Base对象的动态类型实际Derived时,才允许通过Derived指针为Base对象锯齿;任何偏离§3.10 ¶10中指定的异常都会导致未定义的行为。


笔记:

  1. operator>>委托给num_put,在概念上委托给printf%p,其描述归结为"实现定义"。

  2. 这排除了我的担忧,即邪恶的实现在投射到void *时理论上可能会返回不同但等效的值。

(试图从严格混叠的角度回答我自己的问题。 一个好的优化器有权做一些意想不到的事情,这有效地给了我们UB。 但我不是专家,无论如何!

在此函数中,

 void foo(Base &b_ref) {
     Base b;
     ....
 }

很明显,bb_ref不能互相指代。 这个特定的例子不涉及对兼容类型的分析,它只是一个简单的观察,即新构造的局部变量保证是对其自身的唯一引用。 这允许优化器执行一些技巧。 它可以将b存储在寄存器中,然后它可以执行代码,例如b_ref.modify(),修改b_ref,安全地知道b不受影响。 (也许只有真正聪明的优化者才会注意到这一点,但这是允许的。

接下来,考虑一下,

void foo(Base &b_ref, Derived&d_ref);

在此函数的实现中,优化不能假定b_ref和d_ref引用不同的对象。 因此,如果代码调用 d_ref.modify() ,则下次代码访问b_ref时,它必须再次查看存储b_ref对象的内存。 如果 CPU 寄存器中有b_ref数据的副本,则可能是过期数据。

但是,如果这些类型彼此无关,那么这种优化是允许的。

struct Base1 { int i; };  struct Base2 { int i; };
void foo(Base1 & b1_ref, Base2 &b2_ref);

可以假定这些指向不同的对象,因此允许编译器做出某些假设。 b2_ref.i=5;不能改变b1_ref.i,因此编译器可以做出一些假设。 (实际上,可能还有其他线程,甚至是 POSIX 信号,在幕后进行更改,我必须承认我不会清除线程!

因此,允许编译器进行优化的假设。 考虑一下:

Base b; // a global variable
void foo() {
    Derived &d_ref = some_function();
    int x1 = b.i;
    d_ref.i = 5;
    int x2 = b.i;
}

有了这个,优化器就知道b的动态类型,这是一个Base。 对b.i的两次连续调用应该给出相同的值(其他线程或其他线程除外),因此允许编译器优化后者以int x2 = x1。 如果some_function返回一个Base&,即 Base &d_ref = some_function();,编译器将不允许进行此类优化。

因此,给定一个对象,编译器知道它的动态类型是Base,并且Derived&对派生类型的引用,编译器有权假设它们引用不同的对象。 允许编译器稍微重写代码,假设两个对象不相互引用。 这至少会导致不可预测的行为。 你所做的任何违反优化程序允许做出的假设的行为都是未定义的行为。