Bezpieczna zamiana jednostek w C++ część 1

sleep(300);

Ile śpimy? 0.3s (300ms)? 5 minut (300s)? A może parametrem powyższego sleep jest jeszcze inna jednostka? Bez zajrzenia do dokumentacji – albo kodu – nie ma na to pytanie odpowiedzi. Akurat w przypadku jednostek czasu C++11 wprowadził zestaw klas odpowiedzialnych za nie, więc można napisać taki zupełnie jednoznaczny kod:

this_thread::sleep_for(chrono::seconds(30));
this_thread::sleep_for(500ms); // C++14

Ale jeśli chodzi o inne jednostki, np. długości, trzeba sobie radzić samemu. Pomocna będzie w tym klasa (a raczej szablon) std::ratio, który został również użyty dla typów w przestrzeni nazw std::chrono. Służy ona do reprezentacji prawie dowolnych liczb wymiernych. Jedynym ograniczeniem jest to, że zarówno mianownik jak i licznik muszą się mieścić w std::intmax_t oraz mianownik nie może być najniższą możliwe wartością swojego typu.

Wersja super prosta

Zacznijmy od utworzenia klasy reprezentującej wartość z mnożnikiem:

template<std::intmax_t Num, std::intmax_t Denom = 1, typename Type = double>
struct ratioed_value
{
    using type = ratioed_value;
    using value_type = Type;
    using ratio = std::ratio<Num, Denom>;
 
    explicit ratioed_value(value_type v): value{v} {}
 
    value_type count() const { return value; }
 
private:
   value_type value;
};

Wzorując się na chrono::duration_cast, napiszmy analogiczną funkcję dla ratioed_value:

template<typename To, typename From>
To ratioed_value_cast(From&& from) {
    using divide = std::ratio_divide<
        typename std::remove_reference_t<From>::ratio,
        typename To::ratio
    >;
    return To{from.count() * divide::num / divide::den};
}

Teraz wystarczy zdefiniować typy dla np. kilometrów i metrów:

using km = ratioed_value<1000>;
using m = ratioed_value<1>;

I można przystąpić do testów:

km len{2.5};
cout << ratioed_value_cast<m>(len).count(); // 2500

link

Usprawnienia

Powyższy szablon ma bardzo ograniczoną funkcjonalność. Na pewno można dodać mu domyślne konstruktory i operatory przypisania:

template<std::intmax_t Num, std::intmax_t Denom = 1, typename Type = double>
struct ratioed_value
{
    using type = ratioed_value;
    using value_type = Type;
    using ratio = std::ratio<Num, Denom>;
 
    explicit ratioed_value(value_type const& v): value{v} {}
    explicit ratioed_value(value_type&& v): value{std::move(v)} {}
 
    template<typename T, typename = typename std::remove_reference_t<T>::ratio>
    explicit ratioed_value(T&& t):
        ratioed_value{
            ratioed_value_cast<ratioed_value>(std::forward<T>(t))
    } {}
 
    ratioed_value(ratioed_value const& o) = default;
    ratioed_value(ratioed_value&& o) = default;
 
    ratioed_value& operator=(ratioed_value const&) = default;
    ratioed_value& operator=(ratioed_value&&) = default;
 
    template<typename T>
    ratioed_value& operator=(T&& t) {
        return *this = ratioed_value_cast<ratioed_value>(std::forward<T>(t));
    }
 
    value_type count() const { return value; }
 
private:
   value_type value;
};

Można też pokusić się o implementację operatorów (mnożenie i dzielenie jest przez skalar):

ratioed_value operator-() const { return ratioed_value{value}; }
 
template<typename T>
ratioed_value& operator=(T&& t) {
    return *this = ratioed_value_cast<ratioed_value>(std::forward<T>(t));
}
 
ratioed_value& operator+=(ratioed_value const& o) {
    value += o.value;
    return *this;
}
 
ratioed_value& operator-=(ratioed_value const& o) {
    value -= o.value;
    return *this;
}
 
ratioed_value& operator*=(value_type&& o) {
    value *= std::move(o);
    return *this;
}
 
ratioed_value& operator*=(value_type const& o) {
    value *= o;
    return *this;
}
 
friend ratioed_value operator*(ratioed_value l, value_type const& r) {
    return l *= r;
}
 
friend ratioed_value operator*(ratioed_value l, value_type&& r) {
    return l *= std::move(r);
}
 
friend ratioed_value operator*(value_type const& l, ratioed_value r) {
    return r *= l;
}
 
friend ratioed_value operator*(value_type&& l, ratioed_value r) {
    return r *= std::move(l);
}
 
ratioed_value& operator/=(value_type&& o) {
    value /= std::move(o);
    return *this;
}
 
ratioed_value& operator/=(value_type const& o) {
    value /= o;
    return *this;
}
 
friend ratioed_value operator/(ratioed_value l, value_type const& r) {
    return l /= r;
}
 
friend ratioed_value operator/(ratioed_value l, value_type&& r) {
    return l /= std::move(r);
}
 
friend ratioed_value operator/(value_type const& l, ratioed_value r) {
    return r /= l;
}
 
friend ratioed_value operator/(value_type&& l, ratioed_value r) {
    return r /= std::move(l);
}
 
bool operator==(ratioed_value const& o) const { return value == o.value; }
bool operator==(ratioed_value&& o) const { return value == std::move(o.value); }
 
bool operator!=(ratioed_value const& o) const { return *this != o; }
bool operator!=(ratioed_value&& o) const { return *this != std::move(o); }
 
bool operator<=(ratioed_value const& o) const { return value <= o.value; }
bool operator<=(ratioed_value&& o) const { return value <= std::move(o.value); }
 
bool operator>=(ratioed_value const& o) const { return value >= o.value; }
bool operator>=(ratioed_value&& o) const { return value >= std::move(o.value); }
 
bool operator<(ratioed_value const& o) const { return value < o.value; }
bool operator<(ratioed_value&& o) const { return value < std::move(o.value); }
 
bool operator>(ratioed_value const& o) const { return value > o.value; }
bool operator>(ratioed_value&& o) const { return value > std::move(o.value); }

Sporo tego? Owszem, ale najciekawsze dopiero przed nami – operatory dwuargumentowe. Celem jest umożliwienie zapisu

auto marathon = km{42} + m{195};
assert(marathon == m{42195});

Konieczny do tego jest dynamiczny wybór typu o większej precyzji (aby przypadkiem z powyższej sumy nie wyszło 42km). Rozwiązałem to w sposób następujący, ale może da się trochę ładniej:

namespace detail{
template<typename T, typename U>
struct common_ratioed_value{};
 
template<std::intmax_t NumL, std::intmax_t DenomL, typename TypeL,
         std::intmax_t NumR, std::intmax_t DenomR, typename TypeR>
struct common_ratioed_value<
        ratioed_value<NumL, DenomL, TypeL>,
        ratioed_value<NumR, DenomR, TypeR>>
{
    using left = ratioed_value<NumL, DenomL, TypeL>;
    using right = ratioed_value<NumR, DenomR, TypeR>;
    using ratio = typename std::conditional_t<
        std::ratio_less<typename left::ratio, typename right::ratio>::value,
        left,
        right
    >::ratio;
    using value_type = std::common_type_t<TypeL, TypeR>;
    using type = ratioed_value<ratio::num, ratio::den, value_type>;
};
}
 
template<typename T, typename U>
using common_ratioed_value = typename detail::common_ratioed_value<
    std::decay_t<T>, std::decay_t<U>
>::type;
 
template<typename T, typename U>
auto operator+(T&& t, U&& u) -> common_ratioed_value<T, U> {
    using common = common_ratioed_value<T, U>;
    return ratioed_value_cast<common>(std::forward<T>(t)) +=
        ratioed_value_cast<common>(std::forward<U>(u));
}
 
template<typename T, typename U>
auto operator-(T&& t, U&& u) -> common_ratioed_value<T, U> {
    using common = common_ratioed_value<T, U>;
    return ratioed_value_cast<common>(std::forward<T>(t)) -=
        ratioed_value_cast<common>(std::forward<U>(u));
}
 
template<typename T, typename U>
auto operator/(T&& t, U&& u) -> typename common_ratioed_value<T, U>::value_type {
    using common = common_ratioed_value<T, U>;
    return ratioed_value_cast<common>(std::forward<T>(t)).count() /
        ratioed_value_cast<common>(std::forward<U>(u)).count();
}
 
template<typename T, typename U>
using enable_bool_for_different_ratioed_values = std::enable_if_t<
    !std::is_same<std::decay_t<T>,std::decay_t<U>>::value,
    std::conditional_t<
        std::is_same<common_ratioed_value<T, U>, void>::value, bool, bool
    >
>;
 
template<typename T, typename U>
auto operator==(T&& t, U&& u) -> enable_bool_for_different_ratioed_values<T, U> {
    using common = common_ratioed_value<T, U>;
    return ratioed_value_cast<common>(std::forward<T>(t)) ==
        ratioed_value_cast<common>(std::forward<U>(u));
}
 
template<typename T, typename U>
auto operator!=(T&& t, U&& u) -> enable_bool_for_different_ratioed_values<T, U> {
    using common = common_ratioed_value<T, U>;
    return ratioed_value_cast<common>(std::forward<T>(t)) !=
        ratioed_value_cast<common>(std::forward<U>(u));
}
 
template<typename T, typename U>
auto operator>=(T&& t, U&& u) -> enable_bool_for_different_ratioed_values<T, U> {
    using common = common_ratioed_value<T, U>;
    return ratioed_value_cast<common>(std::forward<T>(t)) >=
        ratioed_value_cast<common>(std::forward<U>(u));
}
 
template<typename T, typename U>
auto operator<=(T&& t, U&& u) -> enable_bool_for_different_ratioed_values<T, U> {
    using common = common_ratioed_value<T, U>;
    return ratioed_value_cast<common>(std::forward<T>(t)) <=
        ratioed_value_cast<common>(std::forward<U>(u));
}
 
template<typename T, typename U>
auto operator>(T&& t, U&& u) -> enable_bool_for_different_ratioed_values<T, U> {
    using common = common_ratioed_value<T, U>;
    return ratioed_value_cast<common>(std::forward<T>(t)) >
        ratioed_value_cast<common>(std::forward<U>(u));
}
 
template<typename T, typename U>
auto operator<(T&& t, U&& u) -> enable_bool_for_different_ratioed_values<T, U> {
    using common = common_ratioed_value<T, U>;
    return ratioed_value_cast<common>(std::forward<T>(t)) <
        ratioed_value_cast<common>(std::forward<U>(u));
}

Rezultat

Korzystając z powyższego możemy teraz napawać się sukcesem:

template<std::intmax_t Num, std::intmax_t Denom = 1, typename T>
ostream& operator<<(ostream& o, ratioed_value<Num, Denom, T> const& val)
{
    o << "ratioed_value<" << Num;
    if(Denom != 1)
        o << ", " << Denom;
    o << "> {" << val.count() << "}";
    return o;
}
 
int main()
{
    using km = ratioed_value<1000, 1, long>;
    using m = ratioed_value<1, 1, long>;
    using light_second = ratioed_value<299792000>;
    using light_year = ratioed_value<9461000000000000>; // from WolframAlpha
    auto len = km{42} + m{195};
    DBG(len);
    DBG(m{195} + km{42});
 
    DBG(km{5} == 500 * m{10});
    DBG(km{5} >= 500 * m{10});
    DBG(km{5} >  500 * m{10});
 
    DBG(light_year{1} / light_second{60 * 60 * 24});
    DBG(km{42} == len);
}

Wyjście programu: [link]

len                                                         ratioed_value<1> {42195}
m{195} + km{42}                                             ratioed_value<1> {42195}
km{5} == 500 * m{10}                                        true
km{5} >= 500 * m{10}                                        true
km{5} > 500 * m{10}                                         false
light_year{1} / light_second{60 * 60 * 24}                  365.261
km{42} == len                                               false

O czym nie wspomniałem?

“Explicit is better than implicit”. W powyższym kodzie konwersje dokonywane są niejawnie, co utrudnia implementację. Ponadto w wielu przypadkach preferowane jest jawne wymuszanie zmiany jednostek, szczególnie gdy w przeciwnym wypadku efektem może być utracona precyzja.

Co dalej?

Jak łatwo zauważyć, o ile zamiana jednostek została “poprawiona” i taki kod pozwoliłby uniknąć problemu Gimli Glidera, ale poniższy kod wciąż skompiluje się i wykona bez błędu, mimo jego oczywistej błędności:

using m = ratioed_value<1, 1, long>;
using km = ratioed_value<1000, 1, long>;
using g = ratioed_value<1, 1, long>;
using kg = ratioed_value<1000, 1, long>;
kg weight = km{10};

Ale o tym w części 2.

Leave a Reply

Your email address will not be published.