【问题标题】:Casting a char array to an object pointer - is this UB?将 char 数组转换为对象指针 - 这是 UB 吗?
【发布时间】:2018-12-16 07:46:09
【问题描述】:

我最近看到一个这样的类,它用于“按需”构造对象,而由于各种原因而不必使用动态内存分配。

#include <cassert>

template<typename T>
class StaticObject
{
public:
    StaticObject() : constructed_(false)
    {
    }

    ~StaticObject()
    {
        if (constructed_)
            ((T*)object_)->~T();
    }

    void construct()
    {
        assert(!constructed_);

        new ((T*)object_) T;
        constructed_ = true;
    }

    T& operator*()
    {
        assert(constructed_);

        return *((T*)object_);
    }

    const T& operator*() const
    {
        assert(constructed_);

        return *((T*)object_);
    }

private:
    bool constructed_;
    alignas(alignof(T)) char object_[sizeof(T)];
};

这段代码,即将正确对齐的字符数组转换为对象指针,是否被 C++14 标准视为未定义行为,还是完全没问题?

【问题讨论】:

  • 认真做就好了。此外,C++17 中有 std::optionalboost::optional 做类似的事情。但我会把它留给更了解这个具体案例的人。
  • 使用新的展示位置。这将避免任何 UB。
  • @RichardCritten 不是 new ((T*)object_) T; 完全是新的展示位置吗?
  • 旁注:不需要强制转换,因为放置 new 无论如何都接受 void 指针...
  • There's a standard class intended to be used this way... 没有直接回答 w.r.t char 的问题,但确实证明了该标准至少期望这些方面的某些东西是可能的。

标签: c++ pointers c++14 undefined-behavior


【解决方案1】:

该程序在技术上具有未定义的行为,尽管它可能适用于大多数实现。问题是从char*T* 的转换不能保证产生指向由placement new 创建的T 对象的有效指针,即使char* 指针表示用于的第一个字节的地址T 对象的存储空间。

[basic.compound]/3:

指向布局兼容类型的指针应具有相同的值表示和对齐要求 ([basic.align])。

一般来说,Tcharalignas(T) char[sizeof(T)] 的布局不兼容,因此不要求指针T* 与指针char*void* 具有相同的值表示形式.

[basic.compound]/4:

两个对象 abpointer-interconvertible 如果:

  • 它们是同一个对象,或者

  • 一个是联合对象,另一个是该对象的非静态数据成员([class.union]),或者

  • 一个是标准布局类对象,另一个是该对象的第一个非静态数据成员,或者,如果该对象没有非静态数据成员,则该对象的任何基类子对象([class.内存]),或

  • 存在一个对象 c 使得 ac 是指针可互转换的,并且 c 和 b 是指针可相互转换的。

如果两个对象是指针可互转换的,那么它们具有相同的地址,并且可以通过reinterpret_cast 从指向另一个对象的指针中获得指向其中一个对象的指针。 [ 注意: 一个数组对象和它的第一个元素不是指针可互转换的,即使它们有相同的地址。 — 尾注 ]

[旁白:DR 2287 在 C++17 发布后的第二个项目符号中将“标准布局联合”更改为“联合”。但这并不影响这个程序。]

由放置 new 创建的 T 对象不能与 object_object_[0] 指针互转换。并且注释暗示这可能是演员表的问题......

对于C风格的演员((T*)object_),我们需要看到[expr.cast]/4

执行的转换

  • const_cast,

  • static_cast,

  • static_cast 后跟 const_cast

  • reinterpret_cast,或

  • reinterpret_cast 后跟 const_cast

可以使用显式类型转换的强制转换符号来执行......

如果转换可以用以上列出的一种以上方式解释,则使用列表中第一个出现的解释,即使由该解释产生的强制转换是格式错误的。

除非Tchar 或cv-qualified char,否则这实际上是reinterpret_cast,所以接下来我们看看[expr.reinterpret.cast]/7

对象指针可以显式转换为不同类型的对象指针。当对象指针类型的prvaluev转换为对象指针类型“pointer to cv T”时,结果为static_­cast&lt;cv T*&gt;(static_­cast&lt; 简历 void*&gt;(v)).

首先我们有一个从char*void*static_cast,它执行[conv.ptr]/2 中描述的标准转换:

“指向cvT”类型的纯右值,其中T 是一个对象类型,可以转换为“指向cv的指针”类型的纯右值>void"。此转换未更改指针值([basic.compound])。

这后面是从void*T*static_cast,在[expr.static.cast]/13 中进行了描述:

“指向cv1 void的指针”类型的纯右值可以转换为“指向cv2 T的指针”类型的纯右值,其中T是一个对象类型,并且 cv2 的 cv 限定与 cv1 相同或更高。如果原始指针值表示内存中某个字节的地址A,而A不满足T的对齐要求,则结果指针值未指定。否则,如果原始指针值指向一个对象 a,并且存在一个类型为 T(忽略 cv-qualification)的对象 b 可以与a,结果是一个指向b的指针。否则,指针值不会因转换而改变。

如前所述,T 类型的对象不能与object_[0] 指针互转换,因此该语句不适用,并且不能保证结果T* 指向T 对象!我们只剩下“指针值不变”这句话,但如果char*T* 指针的值表示形式相差太大,这可能不是我们想要的结果。

可以使用union 实现此类的符合标准的版本:

template<typename T>
class StaticObject
{
public:
    StaticObject() : constructed_(false), dummy_(0) {}
    ~StaticObject()
    {
        if (constructed_)
            object_.~T();
    }
    StaticObject(const StaticObject&) = delete; // or implement
    StaticObject& operator=(const StaticObject&) = delete; // or implement

    void construct()
    {
        assert(!constructed_);

        new(&object_) T;
        constructed_ = true;
    }

    T& operator*()
    {
        assert(constructed_);

        return object_;
    }

    const T& operator*() const
    {
        assert(constructed_);

        return object_;
    }

private:
    bool constructed_;
    union {
        unsigned char dummy_;
        T object_;
    }
};

甚至更好,因为这个类本质上是在尝试实现一个optional,如果你有std::optional,如果你没有,就使用boost::optional

【讨论】:

  • 所以总结是:您不能将unsigned char[]-storage 转换为T*,因为它们(通常)不是指针可互转换的,对吗?这如何涵盖aligned_storage 的用例?在 C++17 之前(要求使用 std::launder),这似乎是一个非常有效的用例。我认为这些规则可以涵盖does not satisfy the alignment requirement of T, then the resulting pointer value is unspecified. 所指示的对齐问题另请参阅@eerorika 的答案
  • @Flamefire 对齐要求与指针可互转换要求是分开的。是的,在 C++17 之前,考虑到宽松的指针安全性,这很好,因为 [basic.compound]/3 “如果 T 类型的对象位于地址 A,则 cv 类型的指针 T* 的值是地址 A 被称为指向该对象,无论该值是如何获得的。"并且没有“指针可相互转换”,因此定义了强制转换以保留地址。
  • @Flamefire 但是,强制转换产生了一个指向 T 的指针,但不是安全派生的指针值。因此,即使在 C++11/C++14 中,如果实现具有严格的指针安全性,则该指针也是无效的。修复将是std::declare_reachable。我不确定std::launder 是否真的解决了 C++17 中的任何一个问题,因为不再保证强制转换的结果首先代表相同的地址。
  • std::declare_reachable 不是用于 GC 吗?而且我确实认为“演员表的结果代表相同的地址”,请参阅我的回答。请检查我是否遗漏了什么,但我相信它是正确的。我试图收集所有碎片并将它们与它的工作原理联系起来。否则常用的构造将无效,std::aligned_storage 将毫无用处。
【解决方案2】:

将 char 数组转换为对象指针 - 这是 UB 吗?

使用 C 风格的转换将一个指针(数组衰减为一个指针)转换为不在同一继承层次结构中的另一个指针执行重新解释转换。重新解释演员表本身永远不会有 UB。

但是,如果适当类型的对象尚未构造到该地址中,则间接转换的指针可能具有 UB。在这种情况下,已经在字符数组中构造了一个对象,因此间接具有明确定义的行为。编辑:如果不是严格的别名规则,间接将是 UB 免费的;有关详细信息,请参阅 ascheplers 的答案。 aschepler 展示了一个符合 C++14 的解决方案。在 C++17 中,可以通过以下更改更正您的代码:

void construct()
{
    assert(!constructed_);
    new (object_) T; // removed cast
    constructed_ = true;
}

T& operator*()
{
    assert(constructed_);
    return *(std::launder((T*)object_));
}

要将对象构造成另一种类型的数组,必须满足三个要求才能避免UB:必须允许另一种类型对对象类型进行别名(charunsigned charstd::byte满足此要求所有对象类型),地址必须按照对象类型的要求与内存边界对齐,并且任何内存都不能与另一个对象的生命周期重叠(忽略数组的底层对象,这些对象允许为覆盖的对象起别名) .您的程序满足所有这些要求。

【讨论】:

  • 您的第二段错误,因为您违反了结构别名(object_ 没有T* 类型)
  • @Rakete1111 它不需要类型为T*。允许重复使用char 存储。
  • 允许重用 char 存储是允许的,但我要说的是,您可以将对象指针转换为 char*(并返回),但如果您以 char* 开头,那么即使使用new,您也无法从中获取对象指针。
  • @Rakete1111 这是不正确的。如果允许重用char 存储(确实如此),那么如果“您无法从中获取对象指针” 为真,您将如何建议访问重用的对象?
  • @Rakete1111 我做了一些研究,看起来你是对的,虽然我觉得规则令人沮丧。据我了解,OP 的代码可以通过删除construct 中的错误转换并在间接转换后的指针之前添加std::launder 来修复。但是std::launder 不在 C++14 中:(
【解决方案3】:

在给@aschepler 写评论后,我想我找到了正确的答案:

不,它不是 UB!

非常强烈的提示:aligned_storage 正是为了这样做。

  • basic.compound[4] 为我们提供了“指针互转换”的定义。上述情况均不适用,因此 T*unsigned char[...] 不是指针可互转换的。
  • conv.ptr[2]expr.static.cast[13] 告诉我们reinterprer_cast&lt;T*&gt;(object_) 上发生了什么。基本上,(中间)转换为void* 不会改变指针的值,而从void* 转换为T* 也不会改变它:

    如果原始指针值表示内存中一个字节的地址A,而A不满足T的对齐要求,那么得到的指针值是未指定。否则,如果原始指针值指向对象a,并且存在与apointer-interconvertible的类型为T(忽略cv-qualification)的对象b,则结果是指向b的指针. 否则,指针值不会因转换而改变。

    我们在这里有一个正确对齐的,不是指针可互转换的类型。因此值不变。

  • 现在在 P0137 之前(在another answer 找到)basic.compound[3] 说:

    如果一个类型为 T 的对象位于地址 A 处,则称其值为地址 A 的类型为 cv T* 的指针指向该对象,而不管该值是如何获得的。

    现在是basic.compound[3]

    指针类型的每个值都是以下之一:

    (3.1) 指向对象或函数的指针(据说该指针指向对象或函数),[...]

    我认为与此目的等效。

  • 最后我们需要basic.lval[11]

    如果程序尝试通过类型与以下类型之一不相似 ([conv.qual]) 的泛左值访问对象的存储值,则行为未定义:52 [...]

    (11.3) char、unsigned char 或 std​::​byte 类型。

    这归结为别名规则,它只允许某些类型使用别名,我们的unsigned char 是其中的一部分。

总之:

  • 我们符合对齐和别名规则
  • 我们得到一个已定义的指向T* 的指针值(与unsigned char* 相同)
  • 因此我们在那个地方有一个有效的对象

这基本上是@eeerorika 也有的。但我认为,从上面的论点来看,至少如果 T 没有任何 const 是引用成员,那么代码是完全有效的,在这种情况下必须使用 std::launder。即使这样,如果内存没有被重用(但仅用于创建 1 个T),那么它也应该是有效的。

然而,旧的 GCC (https://godbolt.org/z/Gjs05C 尽管docu 声明:

例如,unsigned int 可以给 int 取别名,但不能给 void* 或 double 取别名。 **一个字符类型可以别名任何其他类型。 **

这是bug

【讨论】:

  • 别名规则不是对称的。允许通过unsigned char 访问任何类型,但不能相反。因此,如果没有在此处创建此类 int 对象,则使用从缓冲区转换为偶数类型(如 int)是非法的。 basic.compound[3] 中的指针值有四个选项,因此您需要证明强制转换的结果是“指向对象的指针”而不是“无效的指针值”。即使指针确实“表示”对象的地址,也不再意味着它“指向”对象。您的解释似乎暗示“指针可相互转换”没有区别。
  • 通过前 2 个点,可以保证强制转换将产生与 unsigned char 数组相同的指针值。放置 new 在那里放置一个对象T。因此,使用该缓冲区之后转换为 T* 是有效的,不是吗?至于别名规则,请参阅我的最后一点:它允许通过unsigned char* 访问任何 T。位置 new 在那里创建了一个对象,因此这再次有效。 “pointer-interconvertible”确实有所不同:如果类型是“pointer-interconvertible”,那么地址可能会改变(这里不需要)
  • 我现在确信“指针值不变”指的是 [basic.compound]/3 中描述的可能值,而不是值表示。所以reinterpret_cast,通过static_cast 来自void*,可以给出一个X* 类型的指针,它指向一个Y 类型的对象,这两种类型不相关。这是en.cppreference.com/w/cpp/language/reinterpret_cast#Notes 的第一个示例中的 cmets 所暗示的含义......所以我的答案确实需要一些编辑。
  • 所以在 OP 中,((T*)object_) 指向char 对象object_[0],而不指向由placement new 创建的T 类型的对象。通过该指针访问是一种别名冲突,因为它通过类型T 访问char,尽管允许通过char 访问任何对象。唯一技术上正确的方法,包括使用std::aligned_storage,是使用std::launder,使用联合技术使对象指针可互转换,或单独存储放置新表达式的指针值结果(即通常是浪费的)。
  • On reddit 我发现“§18.6.2.3/1-2 清楚地表明placement-new 总是准确返回传递给它的地址”。所以我们可以确定placementNewResultPtr == ((T*)object_)basic.lval[11] 允许我们“通过 unsigned char 类型的 glvalue 访问 T 的存储值。那么为什么这是一个别名违规?特别是如果我们只使用 casted-T 指针,我们也不应该遇到生命周期问题。
【解决方案4】:

当您创建这样的StaticObject 时,它会为 T 对象保留具有正确对齐约束和正确大小的存储空间,但不会构造该对象。

construct() 被调用时,它会调用placement-new 来构造保留存储中的对象(正确对齐且不为空)。这不是最自然的方式,但这里没有 UB。

唯一可能是 UB,是放置 new 是否会覆盖已经存在的对象。但这是通过assert() 阻止的。

【讨论】:

  • 不,有 UB。 object_ 不是 T*,因此取消引用是 UB,因为此时没有 T 对象。
  • @Rakete1111 它没有在construct() 中取消引用,所以没有UB。如果在construct()之前在StaticObject上调用解引用运算符,它只会是UB,但是断言阻止了这种情况。
  • 没有。 operator* 中的取消引用无效,因为违反了结构别名。
  • @Rakete1111 请在您自己的答案中详细说明您的论点。因为获胜的答案在这里也看不到 UB。
【解决方案5】:

你确实有未定义的行为。

object_ 不是T*,所以转换和取消引用它是 UB。您不能使用object_ 来引用新创建的对象。这也称为严格别名。

不过,修复很简单:只需创建一个新的成员变量T*,您可以使用它来访问构造的对象。然后你需要将放置 new 的结果分配给那个指针:

ptr = new(object_) T;

[basic.life]p1 说:

类型 T 的对象 o 的生命周期在以下情况下结束:

  • 如果 T 是具有非平凡析构函数的类类型,则析构函数调用开始,或者

  • 对象占用的存储被释放,或者被未嵌套在o中的对象重用。

因此,通过执行new (object_) T;,您将结束原始char[] 对象的生命周期,并开始我们将调用t 的新T 对象的生命周期。

现在我们要检查*((T*)object_)是否有效。

[basic.life]p8 突出显示重要部分:

如果在一个对象的生命周期结束之后并且在该对象占用的存储空间被重用之前,或者 释放后,在原对象占用的存储位置创建一个新对象,一个指针 指向原始对象、引用原始对象的引用或原始对象的名称 对象将自动引用新对象,一旦新对象的生命周期开始,就可以 用于操作新对象,如果

  • 新对象的存储恰好覆盖了原始对象占用的存储位置, 和

  • 新对象与原始对象的类型相同(忽略顶级 cv 限定符),并且

  • 原始对象的类型不是 const 限定的,并且,如果是类类型,则不包含任何非静态 类型为 const 限定或引用类型的数据成员,以及

第二点不成立(T vs char[]),所以不能使用object_作为指向新创建对象t的指针。

【讨论】:

  • 对不起,但我仍然不明白为什么如果指向有效构造的对象,强制转换和取消引用将是 UB。另见stackoverflow.com/a/31615329/3723423。请提供对标准的参考以证明您的案例。
  • @Rakete1111 There is no T object. 这是错误的。 一个T 对象。该对象的生命周期从这一行开始:new ((T*)object_) T;。你认为你的ptr = new(object_) T; 做了什么,原来的行没有?也就是说,我更喜欢你的代码,因为原始代码中的强制转换是多余的。
  • @user2079303 是的,这句话令人困惑。我的意思是你不能使用object_ 来访问新对象。
  • @Christophe Done :) 不幸的是,您的链接答案是错误的。
  • Rakete1111 是对的。您不能只取指针并投射它。您需要std::launder 才能再次获得定义的行为。这里给出一个例子:en.cppreference.com/w/cpp/types/aligned_storage
猜你喜欢
  • 2012-05-03
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2018-06-11
  • 2017-02-12
  • 1970-01-01
  • 2013-01-10
  • 1970-01-01
相关资源
最近更新 更多