Evolution of a utility class

I am currently doing a feasibility study for a new kind of window sensor. At its core it has a 6-axis IMU, so a lot of processing happens for each axis.

When I started the LLM-backed code completion in CLion really helped reduce the amount of typing. I could usually write a piece of code for the x-axis, and most of the time the code completion suggested the code for the y- and z-axis. Of course, that leads to a lot of code duplication. Also for function parameters and class members, I had a mixture of std::array and x-y-z named separate variables. To help with that, the obvious solution is to have a utility class with three members.

It started out as just three floats with appropriate names:

namespace util
{
    struct Vec3
    {
        float x;
        float y;
        float z;
    };
}

The first addition was a multiplication operator:

namespace util
{
    struct Vec3
    {
        // ...

        Vec3 operator*(const float& scalar) const
        {
            return {.x=x * scalar, .y=y * scalar, .z=z * scalar};
        }
    };
}

And of course, I extend the usefulness of it by making it a template:

namespace util
{
    template<typename T>
    struct Vec3
    {
        T x;
        T y;
        T z;
    };

    template<typename T>
    requires std::is_arithmetic_v<T>
    Vec3<T> operator*(const Vec3<T>& vec, const T& scalar) noexcept
    {
        return {vec.x * scalar, vec.y * scalar, vec.z * scalar};
    }
}

The next step involved completing the set of operators (+, - , *, /). And also the first hint of what lies ahead:

namespace util
{
    template<typename T>
    struct Vec3
    {
        // ...

        template<typename U>
        Vec3<U> apply(const auto function, Vec3<U> value)
        {
            return {
                std::invoke(function, x, value.x),
                std::invoke(function, y, value.y),
                std::invoke(function, z, value.z)
            };
        }
    };

    template<typename T>
    requires std::is_arithmetic_v<T>
    Vec3<T> operator+(const Vec3<T>& lhs, const Vec3<T>& rhs) noexcept
    {
        return {lhs.x + rhs.x, lhs.y + rhs.y, lhs.z + rhs.z};
    }

    // same for operator-, operator*, operator/
}

The apply function is used to do some calculation or call a member function of T for each axis separately. In this first case, I had a Vec3<MovingAverage> and I used this to add the three new samples in a single line of code:

movingAverage.apply(&MovingAverage::addValue, gyro);

I added more apply functions as I needed them:

namespace util
{
    template<typename T>
    struct Vec3
    {
        // ...

        template<typename U>
        void apply(const auto function, const Vec3<U>& value)
        {
            std::invoke(function, x, value.x);
            std::invoke(function, y, value.y);
            std::invoke(function, z, value.z);
        }

        template<typename U, typename V>
        void apply(const auto function, const Vec3<U>& value1, const Vec3<V>& value2)
        {
            std::invoke(function, x, value1.x, value2.x);
            std::invoke(function, y, value1.y, value2.y);
            std::invoke(function, z, value1.z, value2.z);
        }

        template<typename U>
        Vec3<U> apply(const auto function)
        {
            return { std::invoke(function, x), std::invoke(function, y), std::invoke(function, z) };
        }
    };

    // ...
}

And of course, I added some more:

namespace util
{
    template<typename T>
    struct Vec3
    {
        // ...

        void apply(auto&& function)
        requires std::is_void_v<decltype(std::invoke(function, x))>
        {
            std::invoke(function, x);
            std::invoke(function, y);
            std::invoke(function, z);
        }

        auto apply(auto&& function) -> Vec3<decltype(std::invoke(function, x))>
        {
            return {std::invoke(function, x), std::invoke(function, y), std::invoke(function, z)};
        }

        template<typename U>
        void apply(auto&& function, const Vec3<U>& value)
        requires std::is_void_v<decltype(std::invoke(function, x, value.x))>
        {
            std::invoke(function, x, value.x);
            std::invoke(function, y, value.y);
            std::invoke(function, z, value.z);
        }

        template<typename U>
        auto apply(auto&& function, const Vec3<U>& value) -> Vec3<decltype(std::invoke(function, x, value.x))>
        {
            return {
                std::invoke(function, x, value.x),
                std::invoke(function, y, value.y),
                std::invoke(function, z, value.z)
            };
        }

        template<typename U, typename V>
        void apply(auto&& function, const Vec3<U>& value1, const Vec3<V>& value2)
        requires std::is_void_v<decltype(std::invoke(function, x, value1.x, value2.x))>
        {
            std::invoke(function, x, value1.x, value2.x);
            std::invoke(function, y, value1.y, value2.y);
            std::invoke(function, z, value1.z, value2.z);
        }

        template<typename U, typename V>
        auto apply(auto&& function, const Vec3<U>& value1, const Vec3<V>& value2)
            -> Vec3<decltype(std::invoke(function, x, value1.x, value2.x))>
        {
            return {
                std::invoke(function, x, value1.x, value2.x),
                std::invoke(function, y, value1.y, value2.y),
                std::invoke(function, z, value1.z, value2.z)
            };
        }
    };

    // ...
}

At this point I realised that for a class that started out as reducing duplicated code, it has a lot of duplicated code. So I replaced the six apply functions with two:

namespace util
{
    template<typename T>
    struct Vec3
    {
        // ...

        template<typename... Us>
        void apply(auto&& function, const Vec3<Us>&... values)
        requires std::is_void_v<decltype(std::invoke(function, x, values.x...))>
        {
            std::invoke(function, x, values.x...);
            std::invoke(function, y, values.y...);
            std::invoke(function, z, values.z...);
        }

        template<typename... Us>
        auto apply(auto&& function, const Vec3<Us>&... values) -> Vec3<decltype(std::invoke(function, x, values.x...))>
        {
            return {
                std::invoke(function, x, values.x...),
                std::invoke(function, y, values.y...),
                std::invoke(function, z, values.z...)
            };
        }
    };

    // ...
}

With this version, I can call member functions of T or process some values in a lambda with any number of arguments. I am sure this class will evolve as the project evolves. But here is the complete code if someone is interested:

#ifndef VEC3_H
#define VEC3_H

#include <functional>
#include <type_traits>

namespace util
{
    template<typename T>
    struct Vec3
    {
        T x;
        T y;
        T z;

        Vec3 operator+=(const Vec3& other) noexcept
        {
            *this = *this + other;
            return *this;
        }

        Vec3 operator/=(const float scalar) noexcept
        {
            *this = *this / scalar;
            return *this;
        }

        template<typename... Us>
        void apply(auto&& function, const Vec3<Us>&... values)
        requires std::is_void_v<decltype(std::invoke(function, x, values.x...))>
        {
            std::invoke(function, x, values.x...);
            std::invoke(function, y, values.y...);
            std::invoke(function, z, values.z...);
        }

        template<typename... Us>
        auto apply(auto&& function, const Vec3<Us>&... values) -> Vec3<decltype(std::invoke(function, x, values.x...))>
        {
            return {
                std::invoke(function, x, values.x...),
                std::invoke(function, y, values.y...),
                std::invoke(function, z, values.z...)
            };
        }
    };

    template<typename T>
    requires std::is_arithmetic_v<T>
    Vec3<T> operator+(const Vec3<T>& lhs, const Vec3<T>& rhs) noexcept
    {
        return {lhs.x + rhs.x, lhs.y + rhs.y, lhs.z + rhs.z};
    }

    template<typename T>
    requires std::is_arithmetic_v<T>
    Vec3<T> operator-(const Vec3<T>& lhs, const Vec3<T>& rhs) noexcept
    {
        return {lhs.x - rhs.x, lhs.y - rhs.y, lhs.z - rhs.z};
    }

    template<typename T>
    requires std::is_arithmetic_v<T>
    Vec3<T> operator*(const Vec3<T>& vec, const T& scalar) noexcept
    {
        return {vec.x * scalar, vec.y * scalar, vec.z * scalar};
    }

    template<typename T>
    requires std::is_arithmetic_v<T>
    Vec3<T> operator/(const Vec3<T>& vec, const T& scalar) noexcept
    {
        return {vec.x / scalar, vec.y / scalar, vec.z / scalar};
    }
}

#endif // VEC3_H