I want to make a library that provides units of measurements, such as length, mass, etc as types. I know such libraries probably exist already, but I want to learn how to do such a thing myself. I took inspiration from std::chrono::duration
, which uses std::ratio
, so I use that approach as well. But I ran into the problem that there is a lot of code duplication, particularly for math operation such as +
.
My approach is that I have a Measurement
base class from which I derive individual measurements such as Length
or Mass
. A lot of the code is the same between those individual measurements, such as operator+
, which takes two measurements and adds the values, correcting for the ratio between them. For example I can add 5 meters to 12 ft. But I can not put it in the base class because if I did that I could add a mass to a length, which is nonsense and the very thing I am trying to rectify in the first place.
My solution would be to put those things in the base class, but add a requires clause that prohibits the other
's type from being different from the type of the object that is being added to.
The problem with that is that I still want a Length unit to be addable to another Length unit, but with a different std::ratio
template parameter. But c++ treats them as different types, and so if I used std::same_as
, it would fail, preventing me to add 5 meters to 12 feet because Length<float, std::ratio<1>>
(1 meter) and Length<float, std::ratio<100'000, 328'084>>
(1 feet) are different types.
How can I make sure that
using Kilogram = Measurements::Mass<float>;
using Meter = Measurements::Length<float>;
using Feet = Length<float, std::ratio<100'000, 328'084>>;
Meter m{5};
Kilogram kg{45};
// m + kg; // should error
Meter m2{Feet{22}}; // should work
Feet f1 = m2; // should work
// Feet f2 = 12; // should error
Meter m3 = m2 + ft; // should work
// Kilogram kg2 = m2; // should error
But without having all the operation overloads and constructors in Mass and Length duplicated?
Here is an example of the problem
#include <iostream>
#include <ratio>
#include <type_traits>
#include <utility>
namespace Measurements
{
template <typename T>
concept IsArithmetic = requires { requires std::is_arithmetic_v<T>; };
template <typename R>
concept IsRatio = requires {
{
R::num
};
{
R::den
};
};
template <IsArithmetic Rep, IsRatio Factor = std::ratio<1, 1>>
class Measurement
{
private:
Rep m_value {};
public:
using REP = Rep;
using FACTOR = Factor;
static const auto NUM {Factor::num};
static const auto DEN {Factor::den};
explicit Measurement(Rep value) noexcept : m_value {value}
{ /* Empty */
}
// C-TORS (copy & move) and assignment (copy & move) overloads for
// Measurements<Rep, Factor> argument (defaulted with = default) and
// template <typename Rep2, Factor2> Measurements<Rep2, Factor2> argument
[[nodiscard]]
constexpr auto Value() const noexcept -> Rep
{
return m_value;
}
// Can't do this, it would allow adding kilograms to meters
/*
template <typename Rep2, typename Factor2>
constexpr auto operator+ (const Measurement<Rep2, Factor2>& other) const
{
using Adjustment = std::ratio_divide<Factor2, Factor>;
constexpr const double FACTOR = static_cast<double>(Adjustment::num) / static_cast<double>(Adjustment::den);
return Measurement<Rep, Factor> {
static_cast<Rep>(static_cast<double>(this->Value()) + (static_cast<double>(other.Value()) * FACTOR))
};
}
template <typename Rep2, typename Factor2>
constexpr auto operator+ (Measurement<Rep2, Factor2>&& other) const
{
using Adjustment = std::ratio_divide<Factor2, Factor>;
constexpr const double FACTOR = static_cast<double>(Adjustment::num) / static_cast<double>(Adjustment::den);
return Length<Rep, Factor> {
static_cast<Rep>(static_cast<double>(this->Value()) + (static_cast<double>(std::move(other).Value()) * FACTOR))
};
}
*/
};
template <typename Rep, typename Factor = std::ratio<1, 1>>
class Length final : public Measurement<Rep, Factor>
{
public:
Length() noexcept : Measurement<Rep, Factor>() {}
explicit Length(Rep value) noexcept : Measurement<Rep, Factor>(value) {};
Length(const Length<Rep, Factor>& other) noexcept : Measurement<Rep, Factor>(other) {};
Length(Length<Rep, Factor>&& other) noexcept : Measurement<Rep, Factor>(std::move(other)) {};
auto operator= (auto&& other) noexcept -> Length<Rep, Factor>&
{
Measurement<Rep, Factor>::operator= (std::forward(other));
return *this;
};
template <typename Rep2, typename Factor2>
explicit Length(const Length<Rep2, Factor2>& other) noexcept : Measurement<Rep, Factor>(other) {};
template <typename Rep2, typename Factor2>
explicit Length(Length<Rep2, Factor2>&& other) noexcept
: Measurement<Rep, Factor>(std::move(other)) {};
//NOLINTNEXTLINE (modernize-use-override)
virtual ~Length() final = default;
template <typename Rep2, typename Factor2>
constexpr auto operator+ (const Length<Rep2, Factor2>& other) const
{
using Adjustment = std::ratio_divide<Factor2, Factor>;
constexpr const double FACTOR = static_cast<double>(Adjustment::num) / static_cast<double>(Adjustment::den);
return Length<Rep, Factor> {
static_cast<Rep>(static_cast<double>(this->Value()) + (static_cast<double>(other.Value()) * FACTOR))
};
}
};
template <typename Rep, typename Factor = std::ratio<1, 1>>
class Mass final : public Measurements::Measurement<Rep, Factor>
{
// copy paste of Length
};
} // namespace Measurements
A complete example can be found here on Godbolt.
As you can see it's extremely verbose. How can I stay type safe but cut back on the code duplication, particularly the operator overloads?
In the best case I would simply inherit from Measurements and not need to do anything but add special overloads, such as dividing an Area by a Length should return a Length.
Edit 1
Having no idea what I'm doing, I tried this
template <template <IsArithmetic Rep1, IsRatio Factor1> typename Outer1, template <IsArithmetic Rep2, IsRatio Factor2> typename Outer2>
concept HasSameOuterType = requires {
requires std::same_as<Outer1<Rep1, Factor1>, Outer2<Rep1, Factor1>>;
};
However it says:
Use of undeclared identifier 'Rep1' (clang undeclared_var_use)
Just comparing Outer1
and Outer2
also gives an error:
Use of template template parameter 'Outer1' requires template arguments (clang template_missing_args)
The concept HasSameOuterType
itself is not that difficult to write. You can use type deduction with a lambda in your requirement.
template <typename T, typename U>
concept HasSameOuterType = requires (T t, U u) {
[]<template <typename R, typename F> typename Outer, typename Rep1, typename Factor1, typename Rep2, typename Factor2>
(Outer<Rep1, Factor1>&, Outer<Rep2, Factor2>&){}(t, u);
};
The concept can be further adapted to your need (e.g. taking into account references and cv-qualifiers, or requiring Rep1
to satisfy IsArithmetic
in the lambda). I'm keeping it simple here.
The following tests pass:
static_assert(Measurements::HasSameOuterType<Meter, Feet>);
static_assert(!Measurements::HasSameOuterType<Meter, Kilogramm>);
static_assert(Measurements::HasSameOuterType<std::pair<int, double>, std::pair<float, char>>);
static_assert(!Measurements::HasSameOuterType<std::pair<int, double>, std::tuple<int, double>>);