Skip to content

Quantities and measurements

formula-cpp provides a compile-time quantity type, formula::Quantity, that carries a variable's own documentation as part of its type; a single metadata-reading access point, formula::Describe<T>, that reaches both our own types and ones we do not own; and a runtime value that may honestly be unmeasured, formula::Measured<Q>. This page explains why a variable's identity is a type, how to declare one, how Describe works for foreign types, what it means for a measurement to be absent, how bounds, precision and conversion behave once absence is possible, and where the limits are. The worked example below is examples/quantities.cpp; every block on this page that is formatted as program output is copied verbatim from that program's actual output, not worked out by hand.

Why a variable is a type

Phase 3 already made a dimension a compile-time thing: formula::RequireSameDimension<Left, Right> lets code refuse, at compile time, to combine two values whose dimensions differ -- adding a volume to a mass fails to compile, with both exponent vectors spelled out in the diagnostic (see docs/dimensions.md). Quantities take the same idea one step further and make a variable -- not just its dimension, but its symbol, its description and its unit -- a compile-time thing too. Declaring WaterVolume and CementVolume as two distinct types, each carrying its own symbol/description/unit through a formula::Quantity base, means they are two different, unrelated C++ types even when every one of their parameters but the tag is identical, and neither is usable where the other is expected.

test/negative/quantity_wrong_type.cpp is exactly this case, kept in the suite as a negative-compile test:

struct WaterVolume: formula::Quantity<WaterVolume, "V", "a volume", formula::unit::Litre>
{
};

struct CementVolume: formula::Quantity<CementVolume, "V", "a volume", formula::unit::Litre>
{
};

void takes_water(WaterVolume);

int main()
{
    takes_water(CementVolume {}); // does not compile
    return 0;
}

Attempting this gives, verbatim, on cl.exe:

error C2664: 'void takes_water(WaterVolume)': cannot convert argument 1 from 'CementVolume' to 'WaterVolume'
note: No user-defined-conversion operator available that can perform this conversion, or the operator cannot be called

The mistake is caught exactly where the wrong call was written, not discovered later by a runtime check, or -- because the two types agree on symbol, description and unit -- not discovered at all. That is the payoff of making a variable's identity a type rather than a runtime tag: a tag has to be compared at run time to catch the same mistake, and only for the inputs that happen to be exercised.

Declaring a quantity

A quantity is declared by deriving from formula::Quantity, which takes exactly four template parameters:

struct WaterVolume:
    formula::Quantity<WaterVolume,                    // the type's own name -- the tag
                       "V_w",                          // symbol
                       "volume of water added",        // description
                       formula::unit::Litre>            // unit
{
};

The tag is first, and it earns its place — but not for the reason it is tempting to give. It is the type's own name, given back to itself.

The tempting claim is that without the tag, two quantities whose symbol, description and unit coincide would be the same C++ type. For the spelling above that is false, and this page said it until a reviewer checked. C++ types are nominal: two separately declared structs are distinct however identical their base. Measured, with the tag removed from a stand-in template:

struct WaterVolume: NoTag<"V", "a volume", unit::Litre> {};
struct CementVolume: NoTag<"V", "a volume", unit::Litre> {};

derived structs are the same type   : 0
aliases are the same type           : 1
their bases are the same type       : 1
a base-taking function accepts both : 2

What the tag actually buys is two narrower things, and both are real. It makes the alias spelling impossible to get wrong — with a tag you cannot name two distinct quantities without distinguishing them, and without one using A = Quantity<...>; using B = Quantity<...>; silently gives one type. And it keeps the bases distinct, so a function taking the base cannot accept two different quantities, as the last line above shows it otherwise would.

examples/quantities.cpp declares WaterVolume and CementVolume, alike in every parameter but the tag:

WaterVolume and CementVolume share symbol, description and unit: yes
...but the tag keeps them different types: yes

There is no fifth parameter for the dimension. A Unit already carries its dimension (unit.dimension), so a separate dimension parameter would state it a second time and let the two disagree. That is not a hypothetical risk: a spike compiled the five-parameter spelling with dim::Mass paired against unit::Litre, and all three compilers accepted the contradiction in silence. Quantity::dimension is derived from the unit instead, so there is no second place for it to disagree with, and no spelling that lets a caller write the contradiction at all.

Describe<T>, and foreign types

Nothing above the metadata layer reads a Quantity base directly. Everything -- our own types and types we do not own alike -- goes through one template, formula::Describe<T>. A type derived from formula::Quantity gets its Describe<T> for free, by base-class detection. A type nobody owns -- a double, something from a vendor SDK, a struct we cannot add a base class to -- gets one by explicit specialisation:

struct ForeignTemperature
{
    double celsius {};
};

template <>
struct formula::Describe<ForeignTemperature>
{
    static constexpr std::string_view symbol = "theta";
    static constexpr std::string_view description = "a temperature from somebody else's library";
    static constexpr formula::Unit unit = formula::unit::Celsius;
    static constexpr formula::Dimension dimension = formula::unit::Celsius.dimension;
};
ForeignTemperature: symbol=theta dimension is temperature: yes

Nothing downstream knows, or needs to know, which of the two ways a type joined. Both are read the same way, which is what lets a foreign type take part in a formula without owning its source.

Describe's primary template is deliberately empty rather than a static_assert with a helpful message: a hard-error static_assert is not in the immediate context, so it would make formula::Described<T> -- whose entire job is to answer "is this type described?" -- fail to compile for every undescribed type, instead of answering false. The helpful diagnostic lives separately, in formula::RequireDescribed<T>, and it only fires once the type is completed -- a bare alias to RequireDescribed<T> instantiates nothing and checks nothing. Write RequireDescribed<T>::value to actually force the check.

Measurements that may be absent

formula::Measured<Q> holds a value of quantity Q, in Q's declared unit -- or nothing at all. A default-constructed Measured is absent, not zero: zero is a measurement, and starting an unmeasured quantity at zero would produce a confident wrong answer, which is exactly the failure this type exists to prevent.

value() throws (formula::ArithmeticException carrying formula::ArithmeticError::DomainError) when the measurement is absent, because there is no number to return and returning zero would be a lie. value_or(fallback) is the sanctioned way to get a number out of an absent measurement, and it is deliberately explicit: the caller states what an absent reading counts as, because there is no default answer the library could supply that would be right for every caller.

Absence propagates rather than producing a wrong number. formula::transform applies a function to a present value and leaves an absent one absent; formula::combine<Result> takes two measurements and is absent if either input is absent, not only if both are -- a formula with one missing input has no answer, and computing one from just the inputs that happen to be present is the wrong number this layer exists to prevent.

Result is named by the caller and is not deduced from either operand. Combining two quantities generally produces a third -- a mass and a volume combine into a density, not into either operand's own quantity -- and there is no honest default combine could deduce instead. An earlier signature deduced the result as the right-hand operand's quantity, so combine(mass, volume, divide) was statically a measurement of volume, reporting a volume's symbol and unit for a value that was actually a density: a wrong label on a right number, worse than a wrong number because it looks authoritative. Write formula::combine<Density>(mass, volume, [](Rational m, Rational v) { return m / v; }) instead. From the worked example, a present volume combined with an absent mass, into a Density that shares neither operand's tag, symbol or unit:

a present volume combined with an absent mass: absent

formula::checked_convert_to<R> converts a Measured<Q> into a Measured<R> and keeps this rule too -- an absent input converts to an absent output, and the dimension check runs regardless, so a conversion nobody could perform is refused even when there was no value to get wrong:

an absent measurement, converted: still absent

Bounds, precision and conversion

formula::checked_within_bounds and formula::checked_round_to_declared are overloaded for Measured<Q> alongside phase 3's Rational-and-Unit forms, and both keep the same absence rule: rounding an absent measurement leaves it absent,

an absent measurement, rounded: still absent

and checking an absent measurement against its unit's declared bounds answers NotMeasured, never a verdict:

an absent measurement, bounds-checked: no value was measured

NotMeasured and NotChecked answer two different questions, and neither substitutes for the other. NotChecked means the unit declares no bounds at all -- there is a value, but nothing to check it against. NotMeasured means there is no value in the first place, regardless of whether the unit declares bounds. A reading nobody took and a range nobody declared are different facts. test/measured_tests.cpp:198-227 pins all five BoundsCheck outcomes side by side -- WithinBounds, BelowMinimum and AboveMaximum for present values against a bounded unit, NotMeasured for an absent value regardless of whether its unit declares bounds, and NotChecked for a present value in a unit (such as unit::Litre) that declares no bounds at all.

A present measurement still converts exactly, carrying its quantity's own unit rather than needing one passed alongside it. From the worked example, 450 l converted to m³:

450 l converted to m3 = 9/20

Limits

formula::detail::FixedString, which gives a quantity's symbol and description their types, counts bytes, not characters: a multi-byte UTF-8 symbol reports its encoded length, not its glyph count.

formula::Measured<Q> is deliberately not a structural type and cannot be used as a non-type template parameter -- std::optional, which it holds, is not structural in any of the standard libraries this project supports. Confirmed on cl.exe: naming Measured<WaterVolume> as a non-type template parameter fails with

error C2993: 'formula::Measured<WaterVolume>': is not a valid type for non-type template parameter 'V'
note: '_value' is not a public, non-mutable, non-static data member

The quantity type itself is different: an empty struct deriving publicly from formula::Quantity (no non-static data members of its own, a structural base) is structural, and the same compiler accepts it cleanly as a non-type template parameter. Nothing in this library uses that, but the type is capable of it, where Measured never can be -- the compile-time identity and the runtime value are deliberately different kinds of thing.

For Dimension's and Unit's own limits -- Exponent's integer width, SymbolCapacity, the fields' bit widths -- see docs/dimensions.md rather than a restatement here. For Rational's and the rounding layer's limits, see docs/numbers.md; Measured is built directly on Rational and the checked_ arithmetic functions, and inherits their overflow behaviour and rounding limits exactly.