Skip to content

Integral comparisons and common_type are problematic #658

Closed
@almania

Description

@almania

The current specification of <=> reads:

Effects: Equivalent to: using ct = std::common_type_t<quantity, quantity<R2, Rep2>>;
const ct ct_lhs(lhs);
const ct ct_rhs(rhs);
return ct_lhs.numerical_value_ref_in(ct::unit) @ ct_rhs.numerical_value_ref_in(ct::unit);

Those casts to std::common_type_t mean that for integral use, the user needs to be very aware of potential overflow on any comparison between units of different magnitudes.

This is problematic, as as the hw_example points out, the library is otherwise well-suited for providing an interface to values representing all kinds of ranges. For instance this fairly simple "16 bit signed ADC, 121V max" definition:

  inline constexpr struct ADC_lsb final
      : named_unit<"ADC_lsb", mag_power<2, -15> * mag<121> * V> {
  } ADC_lsb;
  quantity<ADC_lsb, int16_t> read_adc();

Introduces an implicit 121x multiply of the int16_t on the line below:

  bool is_above_5V() { return read_adc() > 5 * V; }
  using comparison_introduced_cast_type =
      std::common_type_t<decltype(1 * ADC_lsb), decltype(5 * V)>;
  static_assert(
      (1 * ADC_lsb).numerical_value_in(comparison_introduced_cast_type::unit) ==
      121); // scary!

Which means that a value as low as 1V on the ADC (which can read up to 121V) will trigger a signed integer overflow in that innocuous line, that checks if the ADC is above 5V.

And the problem only gets worse the more accurately you define the units, eg by dialling in the exact voltage divider you may end up with much larger magnitude adjustments required to get to the common_type with the base unit, depending on how 'irrational' they are:

  constexpr auto ADC_max_volts = mag_ratio<33, 10>; // 3.3V
  constexpr auto ADC_max_value = mag<0x8000>;       // 16 bit signed

  constexpr auto R1 = 360; // 360 kohm
  constexpr auto R2 = 10;  // 10 kohm
  constexpr auto V_max = ADC_max_volts * mag_ratio<R1 + R2, R2>;

  inline constexpr struct ADC_lsb final
      : named_unit<"ADC_lsb", V_max / ADC_max_value * V> {
  } ADC_lsb;
  using comparison_introduced_cast_type =
      std::common_type_t<decltype(1 * ADC_lsb), decltype(5 * V)>;
  static_assert(
      (1 * ADC_lsb).numerical_value_in(comparison_introduced_cast_type::unit) ==
      1221); // very scary!

As now, with more accurate scaling factors, our voltage comparison will overflow when the ADC is reading a numerical value of just +/-27, out of +/-32767, making 0.1V unsafe to compare against 5V (or 0V, for that matter). I believe this will be both dangerous, and rather surprising for users in its current state.


My thoughts:

  1. Implicit conversions of integral quantities involving scaling values likely should not be allowed, or if they are, at least not for integers below a certain "big enough" and well documented bit width (maybe long).
  2. std::common_type should likely similarly promote integer representations to the next bit-width if scaling is involved, at least up to that "big enough" bit width. If not this, it likely either shouldn't be defined, or its use of automatically generated scaling factors be very carefully considered, imo. This differs from usual C++, but for the much better. Yes, it does mean that adding two quantities of different units may produce a different rep vs two quantities of the same unit, but it's better to make the user aware of the risks of what's going on behind the scenes than silently failing imo.
  3. Even if a solution is adopted such that std::common_type and/or implicit conversions are prohibited altogether (rather than increasing rep widths), comparisons could still be made completely accurate and safe via checking if either quantity is outside the representable range of the other quantity, and returning the appropriate ordering from that. Imo, this solution should be adopted even for "big enough" types, as it's strictly more accurate, at a slight runtime cost. Where values are known at compile-time, such as the 5 * V above, the compiler ought have an easy time producing optimal code at least, and the utility of it a dream.

A question also: should conversions where std::numeric_limits<rep>::max() produces the same value as ::min() be prohibited altogether? eg anything mixing small integrals of mV and MV could be detected at compile time, even more usefully for user derived types. Could make for a nice sanity check that you're dealing with the units you think you are, maybe.

Metadata

Metadata

Assignees

No one assigned

    Labels

    designDesign-related discussionenhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions