为什么 C++ 编译器不用编译时已知的值替换对 const class 成员的这种访问?

Why don't c++ compilers replace this access to a const class member with its value known at compile time?

在这个代码片段中,为什么 c++ 编译器在编译 test() 时不只是 return 1,而是从内存中读取值?

struct Test {
  const int x = 1;

  // Do not allow initializing x with a different value
  Test() {}
};
int test(const Test& t) {
  return t.x; 
}

Code on golbolt

编译器输出:

test(Test const&):                         # @test(Test const&)
    mov     eax, dword ptr [rdi]
    ret

我本以为:

test():                               # @test(Test const&)
    mov     eax, 1
    ret

是否有任何符合标准的方法来修改 Test::x 的值以包含与 1 不同的值?或者是否允许编译器进行此优化,但 gcc 和 clang 都没有实现它?

编辑:当然,您会立即发现我将此作为最小示例的错误,即允许对结构进行聚合初始化。我用一个空的默认构造函数更新了代码,以防止这种情况发生。 (Old code on godbolt)

我相信这是因为您仍然可以使用像这样的初始化列表构造一个具有其他 x 值的 Test 实例:

Test x{2};
cout << test(x);

演示:https://www.ideone.com/7vlCmX

要进行此优化,您需要告诉编译器 x 在任何情况下都不能有不同的值:

struct Test {
    constexpr
    static int x = 1;
};

int test(const Test& t) {
    return t.x;
}

godbolt output

test(Test const&):                         # @test(Test const&)
        mov     eax, 1
        ret

在您的情况下,这意味着您有一个不可修改的变量,它将被设置为给定值 if 未通过任何其他方法给出。但至少还有其他两种方法,例如:

struct X {
    const int y = 1;
};
int test(const X& t) {
    return t.y;
}

struct Y: public X
{
    Y():X{9}{}
};

int main()
{
    X x1{3};
    std::cout << test(x1) << std::endl;

    Y y1{};
    std::cout << test(y1) << std::endl;

}

see it working

如果你想说:My type always have the same constant,你应该简单地写成static constexpr int x = 1;,这是完全不同的语义。如果你这样做,程序集就会如你所愿。

添加: 更改代码后,我们仍然没有看到对小测试功能的优化。与 Peters 的想法相反,对现有对象的 memcpy 是有效的,我相信它是 UB,我看到 no 参数不再是函数无法优化。

但是: 我们有一个函数可能需要更多的东西,我们应该看看完整的上下文。编译器有多个优化步骤。也就是说,在这种情况下,不仅有常量传播,还有内联。如果我们进行更真实的编码,我们会发现代码在内联后将得到全面优化!链接器将从可执行文件中删除您的函数,因为它不会被使用。我们可以相信,两条汇编指令的功能总是内联的,因为 return 值的调用和移动总是更具扩展性。结果如我们所料,常量在 内联发生后 传播。

而且我们还应该问,为什么程序员要编写一个永远无法更改的单独(非静态)const 成员。这将毫无意义地浪费每个单独对象的存储空间。为此,我们有 constexpr static。你的代码不好的一面不是缺少常量传播,正如我们看到的那样,如果我们查看现实世界的代码,稍后会发生这种情况,而是浪费内存,如果我们真的生成对象,我们在给定的代码示例中没有生成对象.我不确定是否允许编译器从永远不会真正使用的对象中删除数据。

简而言之,编译器优化了所有,即使没有任何对象被创建!结果只有:

main:                                   # @main
        mov     eax, 1
        ret

see full optimized code

现在您已禁止使用构造函数创建具有不同 x 值的 Test 对象的实例,但 gcc/clang 仍未优化。

使用 char*memcpy 创建具有不同 x 值的 Test 对象的对象表示可能是合法的,而不会违反严格的-别名规则。那会使优化不合法。

更新,参见; in the ISO standard 6.8.4 basic.type.qualifier "A const object is an object of type const T" and doesn't rule out it being a sub-object, and getting at it via a pointer to the struct probably just counts as a non-const access path to a const object. (Any attempt to modify a const object during its lifetime results in undefined behavior doesn't leave room for loopholes since this is an object, not a reference to an object). So the char* and memcpy methods look to be UB, and even placement-new probably can't help: - 仅当“原始对象的类型不是 const-qualified”时才允许重用。

(关于不重用 const 对象 changed in C++20; it now 的存储的语言为在整个 struct/class 对象上使用 placement-new 敞开了大门-const, 即使它包含 const 成员.)

即使在 ISO C++ 中,通过 std::bit_cast<Test>( int ) 制造具有任意 x 值的全新 Test 对象似乎仍然完全合法。它是平凡可复制的。此外,GCC 和 clang 等实际实现似乎定义了所有这些情况的行为,至少事实上是这样;我没有检查他们的官方文档以查看它是否被称为 GNU 扩展。至于优化器限制,这才是最重要的。


本节取决于一些站不住脚的论点/一厢情愿

   Test foo;
   *(char*)&foo = 3;  // change first byte of the object-representation
                      // which is where foo.x's value lives

在 C++ 的引用上下文中,const 表示您不能通过 this 引用修改此对象。我不知道这如何适用于非 const 对象的 const 成员

这是一种标准布局类型,因此它应该与等效的 C 结构二进制兼容,并且在没有 UB 的情况下 write/read 到文件和返回也是安全的。它是一种 POD 类型(或者我认为 C++20 替代了 POD 的概念)。它甚至可以在有或没有 Test(const Test&) = default; 复制构造函数的情况下进行简单复制,尽管这可能不相关。

如果将其写入文件并读回是合法的,那么即使文件在此期间被修改,它仍然应该是明确定义的。或者如果我们 memcpy 它到一个数组,修改数组,然后复制回来:

   Test foo;
   char buf[sizeof(foo)];
   memcpy(buf, &foo, sizeof(foo));
   buf[0] = 3;         // on a little-endian system like x86, this is buf.x = 3;  - the upper bytes stay 0
   memcpy(&foo, buf, sizeof(foo));

唯一有问题的步骤是最后 memcpy 回到 foo;这就是创建具有构造函数无法生成的 x 值的 Test 对象的原因。

@Klauss 提出了关于覆盖 whole 对象而不破坏它并对新对象进行新放置的担忧。我认为标准布局 POD 类型是允许的,但我还没有检查标准。这应该允许结构或 class 的成员都是非 const;这就是 Standard Layout and POD / TrivialType 的意义所在。在任何情况下,char* 版本都避免这样做,而不是重写整个对象。

仅仅拥有一个 const 成员是否会破坏将对象表示 write/read 写入文件的能力?我不这么认为;拥有 const 成员不会取消类型的标准布局、平凡甚至平凡可复制的资格。 (这一点是最大的延伸;但我仍然认为它是合法的,除非有人可以在标准中向我展示在非 const class 对象的对象表示中闲逛是不合法的。)

如果拥有或不拥有允许 const int x 成员使用不同初始值设定项的构造函数,这将是它是 UB 还是不 write/read 此对象到文件的区别并修改它。 无法以“正常”方式创建具有不同 x 值的 Test 对象,就在对象表示的字节中四处寻找是否合法而言,这是一个转移注意力的问题。 (尽管对于 const 成员的 class 来说,这仍然是一个有效的问题。)

现在我们回到非手波浪的东西我认为仍然是完全正确的

@Tobias 还评论了一个示例(https://godbolt.org/z/3abaEqWdM) that uses C++20 std::bit_cast 使用 x == 2 制造一个 Test 对象,它是 constexpr 安全的,即使在 static_assert 中也能正确计算。std::bit_cast


我们还可以从这个例子中看出,GCC 和 clang 为非内联函数调用留出了空间,以修改已构造的 Test 对象的该成员:

void ext(void*);  // might do anything to the pointed-to memory

int test() {
    Test foo;    // construct with x=1
    ext (&foo);
    return foo.x;   // with ext() commented out,  mov eax, 1
}

Godbolt

# GCC11.2 -O3.  clang is basically equivalent.
test():
        sub     rsp, 24             # stack alignment + wasted 16 bytes
        lea     rdi, [rsp+12]
        mov     DWORD PTR [rsp+12], 1      # construct with x=1
        call    ext(void*)
        mov     eax, DWORD PTR [rsp+12]    # reload from memory, not mov eax, 1
        add     rsp, 24
        ret

它可能是也可能不是错过的优化。许多错过的优化是编译器不寻找的东西,因为它在计算上会很昂贵(即使是提前编译器也不能在潜在的大函数上不小心使用指数时间算法)。

不过,这看起来并不昂贵,只是检查构造函数默认值是否无法被覆盖。尽管它在制作更快/更小的代码方面的价值似乎很低,因为希望大多数代码不会这样做。

这当然是一种次优的代码编写方式,因为您在 class 的每个实例中都浪费了 space 保持此常量。所以希望它不会经常出现在真实的代码库中。 static constexpr 是惯用的并且 const 每个实例成员对象更好,如果你有意有一个每个 class 常量。

然而,常量传播可以非常有价值,所以即使它很少发生,它也可以在它发生的情况下进行重大优化。