Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docs/discussion/concepts/overflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,60 @@ ingredient that lets Au users use a wide variety of integral types with confiden

![The overflow safety surface](../../assets/overflow-safety-surface.png)

#### Integer promotion pitfalls {#integer-promotion}

There's a key subtlety to appreciate here: the safety surface applies to the _destination_ rep. For
"explicit rep" callsites (e.g., `q.as<T>(unit)`), the destination rep is plain to see (it's `T`).
But "implicit rep" callsites (e.g., `q.as(unit)`) require more care.

Through Au 0.5.0, the destination rep for an implicit-rep callsite was just the same as the _input_
rep: if `q` has type `Quantity<U, R>`, it would be `R`. But we realized this choice was suboptimal
overall: instead, Au should do as C++ does, and return the natural type. [0.6.0] will be the first
release that does so. While it's a better state for the library overall, it does have some
surprising implications.

As a concrete example, consider `feet(int8_t{20}).as(inches)`. This conversion multiplies the
underlying `int8_t` value by 12. In C++, any arithmetic on an `int8_t` first promotes it to `int`
(this is [integer promotion]), so the result is **an `int`**, not an `int8_t`. Therefore, we get
the wider overflow safety surface for `int`, not the tiny one for `int8_t`, and the conversion is
permitted.

The conversion itself is perfectly safe, but the real danger comes if you assign back to `int8_t`:

```cpp
Quantity<Feet, int8_t> length_ft = feet(20);

// Risky conversion: not caught by overflow safety surface!
Quantity<Inches, int8_t> length_in = length_ft.as(inches);
```

In the second statement, `length_ft.as(inches)` produces a `Quantity<Inches, int>` --- note: `int`,
not `int8_t`. This can _implicitly_ convert to `Quantity<Inches, int8_t>`, but only because `int`
is the _specific_ type that `int8_t` promotes to. (If we disallowed this implicit conversion, then
integer promotion would make small integer types completely unusable in practice.) And so the
overflow lands silently: 20 feet is 240 inches, which doesn't fit in an `int8_t`, and `length_in`
ends up holding **-16 inches**.

All these decisions --- returning the promoted type, permitting implicit conversion to the original
type --- are reasonable individually, but taken together, they effectively bypass Au's overflow
protection for small integer types. Fortunately, there's a solution: _name the desired rep when you
do the conversion_. For small integer types, this should almost always be the same rep, so we
provide a convenient syntax ([SameRep]) to ask for this. Continuing with our earlier example:

```cpp
// Risky conversion caught when output rep specified:
auto length_in = length_ft.as<SameRep>(inches);
// ^^^^^^^ Names the rep at the point of conversion
```
Comment on lines +173 to +177

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is an interesting example. If I coding this, I'd naturally go for:

Quantity<Inches, int8_t> length_in = length_ft.as<int8_t>(inches);

What I'm really concerned about is that my result fits in the destination type.

Actually, I'd likely use auto here, as I've already specified what I want on the right hand side, so what I'm getting on the left (in the auto) is pretty straight-forward to deduce:

auto length_in = length_ft.as<int8_t>(inches);

All that to say, I'm not sure this is a great example of when SameRep would be used... I could maybe see it with:

auto length_in length_ft.as<SameRep>(inches);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great callout. I really wrestled with what I was trying to say here.

I even realized there are actually three categories.

  1. Explicit rep
  2. SameRep (as source)
  3. Same rep as target

After reading your example, I had started working on an example where we assign to a field in a struct: it seemed better motivated and easier to understand than manually naming a type like Quantity<Inches, int8_t>. That was when I realized that neither explicit rep nor SameRep gives us what we want. The most ergonomic solution I could think of was something like:

convert_into(make_in_out(data.field), q, target_units);

Obviously, make_in_out isn't something we could ship in Au; that uses Aurora-internal utilities. And I wouldn't want to just take a bare reference, because the callsite readability is poor! So actually adding a feature like this is not a clear slam dunk win. Moreover, it's very easy to write this utility in a client codebase without changing Au at all.

So Au natively supports the first two categories, and it's up to end users to support the third --- definitely today, possibly forever.

And I didn't want to muddle the docs with even more nuance.

So where does that leave us?

I ended up liking the auto in your final example the best. The auto actually pairs really nicely with the SameRep: it's a clear expression of author intent, "stay within the numeric type". I added a brief sentence after just saying that we dropped the explicit typename because it's now clearly determined by the RHS. As the above novel ^ indicates, yes, there is still a lot of subtlety lurking around these issues, but I'm hard pressed to improve this example any further at the moment.


Here, we switched to `auto` for conciseness, because the unit and rep are visibly determined by the
right hand side. The `<SameRep>` tag lets the conversion machinery know how much room we actually
have to work with, and empowers Au to provide the full safety check.

Users working with small integer types must already wrestle with their quirky properties in C++.
We recommend these users make `SameRep` a habit whenever they want to stay within a promotable
integer type in Au operations.

### Check every conversion at runtime {#check-at-runtime}

While the overflow safety surface is a leap forward in safety and flexibility, it's still only
Expand Down Expand Up @@ -210,3 +264,5 @@ every conversion as it happens, and be prepared for it to fail.
[threshold]: https://github.com/aurora-opensource/au/blob/dbd79b2/au/conversion_policy.hh#L27-L28
[#352]: https://github.com/aurora-opensource/au/issues/352
[integer promotion]: https://en.cppreference.com/w/c/language/conversion#Integer_promotions
[SameRep]: ../../reference/quantity.md#same-rep
[0.6.0]: https://github.com/aurora-opensource/au/milestone/9
46 changes: 36 additions & 10 deletions docs/discussion/concepts/truncation.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,22 @@ point representation is a statement, by the user, that _exact values do not matt
application. It would be inappropriate for us to raise warnings about perfectly routine properties
of the user's chosen type. Hence: _floating point never truncates_.

### Compound types

Not every rep is a plain arithmetic type. Complex numbers and Eigen vectors/matrices are common
examples of _compound_ reps: types built up out of some underlying _scalar_. Au characterizes each
such rep by that scalar --- its [`ScalarOf<T>`](../../reference/rep.md#scalar-of) --- and then
applies the very same rules from above. So an `Eigen::Vector3d`, whose scalar is `double`, is
governed by the [floating point rule](#float) and never truncates; a vector of integers, on the
other hand, follows the integral rules.

### Other types

Currently, Au has only limited support for non-arithmetic rep types (full support is tracked in
[#52]). As a stopgap, Au treats any non-arithmetic type conservatively, and assumes that it can
truncate. We hope to refine this approach when we strengthen our support for more rep types.
For a rep that is neither a built-in arithmetic type, nor a compound type with an arithmetic
`ScalarOf` trait, Au has no basis to reason about truncation. Therefore, we fall back to the
conservative assumption that the conversion can truncate, and forbid it by default. This is just
a stopgap measure; we plan to support non-arithmetic reps more broadly in the future. See [#52] to
track any progress.

## Truncation in casting

Expand All @@ -91,7 +102,7 @@ Casting from one _arithmetic_ type to another is governed by simple rules.
If the source and destination types are in the same _category_ --- that is, either both integral, or
both floating point --- then the cast never truncates. The reason for integral types is
straightforward: clearly, the source can't hold a non-integer value. As for floating point types,
they are governed by the [philsophy explained above](#float).
they are governed by the [philosophy explained above](#float).

Casting from a floating point type to an integral type does have truncation risk: we would truncate
for any non-integer input. We can check this for individual values by discarding the fractional
Expand All @@ -106,11 +117,24 @@ point as a **non-truncating** operation.

### Non-arithmetic types

Here, too, our support for non-arithmetic rep types is limited (see [#52]), and we take
a conservative approach. Any cast involving a non-arithmetic type, either as source or destination,
is considered to have truncation risk, and will not be allowed by default: users must pass
Here, too, our support for non-arithmetic rep types is limited, and we take a conservative approach.
Any cast involving a rep with non-arithmetic scalar type, either as source or destination, is
considered to have truncation risk, and will not be allowed by default: users must pass
`ignore(TRUNCATION_RISK)` as a second argument to override this. We hope to have better default
behavior once we support non-arithmetic types more fully.
behavior once we support non-arithmetic types more fully ([#52]).

Note that this policy tells you only whether the _truncation rules_ object to a casting conversion.
Passing those rules doesn't mean the conversion will work: the reps involved must also support the
cast in the first place.

As a concrete example, `Eigen::Vector3d` is a compound rep that _does_ have an arithmetic scalar
type, so the truncation rules have no objection to casting it to `Eigen::Vector3f`. Even so, the
conversion doesn't compile: Au can't form a common rep for two Eigen types with different scalars,
and so it never gets as far as the `static_cast` it would ordinarily perform. That's no accident on
Eigen's part --- Eigen deliberately rejects `static_cast` between different scalar types, directing
users to its `.cast<T>()` member function instead. So this is the one case where you must reach for
the [`cast<T>` free function](../../reference/eigen.md#cast), which wraps `.cast<T>()` for use with
`Quantity`.

## Summary

Expand All @@ -122,8 +146,10 @@ whether _individual values_ truncate using the `will_conversion_truncate` functi

Truncation risk depends strongly on the types involved. Integral types are vulnerable to truncation
for non-integer scale factors. On the other hand, we treat floating point types as though they
never "truncate", because they're already inexact. Finally, since we don't yet fully support
non-arithmetic types, we treat them conservatively and assume that they carry truncation risk.
never "truncate", because they're already inexact. Finally, for compound reps such as complex
numbers and Eigen vectors, we defer to the truncation rules of their underlying scalar type; only
when that scalar can't be determined do we fall back to a conservative assumption of truncation
risk.

[overflow]: ./overflow.md
[conversion risks]: ./conversion_risks.md
Expand Down
Loading