Home > Software design >  How to create a type wrapper with reference-like semantics?
How to create a type wrapper with reference-like semantics?

Time:08-29

I want an object which can wrap a value symantic type and pretend that it is a reference.

Something like this:

int a = 20;

std::list<Wrap<int>> l1;
l1.push_back(a);

std::list<Wrap<int>> l2;
l2.push_back(a);

l2.front() = 10;

cout << l1.front() << endl; // output should be 10

While writing this question it occured to me that a shared_ptr might be what I want. However I am not sure if a pointer symantic object is what I am looking for. Perhaps there is no alternative in the standard library?

std::shared_ptr<int> a = std::make_shared(10);

std::list<std::shared_ptr<int>> l1;
l1.push_back(a);

std::list<std::shared_ptr<int>> l2;
l2.push_back(a);

*(l2.front()) = 20; // not exactly what I wanted to write

cout << *l1.front() << endl; // prints 10

I found std::reference_wrapper but this appears to be the opposite of what I want. It appears to permit a reference type to be used like a value type which is the reverse of what I wanted.

Any thoughts?

CodePudding user response:

Proxy Object

As far as I know, there isn’t such a wrapper in the Standard Library. Fortunately though, you can implement one yourself rather easily.

How to start? Well, we’d like our Wrap<T> to be possibly indistinguishable from a T&. To achieve this, we can implement it as a proxy object. So, we’ll be making a class template storing a T& ref and implement operations one by one:

template<typename T>
struct Wrap
{
private:
    T& ref;
public:
    // ???
};

Construction

Let’s start with constructors. If we want Wrap<T> to behave like a T& it should be constructible from the same things that T& is constructible. Well then, what is a reference x constructible from?

A non-const lvalue reference like T& x = ...; needs to be constructed from an lvalue of type T. This means that it can be constructed from:

  • an object of type T
  • an object of a type derived from T
  • a different lvalue reference to T
  • an object convertible to T&
  • a function call returning T&

A const lvalue reference like const T& x = ...; can also be constructed from a braced-initializer-list or materialized from a temporary, however if we wanted to implement that, then we’d need to actually store a T inside of our class. As such, let’s focus on a non-const reference.

First, let’s implement a constructor from T& which will cover all of the cases shown above:

constexpr Wrap(T& t) noexcept :
    ref{t}
{
}

The constructor is constexpr and noexcept because it can be. It is not explicit because we want to be able to use Wrap as transparently as possible.

We need to remember though, that a reference needs to be initializable from a different reference. Because we want our type to behave just like a builtin reference, we need to be able to

  • initialize a Wrap from a T& (already implemented above)
  • initialize a Wrap from a different Wrap
  • initialize a T& from a Wrap

To meet these criteria, we’ll need to implement a conversion operator to T&. If Wrap<T> will be implicitly convertible to T&, then assigning it to a builtin reference will work.

Actually, this:

int x;
Wrap w = x;
Wrap w2 = w;

will also work (ie. w2 will be Wrap<int> rather than a Wrap<Wrap<int>>) because the conversion operator takes precedence.

The implementation of the conversion operator looks like this:

constexpr operator T&() const noexcept
{
    return ref;
}

Note that it is constexpr, noexcept and const, but not explicit (just like the constructor).

Miscelaneous operations

Now that we can construct our custom reference wrapper, we’d also like to be able to use it. The natural question is “what can you do with a reference”? Well, builtin references aren’t objects but merely aliases to existing objects. This means that taking the address of the reference actually returns the address of the referent or that you can access members through a reference.

All these things could be implemented, for example by overloading the arrow operator operator-> or the address-of operator operator&. For simplicity though, I will omit implementing these operations and focus only on the most important one: assignment.

Assignment

Firstly, we’d like to be able to assign a Wrap<T> to a T& or a T or basically anything that can be assigned a T. Fortunately, we already got that covered by having implemented the conversion operator.

Now we only need to implement assignment to Wrap<T>. We could be tempted to just write the operator like this:

constexpr T& operator=(const T& t)
{
    ref = t;
    return ref;
}

constexpr T& operator=(T&& t) noexcept
{
    ref = std::move(t);
    return ref;
}

Seems fine, right? We have a copy assignment operator and a noexcept move assignment operator. We return a reference as per custom.

Well, the problem is that this implementation is incomplete. The thing is that we don’t check

  • is T copy-assignable
    • if it is, then is it nothrow-copy-assignable
  • is T move-assignable
    • if it is, then is it nothrow-move-assignable
  • what is the return type of T’s assignment operator (it could be the customary T&, but it could also theoretically be anything else, like void)
  • does T have any other assignment operators

This is a lot of cases to cover. Fortunately, we can solve this all by making our assignment operator a template.

Let’s say that the operator will take an object of arbitrary type U as its argument. This will cover both the copy and move assignment operators and any other potential assignment operators. Then, let’s say that the return type of the function will be auto, to let the compiler deduce it. This gives us the following implementation:

template <typename U>
constexpr auto operator=(U u)
{
    return (ref = u);
}

Unfortunately though, this implementation is still not complete.

  • We don’t know if the assignment is noexcept
  • We don’t distinguish copy assignment and move assignemnt and could potentially be making unnecessary copies.
  • Is the return type (auto) really correct?

To solve the first issue we can use a conditional noexcept. To check if the assignment operator is noexcept we can either use the type trait std::is_nothrow_assignable_v or the noexcept operator. I think that using the noexcept operator is both shorter and less error-prone, so let’s use that:

template <typename U>
constexpr auto operator=(U u) noexcept(noexcept(ref = u))
{
    return (ref = u);
}

To solve the issue of distinguishing copies and moves, instead of taking a U u, we can take a forwarding reference U&& u, to let the compiler deal with all of this. We also need to remember about using std::forward:

template <typename U>
constexpr auto operator=(U&& u) noexcept(noexcept(ref = std::forward<U>(u)))
{
    return (ref = std::forward<U>(u));
}

There is a bit of code duplication, but, unfortunately, it is inevitable, unless we’d use std::is_nothrow_assignable_v instead.

Finally, is the return type correct? Well, no. Because C is C , parentheses around the returned value actually change its type (ie. return(x); is different from return x;). To return the correct type, we’ll actually also need to apply perfect forwarding to the returned type as well. We can do this by either using a trailing return type or a decltype(auto) return type. I will use decltype(auto) as it’s shorter and avoids duplicating the function body yet again:

template <typename U>
constexpr decltype(auto) operator=(U&& u) noexcept(noexcept(ref = std::forward<U>(u)))
{
    return ref = std::forward<U>(u);
}

Conclusion

Now, finally, we have a complete implementation. To sum things up, here it is all together (godbolt):

template<typename T>
struct Wrap
{
private:
    T& ref;
public:
    constexpr Wrap(T& t) noexcept :
        ref{t}
    {
    }
    constexpr operator T&() const noexcept
    {
        return ref;
    }

    template <typename U>
    constexpr decltype(auto) operator=(U&& u) noexcept(noexcept(ref = std::forward<U>(u)))
    {
        return ref = std::forward<U>(u);
    }
};

That was quite a bit of C type theory to get through to write these 21 lines. Oh, by the way, did I mention value categories...

  • Related