reinterpret_cast与strict aliasing的区别

14

我正在阅读关于严格别名的文章,但仍然有些模糊,我从来不确定定义/未定义行为的界限。我找到的最详细的文章集中在C语言上。因此,如果您能告诉我这是否允许以及自C++98/11以来发生了什么变化,那就太好了。

#include <iostream>
#include <cstring>

template <typename T> T transform(T t);

struct my_buffer {
    char data[128];
    unsigned pos;
    my_buffer() : pos(0) {}
    void rewind() { pos = 0; }    
    template <typename T> void push_via_pointer_cast(const T& t) {
        *reinterpret_cast<T*>(&data[pos]) = transform(t);
        pos += sizeof(T);
    }
    template <typename T> void pop_via_pointer_cast(T& t) {
        t = transform( *reinterpret_cast<T*>(&data[pos]) );
        pos += sizeof(T);
    }            
};    
// actually do some real transformation here (and actually also needs an inverse)
// ie this restricts allowed types for T
template<> int transform<int>(int x) { return x; }
template<> double transform<double>(double x) { return x; }

int main() {
    my_buffer b;
    b.push_via_pointer_cast(1);
    b.push_via_pointer_cast(2.0);
    b.rewind();
    int x;
    double y;
    b.pop_via_pointer_cast(x);
    b.pop_via_pointer_cast(y);
    std::cout << x << " " << y << '\n';
}

请不要过分关注可能存在的越界访问和可能没有必要编写类似代码的事实。我知道char*可以指向任何内容,但我也有一个指向char*T*。也许还有其他我忽略的东西。
这里有一个完整的示例,包括通过memcpy进行push/pop,据我所知,严格别名规则不会影响它。
简而言之:上述代码是否存在未定义行为(暂时忽略越界访问),如果是,为什么?C++11或新标准有什么变化吗?

我的回答“什么是严格别名规则?”广泛涵盖了C++,我相信也回答了你的问题。随着时间的推移,旧答案得到新的和更好的答案并不罕见,因此查看所有答案而不仅仅是前几个非常重要。我最受欢迎的答案是在原问题提出4年后才得到的。 - Shafik Yaghmour
@ShafikYaghmour 谢谢,我会看一下。另一个问题的问题在于它被标记为“C”和“C++faq”,但它没有“C++”标签,所以当我看到顶部答案集中在C上时,我没有考虑它是重复的。如果您认为它是一个重复的问题,请随时标记。 - 463035818_is_not_a_number
3个回答

15

别名(Aliasing)是指两个实体引用或指针指向同一个对象的情况。

int x;
int* p = &x;
int& r = x;
// aliases: x, r и *p  refer to same object.

编译器希望一个值用一个名称写入后,可以通过另一个名称访问它。
int foo(int* a, int* b) {
  *a = 0;
  *b = 1;
  return *a; 
  // *a might be 0, might be 1, if b points at same object. 
  // Compiler can't short-circuit this to "return 0;"
}

现在,如果指针是不相关的类型,则编译器没有理由期望它们指向相同的地址。这是最简单的未定义行为(UB)。
int foo( float *f, int *i ) { 
    *i = 1;               
    *f = 0.f;            
   return *i;
}

int main() {
    int a = 0;

    std::cout << a << std::endl; 
    int x = foo(reinterpret_cast<float*>(&a), &a);
    std::cout << a << "\n"; 
    std::cout << x << "\n";   // Surprise?
}
// Output 0 0 0 or 0 0 1 , depending on optimization. 

简单来说,严格别名规则意味着编译器期望不相关类型的名称指向不同类型的对象,因此这些对象位于单独的存储单元中。由于用于访问这些存储单元的地址实际上相同,因此访问存储值的结果是未定义的,并且通常取决于优化标志。
memcpy()通过使用char指针获取地址并在库函数的代码内部对存储的数据进行复制来绕过该规则。
严格别名规则适用于联合成员,这些成员单独描述,但原因相同:写入联合的一个成员不能保证其他成员的值会更改。这不适用于存储在联合内的结构体开头的共享字段。因此,联合的类型判定被禁止。(出于历史原因和方便维护旧代码的目的,大多数编译器不遵守此规则。)
从2017年标准开始:6.10 Lvalues and rvalues 如果程序尝试通过除以下类型之一的glvalue来访问对象的存储值,则行为是未定义的:
(8.1)- 对象的动态类型,
(8.2)- 对象的动态类型的cv限定版本,
(8.3)- 类型与对象的动态类型相似(如7.5所定义),
(8.4)- 类型是与对象的动态类型对应的有符号或无符号类型,
(8.5)- 类型是与对象的动态类型的cv限定版本对应的有符号或无符号类型,
(8.6)- 包括上述类型之一在其元素或非静态数据成员中的聚合体或联合体(包括递归地属于子聚合体或包含的联合体的元素或非静态数据成员)。
(8.7)- 对象的动态类型的(可能带有cv限定的)基类类型,
(8.8)- char、unsigned char或std::byte类型。
在7.5中。
1. 类型 T 的 cv 分解是一个由 cvi 和 Pi 组成的序列,其中 T 为“cv0 P0 cv1 P1 · · · cvn−1 Pn−1 cvn U”,当 n > 0 时,其中每个cvi是一组 cv 限定符(6.9.3),每个 Pi 是“指向”(11.3.1),“类 Ci 的成员类型的指针”(11.3.3),或者是“Ni 的数组”或“未知大小的数组”(11.3.4)。如果 Pi 指示一个数组,则数组中元素类型上的 cv 限定符 cvi+1 也被视为数组上 cv 限定符 cvi。[例如:由类型标识 const int ** 指示的类型具有两种 cv 分解,以“int”和“指向 const int 的指针”作为U。——end example] 在 T 的最长 cv 分解中第一个之后的 cv 限定符构成的 n 元组,即 cv1、cv2、……、cvn,称为 T 的 cv 限定符签名。 2. 如果两种类型 T1 和 T2 具有相同的 n 的 cv 分解,相应的 Pi 成分相同,并且所表示的类型 U 相同,则它们是类似的。
结果为:虽然可以将指针重新解释为不同的、不相关的、不相似的类型,但不能使用该指针访问存储的值。
char* pc = new char[100]{1,2,3,4,5,6,7,8,9,10}; // Note, initialized.
int* pi = reinterpret_cast<int*>(pc);  // no problem.
int i = *pi; // UB
char* pc2 = reinterpret_cast<char*>(pi+2);  // *(pi+2) would be UB
char c = *pc2; // no problem, unless increment didn't put us beyond array bound.
// c equals to 9

'

'reinterpret_cast'不能创建对象。在非存在的对象上解引用指针是未定义行为,因此如果指向的类不是平凡的,则无法使用转换的解引用结果进行写入。

'

1
顺便提一下,std::memcpy 接受 void* 作为参数,但是如果我没记错的话,对于严格别名,void*char* 存在相同的例外情况。 - 463035818_is_not_a_number
pi+2 这个表达式的行为未定义。你是不是想要使用 reinterpret_cast<char*>(pi) + 2 - Language Lawyer
@LanguageLawyer 在这里对指针进行递增操作并不会导致未定义行为(UB),因为该内存位置位于数组存储器中,它将被视为 char* 进行引用。 - Swift - Friday Pie
这里递增指针不是未定义行为。那么 pi+2 的行为是什么? - Language Lawyer
@Swift-FridayPie:char* pc2 = reinterpret_cast<char*>(pi+2); 为什么会导致未定义行为? - user179156
显示剩余5条评论

10
我知道char*可以指向任何内容,但我也有一个指向char*T*

是的,这是个问题。虽然指针转换本身具有定义行为,但使用它来访问不存在的类型为T的对象是不允许的。

C++不像C语言一样允许即兴创建对象。你不能简单地将某个内存地址分配为类型T并创建该类型的对象,你需要已经有该类型的对象。这需要使用放置new。以前的标准在这方面存在歧义,但目前根据[intro.object]的规定:

1 [...] 通过定义(6.1),通过new-expression(8.3.4),隐式更改联合的活动成员(12.3)或创建临时对象(7.4、15.2)创建对象。[...]

由于您没有执行任何这些操作,因此不会创建对象。

此外,C++不会隐式地将指向同一地址的不同对象视为等价。您的&data[pos]计算出一个指向char对象的指针。将其强制转换为T*并不会使它指向驻留在该地址处的任何T对象,并且对该指针进行解引用具有未定义的行为。C++17添加了std::launder,这是一种让编译器知道您想要访问与您拥有指针不同的对象的方式。

当您修改代码以使用放置newstd::launder,并确保没有对齐访问(我假设您省略了这一点),则您的代码将具有定义行为。

* 有关允许此功能的讨论将在未来版本的C++中进行。


我为了简洁起见省略了memcpy的解决方案。std::launder对我来说是全新的 ;) - 463035818_is_not_a_number
经过思考,我不再确定你的论点是否能说服我了。使用memcpy怎么样?在这种情况下,我并没有真正地在data的位置上创建一个T实例,但据我所知,使用memcpy是没有问题的。 - 463035818_is_not_a_number
@user463035818 对的,但在这种情况下,通过不将其访问为“T”,您避免了需要有一个类型为“T”的对象的需要。 - user743382
1
因此,在char数组中,只是有一些位恰好可以被T复制(因为我将T的位拷贝到了那个位置),但这不是一个可以通过转换检索到的T,因为语言要求如果我将某些内存视为存在某种类型的对象T,我必须首先在该位置上创建一个T类型的对象。 - 463035818_is_not_a_number
@user463035818 没错。 - user743382
太棒了,这正是我理解为什么memcpy有所不同的那一点。 - 463035818_is_not_a_number

3

简短回答:

  1. 在指向的地址上有一个类型为T的对象被构造之前,您不能执行这个操作:*reinterpret_cast<T*>(&data[pos]) =。可以通过放置new来实现。

  2. 即使是这样,由于您通过类型为char*的指针&data[pos]访问创建的对象(类型为T),因此您可能需要使用C++17及更高版本的std::launder

"直接" reinterpret_cast只有在某些特殊情况下才被允许,例如当Tstd::bytecharunsigned char时。

在C++17之前,我会使用基于memcpy的解决方案。编译器很可能会优化掉任何不必要的复制。


2
@KamilCuk 不,C++非常明确对象何时被创建。赋值不会隐式地创建一个对象。 - user743382
好的,谢谢!需要更深入地研究一下这个话题。 - KamilCuk
1
@KamilCuk 这不是一个简单的话题 :). 例如,请参见此答案的底部部分:https://dev59.com/LVkS5IYBdhLWcg3w25sM#39382728。或者,更好的是,这个:https://dev59.com/hV8d5IYBdhLWcg3wRAiE#27049038。 - Daniel Langr
@KamilCuk 注意:如果您尝试访问聚合类型(例如数组的数组或结构体),则std::launder也会有自己的限制。 - Swift - Friday Pie
@user179156 我必须承认我不确定这个方法是否适用于所有情况。此外,不同的标准可能会以不同的方式解决这种情况。但请注意,即使您只是通过reinterpret_castchar来读取数据,也不能使用memcpy来读取非平凡对象的二进制表示。 - Daniel Langr
显示剩余2条评论

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