How to update multiple fields of a struct simultaneously?

Viewed 790

Let's say I have a struct

struct Vector3 {
    float x;
    float y;
    float z;
};

Note that sizeof(Vector3) must remain the same.

EDIT: I am interested in solutions without setters.

Not let's create an instance of that struct Vector3 pos . How can I implement my struct so I can have something like this pos.xy = 10 // updates x and y or pos.yz = 20 // updates y and z or pos.xz = 30 // updates x and z?

5 Answers

Here is a solution that has the desired syntax, and doesn't increase the size of the class. It is technically correct, but rather convoluted:

union Vector3 {
    struct {
        float x, y, z;
        auto& operator=(float f) { x = f; return *this; }
        operator       float&() &        { return  x; }
        operator const float&() const &  { return  x; }
        operator       float () &&       { return  x; }
        float* operator&()               { return &x; }
    } x;
    
    struct {
        float x, y, z;
        auto& operator=(float f) { y = f; return *this; }
        operator       float&() &        { return  y; }
        operator const float&() const &  { return  y; }
        operator       float () &&       { return  y; }
        float* operator&()               { return &y; }
    } y;
    
    struct {
        float x, y, z;
        auto& operator=(float f) { z = f; return *this; }
        operator       float&() &        { return  z; }
        operator const float&() const &  { return  z; }
        operator       float () &&       { return  z; }
        float* operator&()               { return &z; }
    } z;
    
    struct {
        float x, y, z;
        auto& operator=(float f) { x = y = f; return *this; }
    } xy;
    
    struct {
        float x, y, z;
        auto& operator=(float f) { y = z = f; return *this; }
    } yz;
    
    struct {
        float x, y, z;
        auto& operator=(float f) { z = x = f; return *this; }
    } zx;
};

Another which relies on owner_of implemented here: https://gist.github.com/xymopen/352cbb55ddc2a767ed7c5999cfed4d31 which probably depends on some technically implementation specific (possibly undefined) behaviour:

struct Vector3 {
    float x;
    float y;
    float z;
    
    [[no_unique_address]]
    struct {
        auto& operator=(float f) {
            Vector3* v = owner_of(this, &Vector3::xy);
            v->x = v->y = f;
            return *this;
        }
    } xy;
    [[no_unique_address]]
    struct {
        auto& operator=(float f) {
            Vector3* v = owner_of(this, &Vector3::yz);
            v->y = v->z = f;
            return *this;
        } 
    } yz;
    [[no_unique_address]]
    struct {
        auto& operator=(float f) {
            Vector3* v = owner_of(this, &Vector3::zx);
            v->z = v->x = f;
            return *this;
        }
    } zx;
    [[no_unique_address]]
    struct {
        auto& operator=(float f) {
            Vector3* v = owner_of(this, &Vector3::zx);
            v->x = v->y = v->z = f;
            return *this;
        }
    } xyz;
};

The simple way is to provide setters for the combinations you want to set:

struct Vector3 {
    float x = 0;
    float y = 0;
    float z = 0;
    void set_xy(float v) {
        x = v;
        y = v;
    }
};

int main(){
    Vector3 pos;
    pos.set_xy(42);
}

And if you need sizeof(Vector3) to stay the same, thats the only way.


Just "for fun" this is how you can get pos.set_xy = 20; literally:

struct two_setter {
    float& one;    
    float& two;
    void operator=(float v){
        one = v;
        two = v;
    }
};

struct Vector3 {
    float x = 0;
    float y = 0;
    float z = 0;
    two_setter set_xy{x,y};
};

int main(){
    Vector3 pos;
    pos.set_xy = 42;
}

However, it has severe downsides. First it can have almost twice the size of the original Vector3. Moreover, because the two_setter stores references, Vector3 cannot be copied. If it would store pointers, copying would be possible, but then even more code would be required to get it right.

Alternatively it is possible to provide a xy method that returns a proxy that assigns the two members. But I am not going into detail, because pos.xy() = 3; looks really odd, has no advantage to pos.xy(3) and you really should provide a setter (or just rely on the user making two assignments when they want to make two assignments ;).

TL;DR Use a method instead of trying to get a syntax that C++ does not support out of the box.

It is possible to create an empty struct inside Vector3 with an operator=() that sets the variables of the outer struct. Of course for a variable to really take no space itself, you have to use [[no_unique_address]], which is only available since C++20. But here is an example of how it might work:

struct Vector3 {
    [[no_unique_address]] struct {
        auto &operator=(float val) {
            Vector3 *self = (Vector3 *)(this);
            self->x = val;
            self->y = val;
            return *this;
        }
    } xy;

    // Add similar code for xz and yz

    float x;
    float y;
    float z;
};

See it running on godbolt.org.

How can I implement my struct so I can have something like this pos.xy = 10 // updates x and y or pos.yz = 20 // updates y and z or pos.xz = 30 // updates x and z?

Just add the necessary class member functions to do this:

struct Vector3 {
    float x;
    float y;
    float z;
    void update_xy(float value) { x = y = value; }
    void update_yz(float value) { y = z = value; }
    void update_xz(float value) { x = z = value; }
};

Since your type is standard-layout, I think the only legal way to do this, as per the C++ standard, is with a union that contains sub-objects with custom operator= definitions.

With a union, you're allowed to view the common-initial sequence of the active member, provided all types are standard-layout types. So if we carefully craft an object that shares the same common members (e.g. 3 float objects in the same order), then we can "swizzle" between them without violating strict-aliasing.

For us to accomplish this, we will need to create a bunch of members that all have the same data in the same order, in standard-layout type.

As a simple example, lets create a basic proxy type:

template <int...Idx>
class Vector3Proxy
{
public:

    // ...

    template <int...UIdx, 
              typename = std::enable_if_t<(sizeof...(Idx)==sizeof...(UIdx))>>
    auto operator=(const Vector3Proxy<UIdx...>& other) -> Vector3Proxy&
    {
        ((m_data[Idx] = other.m_data[UIdx]),...);
        return (*this);
    }

    auto operator=(float x) -> Vector3Proxy&
    {
        ((m_data[Idx] = x),...);
        return (*this);
    }

    // ...

private:

    float m_data[3];
    template <int...> friend class Vector3Proxy;
};

In this example, not all members of m_data are used -- but they exist so that the "common-initial sequence" requirement is satisfied, which will allow us to view it through other standard-layout types within the union.

This can be built up as much as you need; float conversion for single-component operators, support for arithmetic, etc.

With a type like this, we can now build a Vector3 objects out of these proxy types

struct Vector3
{
    union {
        float _storage[3]; // for easy initialization
        Vector3Proxy<0> x;
        Vector3Proxy<1> y;
        Vector3Proxy<2> z;
        Vector3Proxy<0,1> xy;
        Vector3Proxy<1,2> yz;
        Vector3Proxy<0,2> xz;
        // ...
    };
};

Then the type can easily be used to assign to multiple values at once:

Vector3 x = {1,2,3};

x.xy = 5;

Or to assign components of one part to another:

Vector3 a = {1,2,3};
Vector3 b = {4,5,6};

a.xy = b.yz; // produces {5,6,3}

Live Example

This solution also ensures that sizeof(Vector3) does not change, since all proxy objects are the same size.


Note: It's not valid in C++ to use a union with anonymous structs, though some compilers support it. So although it might be tempting to rewrite this like:

union {
    struct {
        float x;
        float y;
        float z;
    }; // invalid, since this is anonymous
    struct {
        ...
    } xy;
}

This is not valid in standard C++, and would not be a portable solution.

Related