Search code examples
c++templatestype-traitsc++-conceptsc++23

How to check if a template typename is the same as another type but ignoring template parameters


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)


Solution

  • 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>>);
    

    Demo: https://godbolt.org/z/do76oY1rc