5

In this code snippet, why don't c++ compilers just return 1 when compiling test(), but read the value from memory?

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

Compiler output:

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

I would have expected:

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

Is there any standard-compliant way to modify the value of Test::x to contain a different value than 1? Or would the compilers be allowed to do this optimization, but neither gcc nor clang have implemented it?

EDIT: Of course you immediately found my mistake in making this a minimum example, that is allowing aggregate initialization for the struct. I updated the code with an empty default constructor that prevents that. (Old code on godbolt)

Tobias
  • 178
  • 1
  • 9
  • Try to `return` the value of `test` from `main` and you should see *that* call being optimized out. `test` doesn't do anything in itself, so there is no loss if only its calls are optimized instead of the function itself. https://godbolt.org/z/xrxa6hh9T – François Andrieux Dec 29 '21 at 17:38
  • 5
    You could create a new class that inherits from `Test` and sets a different value for `x` via a constructor – UnholySheep Dec 29 '21 at 17:38
  • @Jarod42: Are you sure that's right? I thought `mutable` could only be applied to class member *variables*, as in https://en.cppreference.com/w/cpp/language/cv, to allow modifying that member of a class object in a `const` member function. I don't think you can apply that keyword to a function. (It might be legal to use `memcpy` or `char*` to bypass `const` on the `int` member, though, as long as the underlying object isn't actually `const`, so it's not in read-only memory. So this would make it possible for `Test` objects with x!=1 to legally exist even if you delete other constructors.) – Peter Cordes Dec 29 '21 at 21:58
  • @PeterCordes: indeed, I was wrong. – Jarod42 Dec 29 '21 at 22:05
  • 1
    @Tobias: note [my comments](https://stackoverflow.com/questions/70522927/why-dont-c-compilers-replace-this-access-to-a-const-class-member-with-its-val#comment124667486_70523051) on @Slava's answer and here: it may be legal to use `char*` or `memcpy` to create an object-representation of a `Test` object with a different `x` value. So it may or may not be a missed optimization. It's certainly a sub-optimal way to write code, because you're wasting space in each instance of the class holding this constant. – Peter Cordes Dec 29 '21 at 22:38
  • @Peter Cordes Good point about the char* stuff. I will try and dig to check if there is a legal way to do that. For me, it feels that is shouldn't, but it might very well be! – Tobias Dec 29 '21 at 22:43
  • Regarding the "suboptimal way to write code": I absolutely agree, but I am actually trying to understand a more complex case and wanted to start simple. – Tobias Dec 29 '21 at 22:45
  • I think using @PeterCordes hint, I found an answer to my question. Here, I use compile-time programming to construct a `Test` object with `x=2`. If I understood correctly, since the compiler doesn't complain, the program is not undefined and this legal C++. construct with `std::bit_cast`: https://godbolt.org/z/3abaEqWdM Do you have a minute to check this, @PeterCordes? If you think this is correct, I can answer my question :) – Tobias Dec 29 '21 at 23:06
  • Ah, I just saw your answer below. If you think my example is correct, you can add it to your answer as a simpler and even compile-time checked code, and I accept your answer! – Tobias Dec 29 '21 at 23:10
  • @Tobias: C++20 `std::bit_cast` is equivalent to memcpy in/out of a `Test` object, or other ways of type-punning or messing around with the object-representation (e.g. via `char*` which is allowed to alias anything); I don't think it introduces anything new in terms of what C++ compilers can legally prove about a program. – Peter Cordes Dec 29 '21 at 23:45
  • 2
    @PeterCordes It is not the bit_cast that allows the compiler to prove more, but executing it at compile time. In my example, `Test::x` gets value 2 in a compile-time evaluated expression. For this, undefined behavior is forbidden (https://timsong-cpp.github.io/cppwp/expr.const#5.8), so if the compiler does this correctly, this verifies that it is defined behavior for sure. This is the added bonus to your example. – Tobias Dec 30 '21 at 15:35

4 Answers4

7

I believe it's because you can still construct an instance of Test with other values of x with an initializer list like this:

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

Demo: https://www.ideone.com/7vlCmX

Al.G.
  • 4,327
  • 6
  • 31
  • 56
  • Thank you for your answer! You immediately found the mistake I made while providing a minimal example. I updated the question to not allow aggregate initalization, and it still isn't optimized. – Tobias Dec 29 '21 at 22:36
5

Now you've disallowed using a constructor to create an instance of a Test object with a different x value, but gcc/clang still aren't optimizing.

It may be legal to use char* or memcpy to create an object-representation of a Test object with a different x value, without violating the strict-aliasing rule. That would make the optimization illegal.

Update, see discussion in comments; 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: Placement new and assignment of class with const member - reuse is allowed only if "the type of the original object is not const-qualified".

(That language about not reusing the storage of a const object changed in C++20; it now leaves the door open for using placement-new on a whole struct/class object that's non-const, even if it contains const members.)

Manufacturing a brand new Test object with arbitrary x value via std::bit_cast<Test>( int ) still appears to be fully legal even in ISO C++. It is Trivially Copyable. Also, it appears that real implementations such as GCC and clang define the behaviour for all these cases, at least de-facto; I didn't check their official docs to see if it's mentioned as a GNU extension. As far as optimizer limits, that's what matters.


This section hinges on some flimsy arguments / wishful thinking

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

In reference contexts in C++, const means you can't modify this object through this reference. I don't know how that applies for a const member of a non-const object.

This is a Standard Layout type, so it should be binary compatible with an equivalent C struct, and also safe to write/read to a file and back without UB. It's a POD type (or I think the C++20 replacement for the concept of POD). It's even trivially copyable with or without a Test(const Test&) = default; copy-constructor, although that's probably not relevant.

If it's legal to write it out to a file and read it back, it should still be well-defined even if the file is modified in between. Or if we memcpy it to an array, modify the array, and copy back:

   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));

The only questionable step is the final memcpy back into foo; this is what creates a Test object with an x value the constructor couldn't produce.

@Klauss raised a concern about overwriting the whole object without destructing it and doing a placement-new of the new one. I thought that was allowed for Standard Layout POD types, but I haven't checked the standard. That should be allowed for a struct or class whose members are all non-const; that's the point of Standard Layout and POD / TrivialType. In any case, the char* version avoids doing that, not rewriting the whole object.

Does merely having a const member break the ability to write/read the object representation to a file? I don't think so; having a const member doesn't disqualify a type from being Standard Layout, Trivial, and even Trivially Copyable. (This point is the biggest stretch; but I still think it's legal unless someone can show me in the standard where it isn't legal to poke around in the object-representation of a non-const class object.)

It would be extremely weird if having or not-having a constructor that allowed different initializers for the const int x member was the difference between it being UB or not to write/read this object to a file and modify it. The inability to create a Test object with a different x value the "normal" way is a red herring as far as whether it's legal to poke around in the bytes of the object representation. (Although that is still a valid question for a class with a const member.)

And now we're back to non-hand-wavy stuff I think is still fully correct

@Tobias also commented with an example (https://godbolt.org/z/3abaEqWdM) that uses C++20 std::bit_cast to manufacture a Test object with x == 2 that's constexpr-safe and evaluates correctly even inside a static_assert. std::bit_cast


We can also see from this example that GCC and clang leave room for non-inline function calls to modify that member of an already-constructed Test object:

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

It may or may not be a missed optimization. Many missed-optimizations are things compilers don't look for because it would be computationally expensive (even an ahead-of-time compiler can't use exponential-time algorithms carelessly on potentially-large functions).

This doesn't seem too expensive to look for, though, just checking if a constructor default has no way to be overridden. Although it seems lowish in value in terms of making faster / smaller code since hopefully most code won't do this.

It's certainly a sub-optimal way to write code, because you're wasting space in each instance of the class holding this constant. So hopefully it doesn't appear often in real code-bases. static constexpr is idiomatic and much better than a const per-instance member object if you intentionally have a per-class constant.

However, constant-propagation can be very valuable, so even if it only happens rarely, it can open up major optimizations in the cases it does.

Peter Cordes
  • 328,167
  • 45
  • 605
  • 847
  • 1
    Copy data over an existing object is UB, isn't it? Even if the object has no const members, it violates the C++ rules. The overridden object has no chance to use its destructor and so on. So I see no argument that the possibility of using memcpy over an object can be an argument in that case. – Klaus Dec 30 '21 at 09:10
  • 1
    Also referencing it here: @Klaus I added some comments to the question. I wrote an example (https://godbolt.org/z/3abaEqWdM) where `Test::x` evaluates to `2` at compile time. According to https://timsong-cpp.github.io/cppwp/expr.const#5.8, it that works, the compiler at least thinks that it is defined behavior. I suggest to add this example to Peters answer, as it is essentially the same (`std::bit_cast` might use `std::memcpy` in its implementation), but adds the compile time verification of defined behavior. – Tobias Dec 30 '21 at 15:42
  • @Klaus: `Test` doesn't have a non-trivial destructor and is (I think) Standard Layout and POD. I don't think this is UB (usually not recommended style, but not illegal, e.g. to read raw binary objects from disk). But we could bypass your objection by using `memcpy` into anonymous bytes that were dynamically allocated, and pointing a `Test *` at that storage. We know the constructor has no side-effects, and we can even make it trivially-copyable by adding `Test(const Test&) = default;` (https://godbolt.org/z/zEEE9TMee). – Peter Cordes Dec 30 '21 at 18:47
  • @Klaus: Or instead of overwriting the "whole object", we could use `char*` to modify a byte of the existing `Test foo` object, like `*(char*)&foo = 3;`. For any type other than [signed/unsigned] `char` that would be strict-aliasing UB, but `char*` is specifically allowed to alias any other type. – Peter Cordes Dec 30 '21 at 18:50
  • @Klaus: Hmm, I think my first idea, of using anonymous memory, wouldn't work without placement-new, which would run the constructor. So yeah, maybe only modifying the bytes of an existing non-`const` `Test` object is fully legal with regard to lifetimes, although I'd have to remind myself if POD types are allowed to get away with more, since they're interchangeable with their C equivalents. (Or the C++20 equivalent concepts to POD.) – Peter Cordes Dec 30 '21 at 19:05
  • @Klaus: Updated my answer with more emphasis on the fact that it's Standard Layout and Trivial / POD. It should be legal to write this object to a file and read it back, as well as to poke at the bytes of the object representation using `unsigned char*`. I guess I (or you) could ask a new language-lawyer question about binary I/O / memcpy / `char*` on such types. Your lack-of-destructor argument would appear to apply even for types *without* a `const` member, so I'm not convinced. Or maybe it's just one of those things that ISO C++ leaves undefined but which real compilers define? – Peter Cordes Dec 31 '21 at 00:01
  • "const means you can't modify this object through this reference.": That is true for `const`-ness of the glvalue used, but there is another rule forbidding modification of objects which are of `const` type: https://timsong-cpp.github.io/cppwp/n4868/dcl.type.cv#4. This includes `const` subobjects of non-`const` objects (see link to definition). I think `*(char*)&foo = 3;` should also fall under that, if it is allowed at all (in the standard only exactly copying the underlying array is explicitly mentioned). – user17732522 Dec 31 '21 at 02:25
  • 1
    But whether it is ok to modify the bits doesn't really matter, since it is allowed to placement-new the `const` member to create a new object of the same type (with or without `const` qualification) and with any value. `t.x` is then also explicitly guaranteed to refer to the new object, so that no assumptions on the value can be made. – user17732522 Dec 31 '21 at 02:27
  • @user17732522: Thanks for finding the right section of the standard. Yeah, I knew what I was saying there was pretty hand-wavy. So yeah, I think you might be right about `*(char*)` not being legal; casting from the struct itself is probably what the standard would consider a non-const access path to a const object. Are you sure about placement-new over a const member being legal? [Placement new and assignment of class with const member](https://stackoverflow.com/q/47473621) points out that reusing the storage is ok only if "the type of the original object is not const-qualified" – Peter Cordes Dec 31 '21 at 04:28
  • And for replacing a whole class-type object object with placement-new, only if it "does not contain any non-static data member whose type is const-qualified". (The subject of that linked Q&A; it was about re-`new`ing the whole class object, not the member.) So it's less clear that write/read is safe on a POD type with a `const` member, at least in ISO C++. Real compilers appear to de-facto define the behaviour, assuming that calls to non-inline functions can modify the storage holding the const member. – Peter Cordes Dec 31 '21 at 04:30
  • @user17732522: Updated my answer in light of that, thanks again. – Peter Cordes Dec 31 '21 at 04:49
  • @PeterCordes The section of the standard mentioned in the linked answer was changed in C++20: https://github.com/cplusplus/draft/commit/fd8ff6441f93024bd0ee6e03a03c08be8e1b5ce0 – user17732522 Dec 31 '21 at 11:50
4

In your case it means that you have a non modifiable variable which will be set to a given value if not given by any other method. But there are at minimum two other methods like:

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

If you want to say: My type always have the same constant, you simply should write static constexpr int x = 1; which is a totally different semantic. And if you do that, the assembly will be what you expected.

Add on: After you changed your code, we see still no optimization for the little test function. And in opposite to Peters idea, that memcpy over an existing object is valid, I believe it is UB, I see no argument any longer that the function can't be optimized.

BUT: That we have a single function which takes maybe something more as expected, we should take a look at the full context. Compilers have multiple steps for optimizing. That is in this case not only constant propagation, there is also inlining. And if we do a more real world coding, we see that the code will be fully optimized after it was inlined! And the linker will remove your function from the executable, as it will not be used. We can trust, that the function of two assembler instructions is always inlined, because the call and moving of return value is always more expansive. The result is what we expect, the constant is propagated after inline has happened.

And we also should ask, why a programmer write a individual ( non static ) const member which never can be changed. This will waste the storage for each individual object without any sense. For this we have constexpr static. The bad side of your code is not the missing constant propagation, which, as we see will happen later if we look at the real world code, but the wasted memory, if we really generate objects, which we do not in the given code exaple. I am not sure if the compiler is allowed to remove data from objects which can never really used.

In a short, the compiler optimizes all away, even so no object is created at any time! The result is only:

main:                                   # @main
        mov     eax, 1
        ret

see full optimized code

Klaus
  • 24,205
  • 7
  • 58
  • 113
  • Thank you for your answer! You immediately found the mistake I made while providing a minimal example. I updated the question to not allow aggregate initalization, and it still isn't optimized. – Tobias Dec 29 '21 at 22:36
  • @Tobias: I add some more to my answer. As I believe, the argument that a memcpy is the reasoning why the function can't be optimzed is wrong I believe, as it is always UB. – Klaus Dec 30 '21 at 09:21
  • I added some comments to the question. I wrote an example (https://godbolt.org/z/3abaEqWdM) where `Test::x` evaluates to `2` at compile time. According to https://timsong-cpp.github.io/cppwp/expr.const#5.8, it that works, the compiler at least thinks that it is defined behavior. I suggested to add this example to Peters answer, as it is essentially the same (`std::bit_cast` might use `std::memcpy` in its implementation), but adds the compile time verification of defined behavior. – Tobias Dec 30 '21 at 15:41
  • ISO C++ in theory allows compilers to remove unused members from structs/classes, but doing so in practice would change the ABI. Thus it can't be done as an optional optimization. (e.g. it would change the `sizeof`, and might affect the layout of another struct that had this as a member). As in [Is the compiler allowed to optimise out private data members?](https://stackoverflow.com/q/65201486) - it would only be possible with truly whole-program optimization that forbids separate libraries. – Peter Cordes Dec 30 '21 at 23:07
  • Even if `memcpy` is not allowed, it is allowed to placement-new a new `int` object with any value into the storage of `t.x` and `t.x` is then also explicitly guaranteed to refer to this new object. – user17732522 Dec 31 '21 at 02:30
3

To make this optimization happen you need to tell compiler that x cannot have different value in any case:

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
Slava
  • 43,454
  • 1
  • 47
  • 90
  • "cannot be *changed*" isn't quite the right word; that is what `const` means. What the code in the question fails to do is rule out constructing instances of the class with different `x` values, preferably as you're doing by using `static` to make it not a member variable at all. Another way might be by deleting other constructors, and making the class `final` so nothing can inherit and add new members. (non-static members can't be `constexpr`, though, so this would only ever be an optimization, not like you're doing which would make it legal to do `int foo[t.x]` or similar.) – Peter Cordes Dec 29 '21 at 21:50
  • 1
    But https://godbolt.org/z/dqMzvqPb7 doesn't do that optimization. It might be a missed optimization, or it might still be well-defined to use memcpy or `char*` to manufacture a `Test` object with a different `x` value, without using any of the constructors, in which case the compiler can't assume that a `Test` object has a known `x` value. – Peter Cordes Dec 29 '21 at 21:51
  • 1
    @PeterCordes "isn't quite the right word; that is what const means" agree, reworded, should be better now. Thanks for the note. – Slava Dec 29 '21 at 22:02
  • (Correction to my previous comment: `final` shouldn't be relevant; a derived class can't directly initialize base-class members in its constructor since they aren't members of the derived class. Only via a base-class constructor, so we only have to worry about what's actually in the class declaration. (Or if they're non-`const` then just `=` in the body of the constructor is possible.)) – Peter Cordes Dec 29 '21 at 22:44