Units
is a compile-time enabled Modern C++ library that provides compile-time dimensional
analysis and unit/quantity manipulation. The basic idea and design heavily bases on
std::chrono::duration
and extends it to work properly with many dimensions.
Here is a small example of possible operations:
static_assert(1000 / 1_s == 1_kHz);
static_assert(1_h == 3600_s);
static_assert(1_km + 1_m == 1001_m);
static_assert(10_km / 5_km == 2);
static_assert(10_km / 2 == 5_km);
static_assert(1_km / 1_s == 1000_mps);
static_assert(2_kmph * 2_h == 4_km);
static_assert(2_km / 2_kmph == 1_h);
- Safety and performance
- strong types
- template metaprogramming
constexpr
all the things
- The best possible user experience
- compiler errors
- debugging
- No macros in the user interface
- Easy extensibility
- No external dependencies
- Possibility to be standardized as a part of the C++ Standard Library
units::dimension
is a type-list like type that stores an ordered list of exponents of one
or more base dimensions:
template<Exponent... Es>
struct dimension : upcast_base<dimension<Es...>> {};
units::Dimension
is a Concept that is satisfied by a type that is empty and publicly
derived from units::dimension
class template:
template<typename T>
concept Dimension =
std::is_empty_v<T> &&
detail::is_dimension<typename T::base_type> && // exposition only
DerivedFrom<T, typename T::base_type>;
units::exp
provides an information about a single base dimension and its exponent in a derived
dimension:
template<typename BaseDimension, int Value>
struct exp {
using dimension = BaseDimension;
static constexpr int value = Value;
};
where BaseDimension
is a unique sortable compile-time value and for now is implemented as:
template<int UniqueValue>
using dim_id = std::integral_constant<int, UniqueValue>;
but it is meant to be replaced with C++20 class constexpr
values provided as non-type template
parameters (when feature will be available in a compiler) so that for example base dimension for
length will be expressed as dimension<exp<"length", 1>>
.
units::Exponent
concept is satisfied if provided type is an instantiation of units::exp
class
template:
template<typename T>
concept Exponent =
detail::is_exp<T>; // exposition only
Above design of dimensions is created with the ease of use for end users in mind. Compile-time
errors should provide as short as possible template instantiations strings that should be easy to
understand by every engineer. Also types visible in a debugger should be easy to understand.
That is why units::dimension
type for derived dimensions always stores information about only
those base dimensions that are used to form that derived dimension.
However, such an approach have some challenges:
constexpr Velocity auto v1 = 1_m / 1_s;
constexpr Velocity auto v2 = 2 / 2_s * 1_m;
static_assert(Same<decltype(v1), decltype(v2)>);
static_assert(v1 == v2);
Above code, no matter what is the order of the base dimensions in an expression forming our result,
must produce the same Velocity
type so that both values can be easily compared. In order to achieve
that, dimension
class templates should never be instantiated manually but through a make_dimension_t
template metaprogramming factory function:
template<Exponent... Es>
struct make_dimension {
using type = /* unspecified */;
};
template<Exponent... Es>
using make_dimension_t = typename make_dimension<Es...>::type;
So for example to create a dimension_velocity
type we have to do:
struct dimension_velocity : make_dimension_t<exp<base_dim_length, 1>, exp<base_dim_time, -1>> {};
In order to make make_dimension_t
work as expected it has to provide unique ordering for
contained base dimensions. Beside providing ordering to base dimensions it also has to:
- aggregate two arguments of the same base dimension but different exponents
- eliminate two arguments of the same base dimension and with opposite equal exponents
Additionally, it would be good if the final type produced by make_dimension_t
would be easy to
understand for the user. For example we may decide to order base dimensions with decreasing order of
their exponents. That is why second sorting of a type list may be required. For example:
template<Exponent... Es>
struct make_dimension {
using type = mp::type_list_sort_t<detail::dim_consolidate_t<mp::type_list_sort_t<dimension<Es...>, exp_dim_id_less>>, exp_greater_equal>;
};
units::merge_dimension
is similar to make_dimension
but instead of sorting the whole list
of base dimensions from scratch it assumes that provided input dimension
types are already
sorted as a result of make_dimension
.
Typical use case for merge_dimension
is to produce final dimension
return type of multiplying
two different dimensions:
template<Dimension D1, Dimension D2>
struct dimension_multiply;
template<Exponent... E1, Exponent... E2>
struct dimension_multiply<dimension<E1...>, dimension<E2...>> {
using type = upcasting_traits_t<merge_dimension_t<dimension<E1...>, dimension<E2...>>>;
};
template<Dimension D1, Dimension D2>
using dimension_multiply_t = typename dimension_multiply<typename D1::base_type, typename D2::base_type>::type;
Example implementation of merge_dimension
may look like:
template<Dimension D1, Dimension D2>
struct merge_dimension {
using type = mp::type_list_sort_t<detail::dim_consolidate_t<mp::type_list_merge_sorted_t<D1, D2, exp_dim_id_less>>, exp_greater_equal>;
};
units::unit
is a class template that expresses the unit of a specific physical dimension:
template<Dimension D, Ratio R>
requires (R::num > 0)
struct unit : upcast_base<unit<D, R>> {
using dimension = D;
using ratio = R;
};
units::Unit
is a Concept that is satisfied by a type that is empty and publicly
derived from units::unit
class template:
template<typename T>
concept Unit =
std::is_empty_v<T> &&
detail::is_unit<typename T::base_type> && // exposition only
DerivedFrom<T, typename T::base_type>;
units::quantity
is a class template that expresses the quantity/amount of a specific dimension
expressed in a specific unit of that dimension:
template<Dimension D, Unit U, Number Rep>
requires Same<D, typename U::dimension>
class quantity;
units::Quantity
is a Concept that is satisfied by a type that is an instantiation of units::quantity
class template:
template<typename T>
concept Quantity =
detail::is_quantity<T>; // exposition only
units::quantity
provides the interface really similar to std::chrono::duration
with additional
member types and functions as below:
template<Dimension D, Unit U, Number Rep>
requires Same<D, typename U::dimension>
class quantity {
public:
using dimension = D;
using unit = U;
template<Dimension D1, Unit U1, Number Rep1, Dimension D2, Unit U2, Number Rep2>
requires treat_as_floating_point<std::common_type_t<Rep1, Rep2>> || std::ratio_multiply<typename U1::ratio, typename U2::ratio>::den == 1
quantity<dimension_multiply_t<D1, D2>, upcasting_traits_t<unit<dimension_multiply_t<D1, D2>, std::ratio_multiply<typename U1::ratio, typename U2::ratio>>>, std::common_type_t<Rep1, Rep2>>
constexpr operator*(const quantity<D1, U1, Rep1>& lhs,
const quantity<D2, U2, Rep2>& rhs);
template<Number Rep1, Dimension D, Unit U, Number Rep2>
quantity<dim_invert_t<D>, upcasting_traits_t<unit<dim_invert_t<D>, std::ratio<U::ratio::den, U::ratio::num>>>, std::common_type_t<Rep1, Rep2>>
constexpr operator/(const Rep1& v,
const quantity<D, U, Rep2>& q);
template<Dimension D1, Unit U1, Number Rep1, Dimension D2, Unit U2, Number Rep2>
requires treat_as_floating_point<std::common_type_t<Rep1, Rep2>> || std::ratio_divide<typename U1::ratio, typename U2::ratio>::den == 1
quantity<dimension_divide_t<D1, D2>, upcasting_traits_t<unit<dimension_divide_t<D1, D2>, std::ratio_divide<typename U1::ratio, typename U2::ratio>>>, std::common_type_t<Rep1, Rep2>>
constexpr operator/(const quantity<D1, U1, Rep1>& lhs,
const quantity<D2, U2, Rep2>& rhs);
};
Additional functions provide the support for operations that result in a different dimension type than those of their arguments.
To explicitly force truncating conversions quantity_cast
function is provided which is a direct
counterpart of std::chrono::duration_cast
.
Most of the important design decisions in the library are dictated by the requirement of providing the best user experience as possible.
For example with template aliases usage the following code:
const Velocity t = 20_s;
could generate a following compile time error:
C:\repos\units\example\example.cpp:39:22: error: deduced initializer does not satisfy placeholder constraints
const Velocity t = 20_s;
^~~~
In file included from C:\repos\units\example\example.cpp:23:
C:/repos/units/src/include/units/si/velocity.h:41:16: note: within 'template<class T> concept const bool units::Velocity<T> [with T = units::quantity<units::dimension<units::exp<units::base_dim_time, 1> >, units::unit<units::dimension<units::exp<units::base_dim_time, 1> >, std::ratio<1> >, long long int>]'
concept Velocity = Quantity<T> && Same<typename T::dimension, dimension_velocity>;
^~~~~~~~
In file included from C:/repos/units/src/include/units/bits/tools.h:25,
from C:/repos/units/src/include/units/dimension.h:25,
from C:/repos/units/src/include/units/si/base_dimensions.h:25,
from C:/repos/units/src/include/units/si/velocity.h:25,
from C:\repos\units\example\example.cpp:23:
C:/repos/units/src/include/units/bits/stdconcepts.h:33:18: note: within 'template<class T, class U> concept const bool mp::std_concepts::Same<T, U> [with T = units::dimension<units::exp<units::base_dim_time, 1> >; U = units::dimension<units::exp<units::base_dim_length, 1>, units::exp<units::base_dim_time, -1> >]'
concept Same = std::is_same_v<T, U>;
^~~~
C:/repos/units/src/include/units/bits/stdconcepts.h:33:18: note: 'std::is_same_v' evaluated to false
Time and velocity are not that complicated dimensions and there are much more complicated dimensions out there, but even for those dimensions
[with T = units::quantity<units::dimension<units::exp<units::base_dim_time, 1> >, units::unit<units::dimension<units::exp<units::base_dim_time, 1> >, std::ratio<1> >, long long int>]
and
[with T = units::dimension<units::exp<units::base_dim_time, 1> >; U = units::dimension<units::exp<units::base_dim_length, 1>, units::exp<units::base_dim_time, -1> >]
starts to be really hard to analyze or debug.
That is why it was decided to provide automated upcasting capability when possible. With that the same code will result with such an error:
C:\repos\units\example\example.cpp:40:22: error: deduced initializer does not satisfy placeholder constraints
const Velocity t = 20_s;
^~~~
In file included from C:\repos\units\example\example.cpp:23:
C:/repos/units/src/include/units/si/velocity.h:48:16: note: within 'template<class T> concept const bool units::Velocity<T> [with T = units::quantity<units::dimension_time, units::second, long long int>]'
concept Velocity = Quantity<T> && Same<typename T::dimension, dimension_velocity>;
^~~~~~~~
In file included from C:/repos/units/src/include/units/bits/tools.h:25,
from C:/repos/units/src/include/units/dimension.h:25,
from C:/repos/units/src/include/units/si/base_dimensions.h:25,
from C:/repos/units/src/include/units/si/velocity.h:25,
from C:\repos\units\example\example.cpp:23:
C:/repos/units/src/include/units/bits/stdconcepts.h:33:18: note: within 'template<class T, class U> concept const bool mp::std_concepts::Same<T, U> [with T = units::dimension_time; U = units::dimension_velocity]'
concept Same = std::is_same_v<T, U>;
^~~~
C:/repos/units/src/include/units/bits/stdconcepts.h:33:18: note: 'std::is_same_v' evaluated to false
Now
[with T = units::quantity<units::dimension_time, units::second, long long int>]
and
[with T = units::dimension_time; U = units::dimension_velocity]
are not arguably much easier to understand thus provide better user experience.
Upcasting capability is provided through dedicated upcasting_traits
and by base_type
member
type in upcast_base
class template.
template<typename T>
struct upcasting_traits : std::type_identity<T> {};
template<typename T>
using upcasting_traits_t = typename upcasting_traits<T>::type;
struct dimension_length : make_dimension_t<exp<base_dim_length, 1>> {};
template<>
struct upcasting_traits<typename dimension_length::base_type> :
std::type_identity<dimension_length> {};
struct kilometer : unit<dimension_length, std::kilo> {};
template<>
struct upcasting_traits<typename kilometer::base_type> :
std::type_identity<kilometer> {};
In order to extend the library with custom dimensions the user has to:
- Create a new dimension type and provide upcasting trait for it:
struct dimension_velocity : make_dimension_t<exp<base_dim_length, 1>, exp<base_dim_time, -1>> {};
template<> struct upcasting_traits<typename dimension_velocity::base_type> : std::type_identity<dimension_velocity> {};
- Define the base unit (
std::ratio<1>
) and secondary ones and provide upcasting traits for them via:
struct meter_per_second : unit<dimension_velocity, std::ratio<1>> {};
template<> struct upcasting_traits<typename meter_per_second::base_type> : std::type_identity<meter_per_second> {};
- Define a concept that will match a new dimension:
template<typename T>
concept Velocity = Quantity<T> && Same<typename T::dimension, dimension_velocity>;
- Provide user-defined literals for the most important units:
namespace literals {
constexpr auto operator""_mps(unsigned long long l) { return velocity<meter_per_second, std::int64_t>(l); }
constexpr auto operator""_mps(long double l) { return velocity<meter_per_second, long double>(l); }
}
For now base dimensions are defined in terms of std::integral_constant<int, ...>
and the provided
values must be unique. For example:
struct base_dim_length : dim_id<0> {};
struct base_dim_mass : dim_id<1> {};
struct base_dim_time : dim_id<2> {};
struct base_dim_electric_current : dim_id<3> {};
struct base_dim_temperature : dim_id<4> {};
struct base_dim_amount_of_substance : dim_id<5> {};
struct base_dim_luminous_intensity : dim_id<6> {};
However, as soon as C++20 class type values will be supported as non-type template parameters base dimensions will be just a text values. For example:
inline constexpr base_dim base_dim_length = "length";
With that it should be really easy to add support for any new non-standard base units to the library without the risk of collision with any dimension type defined by the library itself or by other users extending the library with their own dimension types.
Additionally, it should make the error logs even shorter thus easier to understand.
-
Should we ensure that dimension is always a result of
make_dimension
? How to do it? -
Should we provide strong types and upcasting_traits for
quantity
type?In such a case all the operators have to be provided to a child class. Or maybe use CRTP?
-
What to do with
time
which is ambiguous (conflict wit ANSI C)? -
What to do with
std::chrono::duration
? Is it possible to make it derive fromquantity<dimension_time, U, Rep>
which will most probably an ABI break? Alternatively, should we provide specialization ofquantity<dimension_time, U, Rep>
to work with/covnert from/tostd::duration
? -
Should we provide
seconds<int>
or stay withtime<second, int>
? What about CTAD problem forunits::length<units::mile> d3(3);
? -
What is the best way to add support for temperatures?
Temperatures not only require
std::ratio
but also should be adjusted/shifted by some constant values (i.e. [°C] = [K] − 273.15). -
Should we use
units::multiply
or stay withstd::ratio
for multiplication? -
Should we consider making
units::multiply
andunits::offset
a non-class template parameters as they provide different ratio values rather than types?In example instead:
struct celsius : unit<dimension_temperature, convert<offset<-27315, 100>>> {};
we could think about something like:
struct celsius : unit<dimension_temperature, kelvin() - 27315/100>>> {};
-
Do we need non-linear scale?
-
Should we provide cmath-like functions for quantities?
-
What should be the resulting type of
auto d = 1_km + 1_ft;
? -
Should we require explicit casts (i.e. quantity_cast) between different systems of measurement?
-
Should we provide Boost-like support for a
quantity_cast
to a reference that allows direct access to the underlying value of a quantity variable? -
What should be the default representation (integral or
double
)? -
Provide ostream overloads to print quantity units (use
std::format
)? -
Should we provide support for dimensionless quantities?
Because dimensionless quantities have no associated units, they behave as normal scalars, and allow implicit conversion to and from the underlying value type or types that are convertible to/from that value type.