7
\$\begingroup\$

I've put together a class whose goal is to store objects of arbitrary type, with each type having its own vector for contiguity. I'd like to gather advice on what I have so far before I carry on.

Let me show you the code, along with a main() so you can compile and run it easily (you'll need C++14 support). Here it is on Coliru. I'll highlight some of the "interesting" points.

Note: this code has not been written is response to a real problem. It is an exploration of how much C++ allows us to streamline object management, and may produce an actual useful class. What I'm thinking about is a way of centralizing objects (say, game objects) library-side, and later be able to run code for some/all of them transparently.

#include <iostream>
#include <string>
#include <vector>
#include <cassert>

// TypeEraser.h

template <std::size_t Tsize, std::size_t Talign>
struct TypeEraser {
public:
    template <class T>
    TypeEraser(T *typeTag);                                             // (1)

    TypeEraser(TypeEraser &&other);

    ~TypeEraser();

    TypeEraser(TypeEraser const &) = delete;
    TypeEraser &operator = (TypeEraser const &) = delete;

    template <class T>
    T &get();

private:
    void (*_deleter)(char *s);                                          // (2)
    void (*_mover)(TypeEraser &self, TypeEraser &&other);               //
    alignas(Talign) char _storage[Tsize];                               //
};

template <class Tarchetype>
using TypeEraserFor = TypeEraser<                                       // (3)
    sizeof(Tarchetype),                                                 //
    alignof(Tarchetype)                                                 //
>;

template <std::size_t Tsize, std::size_t Talign>
template <class T>
TypeEraser<Tsize, Talign>::TypeEraser(T*)
: _deleter([](char *s) {
    reinterpret_cast<T*>(s)->~T();                                      // (4)
})
, _mover([](TypeEraser &self, TypeEraser &&other) {
    new (self._storage) T(std::move(other.get<T>()));
}) {
    static_assert(sizeof(T) == Tsize, "Wrong object size !");           // (3)
    static_assert(alignof(T) == Talign, "Wrong object alignment !");    //
    new (_storage) T;
}

template <std::size_t Tsize, std::size_t Talign>
template <class T>
T &TypeEraser<Tsize, Talign>::get() {
    #pragma GCC diagnostic ignored "-Wstrict-aliasing"
    return *reinterpret_cast<T*>(_storage);                             // (4)
    #pragma GCC diagnostic pop
}

template <std::size_t Tsize, std::size_t Talign>
TypeEraser<Tsize, Talign>::TypeEraser(TypeEraser &&other)
: _deleter(other._deleter)
, _mover(other._mover) {
    _mover(*this, std::move(other));
}


template <std::size_t Tsize, std::size_t Talign>
TypeEraser<Tsize, Talign>::~TypeEraser() {
    _deleter(_storage);
}

// MultiVector.h

struct MultiVector {
    template <class T>
    std::vector<T> &all();

    template <class T, class... Args>
    T &emplace(Args &&... args);

    template <class T>
    T &add(T &&orig);

private:
    static std::size_t _tIdCounter;

    template <class T>
    static std::size_t typeId();

    std::vector<TypeEraserFor<std::vector<char>>> subVectors;
};

template <class T>
std::size_t MultiVector::typeId() {                                     // (5)
    static std::size_t id = _tIdCounter++;
    return id;
}

template <class T>
std::vector<T> &MultiVector::all() {
    std::size_t tId = typeId<T>();
    assert(tId <= subVectors.size());
    if(tId == subVectors.size()) {
        subVectors.emplace_back(static_cast<std::vector<T>*>(nullptr)); // (1)
    }
    return subVectors[tId].get<std::vector<T>>();
}

template <class T, class... Args>
T &MultiVector::emplace(Args &&... args) {
    auto &allT = all<T>();
    allT.emplace_back(std::forward<Args>(args)...);
    return allT.back();
}

template <class T>
T &MultiVector::add(T &&orig) {
    auto &allT = all<std::remove_reference_t<T>>();
    allT.push_back(std::forward<T>(orig));
    return allT.back();
}

// MultiVector.cpp

std::size_t MultiVector::_tIdCounter = 0u;

int main()
{    
    MultiVector mv;

    for(int i = 0; i < 10; ++i)
        mv.add(i);

    mv.emplace<std::string>("Hello");
    mv.emplace<std::string>("World");
    mv.emplace<std::string>("!");

    for(auto &i : mv.all<int>())
        std::cout << i << ' ';
    std::cout << '\n';
    for(auto &s : mv.all<std::string>())
        std::cout << s << ' ';
    std::cout << '\n';

    assert(mv.all<float>().empty());

    return 0;
}

Highlights

  1. Tag dispatching: to construct a TypeEraser, I'm passing an unused pointer to its constructor so it can deduce its type.
  2. Type erasure: TypeEraser stores the object in a properly (see 3.) sized and aligned memory segment. It also stores pointers to functions needed to delete the contained object , or constructing it by moving an existing one.
  3. Assumptions about the contained object: TypeEraser is parameterized with the size and alignment of its storage, and checks that they match with the object you try to put in. The helper type TypeEraserFor takes in an "archetype", a type that you expect to share size and alignment with every other type you'll store in this TypeEraser. In this example, I assume that every specialization of std::vector (that I'd want to use) shares the same size and alignment as std::vector<char>.
  4. Placement new: according to the documentation for operator new on cppreference (5th overload), placement new returns its pointer argument unchanged. Thus, I'm assuming I can just retrieve said pointer "on the fly" by taking the address of storage again. Am I right, and is GCC's type-punning warning a false positive indeed? I've also read about reinterpret_cast<T*>() being an ill choice because it is implementation-defined. How about static_cast<T*>(static_cast<void*>())?
  5. Note that TypeEraser doesn't actually retain any more information on its content than how to move it and how to delete it. The user of the class is supposed to feed it back the type when she wants to retrieve its content. This is done by MultiVector itself, using a handy trick about static variables inside templates: every time you instanciate typeId() (arguably a bad name...) with a new type, a new static variable gets initialized, increments the global counter, and is returned. Subsequent calls with the same type will retrieve the same static variable, and return the same ID.

Future expansion

The typeId() trick is a neat one, but it works only in one direction (mainly because identifiers are generated at runtime in indeterminate order). Thus I'm afraid of throwing out too much type information. For example, a neat feature would be to iterate over every subclass-type vector given a superclass, which is not possible as-is. The only solution I'm seeing is declaring and keeping the full typelist of what can be stored in the MultiVector. This implies a full enumeration of storable types, which would make it more rigid. I guess you can't have your cake and eat it, but I'd like to know if there are some possibilities between these extrema.

\$\endgroup\$
  • \$\begingroup\$ You might want to look at Boost Any library. It may give you inspiration for your own and is well debugged and checked. \$\endgroup\$ – Ant Sep 29 '15 at 16:27
  • \$\begingroup\$ What does this give you that std::vector<boost::any> doesn't? \$\endgroup\$ – Barry Oct 5 '15 at 21:26
  • \$\begingroup\$ @Barry contiguity for each object type. \$\endgroup\$ – Quentin Oct 6 '15 at 15:31
5
\$\begingroup\$

This looks pretty solid. I have only some minor comments about your current implementation, and just a thought for an alternate one. First the thought.

Boost.Any

In case you don't want to reinvent the wheel, you can accomplish all of what you need with a vector<boost::any>. The main difference in implementation would be in your all(), which would look something like:

template <class T>
std::vector<T> &MultiVector::all() {
    std::size_t tId = typeId<T>();
    assert(tId <= subVectors.size());
    if(tId == subVectors.size()) {
        subVectors.emplace_back(std::vector<T>{});
    }

    return *unsafe_any_cast<std::vector<T>>(&subVectors[tId]);
}

We know what type it is, so we can use unsafe_any_cast - which just does a static_cast. The advantage here is that we're just using pre-existing code.

Construction

Right now, your TypeEraser takes a T* that is always a null pointer and then you always default construct a T in it. We can make this more functional. First, passing a pointer just as a tag is confusing. Let's create a tag type for this:

template <typename > struct tag;

template <std::size_t Tsize, std::size_t Talign>
struct TypeEraser {
    template <class T>
    TypeEraser(tag<T> );
};

The body is basically the same, we're just passing a type that is more clearly indicative of what we're actually doing.

Now, we can generalize this to add arbitrary args:

template <class T, class... Args>
TypeEraser(tag<T>, Args&&... args ) {
    // ...
    new (_storage) T(std::forawrd<Args>(args)...);
}

And then add single-argument constructors to boot:

template <typename T>
TypeEraser(T&& arg)
: TypeEraser(tag<std::remove_reference_t<T>>{}, std::forward<T>(arg))
{ }

That will let us do either:

subVectors.emplace_back(std::vector<T>{});
subVectors.emplace_back(tag<std::vector<T>>{});

Both of which I prefer to static_cast with nullptr.

Add

We have:

template <class T>
T &add(T &&orig);

This has potential issues compared to emplace, where we have to specify the type T. Consider:

mv.add(4);
mv.add(5u);

That adds to two different type vectors internally. Is that intended by the user? Does it make sense? I don't know. Perhaps consider requiring the user to write:

mv.add<int>(4);
mv.add<int>(5u);

I'm not saying you should do this, but it's worth considering and explicitly ruling out.

Move Assignment

I would explicitly delete it:

 TypeEraser& operator=(TypeEraser&& ) = delete;

Generalizing

So where do we go from here? We have aligned storage, and we have function pointers that are appropriately set for deleting and move-constructing. What if we want to support copying? Copy-assigning? Streaming? I'd suggest creating some policies:

template <std::size_t Tsize, std::size_t Talign,
          typename... Policies>
struct TypeEraser : Something<Policies...>;

where each Policy would define whatever valid operations via function pointers. We could have policies like copyable or movable or streamable. Fun project. This is sort of how Boost.TypeErasure does it.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.