formula-cpp provides a compile-time dimension vector, formula::Dimension,
and a unit descriptor built on top of it, formula::Unit. This page explains
why a dimension is a type rather than a runtime tag, how to compose one, why
its exponents are rational rather than integer, what a Unit carries, how a
unit is keyed for serialising and compared, how conversion between units stays
exact, where the declared-precision and bounds machinery sits, and how an
application declares a base dimension the SI does not have, such as money.
The worked example is examples/dimensions_and_units.cpp. Program output
on this page is copied verbatim from that program's output, and
docs.dimensions-output fails unless each output block is a run of
consecutive lines the program prints, exactly as quoted
(cmake/CheckGuideOutput.cmake). Code is copied from the example's
source, and docs.dimensions-snippets fails unless each code block appears
there as a run of consecutive lines, compared without their indentation
(cmake/CheckGuideSnippets.cmake). A code block deliberately not from the
example carries a <!-- snippet: not from the example --> comment directly
above it; two on this page do, the block declaring a unit with an ASCII key
and the block showing that formula::exponent(1, 0) does not compile. A couple
of numeric facts that the example does not itself print are given as plain
rationals instead, each naming the static_assert in the test suite that pins
it -- never formatted as if a program had printed them.
A Dimension is an exponent vector over the seven SI base quantities --
length, mass, time, current, temperature, amount and luminosity -- and up to
four base dimensions the SI does not have, which the application names itself:
money in one currency is the usual one (see
Base dimensions the SI does not have).
It is structural: every member public, recursively, which is what lets it be
used as a non-type template parameter. That is the point of the design, not an
implementation detail. A quantity's unit names its dimension as part of the
quantity's type, so a mismatch between two dimensions is something the
compiler catches while reading the declaration, not something a running
program has to notice and report.
The mismatch diagnostic, formula::RequireSameDimension<Left, Right>, is a
static_assert inside a class template, instantiated on the two dimensions'
actual values rather than on their (opaque) types. That is deliberate: naming
the specialisation is not instantiating it, so a bare alias checks nothing,
but writing RequireSameDimension<Left, Right>::value forces the
instantiation and the compiler prints the two exponent vectors themselves as
part of the error -- not two anonymous type names, the actual numbers, in the
order length, mass, time, current, temperature, amount, luminosity, then the
named base dimensions by name. Adding a volume to a mass
(RequireSameDimension<dim::Volume, dim::Mass>::value) fails to compile with
both vectors spelled out in the diagnostic. A named base appears there as its
name and its exponent, and how legibly depends on the compiler: g++ prints
formula::Symbol{"EUR"}, while cl, clang-cl and clang++ print the name's
character codes (69, 85, 82 for EUR). formula::Unit has the analogous
RequireSameUnitDimension<From, To> for conversions between units of
different dimensions.
The guard fires only when the type is completed. using Checked = RequireSameDimension<A, B>; and a function parameter of that type compile
silently even when A and B differ -- the class template was named, not
instantiated. Only ::value, sizeof(...), or a variable of that type forces
completion and runs the static_assert. Where a plain bool is enough and no
diagnostic text is needed, formula::SameDimension<Left, Right> is a variable
template and is always evaluated.
Nobody spells out Dimension{.length = exponent(2)} for an area. The
dim:: namespace supplies the seven base dimensions and a handful of derived
ones (Area, Volume, Density, Velocity, Acceleration, Force,
Pressure, Energy, Power, Frequency, and MassPerArea, ForcePerLength,
DynamicViscosity, KinematicViscosity), and the arithmetic operators build the
rest: operator* adds two dimensions' exponents (composing quantities that
multiply), operator/ subtracts them, power scales by an integer exponent,
and nth_root divides them by the degree of the root. From the example:
Dimension const area = dim::Length * dim::Length;
Dimension const volume = area * dim::Length;
Dimension const density = dim::Mass / volume;prints, and matches the named constants exactly:
area (length * length) = L^2
volume (area * length) = L^3
density (mass / volume) = L^-3 M^1
composed dimensions match the named constants: yes
Composing from constants rather than writing exponents by hand keeps the
representation swappable: Dimension holds more than the seven SI base
quantities -- the named base dimensions described below as well -- and a call
site composed from constants does not depend on how many there are.
An integer exponent is enough for area (L^2) or volume (L^3), but not for
every quantity a norm-style formula needs. The square root of an area is a
length -- an ordinary integer power, L^1 -- but the square root of a
length is length to the one half, an exponent no integer can name at all.
Formulas that take such roots are common in size and shape calculations, which
is why Exponent is a rational (numerator / denominator, always reduced,
denominator always positive) rather than a plain integer:
Dimension const rootOfLength = formula::nth_root(dim::Length, 2);sqrt(length) = L^(1/2)
sqrt(length) has exponent one half: yes
The same mechanism covers a fractional power such as two-thirds: power( nth_root(dimension, 3), 2) raises a dimension to the 2/3 power by first
taking a cube root, then squaring -- the ordinary way to build a non-unit
rational exponent out of the two integer operations that already exist.
Exponent canonicalises through a single internal function on every
construction, so exponent(2, 4) and exponent(1, 2) are the same value
(and, as non-type template arguments, name the same type) even though they
were written differently.
A formula::Unit is a small aggregate, and every field earns its place:
| Field | Purpose |
|---|---|
dimension |
which quantity this unit measures |
magnitudeNumerator / magnitudeDenominator |
the exact multiplicative factor to the coherent unit -- the coherent SI unit, times one of each named base dimension -- as an integer ratio |
offsetNumerator / offsetDenominator |
the exact additive offset, for an affine scale such as degrees Celsius or degrees Fahrenheit |
symbolText |
a fixed-capacity display symbol (a Symbol, not a std::string_view); required for a dimensionless unit with a scale |
asciiText |
a stable ASCII key for serialising the unit, read with view_ascii; empty when the symbol is its own key, required when the symbol is not ASCII |
decimals |
the declared display precision |
bounds |
an optional valid range -- a minimum, a maximum, or both -- in the unit's own scale |
Like Dimension, Unit is structural on purpose: a quantity (see
Quantities and measurements) names its unit as a template
argument, so Unit has to stay a single ordinary type usable as one. That
rules out std::string_view for the symbol (private members, not structural)
and Rational for the magnitude and offset (same reason) -- hence plain
fixed-size integer and character-array fields here, with the convenient types
(Rational, std::string_view) appearing only at the point of use, via
formula::view() and the conversion functions below.
A dimensionless unit with a scale or an offset must have a symbol. One half in
hundredths with no symbol would be shown as 50, a number in a scale nothing
names, and no spelling of the unit could name it: the coherent dimensionless
unit is written as nothing. Such a unit is refused wherever it is written -- as
a quantity's unit, a constant's, a rounding's, or a table's key or result --
with formula: a dimensionless unit with a scale must have a symbol. A
dimensioned unit may have no symbol: its values are shown in the coherent unit,
which its dimension spells.
The formula::unit:: namespace declares fifty-six of these: the coherent SI
units (Metre, Kilogram, Second, Kelvin, Newton, Pascal, Watt, ...)
alongside scaled ones (Millimetre, Tonne, Hour, Megapascal,
KilowattHour, ...), compound ones (KilogramPerCubicMetre,
MillimetrePerMinute, PascalSecond, ...) and five dimensionless ones (One,
Percent, PerMille, PartsPerMillion, MilligramPerKilogram). Two pairs are
deliberately the same magnitude under two names -- Megapascal and
NewtonPerSquareMillimetre, PartsPerMillion and MilligramPerKilogram --
because both spellings are in ordinary use, and a test pins that each pair
converts into the other exactly. None of them is a currency: which currencies
an application deals in, and to how many decimals each is shown, is its own
policy, and it declares those units itself (see
Base dimensions the SI does not have).
Power and energy have four of them: Watt and Kilowatt measure dim::Power,
WattHour and KilowattHour measure dim::Energy. A watt-hour is the energy
of one watt sustained for an hour, exactly 3600 joules, so a kilowatt-hour --
the unit an electricity bill is usually written in -- is exactly 3600000
joules, a whole number, and a power times a time converts into kilowatt-hours
without a rounded factor. A kilowatt and a kilowatt-hour differ only by a
factor of time, but they are different dimensions: the type system keeps a
power and an energy apart. The worked example converts one kilowatt-hour, and
its output below really is copied from the program:
1 kWh = 3600000 J
Temperature has Kelvin and two affine scales, Celsius and Fahrenheit,
which the section on the affine case below covers.
There is no angle unit. A degree is pi/180 radians, which is not a rational
number, and every conversion here is by exact rational magnitude; a Degree
would have to be either inexact or unconvertible, and neither is a choice to
make silently.
Their decimals values are ordinary engineering defaults, not a requirement
taken from any standard -- a caller that needs a different precision states it
at the point of use, or declares it on the quantity (see
Quantities and measurements).
A unit's symbol is for display, and may be restyled: µ or u, a middle dot,
a superscript. Code that serialises a unit -- a JSON annotation, a database
column naming a unit, a client's choice of unit -- needs a name that stays the
same, and reads it with formula::view_ascii(unit): the unit's asciiText
when it declares one, otherwise its symbolText. ASCII here means printable
ASCII, the bytes 0x20 to 0x7E; the empty text counts.
A unit whose symbol is not ASCII must declare its key:
inline constexpr formula::Unit MicrogramPerLitre { .dimension = formula::dim::Mass / formula::dim::Volume,
.magnitudeNumerator = 1,
.magnitudeDenominator = 1'000'000,
.symbolText = formula::symbol("µg/L"),
.asciiText = formula::symbol("ug/L") };Without the key, the unit is refused wherever it is written -- as a quantity's,
constant's, rounding's, table's or other formula node's unit, the same places
a dimensionless unit with a scale and no symbol is refused -- with
formula: a unit whose symbol is not ASCII must declare an ASCII key. A key
that is itself not ASCII is refused the same way: the key is what a serialiser
trusts. The four built-in units whose symbols are not ASCII declare theirs:
| Unit | Symbol | asciiText |
|---|---|---|
PerMille |
‰ |
permille |
Micrometre |
µm |
um |
Celsius |
°C |
degC |
Fahrenheit |
°F |
degF |
A unit built at run time is not checked where it is written, so ask
formula::has_ascii_key(unit) before trusting view_ascii; build its key from
run-time text with formula::checked_ascii_symbol(), which refuses what
checked_symbol() refuses and returns SymbolError::NotAscii for any byte
outside printable ASCII. A trace's derived quotient unit carries a key when
either of its units declares one: micrometres per second are shown as µm/s
and keyed um/s.
The key is never displayed: render(), traces, number_text and
std::format write symbolText. It can still decide which unit a trace shows:
when either unit declares a key, the quotient's key must fit a Symbol and
hold no /. When it does not, the trace borrows no quotient of those two units,
even where their symbols would fit, and falls back to the coherent unit.
== compares every member of two units, asciiText, decimals and bounds
among them: a unit declared to one decimal place is not == to the same unit
declared to none. formula::same_unit(leftUnit, rightUnit) asks whether two
units are the same unit: the same dimension, the same factor and offset as
declared, the same symbolText and the same key (view_ascii), whatever their
decimals and bounds. The factor and offset are compared as the integer pairs
written, not reduced: a unit declared with a factor of 2/2000 is not the same
unit as one declared with 1/1000, though it converts identically. A key
declared equal to the symbol is the same key as none declared.
Use same_unit, or view_ascii, to key a table by unit, and to ask whether two
quantities that declare their own decimal places (see
Quantities and measurements)
share a unit.
Converting between two units multiplies by the source unit's magnitude, adds
its offset, subtracts the target unit's offset, then divides by the target
unit's magnitude -- as exact integer ratios throughout, and always
multiply-then-divide rather than a single precomputed floating-point factor.
That ordering is why 30 MPa converts to exactly 30000000 Pa and back to
exactly 30, rather than to some binary approximation that happens to print
as 30. As exact rationals, not program output, and not printed by the example
below: 30/1 MPa converts to 30000000/1 Pa, and converting that back gives 30/1
MPa again. static_asserts in test/unit_tests.cpp pin both, the ones that
read converted(30, 1, unit::Megapascal, unit::Pascal) and
converted(30000000, 1, unit::Pascal, unit::Megapascal).
The worked example does perform this round trip on a volume, though, and its output below really is copied from the program:
450 l = 9/20 m3
... converted back = 450 l
volume round trip exact: yes
formula::checked_convert returns std::expected<Rational, ArithmeticError>
and never produces a wrong number -- a dimension mismatch or an
unrepresentable intermediate is reported, not silently rounded away.
formula::convert is its throwing counterpart, for callers who already know
the conversion is well-formed.
Degrees Celsius is why Unit carries an offset at all: converting to Kelvin
is not a plain scaling. Degrees Fahrenheit is the second unit with an offset,
and everything here holds for both. checked_convert/convert move a point
on a scale, not a difference between two points -- a distinction that matters
because the two operations give different answers for the same nominal
number. As exact rationals, not program output -- pinned by the static_asserts
in test/unit_tests.cpp that read converted(0, 1, unit::Celsius, unit::Kelvin),
converted(1, 1, unit::Celsius, unit::Kelvin) and
converted(100, 1, unit::Celsius, unit::Kelvin):
| Input | In degC | In Kelvin |
|---|---|---|
| 0 degC | 0/1 | 5463/20 |
| 1 degC | 1/1 | 5483/20 |
| 100 degC | 100/1 | 7463/20 |
5463/20 is 273.15 and 5483/20 is 274.15: 1 degree Celsius converts to 274.15 K, not to 1 K. A caller that wants "how much did the temperature change" needs a difference, which this function does not compute -- it always applies the offset, because it always converts a point.
Degrees Fahrenheit has a different degree as well as a different offset: one is
exactly 5/9 of a kelvin, and 0 degF is 45967/180 K. Again exact rationals, not
program output, pinned by the static_asserts that read
converted(0, 1, unit::Fahrenheit, unit::Kelvin),
converted(1, 1, unit::Fahrenheit, unit::Kelvin) and
converted(32, 1, unit::Fahrenheit, unit::Kelvin):
| Input | In degF | In Kelvin |
|---|---|---|
| 0 degF | 0/1 | 45967/180 |
| 1 degF | 1/1 | 46067/180 |
| 32 degF | 32/1 | 5463/20 |
The first two rows differ by 100/180 K, which is 5/9 K: a step of one degree Fahrenheit is 5/9 K, but the point 1 degF is 46067/180 K, not 5/9 K, for the same reason 1 degC is not 1 K. The last row is the freezing point of water, 5463/20 K, which is also where 0 degC sits.
The two scales agree at one point only, and the example prints it: -40 degF is -40 degC. Converting from Fahrenheit divides by 9, and a ninth is not a terminating decimal, so a reading in whole degrees Fahrenheit has no finite decimal form in Celsius unless it lies a multiple of 9 degrees from 32 degF: 100 degF is 340/9 degC, which is 37.777... degC. The conversion returns the fraction rather than a rounded 37.78, because a conversion that rounded would stop round-tripping: 37.78 degC converts back to 100.004 degF, not to 100. Going the other way multiplies by 9/5, which keeps a terminating decimal terminating -- 37 degC is 493/5 degF, which is 98.6 -- and rounding a result for display is the job of the unit's declared precision (see below), not of the conversion.
The worked example converts 100 degC, and its output below really is copied from the program, confirming the round trip holds anyway, offset included. The last two lines are the two Fahrenheit conversions above:
100 degC = 7463/20 K
... converted back = 100 degC
temperature round trip exact: yes
-40 degF = -40 degC
100 degF = 340/9 degC
Every unit declares a display precision (decimals) and, optionally, a valid
range (bounds), and both apply to a computed value, not just to a literal:
Rational const computedMass = genericDensity * volumeInCubicMetres;
auto const roundedMass =
formula::checked_round_to_declared(computedMass, unit::Kilogram, RoundingMode::HalfAwayFromZero);
if (!roundedMass)
{
std::println("rounding the computed mass: {}", roundedMass.error());
return 1;
}computed mass = 450/7 kg
rounded to kg's declared precision (3 places) = 64.286 kg
formula::declared_decimals returns the rounding layer's own DecimalPlaces
type, not a bare int, so it plugs directly into formula::round /
formula::checked_round (see docs/numbers.md).
checked_round_to_declared is the two calls composed, and returns a
std::expected like every other checked_ function here: the rounded value,
or the ArithmeticError that stopped it, which {} writes in words. The
example checks it before reading the value. round_to_declared is the same
thing spelled to throw, as convert is to checked_convert.
formula::checked_within_bounds checks a value, in the unit's own scale,
against that unit's declared bounds, and returns, in a std::expected the
example checks before reading it, one of five BoundsCheck values:
WithinBounds, BelowMinimum, AboveMaximum, NotChecked, or
NotMeasured -- or an ArithmeticError, for a unit whose declared range is
malformed. NotChecked is deliberately not the same thing as
WithinBounds. A unit that declares no bounds at all has not validated
anything, and reporting it as "within bounds" would make an unvalidated value
indistinguishable from one that was actually checked and passed:
unbounded unit (litre) reports: no bounds declared for this unit
bounded gauge at 42%: within the declared bounds
unit::Litre declares no bounds, so it always reports NotChecked,
regardless of the value; a unit that does declare bounds (the example builds
one, a generic 0-to-100 gauge) can report WithinBounds, BelowMinimum or
AboveMaximum. NotMeasured is the fifth, and it belongs to values rather
than units: it is what checked_within_bounds answers for a Measured that
holds nothing. A reading nobody took and a range nobody declared are
different facts, for the same reason NotChecked is not WithinBounds. formula::describe(BoundsCheck) gives each outcome its own
non-empty, mutually distinct wording, as shown above.
A unit declares each end of its range on its own: Bounds holds lowPresent
and highPresent, one for each end. formula::bounds(lowNumerator, lowDenominator, highNumerator, highDenominator) declares both,
formula::at_least(numerator, denominator) a minimum only, and
formula::at_most(numerator, denominator) a maximum only. Both ends are
inclusive, and a unit that declares neither reports NotChecked. Each flag is
a BoundsEnd, which reads as a bool but can be set only from a bool, so a
positional initialiser that puts a number where a flag belongs does not
compile: in { true, 0, 1, 100, 1 } the 0 would land on highPresent. {}
and { false } declare no bounds, and { true } declares a minimum of 0.
Write bounds(), at_least(), at_most() or designated initialisers.
Limits known only at run time -- a specification row, a catalogue entry -- need
no unit to carry them. formula::checked_within(value, lowEnd, highEnd) takes
each end as a std::optional<Rational>, and either may be absent. Here a
catalogue row gives the gauge a minimum and no maximum:
std::optional<Rational> const catalogueMinimum = Rational { 25 };
Rational const gaugeReading = 42;
auto const catalogueVerdict = formula::checked_within(gaugeReading, catalogueMinimum, std::nullopt);
if (!catalogueVerdict)
{
std::println("checking the gauge against the catalogue: {}", catalogueVerdict.error());
return 1;
}
std::println("gauge at 42% against a catalogue minimum of 25%: {}", *catalogueVerdict);gauge at 42% against a catalogue minimum of 25%: within the declared bounds
It answers by the rule checked_within_bounds applies to a unit's declared
ends: NotChecked when neither end is given, never WithinBounds, and
DomainError when the lower end is above the upper one, a malformed pair of
limits rather than a value to judge. Given a Measured, it reports
NotMeasured for a value nobody took. formula::within is the same check
spelled to throw.
The seven SI base quantities describe physics, and formulas are often about
money as well: a tariff in euros per kilowatt-hour, a price per tonne. A
currency is not a bare number. Declared as one -- a Unit of dim::Scalar --
a price in euros could be added to a ratio, or to a price in yen, and the
dimension system would have nothing to object to. So an application declares a
base dimension of its own for each currency it deals in, with
formula::base_dimension, and composes it like any other. From the example:
Dimension const euros = formula::base_dimension("EUR");
Dimension const tariff = euros / dim::Energy;
Dimension const tariffTimesEnergy = tariff * dim::Energy;tariff (EUR / energy) = L^-2 M^-1 T^2 EUR^1
tariff * energy = EUR^1
The example prints every dimension with std::println. {} of a Dimension
(<formula-cpp/format.hpp>) writes each exponent that is not zero, in the
order L, M, T, I, Theta, N, J, and then each named base by its name; a pure
number reads (dimensionless). A tariff is euros over an energy --
L^-2 M^-1 T^2 from the joule, EUR^1 from the base -- and times an energy
it is euros again: the same value as base_dimension("EUR") itself, which the
example checks.
Identity is the name, byte for byte. Two parts of a program, or two
libraries, that both write base_dimension("EUR") get the same dimension --
the same value, and the same template argument -- so a quantity one of them
declares in euros is a quantity the other accepts. For a three-letter currency
code that is what you want. For a generic word it may not be: another
library's base_dimension("credit") would be yours, whatever it meant by it,
so pick a distinctive name ("AcmeCredit" rather than "credit").
Agreeing on the dimension is half of it; agreeing on its units is the other
half, and a convention covers that: the unit named after a base has
magnitude one. A euro is the coherent unit of euros, and a cent is a
hundredth of it. The library cannot enforce the convention -- a Unit is an
aggregate anyone may fill in -- but every conversion between two units of one
base relies on it, as conversions between lengths rely on the metre having
magnitude one. The coherent unit of any dimension is then the coherent SI
unit times one of each of its named bases, and it is the unit every value is
carried in while a formula is evaluated. The example declares three units:
constexpr Unit Euro { .dimension = formula::base_dimension("EUR"),
.symbolText = formula::symbol("EUR"),
.decimals = 2 };
constexpr Unit EuroCent { .dimension = formula::base_dimension("EUR"),
.magnitudeNumerator = 1,
.magnitudeDenominator = 100,
.symbolText = formula::symbol("ct"),
.decimals = 0 };
constexpr Unit Yen { .dimension = formula::base_dimension("JPY"),
.symbolText = formula::symbol("JPY"),
.decimals = 0 };and converts 250 euros into cents, back into euros, and then into yen:
250 EUR = 25000 ct
... converted back = 250 EUR
250 EUR to JPY: argument outside the domain of the operation
Each currency is a base of its own. Euros and yen never convert into each
other: checked_convert refuses them as it refuses any two units of different
dimensions, with ArithmeticError::DomainError, and
RequireSameUnitDimension<Euro, Yen>::value does not compile. That is
deliberate. An exchange rate is not a property of two units; it changes from
day to day and is agreed per transaction. It is data -- a quantity in yen per
euro, of dimension base_dimension("JPY") / base_dimension("EUR") -- which a
formula multiplies by, and which the trace records like any other input. For
the same reason there is no dim::Money: one money dimension could not tell
euros from yen. In a formula the same rules hold at the formula's own source
line; Expressions and evaluation
shows the two additions it refuses. The library itself declares no currency:
formula::unit is generic physics, and the currencies and their decimals are
the application's.
Names. A base's name must be an ASCII letter followed by ASCII letters or
digits, at most 31 bytes long -- it is a Symbol, as a unit's symbol is --
and not the symbol of an SI base unit: m, kg, s, A, K, mol or cd,
since a base named m would read as metres wherever it is printed. And it is
printed: it is the symbol of the base's coherent unit, written into a trace
beside spaces, /, ^ and parentheses, and into Markdown, where _, * and
[ are markup -- hence letters and digits only. base_dimension is
consteval, so a name that breaks a rule is always a compile error, and the
error names the rule: formula_base_dimension_name_must_not_be_empty,
formula_base_dimension_name_too_long,
formula_base_dimension_name_must_be_a_letter_then_letters_or_digits or
formula_base_dimension_name_is_an_si_base_unit_symbol. A helper that passes
a name on to base_dimension must be consteval as well.
Capacity, and cancellation. One dimension holds at most four named bases
(NamedBaseCapacity): a tariff needs one, an exchange rate two. Composition
merges the two lists by name. A name both sides carry has its exponents added
or subtracted, and a name whose exponent comes to zero drops out before
anything is counted, so (EUR / USD) * (USD / JPY) is EUR / JPY: two bases,
not three. A product or quotient that still needs a fifth fails to compile,
naming formula_dimension_has_too_many_named_bases -- where a Dimension is
composed in a constant expression, and where a formula multiplies quantities
whose dimensions would need it
(test/negative/expression_too_many_named_bases.cpp). The bases are kept
sorted by name, so EUR * JPY and JPY * EUR are one value
and one template argument. Filling namedBases by hand bypasses that order,
which is why a dimension should only ever be built with base_dimension and
the operators.
In a coherent unit, the name is the symbol. A computed step in a trace
that has no unit to borrow from the steps it read is shown in the coherent
unit, and the trace spells that unit out after its number (see
Tracing and audit trails) -- as it does for
an opaque operation's output that no input's unit fits
(Opaque operations and bounded retry). A named base is
written by its name, ahead of the SI units: on its side of the slash, or
among the negated factors when nothing stands above it. So euros per joule
are written EUR s^2/(m^2 kg), and the others read EUR^-1 s^-1, JPY^-1,
EUR/JPY and EUR^(1/2).
The money comes first because a tariff is read as money per energy.
Exponent's numerator and denominator are std::int32_t. Building one with a
zero denominator, or one whose reduced form does not fit std::int32_t, never
produces a wrapped or truncated value. Every Exponent in the library is
constructed through one internal function, and that function calls a
deliberately non-constexpr sentinel on either failure. What the sentinel does
depends on where you are:
- In a constant expression -- which is how dimensions are normally built,
and how every
dim::constant is built -- calling a non-constexprfunction is not allowed, so the compiler rejects it and names the sentinel. This is the case the negative-compile tests pin. - At run time, from a value the compiler cannot see, the sentinel aborts.
constexpr auto bad = formula::exponent(1, 0); // does not compile
auto const alsoBad = formula::exponent(1, argc - 1); // compiles; aborts if argc == 1Both are fail-fast; only the first is a diagnostic. Measured on clang 22: the second compiles cleanly and terminates at run time. So write dimensions in constant expressions and you get the error at the point you wrote the mistake; build one from runtime input and you get a crash instead of a wrong answer.
NamedBaseCapacity is 4: a Dimension holds at most four named bases at
once, counted after cancellation. A fifth is refused through a sentinel of
the same kind, formula_dimension_has_too_many_named_bases -- a compile error
in a constant expression, an abort at run time -- never by dropping a base. A
named base's exponent is an Exponent, with the limits above.
SymbolCapacity is 32 bytes including the terminator -- 31 usable
bytes, not 32 -- which holds compound laboratory units such as
µmol/(L·min·kg) (18 bytes of UTF-8). symbol() is consteval: it accepts
only a constant expression, so a literal that does not fit is a compile error
naming formula_unit_symbol_too_long, and no run-time text can reach it. A
symbol from run-time text -- a catalogue row, a configuration file -- is built
with checked_symbol(), which returns the Symbol byte for byte, or
SymbolError::TooLong or SymbolError::EmbeddedNull instead of aborting or
truncating; describe() spells either for an error message.
A base's name is a Symbol too, so it is at most 31 bytes long. The name is
checked only where it is made, and base_dimension is consteval, so its four
sentinels are always compile errors, never aborts:
formula_base_dimension_name_must_not_be_empty,
formula_base_dimension_name_too_long,
formula_base_dimension_name_must_be_a_letter_then_letters_or_digits and
formula_base_dimension_name_is_an_si_base_unit_symbol. A namedBases array
filled by hand is checked by none of them.
Unit's magnitudeNumerator, magnitudeDenominator,
offsetNumerator, offsetDenominator and the four integer fields of Bounds are all
std::int64_t. Rational's own numerator and denominator are 128-bit, so every
value these fields state converts to one exactly.
Conversion is built on formula::Rational and the checked_ arithmetic
functions, so it inherits their overflow behaviour and rounding limits
exactly -- including the ±18-decimal-place ceiling on DecimalPlaces and the
numerator-magnitude-dependent limits on rounding described in
docs/numbers.md. Nothing in dimension.hpp or
unit.hpp widens or narrows those limits; a Unit's magnitude and offset are
exactly the integer pairs Rational already knows how to handle exactly.