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