什么是严格别名,为什么我们要关心? 第一部分

(或古怪的打字,含糊的行为和对齐方式,哦,天哪!)

大家好,几周后,我们将在“ C ++ Developer”课程中启动一个新线程。 该活动将致力于我们今天的材料。

什么是严格的别名? 首先,我们描述什么是混叠,然后找出严格意义。

在C和C ++中,别名与允许访问存储值的表达式类型有关。 在C和C ++中,该标准定义了哪些命名表达式对哪些类型有效。 允许编译器和优化器假设我们严格遵循别名规则,因此,术语-严格别名规则(strict aliasing rule)。 如果我们尝试使用无效类型访问值,则该值将归类为未定义行为(UB)。 当我们的行为不确定时,所有的赌注都会下注,我们的程序结果将不再可靠。

不幸的是,在严格违反别名的情况下,我们通常会获得预期的结果,从而可能导致具有新优化功能的将来版本的编译器违反我们认为有效的代码。 这是不希望的,值得理解严格的别名规则并避免破坏它们。



为了更好地理解我们为什么要为此担心,我们将讨论在违反严格的别名规则,键入punning(在严格的别名规则中经常使用)以及如何正确创建双关语时出现的问题。 C ++ 20提供了一些可能的帮助,以简化双关语并减少出错的机会。 我们将通过考虑一些用于检测违反严格别名规则的方法来总结讨论。

初步例子

让我们看一些示例,然后我们可以讨论标准中确切说明的内容,考虑其他示例,然后查看如何避免严格的混叠并识别我们遗漏的违规行为。 这是一个不应令您惊讶的示例

int x = 10; int *ip = &x; std::cout << *ip << "\n"; *ip = 12; std::cout << x << "\n"; 

我们有int *指向int占用的内存,这是有效的别名。 优化器应假定通过ip进行的分配可以更新x占用的值。

下面的示例显示别名,这将导致未定义的行为:

 int foo( float *f, int *i ) { *i = 1; *f = 0.f; return *i; } int main() { int x = 0; std::cout << x << "\n"; // Expect 0 x = foo(reinterpret_cast<float*>(&x), &x); std::cout << x << "\n"; // Expect 0? } 

在foo函数中,我们使用int *并使用float *。 在此示例中,我们调用foo并将两个参数设置为指向同一内存位置,在此示例中,该内存位置包含一个int。 请注意, reinterpret_cast告诉编译器将表达式视为具有模板参数指定的类型。 在这种情况下,我们告诉他将&x表达式当作float *类型来处理。 我们可以天真地希望第二个cout的结果为0,但是当使用-O2和gcc启用优化时,clang将获得以下结果:
0
1个

这可能是意外的,但完全正确,因为我们导致了不确定的行为。 浮点数不能是int对象的有效别名。 因此,优化器可以假定在解引用i期间存储的常数1将是返回值,因为通过f进行保存无法正确影响int对象。 在Compiler Explorer中连接代码表明,这正是发生的情况( 例如 ):

 foo(float*, int*): # @foo(float*, int*) mov dword ptr [rsi], 1 mov dword ptr [rdi], 0 mov eax, 1 ret 

使用基于类型的别名分析(TBAA)的优化器假定将返回1,然后将常量值直接移动到eax寄存器,该寄存器存储返回值。 TBAA使用关于允许使用哪种类型的别名的语言规则来优化加载和存储。 在这种情况下,TBAA知道float不能是int的别名,并优化了i的加载。

现在参考

该标准确切说明了我们被允许和不允许做的事情? 标准语言不是很简单,因此我将为每个元素尝试提供演示含义的代码示例。

C11标准怎么说?

C11标准在第7段的“ 6.5表达式”部分中指出以下内容:

对象必须具有自己的存储值,只能使用左值表达式进行访问,该值具有以下类型之一:88)-与对象的有效类型兼容的类型,

 int x = 1; int *p = &x; printf("%d\n", *p); //* p   lvalue-  int,    int 

-与当前对象类型兼容的类型的限定版本,

 int x = 1; const int *p = &x; printf("%d\n", *p); // * p   lvalue-  const int,    int 

-一种类型,带有或不带有符号的类型对应于合格的对象类型,

 int x = 1; unsigned int *p = (unsigned int*)&x; printf("%u\n", *p ); // *p   lvalue-  unsigned int,      

请参见脚注12中的gcc / clang扩展名 ,即使它们不是兼容类型,它也允许您分配unsigned int * int *。

-一种类型,即带有或不带有符号的类型对应于当前对象类型的限定版本,

 int x = 1; const unsigned int *p = (const unsigned int*)&x; printf("%u\n", *p ); // *p   lvalue-  const unsigned int,     ,        

-在其成员(包括递归地,子聚合或包含的关联的成员)中包括上述类型之一的聚合或组合类型,或

 struct foo { int x; }; void foobar( struct foo *fp, int *ip );// struct foo -  ,   int   ,       *ip // foo f; foobar( &f, &f.x ); 

-字符类型。

 int x = 65; char *p = (char *)&x; printf("%c\n", *p ); // * p   lvalue-  char,    . //    -    . 

C ++ 17草案标准怎么说

第11节[basic.lval]中的C ++ 17项目标准指出:如果程序尝试通过除以下类型之一以外的glvalue来访问对象的存储值,则行为是不确定的:63(11.1)是对象的动态类型,

 void *p = malloc( sizeof(int) ); //   ,       int *ip = new (p) int{0}; // placement new      int std::cout << *ip << "\n"; // * ip   glvalue-  int,       

(11.2)-对象的动态类型的cv限定版本(cv-const和volatile),

 int x = 1; const int *cip = &x; std::cout << *cip << "\n"; // * cip    glvalue  const int,   cv-    x 

(11.3)-与对象的动态类型类似的类型(定义见7.5),

//

(11.4)-一种类型,即带有或不带有符号的类型对应于对象的动态类型,
// si ui ,
// godbolt (https://godbolt.org/g/KowGXB) , .

 signed int foo( signed int &si, unsigned int &ui ) { si = 1; ui = 2; return si; } 

(11.5)-一种类型,带有或不带有符号的类型,对应于对象的动态类型的cv限定版本,

 signed int foo( const signed int &si1, int &si2); //  ,     

(11.6)-在其元素或非静态数据元素(包括递归地,子聚合或包含关联的元素或非静态数据元素)中包括上述类型之一的聚合或组合类型,

 struct foo { int x; }; 

// Compiler Explorer (https://godbolt.org/g/z2wJTC)

 int foobar( foo &fp, int &ip ) { fp.x = 1; ip = 2; return fp.x; } foo f; foobar( f, fx ); 

(11.7)-一种类型(可能是cv限定)动态对象类型的基类类型,

 struct foo { int x ; }; struct bar : public foo {}; int foobar( foo &f, bar &b ) { fx = 1; bx = 2; return fx; } 

(11.8)-输入char,unsigned char或std :: byte。

 int foo( std::byte &b, uint32_t &ui ) { b = static_cast<std::byte>('a'); ui = 0xFFFFFFFF; return std::to_integer<int>( b ); // b   glvalue-  std::byte,      uint32_t } 

值得注意的是,上面列出的列表中未包括带signed char ,这与C(字符类型)有明显区别。

细微的差异

因此,尽管我们可以看到C和C ++在别名方面有类似的说法,但我们仍需要注意一些差异。 C ++没有有效兼容类型的C概念,C没有动态或相似类型的C ++概念。 尽管两者都具有左值和右值表达式,但C ++也具有glvalue,prvalue和xvalue表达式。 这些差异在很大程度上不在本文讨论范围之内,但是一个有趣的示例是如何从malloc所使用的内存中创建一个对象。 在C语言中,我们可以设置有效的类型,例如,通过lvalue或memcpy写入内存。

 //     C,    C ++ void *p = malloc(sizeof(float)); float f = 1.0f; memcpy( p, &f, sizeof(float)); //   *p - float  C //  float *fp = p; *fp = 1.0f; //   *p - float  C 

这些方法在C ++中都不足够,这需要放置new:

 float *fp = new (p) float{1.0f} ; //   *p  float 

是int8_t和uint8_t char类型吗?

从理论上讲,int8_t和uint8_t都不应该是char类型,但是在实践中,它们是通过这种方式实现的。 这很重要,因为如果它们是真正的字符类型,那么它们也是像char类型这样的别名。 如果您不了解这一点,可能会导致 性能 意外下降 。 我们看到,对于带signed charunsigned charglibc typedef int8_tuint8_t

这将很难更改,因为对于C ++,这将是ABI的差距。 这将改变名称失真,并在其接口中使用这些类型中的任何一种破坏任何API。

第一部分结束。 几天后,我们将讨论打字和对齐双关语。

请写下您的评论,不要错过3月6日由Rambler&Co, Dmitry Shebordaev的技术开发主管举行的公开网络研讨会

Source: https://habr.com/ru/post/zh-CN442554/


All Articles