8

Should assignment affect `is_trivially_relocatable`? – Arthur O'Dwyer – Stuff mo...

 1 year ago
source link: https://quuxplusone.github.io/blog/2024/01/02/bsl-vector-erase/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Should assignment affect is_trivially_relocatable?

Consider the following piece of code that uses Bloomberg’s bsl::vector:

#include <bsl_vector.h>
using namespace BloombergLP::bslmf;

struct T1 {
    int i_;
    T1(int i) : i_(i) {}
    T1(const T1&) = default;
    void operator=(const T1&) { puts("Assigned"); }
    ~T1() = default;
};
static_assert(!IsBitwiseMoveable<T1>::value);

int main() {
    bsl::vector<T1> v = {1,2};
    v.erase(v.begin());
}

bsl::vector::erase uses T1::operator= to shift element 2 leftward into the position of element 1 (and then destroys the moved-from object in position 2). The program prints “Assigned.”

But that’s because type T1 is not trivially relocatable!

BSL calls the relevant trait IsBitwiseMoveable, not IsTriviallyRelocatable; but that’s only because their codebase predates the C++11 meaning of “move.” Qt, a codebase of similar antiquity, upgraded their own terminology from “movable” to “relocatable” only in November 2020.

I don’t give a Godbolt link here only because BSL isn’t available on Godbolt yet: #5933.

Let’s try the same thing with a trivially relocatable type T2:

struct T2 : NestedTraitDeclaration<T2, IsBitwiseMoveable> {
    int i_;
    T2(int i) : i_(i) {}
    T2(const T2&) = default;
    void operator=(const T2&) { puts("Assigned"); }
    ~T2() = default;
};
static_assert(IsBitwiseMoveable<T2>::value);

int main() {
    bsl::vector<T2> v = {1,2};
    v.erase(v.begin());
}

Now BSL knows that elements of type T2 can be trivially (that is, bitwise) relocated; so bsl::vector::erase destroys element 1 and then uses memmove to shift element 2 leftward into that vacant position. The program prints nothing; T2::operator= is never called.

Okay, what’s the moral of this story?

  • If you warrant a type as “trivially relocatable,” you must be prepared for library-writers not only to skip some construct/destroy pairs, but also to skip some assignment operations.

  • To put it from the library-writer’s point of view: Every trivially relocatable type has a “sane” assignment operator. Assigning a trivially relocatable type means no more or less than transferring its value. Library-writers can and do optimize based on this fact.

  • To put it from the P1144 compiler’s point of view: Suppose we have a type like T1 that appears perfectly trivially relocatable except that it has a user-provided operator=. That user-provided assignment operator could do anything. We must consider the type non-trivially-relocatable. (Indeed, std::is_trivially_relocatable_v<T1> is false, for the same reason that bslmf::IsBitwiseMoveable<T1> is false.)

Now, of course a type with a customized assignment operator can be explicitly warranted as trivially relocatable; that’s exactly what we do in T2 (using BSL’s library-based opt-in). Here’s the same example using Qt’s library syntax for the warrant (Godbolt):

#include <QList>

struct T2 {
    int i_;
    T2(int i) : i_(i) {}
    T2(const T2&) = default;
    void operator=(const T2&) { puts("Assigned"); }
    ~T2() = default;
};
Q_DECLARE_TYPEINFO(T2, Q_RELOCATABLE_TYPE);

static_assert(QTypeInfo<T2>::isRelocatable);

int main() {
    QList<T2> v = {1,2,3};
    v.erase(v.begin() + 1);
      // does not print "Assigned"
}

And using P1144’s syntax for the warrant:

struct [[trivially_relocatable]] T2 {
    int i_;
    T2(int i) : i_(i) {}
    T2(const T2&) = default;
    void operator=(const T2&) { puts("Assigned"); }
    ~T2() = default;
};
static_assert(std::is_trivially_relocatable<T2>::value);

As of this writing, my libc++ fork follows BSL-and-Qt’s lead in optimizing vector::erase for trivially relocatable types. My newer libstdc++ fork does not, yet; but eventually it will. (Godbolt.)


“Wait, isn’t it technically non-conforming to use anything but assignment in vector::erase, because vector::erase’s Complexity element specifically requires the assignment operator of T to be called a certain number of times?” — Well, yes, you’ve got me there, for now. But we (STL) would really like it to be conforming, because we (BSL, Qt, Folly, …) already do it! WG21 likes to say that C++ should “leave no room for a lower-level language”; it doesn’t really make sense that a valuable optimization that every third-party library vendor already does should be forbidden to std::vector on a technicality.

My P3055R0 “Relax wording to permit relocation optimizations in the STL” (December 2023) aims to patch this hole. D3055R1 adds a few “stretch goal” patches on top of R0. Feedback welcome; send me an email!

Test yourself

  • As a type author: Suppose your type Cat has a defaulted copy constructor and defaulted destructor, but you rely on non-value-semantic side-effects of Cat::operator=; your program will misbehave if an assignment operation is replaced with destroy-and-reconstruct. Is Cat “trivially relocatable”?

  • As a type author: Your program’s correctness depends on the compiler’s never eliding assignments of Cat. Suppose you mark it with the attribute — struct [[trivially_relocatable]] Cat — thus forcing is_trivially_relocatable_v<Cat> to yield true. Is this “lying to the compiler”? Will your program behave correctly after that change?

  • As the compiler (or the human reader): Suppose you see a type struct Dog (not marked with the attribute). All of its data members are trivially relocatable. Its destructor and move-constructor are defaulted. But its move-assignment operator is user-provided; you can’t tell exactly what it does. As the compiler (or the human reader), is it safe to assume that Dog is trivially relocatable?

(No; yes; no; no.)


Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK