user9088793
user9088793

Reputation:

Struct with optional members in modern C++

We have inherited old code which we are converting to modern C++ to gain better type safety, abstraction, and other goodies. We have a number of structs with many optional members, for example:

struct Location {
    int area;
    QPoint coarse_position;
    int layer;
    QVector3D fine_position;
    QQuaternion rotation;
};

The important point is that all of the members are optional. At least one will be present in any given instance of Location, but not necessarily all. More combinations are possible than the original designer apparently found convenient to express with separate structs for each.

The structs are deserialized in this manner (pseudocode):

Location loc;
// Bitfield expressing whether each member is present in this instance
uchar flags = read_byte();
// If _area_ is present, read it from the stream, else it is filled with garbage
if (flags & area_is_present)
    loc.area = read_byte();
if (flags & coarse_position_present)
    loc.coarse_position = read_QPoint();
etc.

In the old code, these flags are stored in the struct permanently, and getter functions for each struct member test these flags at runtime to ensure the requested member is present in the given instance of Location.

We don't like this system of runtime checks. Requesting a member that isn't present is a serious logic error that we would like to find at compile time. This should be possible because whenever a Location is read, it is known which combination of member variables should be present.

At first, we thought of using std::optional:

struct Location {
    std::optional<int> area;
    std::optional<QPoint> coarse_location;
    // etc.
};

This solution modernizes the design flaw rather than fixing it.

We thought of using std::variant like this:

struct Location {
    struct Has_Area_and_Coarse {
        int area;
        QPoint coarse_location;
    };
    struct Has_Area_and_Coarse_and_Fine {
        int area;
        QPoint coarse_location;
        QVector3D fine_location;
    };
    // etc.
    std::variant<Has_Area_and_Coarse,
                 Has_Area_and_Coarse_and_Fine /*, etc.*/> data;
};

This solution makes illegal states impossible to represent, but doesn't scale well, when more than a few combinations of member variables are possible. Furthermore, we would not want to access by specifying Has_Area_and_Coarse, but by something closer to loc.fine_position.

Is there a standard solution to this problem that we haven't considered?

Upvotes: 14

Views: 17266

Answers (4)

nate
nate

Reputation: 1871

If you always know at read or construction time what fields will be present then making the validity bit a template argument and checking with static_assert would work.

#include <stdexcept>
#include <iostream>

struct stream {
    template <typename value> value read ();
    template <typename value> void read (value &);
};

template <unsigned valid>
struct foo
{
    int a;
    float b;
    char c;

    auto & get_a () { static_assert (valid & 1); return a; }
    auto & get_b () { static_assert (valid & 2); return b; }
    auto & get_c () { static_assert (valid & 4); return c; }
};

template <unsigned valid>
foo <valid> read_foo (stream & Stream)
{
    if (Stream.read <unsigned> () != valid)
        throw std::runtime_error ("unexpected input");

    foo <valid> Foo;

    if (valid & 1) Stream.read (Foo.a);
    if (valid & 2) Stream.read (Foo.b);
    if (valid & 4) Stream.read (Foo.c);
}

void do_something (stream & Stream)
{
    auto Foo = read_foo <3> (Stream);

    std::cout << Foo.get_a () << ", " << Foo.get_b () << "\n";

    // don't touch c cause it will fail here
    // Foo.get_c ();
}

This also allows for templates to deal with missing fields using if constexpr.

template <unsigned valid>
void print_foo (std::ostream & os, foo <valid> const & Foo)
{
    if constexpr (valid & 1)
        os << "a = " << Foo.get_a () << "\n";
    if constexpr (valid & 2)
        os << "b = " << Foo.get_b () << "\n";
    if constexpr (valid & 4)
        os << "c = " << Foo.get_c () << "\n";
}

Upvotes: 0

nate
nate

Reputation: 1871

You could have a version of the structure that makes the bitmap compile time and checks it there. I assume that for a particular piece of code, you make assumptions about what is present. In that code you can take the version with the compile time bitmap. In order to successfully convert a run-time bit-mapped version to the compile-time bit-mapped, the bit map would be validated.

#include <stdexcept>

struct foo
{
    int a;
    float b;
    char c;
};

struct rt_foo : foo
{
    unsigned valid;
};

template <unsigned valid>
struct ct_foo : foo
{
    // cannnot default construct
    ct_foo () = delete;

    // cannot copy from version withouth validity flags
    ct_foo (foo const &) = delete;
    ct_foo & operator = (foo const &) = delete;

    // copying from self is ok
    ct_foo (ct_foo const &) = default;
    ct_foo & operator = (ct_foo const &) = default;

    // converting constructor and assignement verify the flags 
    ct_foo (rt_foo const & rtf) :
        foo (check (rtf))
    {
    }

    ct_foo & operator = (rt_foo const & rtf)
    {
        *static_cast <foo*> (this) = check (rtf);

        return *this;
    }

    // using a member that is not initialize will be a compile time error at when
    // instantiated, which will occur at the time of use

    auto & get_a () { static_assert (valid & 1); return a; }
    auto & get_b () { static_assert (valid & 2); return a; }
    auto & get_c () { static_assert (valid & 3); return a; }

    // helper to validate the runtime conversion

    static foo & check (rt_foo const & rtf)
    {
        if ((valid & rtf.valid) != 0)
            throw std::logic_error ("bad programmer!");
    }
};

Upvotes: 0

einpoklum
einpoklum

Reputation: 132310

I'll first say that I've also occasionally wanted to have an "optionalization" of a class, where all members become optional. I'm thinking perhaps this could be possible without proper metaprogramming using code similar to Antony Polukhin's magic_get.

But be that as it may... You could have a partially-type-safe attribute map with arbitrary-typed values:

class Location {
    enum class Attribute { area, coarse_position, fine_position, layer };   
    std::unoredered_map<Attribute, std::any> attributes;
}

std::any can hold any type (something by allocating space on the stack, sometimes internally). Facing the outside the type is erased, but you can restore it with a get<T>() method. That's safe in the sense that you'll get an exception if you stored an object of one type and are trying to get() another type, but it's unsafe in that you won't get an error thrown in compile-time.

This can be adapted to the case of arbitrary attributes, beyond those you've originally planned, e.g.:

class Location {
    using AttributeCode = uint8_t;
    enum : AttributeCode { 
        area            = 12,
        coarse_position = 34,
        fine_position   = 56,
        layer           = 789
    };   
    std::unoredered_map<AttributeCode, std::any> attributes;
}

The use of the attributes could involve free functions which check for the presence of relevant attributes.

In practice, by the way, an std::vector would probably be faster to search than the std::unordered_map.

Caveat: This solution does not give you much of the type safety you desire.

Upvotes: 0

Yola
Yola

Reputation: 19093

What about mixins?

struct QPoint {};
struct QVector3D {};
struct Area {
    int area;
};
struct CoarsePosition {
    QPoint coarse_position;
};
struct FinePosition {
    QVector3D fine_position;
};
template <class ...Bases>
struct Location : Bases... {
};

Location<Area, CoarsePosition> l1;
Location<Area, FinePosition> l2;

Upvotes: 3

Related Questions