Skip to content

Relocation to simplify the type system #40

Description

@SidneyCogdill

Currently in C++, if you write a struct which uniquely owns a resource, you have to write a lot of boilerplates in order to be "correct":

struct A {
    A(A &&src) : ptr(src.ptr) {
        src.ptr = nullptr;
    }
    A(const A &) = delete;
    A &operator=(A &&src) {
        delete ptr;
        ptr = src.ptr;
        src.ptr = nullptr;
        return *this;
    }
    A &operator=(const A &) = delete;
    ~A() {
        delete ptr;
    }

private:
    T *ptr;
};

And this is just one ptr, the required amount of duplicated efforts are correlated by the count of uniquely-owned resources in the wrapper type. Requiring the users to manually write all the assignments and then setting the source object back to null state can be tiresome and bug-prone. All the efforts went in, just to satisfy a niche scenario (use after move) which even static analyzers warn you against:

A a{};
auto b = std::move(a);
auto c = a; // Use after move, allowed by C++ standard (flagged by clang-tidy and cppcheck)

With relocation constructors, this is greatly simplified, because now there's less situations (moved-from state) to worry about:

struct A {
    A(A) = default;
    A(A &&) = delete;
    A(const A &) = delete;
    ~A() {
        delete ptr;
    }

private:
    T *ptr;
};
A a{};
auto b = reloc a;
auto c = a; // Error: use after relocate.

Even if the structure isn't trivially relocatable and if you're not working with move-only types, relocation constructor can still be significantly simpler than the old ways. This requires adding two new mechanisms (plus a syntax sugar):

1. Allow the "re-initialization" of relocated-from object:

auto a = 0;
auto b = reloc a; // `a` is now in relocated-from state.
a = 0; // `a` is re-initialized.

This does not conflict with the operator=(T) relocation assignment in the proposal. The re-initialization only occurs if the left operand is in relocated-from state.

This also doesn't break existing codes, because there are no relocated-from objects in any existing codes because reloc doesn't exist today.

2. When an object of a class type with no default constructor, is declared without initialization, then treat it as relocated-from state:

struct A {
    A() = delete;
    A(int);
};

auto f() {
    A a; // `a` is considered to be relocated-from state
}

The idea comes from a "common sense" where:

int a;
a = 0;

is semantically equivalent to:

int a = 0;

except it doesn't today, because class types with custom constructors have very different meaning:

int a;
// Fails to compile, because reference_wrapper
// doesn't have a constructor that takes no argument.
std::reference_wrapper<int> b;
b = a;

This mechanism would partially fix that:

int a;
// Can't be default constructed,
// therefore it's declared but in relocated-from state.
std::reference_wrapper<int> b;
b = a; // `b` is now initialized.

is now the same as std::reference_wrapper<int> b = a.

This doesn't break existing codes, because the above code snippet don't compile today due to unmatched constructor parameters. Those that compile today don't change semantic:

std::string a; // `a` is still default constructed because a default constructor can be found.
a = "test";

3. Destroy-then-re-initialize if no matching operator= is found:

struct A {
    A(A);
    A &operator=(const A &) = delete;
    A &operator=(A) = delete;
}

auto f1() {
    A a{};
    A b{};
    // attempts to match `operator=(A)` and `operator=(A &)` first, 
    // no matching overload found, then fall back to
    // `reloc a` + `A(A)` (destory then re-initialize)
    a = b;
}

It's bascially a syntax sugar for reloc left; left = right;.

This doesn't break existing codes, because the code doesn't compile today due to missing operator=.


With the new mechanisms in place, the following demonstrates how it simplifies libstdc++ style std::string SBO:

struct A {
    A(std::span<T> input);

    A(A src)
    : storage(src.storage)
    , ptr(src.is_small() ? &storage.buf : src.ptr)
    {}

    A(A &&) = delete;

    A(const A &src)
    : storage(src.storage)
    , ptr(src.is_small()
                ? &storage.buf
                : (T *) malloc(sizeof(T) * storage.capacity))
    {}

    ~A() {
        if(!is_small()) {
            free(ptr);
        }
    }

private:
    bool is_small() {
        return ptr == &storage.buf;
    }
    union {
        std::byte buf[16];
        std::size_t capacity;
    } storage;
    T *ptr;
};
auto f1(std::span<char> data) {
    auto a = A{data};
    auto b = a; // A(const A &) called
    auto c = reloc a; // A(A) called

    A d; // uninitialized due to no matching constructors, same as relocated-from state.
    //std::dump(d); // This will be a compile time error due to use after reloc.
    d = reloc b; // initializes `d` with A(A), `d` is alive now.
    d = c; // Due to no matching operator=, equivalent to `reloc d;` followed by `A(A)`.
    return d; // NRVO, or A(A) called
}

vs the traditional way:

struct A {
    A(std::span<T> input);

    A(A &&src)
    : storage(src.storage)
    , ptr(src.is_small() ? &storage.buf : src.ptr)
    {
        src.ptr = &src.storage.buf;
    }

    A &operator=(A &&src) {
        if (&src == this) {
            return *this;
        }
        if(!is_small()) {
            free(ptr);
        }
        storage = src.storage;
        ptr = src.is_small() ? &storage.buf : src.ptr;
        src.ptr = &src.storage.buf;
        return *this;
    }

    A(const A &src)
    : storage(src.storage)
    , ptr(src.is_small()
                ? &storage.buf
                : (T *) malloc(sizeof(T) * storage.capacity))
    {}

    A &operator=(const A &src) {
        if (&src == this) {
            return *this;
        }
        if(!is_small()) {
            free(ptr);
        }
        storage = src.storage;
        ptr = src.is_small()
                    ? &storage.buf
                    : (T *) malloc(sizeof(T) * storage.capacity);
        return *this;
    }

    ~A() {
        if(!is_small()) {
            free(ptr);
        }
    }

private:
    bool is_small() {
        return ptr == &storage.buf;
    }
    union {
        std::byte buf[16];
        std::size_t capacity;
    } storage;
    T *ptr;
};
auto f1(std::span<char> data) {
    auto a = A{data};
    auto b = a; // A(const A &) called
    auto c = std::move(a); // A(A &&) called

    // A d; // It isn't possible to declare a variable without initializing it.
    auto d = A{data};
    d = std::move(b); // A(A &&) called
    d = c; // A(const A &) called
    return d; // NRVO, or A(A &&) called
}

All common use cases that originally required copy constructor + copy assignment + move constructor + move assignment, are now supported by just a relocation constructor + a copy constructor.

And not only it's simpler, it's also safer, because the users now repeat less than before.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions