不使用std::launder,将std::aligned_storage*重新解释为T*是否违反了严格别名规则?

11
下面的例子来自于cppreference.com的std::aligned_storage页面:

(链接)

#include <iostream>
#include <type_traits>
#include <string>

template<class T, std::size_t N>
class static_vector
{
    // properly aligned uninitialized storage for N T's
    typename std::aligned_storage<sizeof(T), alignof(T)>::type data[N];
    std::size_t m_size = 0;

public:
    // Create an object in aligned storage
    template<typename ...Args> void emplace_back(Args&&... args) 
    {
        if( m_size >= N ) // possible error handling
            throw std::bad_alloc{};
        new(data+m_size) T(std::forward<Args>(args)...);
        ++m_size;
    }

    // Access an object in aligned storage
    const T& operator[](std::size_t pos) const 
    {
        return *reinterpret_cast<const T*>(data+pos);
    }

    // Delete objects from aligned storage
    ~static_vector() 
    {
        for(std::size_t pos = 0; pos < m_size; ++pos) {
            reinterpret_cast<T*>(data+pos)->~T();
        }
    }
};

int main()
{
    static_vector<std::string, 10> v1;
    v1.emplace_back(5, '*');
    v1.emplace_back(10, '*');
    std::cout << v1[0] << '\n' << v1[1] << '\n';
}

在这个例子中,operator[] 直接使用 reinterpret_caststd::aligned_storage* 转换为 T*,并直接执行间接引用。然而,根据这个问题,即使已经创建了类型为 T 的对象,这似乎也是未定义的。
因此我的问题是:这个示例程序是否真的违反了严格别名规则?如果没有,那么我的理解有什么问题?

1
你的问题基本上可以归结为:“那个答案真的说了它所说的吗?”是的,它确实说了它所说的。 - Nicol Bolas
2
@NicolBolas,因为cppreference.com是一个著名的网站,所以当我的理解与它相冲突时,我首先认为是我的理解有问题。 - xskxzr
答案 https://dev59.com/ilkS5IYBdhLWcg3wxJAF#39477623 表示实际上是可以的。 - user202729
2个回答

9

我在 ISO C++ 标准 - 讨论论坛中发表了一个相关问题。通过这些讨论,我得到了答案,并将其写在这里,希望能帮助其他对这个问题感到困惑的人。根据这些讨论,我将继续更新这个答案。

P0137 之前,请参阅 [basic.compound] 段落 3:

如果类型为 T 的对象位于地址 A 上,则值为地址 A 的类型为 cv T* 的指针称为指向该对象指针,无论该值是如何获得的。

和 [expr.static.cast] 段落 13:

如果原指针的值表示内存中字节的地址 A,并且 A 满足 T 的对齐要求,则结果指针的值表示与原指针的值相同的地址,即 A。

表达式 reinterpret_cast<const T*>(data+pos) 表示先前创建的类型为 T 的对象的地址,因此指向该对象。通过这个指针间接引用确实可以得到该对象,这是明确定义的。

然而,在 P0137 之后,指针值的定义发生了变化,第一个块引用的单词被删除。现在请参阅 [basic.compound] 段落 3:

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

  • 指向对象或函数的指针(称为指向该对象或函数的指针),或

  • ...

和 [expr.static.cast] 段落 13:

如果原始指针值表示内存中字节的地址 A,并且 A 不满足 T 的对齐要求,则结果指针值是未指定的。否则,如果原始指针值指向对象 a,并且存在与 a 相互转换指针的类型为 T 的对象 b(忽略 cv-qualification),则结果是指向 b 的指针。否则,指针值由转换保持不变

表达式reinterpret_cast<const T*>(data+pos)仍然指向类型为std::aligned_storage<...>::type的对象,并通过间接引用获取指向该对象的lvalue,尽管lvalue的类型是const T。在示例中,对表达式v1[0]的求值尝试通过lvalue访问std::aligned_storage<...>::type对象的值,根据[basic.lval]第11段(即严格别名规则),这是未定义的行为:

如果程序尝试通过不是以下类型之一的glvalue来访问对象的存储值,则行为是未定义的:

  • 对象的动态类型,

  • 对象的cv限定版本的动态类型,

  • 与对象的动态类型相似的类型(如[conv.qual]中定义),

  • 与对象的动态类型相应的有符号或无符号类型,

  • 与对象的cv限定版本的动态类型相应的有符号或无符号类型,

  • 包括上述类型之一在其元素或非静态数据成员中的聚合体或联合体(包括递归地作为子聚合体或包含的联合体的元素或非静态数据成员),

  • 动态类型的(可能具有cv限定符的)基类类型,

  • char、unsigned char或std::byte类型。


我想知道类型为std::aligned_storage<T>::type的对象是否仍然存在。我相信,鉴于这段代码std::aligned_storage_t<T> storage; new (static_cast<void*>(&storage)) T{};在调用放置new之前会隐式调用storage的平凡析构函数,这意味着storage的生命周期已经结束,在&storage处唯一存在的对象是T类型的。但这可能不正确。 - SJL
查看C++17草案,第6.6.3节规定具有平凡析构函数的对象的生命周期在该对象占用的存储被释放或被另一个对象重用时结束。因此,在这种情况下,我认为严格别名不会被违反,因为类型为std::aligned_storage_t<T>的对象的生命周期已经在reinterpret_cast时结束了。 - SJL
@SJL 我没有找到任何关于这种情况下指向生命周期结束对象的特殊规则。如果我的答案中的规则仍适用于生命周期已结束的对象,则当然是未定义的,否则我认为未提及就是未定义的。 - xskxzr
我认为你说得对,行为确实没有定义,但这似乎是一个疏忽。6.6.2.3节定义了“提供存储”的概念(我们可以假设std::aligned_storage_t<T>将满足类型T)。然后,6.6.2.4.2节定义了如果b“为a提供存储”,则a嵌套在b中。最后,6.6.2.8表明,如果一个对象嵌套在另一个对象中,则允许具有相同的地址。它肯定似乎旨在确保这样的转换是定义良好的,但它并没有明确说明(在6.6.2或其他地方)。 - SJL

1
代码没有以任何方式违反严格别名规则。使用类型为const T的lvalue来访问类型为T的对象是允许的。

所涉及的规则,如链接的问题所述,是一个生命周期规则;C++14(N4140)[basic.life]/7。 问题在于,根据此规则,指针data+pos不能用于操作由就地new创建的对象。您应该使用由就地new“返回”的值。

自然而然的问题是:指针reinterpret_cast<T *>(data+pos)怎么样?不清楚通过这个新指针访问新对象是否违反[basic.life]/7。

您链接到的答案的作者假设(未提供任何理由)这个新指针仍然是“指向原始对象的指针”。但是我认为也可以争辩说,作为T *,它不能指向原始对象,原始对象是std::aligned_storage而不是T

这表明对象模型是未指定的。建议 P0137 被纳入 C++17,解决了对象模型中不同部分的问题。但它引入了 std::launder,这是一种类似于mjolnir的东西,可以解决广泛的别名、生命周期和来源问题。
毫无疑问,带有 std::launder 的版本在 C++17 中是正确的。然而,就我所知,P0137 和 C++17 并没有进一步说明是否不带 launder 的版本是正确的。
在 C++14 中将代码称为 UB 是不切实际的,因为它没有 std::launder,除了浪费内存存储所有放置新的结果指针之外,没有其他方法解决这个问题。如果这是 UB,则无法在 C++14 中实现 std::vector,这远非理想。

在C++14中,我认为这是明确定义的,因为在这个例子中,新对象是在指针转换之前创建的,所以根据[basic.compound]/9,强制转换结果指向新创建的对象。然而,P0137更改了指针值的用词,导致强制转换结果指向原始对象。std::launder旨在处理指针转换之后创建新对象的情况,但在P0137的更改之后,如果没有std::launder,则会使“before”的情况变得未定义。我说得对吗? - xskxzr
static_cast新词,以及指针值的那些词语,可能是强制转换结果指向原始对象的证明。此外,在cpprefernce.com的reinterpret_cast 页面的“注释”部分中的例子也是一种证据。 - xskxzr
1
此外,在我看来,在C++14中,尽管data+pos可能不会自动指向新对象,但其指针值仍然可以以一些有限的方式使用(例如,转换为const T*)。由于reinterpret_cast<const T*>(data+pos)将指向新创建的对象,因此没有问题(*reinterpret_cast<const T*>(data+pos)用于操作对象而不是data+pos)。 - xskxzr

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