6

While testing handling of const object members I ran into this apparent bug in clang. The code works in msvc and gcc. However, the bug only appears with non-consts which is certainly the most common use. Am I doing something wrong or is this a real bug?

https://godbolt.org/z/Gbxjo19Ez

#include <string>
#include <memory>

struct A
{
    // const std::string s; // Oddly, declaring s const compiles
    std::string s;
    constexpr A() = default;
    constexpr A(A&& rh) = default;
    constexpr A& operator=(A&& rh) noexcept
    {
        std::destroy_at(this);
        std::construct_at(this, std::move(rh));
        return *this;
    }
};

constexpr int foo()
{
    A i0{};    // call ctor
    // Fails with clang. OK msvc, gcc
    // construction of subobject of member '_M_local_buf' of union with no active member is not allowed in a constant expression { return ::new((void*)__location) _Tp(std::forward<_Args>(__args)...); }
    i0 = A{};  // call assign rctor
    return 42;
}

int main() {
    constexpr int i = foo();
    return i;
}

For those interested, here's the full version that turns const objects into first class citizens (usable in vectors, sorting, and such). I really dislike adding getters to maintain immutability.

https://godbolt.org/z/hx7f9Krn8

doug
  • 2,590
  • 1
  • 10
  • 17
  • `_M_local_buf` seems to be the SSO buffer of the string used in a union member. Libstdc++ seems to not explicitly start the lifetime of this union subobject, instead starts to copy directly into it. If this was done via an expression of the form `_M_local_buf[i] = ...` it would be fine as these start the lifetime as necessary, but instead it goes through a reference and technically that is UB. Clang seems to diagnose that, while GCC is more lenient. Either way it ought to work without UB, so it is a bug. – user17732522 Apr 15 '22 at 15:47
  • @user17732522 I've noticed gcc sometimes plays fast and loose too. I've seen `reinterpret_cast` in a constexpr w/o gcc complaining. Given c++'s complexity it's amazing things work most of the time. And yet using it in code has gotten simpler/easier over the years. Can't remember the last time I've had a memory leak coding bug. – doug Apr 15 '22 at 17:40
  • Here are some related GCC bugs (for the invalid active member change): https://gcc.gnu.org/bugzilla/show_bug.cgi?id=102286, https://gcc.gnu.org/bugzilla/show_bug.cgi?id=101631. I assume if/when they should be fixed, libstdc++ will also change the `std::string` behavior. Or maybe the rules for active member changes will be relaxed in the standard at some point. – user17732522 Apr 15 '22 at 17:49

2 Answers2

3

Yes this is a libstdc++ or clang issue: std::string's move constructor cannot be used in a constant expression. The following gives the same error:

#include <string>

constexpr int f() {
    std::string a;
    std::string b(std::move(a));
    return 42;
}

static_assert(f() == 42);

https://godbolt.org/z/3xWxYW717

https://en.cppreference.com/w/cpp/compiler_support does not show that clang supports constexpr std::string yet.

Artyer
  • 20,910
  • 3
  • 38
  • 60
  • Thanks. Since the problem didn't show up in clang with my original constexpr code when string and int objects were declared const, I was happy. Then I wondered how C++ handled stealing the guts out of a const v non const sub object. I suspected it was just copying them. Turned out it does actually steal the guts from the sub objects. So no performance hit with const subs. – doug Apr 15 '22 at 17:00
0

Your game of "construct a new object in place of the old one" is the problem.

  1. It is completely forbidden if the object is const or contains any const member subobjects.

due to the following rule in [basic.life] (note that a rewrite1 of this rule is proposed in post-C++17 drafts)

If, after the lifetime of an object has ended and before the storage which the object occupied is reused or released, a new object is created at the storage location which the original object occupied, a pointer that pointed to the original object, a reference that referred to the original object, or the name of the original object will automatically refer to the new object and, once the lifetime of the new object has started, can be used to manipulate the new object, if:

  • the storage for the new object exactly overlays the storage location which the original object occupied,

and

  • the new object is of the same type as the original object (ignoring the top-level cv-qualifiers)

and

  • the type of the original object is not const-qualified, and, if a class type, does not contain any non-static data member whose type is const-qualified or a reference type

and

  • the original object was a most derived object of type T and the new object is a most derived object of type T (that is, they are not base class subobjects).

You have to abide by this rule for the purposes both of return *this; and also the implicit destructor call.

  1. It also doesn't work during constexpr evaluation.

... this one is specific to the fact that std::string small-string optimization may be implemented using a union, and changing an active union member has been forbidden during constexpr evaluation, although this rule too seems to have changed post-C++17.


1 I consider said change to be misguided (it doesn't even permit the pattern it was supposed to fix) and break legitimate coding patterns. While it's true that a pointer to const-qualified object only made my view readonly and did not let me assume that the object wasn't being changed by someone else holding a pointer/reference that wasn't so qualified, in the past if I was given a pointer (qualified or not) to an object with a const member, I was assured that no one was changing that member and I (or my optimizing compiler) could safely use a cached copy of that member (or data derived from that member value, such as a hash or a comparison result).

Apparently this is no longer true.

While changing the language rule may automatically remove all compiler optimizations that would have assumed immutability of a const member, there's no automatic patch to user-written code which was correct and bug-free under the old rules, for example std::map and std::unordered_map code using std::pair<const Key, Value>. Yet the DR doesn't appear to have considered this as a breaking change...

I was asked for a code snippet that illustrates a behavior change of existing valid code, here it is. This code was formerly illegal, under the new rules it's legal, and the map will fail to maintain its invariants.

std::map<int, T> m{data_source()};
/* new code, now legal */
for( auto& keyvalue : m ) {
    int newkey = -keyvalue.first;
    std::construct_at(&keyvalue.first, newkey);
    // or new (&keyvalue.first) int(newkey);
}
/* existing valid code that breaks */
std::cout << m[some_key()];

Consider the new relaxed wording of the restriction

the original object is neither a complete object that is const-qualified nor a subobject of such an object

keyvalue.first is const-qualified, but it is not a complete object, and it is a subobject of a complete object (std::pair<const Key, Value>) that is not const-qualified. This code is now legal. It's not even against the spirit of the rule, the DR explicitly mentioned the intent to perform in-place replacement of container elements with const subobjects.

It's the implementation of std::map that breaks, along with all existing code that uses the map instance, an unfortunate action-at-a-distance resulting from addition of now-legal code.

Please note that the actual replacement of the key could take place in code that merely has the pointer &keyvalue and needn't know that std::pair instance is actually inside a std::map), so the stupidity of what's being done won't be so obvious.

Ben Voigt
  • 269,602
  • 39
  • 394
  • 697
  • 1
    The change happened already for C++20 if I am not mistaken, as a result of the NB comments on the draft: https://github.com/cplusplus/nbballot/issues/7 – user17732522 Apr 15 '22 at 15:24
  • 2
    Since C++20 `std::construct_at` may be used in this way in constant expressions. – user17732522 Apr 15 '22 at 15:25
  • Re your last paragraph, could you share a small code snippet where the behaviour of valid pre-C++20 user code changes due to this language change? I'm not sure I understand the issue you're referring to in your map example. – cigien Apr 15 '22 at 16:43
  • @cigien: I cannot find any other rule in the Standard which forbids changing the key in-place when iterating on a map (dereferencing an iterator grants an in-place reference to a `value_type` which is `std::pair`). Changing the value portion in-place has always been legal, changing the key in-place was formerly forbidden by the rule which I emphasized in bold. The implementation of `map`, `unordered_map`, and a huge amount of user code working with these types relies on the assumption that a key can't change post-insertion. Now they can... – Ben Voigt Apr 15 '22 at 17:12
  • @cigien: Added an example for you. This isn't the valid pre-C++20 code that changes behavior, it is the new code that causes the behavior change in other valid code. – Ben Voigt Apr 15 '22 at 17:23
  • Since a map object can be assigned to another map object, any caching of an underlying const element could also fail. And that's long been legal in library code. Yet it changes stuff that was const in the same memory location. Seems one has to go out of one's way for the basic.life change to be a problem. Has anyone ever used placement new to change the const key element in a map? It would be compilable but UB before and legal now though it would sure mess up map. – doug Apr 15 '22 at 18:49
  • @doug: A map object can be assigned to another map object is true but in no case (copy constructor, move construction, copy assignment, move assignment) would that rewrite any of the `std::pair` elements inside, certainly not in any way that existing pointers/references/iterators, whether inside the map or outside, are allowed to continue accessing them. – Ben Voigt Apr 15 '22 at 19:16
  • There is no guarantee that a const map key will not change yet have the same memory address after some future set of assignments replacing the map entity. The notion that a const object at a location is immutable and can't have another value later is not a problem if the lifetime is ended in between. basic.life's new description recognizes this. – doug Apr 15 '22 at 20:10
  • @doug: The only code that should cause the map item to be replaced by a new entry is the map itself. We're talking about a guarantee made to the "owner" which allocates and initially constructs the object, which extends to other parts of the program only for as long as the owner permits (in case of map elements, that guarantee is formalized as "iterator invalidation" effects explicitly stated on each and every one of the map member functions). The map itself requires that guarantee in order to maintain its invariants. The new `[basic.life]` is completely and irretrievably broken. – Ben Voigt Apr 16 '22 at 21:13
  • Ok, how about an example of real life existing code the change breaks. Modifying the const in a map though a ptr casted to a non -const was and remains UB. The change makes prior placement new hacks to get const member objects to work legal. They actually worked in spite of being technically UB. Now they are no longer technical violations. It's a good thing. – doug Apr 16 '22 at 22:00
  • @doug: The last line of code, marked "existing valid code that breaks", isn't realistic enough for you? Having "placement new hacks" on const member subobjects become legal is not a good thing, being UB was correct. The `const Key` in a map *is* a member subobject, your claim that is "was and remains UB" is false, it is no longer UB. I'm glad though that you agree that it would be better if it were still UB. – Ben Voigt Apr 18 '22 at 02:06
  • @BenVoigt. I consider the change a good thing and, as I said, probably makes legal a lot of placement new hacks. Obviously compiler vendors had no problem with it since it's going on 2 years and is unchanged in the c++23 proposal. There is no existing code from c++17 that it breaks. Further, it makes possible normal use of consts in classes like sorting vectors. And it improves coding making it easier to maintain object invariance when coding member functions since inadvertent attempts to change the consts will be flagged. IMO, it's a very good, welcome change. – doug Apr 18 '22 at 05:04
  • @doug: It's not only std::map that breaks, lifetime extension by references is also kaput. Please see http://www.open-std.org/jtc1/sc22/wg21/docs/cwg_defects.html#89 -- your "very good, welcome change" stomped all over this. Which is what happens when people change rules without first understanding why they are there. The rule was there for a very good reason, and people wrote code relying on it for 20 years. The change is very unwelcome. – Ben Voigt Apr 19 '22 at 01:54
  • @BenVoigt that was resolved the next year. [Core Language Changes for NB Comments at the November, 2019 (Belfast) meeting](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1971r0.html) – doug Apr 19 '22 at 20:46
  • @doug P1971R0 is what I've been looking at this whole time. There are definitely bugs in the Standard surrounding this, currently working with Jens to try to figure out what's doable. – Ben Voigt Apr 19 '22 at 21:22
  • @BenVoigt While I'm pleased with the change as it now allows me to use consts in structs w/o making them almost unusable, I'm concerned about references. They can be hard to use correctly. And proper use of references, whether referring to subobjects within the same object that may move as in a vector or external objects that need to stay in place, it can be a source of hard to find bugs. – doug Apr 19 '22 at 22:45