Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interconvertibility of derived quantities #427

Closed
mpusz opened this issue Jan 23, 2023 · 87 comments
Closed

Interconvertibility of derived quantities #427

mpusz opened this issue Jan 23, 2023 · 87 comments

Comments

@mpusz
Copy link
Owner

mpusz commented Jan 23, 2023

I just added a new discussion thread (#426) about the V2 design rules. As stated in TL;DR, the main idea is to:

  • Allow comparison and addition/subtraction for quantities of the same kinds. This is trivial and I already implemented it. This is consistent with ISO 80000.
  • Limit quantities construction to only interconvertible quantities where two quantities are interconvertible if their definitions
    are from the same branch in the quantity types hierarchy. This is to provide type safety, so the user can not reorder arguments for foo(length, width).
    It turns out that this point is quite hard to define for derived quantities.

Should the following quantities be interconvertible?

  1. length * length and area
  • this is trivial and most probably should be true
  1. width * height and area
  • both width and height are interconvertible with length so this probably should be true as well
  1. path_length * distance and pow<2>(path_length)
  • similar case to the above but this time we have two "unnamed" quantities
  1. width * distance and path_length * width
  • distance is interconvertible with path_length and not with width
  1. altitude * distance and path_length * height
  • altitude is interconvertible with height, and distance is interconvertible with path_length
  1. width * length and length * height
  • depending how you look at it both width and height are interconvertible with length
  • but width is not interconvertible with height
  • the final result can depend on the ordering of the "cancelling" operations
  1. length * distance and path_length * altitude
  • depending how you look at it both distance is interconvertible with path_length and length is interconvertible with altitude
  • but also path_length is interconvertible with length and then distance is NOT interconvertible with altitude
  • the final result can depend on the ordering of the "cancelling" operations
@mpusz
Copy link
Owner Author

mpusz commented Jan 23, 2023

Is it better to be on the safe sound and disallow non-trivial conversions?
Or maybe it is better to be user-friendly and disallow only what we are sure that should not be interconvertible?

@mpusz
Copy link
Owner Author

mpusz commented Jan 28, 2023

@burnpanck, @JohelEGP, @chiphogg

Do we have any ideas on how to resolve that?

The only thing that somehow resonates with me here is to allow implicit interconvertibility only if we are sure about the result. This covers the following cases:

  • we have only one interconvertible quantity of a specific dimension in the equation (i.e. length / time should be interconvertible with speed)
  • all combinations of quantities of the same dimensions are interconvertible (this covers 1, 2, 3)

In case when some combinations result in a lack of interconvertibility (but there is at least one valid combination) a quantity_cast should be used to make an explicit conversion if needed.

Otherwise, the quantities should not be interconvertible.

What do you think about that?

@JohelEGP
Copy link
Collaborator

only if we are sure about the result

  • we have only one interconvertible quantity of a specific dimension in the equation

(but there is at least one valid combination)

Is that possible without using friend?

@mpusz
Copy link
Owner Author

mpusz commented Jan 28, 2023

I hope so :-) I am going to compare quantity_spec and not quantity types. I am thinking about something like that:

namespace detail {

enum class interconvertible_result { no, yes, maybe };

template<typename... Q1, typename... Q2>
interconvertible_result are_ingredients_interconvertible(derived_quantity_spec<Q1...> q1,
                                                         derived_quantity_spec<Q2...> q2)
{
  // order ingredients by their complexity (number of base quantities involved in the definition)
  // if first ingredients are of different complexity extract the most complex one
  // repeat above until first ingredients will have the same complexity and dimension
  // check interconvertibility of quantities with the same dimension and continue to the nex one if successful
  // repeat until the end of the list or not interconvertible quantities are found
}

template<QuantitySpec Q1, QuantitySpec Q2>
[[nodiscard]] consteval interconvertible_result are_interconvertible(Q1 q1, Q2 q2)
{
  using enum interconvertible_result;

  if constexpr (Q1::dimension != Q2::dimension)
    return no;
  else if constexpr (have_common_base(q1, q2))
    return (std::derived_from<Q1, Q2> || std::derived_from<Q2, Q1>) ? yes : no;
  else if constexpr (DerivedQuantitySpec<Q1> && DerivedQuantitySpec<Q2>)
    return are_ingredients_interconvertible(q1, q2);
  else
    return no;
}

}  // namespace detail

template<QuantitySpec Q1, QuantitySpec Q2>
[[nodiscard]] consteval bool interconvertible(Q1 q1, Q2 q2)
{
  return are_are_interconvertible(q1, q2) == interconvertible_result::yes;
}

If are_interconvertible returns maybe, in such case quantities are not implicitly interconvertible, but the explicit conversion can be forced with quantity_cast. I hope that will work as expected, but still, I am not sure how to deal with a list of consecutive quantities of the same dimension but different types/kinds. That is why I started this thread, and I hope that #427 (comment) might be good enough. Unless you have some better proposals?

@JohelEGP
Copy link
Collaborator

Maybe I'm misunderstanding something. Let's consider the given example:

  • we have only one interconvertible quantity of a specific dimension in the equation (i.e. length / time should be interconvertible with speed)

A user can add a new quantity of dimension equal to those of length / time and speed. So a pure function can't answer whether "we have only one" by looking only at its inputs, when the set of such quantities is extensible. Would you be able to do it using virtual?

@mpusz
Copy link
Owner Author

mpusz commented Jan 29, 2023

By "we have only one interconvertible quantity of a specific dimension in the equation" I mean there is only one ingredient quantity of each dimension in Q1... above, and we have to check if it is interconvertible with the ingredient quantity of the same dimension in Q2.... Q1... and Q2... are the ingredients used in the quantity equation used to define two derived quantities I am comparing for interconvertibility.

So to make this rule happy two quantities of dimension length and two quantities of dimension time should be interconvertible. So length / time will be interconvertible with width / duration, but height / time should not be interconvertible with width / time. I think this is quite straightforward to reason about and easy to implement. The problems start when there is more than one ingredient quantity of the same dimension in the equation (i.e. width * length / time and length * height / time).

So a pure function can't answer whether "we have only one" by looking only at its inputs, when the set of such quantities is extensible.

I do not care if there are any other derived quantities of the same dimension besides the two I am comparing now. In fact. there might be an infinite number of quantities of the same dimension.

@burnpanck
Copy link
Contributor

I still believe that the hierarchical model I presented previously should be able to give a clear and consistent answer to all of these questions. One important thing to note is that my hierarchical model does not speak of "interconvertibility", because that word implies a symmetric relationship between the two involved quantities. I don't think that the conversion rules should necessarily be symmetric. If two quantities really are interconvertible, then it means you can substitute one for the other. That is an equivalence relation. But as such, it should be transitive: If you can substitute A for B in every place and you can substitute B for C everywhere, then you should be able to substitute A for C. Thus, I believe it is impossible to make both height and length "interconvertible", and simultaneously width and length "interconvertible", but not width and height. Either they are all "interconvertible", or at least one of them is not "interconvertible" to any of the other.

Given the constraint that we want to make width and height incompatible with each other, but each of them individually to be compatible in some suitable sense with length, I see only one solution: That compatibility necessarily has to be an asymmetric relation. That is, between width and length, we want one to be able to stand in for the other, but not the other way around. In principle we can choose either direction, but the only choice that lets us have width and height incompatible for algebra is when length can stand in for width, but not the other way around. They still remain "compatible" for algebraic purposes, as I will try to highlight below:

Let us have a look at a quantity expression length + width:

  • If width can be converted to length but not vice versa, then the only consistent interpretation of the expression above is that the width first gets converted to a length, and then two lengths are added, creating a length as a result. With that (width + length) + height also is a length, and consequently we can reorder the terms such that the subterm width + height is valid. width and height are still not "interconvertible", but algebraically "compatible".
  • However, if length can be converted to width but not the other way around, then the only consistent interpretation leads to a width result, and thus (width + length) + height reduces to width + height, which can remain incompatible, as there is no allowed conversion that brings both arguments to the same quantity.
  • If length and width were interconvertible/equivalent, we can first convert either to the other, and then perform the addition. Depending on which one we convert first, the result will be a length or a width, but since they are equivalent, it doesn't matter and the distinction is really meaningless. This is a viable choice, but then again, we necessarily have that width and height are equivalent and interconvertible too.

@burnpanck
Copy link
Contributor

So long story short: "interconvertibility" is not the right concept that allows us model the type of "compatibility" that we are looking for, both in function arguments and algebraic operations. We need to consider asymmetric conversion rules.

@burnpanck
Copy link
Contributor

burnpanck commented Jan 31, 2023

If we accept such asymmetric conversion rules, it means we could draw a directed graph of all possible quantities and their allowed (implicit) conversions. That graph would be infinite, as we need to consider all sorts of composed quantities too. Due to the associativity of quantity calculus, the graph further needs to be transitive: if A is convertible to B and B is convertible to C, then A should be convertible to C: The expression (B + A) + C has a result of type C, but algebraically is the same expression as B + (A + C). Thus, once we find a cycle in this directed graph of quantities, we immediately know that each of the involved quantities are convertible to all of them and thus truly are interconvertible or equivalent for all purposes. We follow from this that the graph will form groups or clusters of equivalent quantities and a higher-order directed acyclic graph of cluster emerges (if there were a cycle among the clusters, the clusters were all equivalent and thus a single cluster - proof by contradiction).

So far we have only looked at addition. We also need to make sure that this meta-DAG of equivalent quantities respects the quantity algebra of multiplication. Distributivity says that if A is convertible to B, then A*C should be convertible to B*C, because (A+B) * C is algebraically equivalent to A*C + B*C. So this will be another constraint that limits conversion graphs we may propose without breaking quantity calculus.

Apart from those two constraints, we are free to draw this graph of convertibility such that it matches the expectations of our users, or the scientific and engineering communities in general. There is a simple way to ensure both constraints, both by design and in the implementation: With every convertibility we declare explicitly, we make sure that all other convertibilities that are needed to fulfil those constraints are allowed implicitly. Thus if we declare length can be converted to width (but not vice-versa), then, automatically, length * time needs to become convertible to width * time. I did describe a more detailed algorithm how to "build" that graph of convertibility from a few basic/atomic definitions in another issue before: #405 (comment). I did provide an illustration showing a subset of the resulting graph here: #405 (comment).

@burnpanck
Copy link
Contributor

Now finally: Let us explore some potential choices for this graph and what it implies for the examples given in the initial issue description.

Trivial choice 1: Dimensional analysis only

Every quantity is equivalent/interconvertible with every other quantity of the same dimension. This is what most units libraries do. But it makes width + height acceptable, and so will it make frequency + radioactivity (now my favourite examples of incompatible kinds because it is explicitly in the standard).

Trivial choice 2: "Extended dimensional analysis" only

We draw some clusters of equivalent quantities following the constraints given before, but do not allow any conversion anywhere between non-equivalent quantities. This is essentially the same as dimensional analysis, except that we effectively introduce additional dimensions. For the width + height example, we could make all of length, width and height into a separate, non-equivalent group of quantities. None of them is compatible with any of the other. Crucially, we could define area as either as a) length^2 or as b) width * height, but the quantities length^2 and width*height necessarily would remain non-equivalent and thus incompatible in this design. Only one of them can be the area. Note that to "define" area could be interpreted as defining a new quantity area that is equivalent/interconvertible with, say, length^2, or it could simply be an alias. Because interconvertibility is an equivalence relation, it is of no further consequence, as the two should remain substitutable in all sitauations anyway. We can exploit this in the implementation so as to make area a strong type which is a separate but essentially equivalent quantity to length^2.

Non-trivial choices

Instead, we may want to hand-craft some of the convertibilities, and see where it leads us. To explore the examples of the initial issue description, let me just assume the following set of hand-crafted basic conversions:

  • length implicitly converts to width, but not vice versa: They are compatible, but not interconvertible.
  • length implicitly converts to height, but not vice versa.
  • height implicitly converts to altitude. (As a consequence, length also implicitly converts to altitude).
  • length implicitly converts to distance, but not vice versa.
  • path_length implicitly converts to distance, and vice versa: they are interconvertible/equivalent. (As a consequence, length implicitly converts to path_length).
  • area is equivalent to length^2, they are interconvertible/equivalent.
  • No further conversions are allowed, unless required by consistency with quantity calculus.

Consistency with quantity calculus leads to the following results for the examples provided in the first post:

  1. length * length and area are interconvertible/equivalent by definition.
  2. area is convertible to width * height, but not vice-versa: The expression length * length + width * height has a result of quantity type width * height. Converting that to area loses information and thus should not be allowed without explicit input from the user (quantity cast).
  3. path_length * distance and path_length^2 are equivalent/interconvertible as required by distributivity path_length * (path_length + distance) = path_length^2 + path_length * distance, and the term in brackets on the left-hand side are equivalent by definition.
  4. width * distance and path_length * width are necessarily equivalent by the symmetry of * and distributivity again (and as expected for dimensional analysis; there is no "order" just the power of the dimension matters).
  5. path_length * height is convertible to altitude * distance, but not vice-versa: altitude is "more refined" than height.
  6. width * length and length * height are incompatible / neither is convertible to the other. There is no allowed conversion to any of the "parts" that leads to a common quantity type.
  7. length * distance is convertible to path_length * altitude, but not vice-versa: altitude is more refined than length (transitively through height).

To me, all of these results look reasonable. What do you think? I might be able to sketch an implementation of these rules at the start of the next week.

@JohelEGP
Copy link
Collaborator

JohelEGP commented Feb 1, 2023

Crucially, we could define area as either as a) length^2 or as b) width * height, but the quantities length^2 and width*height necessarily would remain non-equivalent and thus incompatible in this design. Only one of them can be the area.

An area has dimension L². Both $l * l$ and $w * h$ (where $l$, $w$, and $h$ are quantities of length, width, and height, respectively) are valid expressions of area. It'd be nice if the library didn't require either to be more verbose just due to the way it defines area.

All those implicit conversions in "non-trivial choices" seem backwards and unsafe. A width is always a length, and so the library could make width implicitly convertible to length. But a length doesn't necessarily represent a width, so a library that allows implicitly converting a length to a width opens up opportunities for logical errors that could be caught by the type system.

@burnpanck
Copy link
Contributor

burnpanck commented Feb 1, 2023

It'd be nice if the library didn't require either to be more verbose just due to the way it defines area.

I completely agree. I don't want to implement the trivial choice 2. I was trying to prove that we need a non-trivial choice if we want both "L x L" and "W x H" to be compatible with "A" (even if not interconvertible!) in the same way that "L" and "W" should be compatible, but at the same time we want "W" and "H" to be incompatible.

All those implicit conversions in "non-trivial choices" seem backwards and unsafe. A width is always a length, and so the library could make width implicitly convertible to length. But a length doesn't necessarily represent a width, so a library that allows implicitly converting a length to a width opens up opportunities for logical errors that could be caught by the type system.

I feel you, that was my first thought too, and in the V2 thread I had initially proposed rules to be the other way around. However, I believe that is a reflex coming from object oriented programming, where refining an object adds instance state. In that case, the state space actually becomes "wider" in some sense. However, refining a quantity adds a compile-time constraint to the physical phenomenon that is described by the quantity; it does not in fact change the state-space, but narrows down the space of applicability of the quantity. I believe that we have here a case of a contravariant relationship between quantities and expressions involving them. The C++ type hierarchy unfortunately does not implement contravariance even though there are many situations where that would be needed as Arthur O’Dwyer points out in the article I linked. So indeed, if we were to make actual instances of quantities to form an inheritance tree according to these refinement rules, then at least for pointers to quantities, C++ would allow us to provide a width for a length. But we are not talking about quantity instances here, but a hierarchy of quantity types/kinds. We can in fact implement contravariance easily in modern C++ if we do not make the quantity instances form a corresponding class hierarchy.

Let me now discuss why I think that contravariance is in fact intuitively correct for the quantity hierarchy. Consider an expression width + "1 meter". I believe everyone would intuitively agree that the result of this operation is still a width, despite "1 meter" not being a width explicitly. This can be formalised as follows: Replacing a general quantity with a refined quantity in an expression is like adding in a constraint. An expression involving multiple constraints can only be valid if all constraints are fulfilled. Thus, converting from refined to general actually removes a constraint, that is, removing information about the quantity. Therefore, converting from width to length is in fact unsafe, as it removes an explicit statement about the applicability of the quantity to a physical phenomenon (and therefore the applicability to algebraic expressions modelling that phenomenon).

That the converse does not lead to the expected result can be illustrated with the width + length + height example. If we do not want to redefine the rules of algebra, then that expression should be equivalent to (width + length) + height. If we discard the width constraint and say it is just a length, then ultimately, we have length + height, which again should become a length. But the rules of algebra also say that the same expression could also be evaluated as (width + height) + length. This is exactly the type of expression you would want to forbid, because width and height each have a constraint on applicable physical phenomenons that are mutually disjunct such that the combined expression is not applicable to any physical phenomenon at all.

@mpusz
Copy link
Owner Author

mpusz commented Feb 1, 2023

@burnpanck Thanks a lot for your answers.

Yeah, we went a long way back and forth with the design options here. I agree with you on most points, and actually, I was the one that said that in order for 1 Hz + 1/s + 1 Bq to not compile, we need to make 1 Hz + 1/s return a frequency. Following this logic, width + length + heigth should not compile as well, and the problem would be solved.

However, ISO 80000 explicitly states in multiple places:

The quantities diameter, circumference, and wavelength are generally considered to be quantities of
the same kind, namely, of the kind of quantity called length.

Quantities may be grouped together into categories of quantities that are mutually comparable. Diameters,
distances, heights, wavelengths and so on would constitute such a category, generally called length. Mutually
comparable quantities are called quantities of the same kind.

Quantities that are comparable are said to be of the same kind and are instances of the same general quantity. Hence, diameters and heights are quantities of the same kind, being instances of the general quantity length.

the diameter of a cylindrical rod can be compared to the height of a block

However, it also states:

Quantities of the same kind within a given system of quantities have the same quantity dimension. However,
quantities of the same dimension are not necessarily of the same kind.

The quantities moment of force and energy are, by convention, not regarded as being of the same kind,
although they have the same dimension. Similarly for heat capacity and entropy, as well as for number of entities,
relative permeability, and mass fraction.

However, in some cases special measurement unit names are restricted
to be used with quantities of specific kind only. For example, the measurement unit ‘second to the power minus one’ (1/s)
is called hertz (Hz) when used for frequencies and becquerel (Bq) when used for activities of radionuclides. As another
example, the joule (J) is used as a unit of energy, but never as a unit of moment of force, i.e. the newton metre (N · m).

Two or more quantities cannot be added or subtracted unless they belong to the same category of mutually
comparable quantities. Hence, quantities on each side of an equal sign in an equation must also be of the
same kind

Of course, we can ignore the above requirements in ISO 80000 and do mathematical and strong-typing correct things. But I am not sure if that is a good idea. I hoped that we could somehow invent a way to do comparisons and arithmetics in terms of kinds and convertibility in terms of their quantity hierarchy.

Please note that function templates (i.e. op+()) will not do automatic implicit conversions between arguments to make the code compile. So even if we make width and height interconvertible with length, they will not do that conversion by themselves.

The interconvertibility requirement comes from the usability fact that I believe that the following should work fine:

void foo(quantity<isq::length[m]>);
void boo(quantity<isq::height[m]>);

foo(isq::height(42 * m));
boo(42 * m);

otherwise, the users may complain the library is not user-friendly. However, I feel that it should not be allowed to call boo(isq::width(42 * m)) to prevent errors in interfaces.

With the rules you described, it seems that foo(isq::height(42 * m)); should not compile without an explicit cast, but this might be the most common case here. Most generic libraries will define functions like:

quantity_of<isq::speed> auto avg_speed(quantity_of<isq::length> auto, quantity_of<isq::time> auto);

If the user works on more specific types in his/her code, then it will need to explicitly cast them to use such generic interfaces. I think this is what @JohelEGP was worried about above.

But as such, it should be transitive: If you can substitute A for B in every place and you can substitute B for C everywhere, then you should be able to substitute A for C. Thus, I believe it is impossible to make both height and length "interconvertible", and simultaneously width and length "interconvertible", but not width and height. Either they are all "interconvertible", or at least one of them is not "interconvertible" to any of the other.

Not necessary. Even in the C++ language, in most cases, we support only one implicit conversion step at a time. For example, if class X is implicitly convertible from std::string, class X will not be implicitly convertible from const char * as this requires two steps at a time. So a conversion between A and C is possible but not implicitly in one step. A user will have to explicitly provide an intermediate step of B, which will be good for code readability and maintenance.

We need to consider asymmetric conversion rules.

Sure, I am open to it. However, please keep the above foo() and boo() examples in mind. Which one do you think should compile, and which one not?

and so will it make frequency + radioactivity (now my favourite examples of incompatible kinds because it is explicitly in the standard).

This would not work because even though they have the same dimension and are part of the same hierarchy tree, they are different kinds, and we can easily define them like that in the library.

path_length implicitly converts to distance, and vice versa:

I am not sure why they should be interconvertible as distance derives from path_length the same as path_length derives from length.

area is equivalent to length^2, they are interconvertible/equivalent.

Does it mean that energy and moment of force are equivalent as well?

@JohelEGP
Copy link
Collaborator

JohelEGP commented Feb 1, 2023

void boo(quantity<isq::height[m]>);
boo(42 * m);

This is what I'm worried about. Conversion from length to height should be explicit.

@mpusz
Copy link
Owner Author

mpusz commented Feb 1, 2023

But this is what we wanted to achieve:

std::array<quantity<isq::height[m]>, 3> a = { 1 * m, 2 * m, 3 * m };
auto h = isq::height(42 * m);

If the conversion is explicit, the first one will not compile. I am not sure about the second one.

@burnpanck
Copy link
Contributor

I agree with @mpusz here: I think the boo example should compile, and even you @JohelEGP requested this at some point in the V2 review (state-space controller initialisation). As for the foo example, I'm not sure we would want to let that one compile.

@mpusz
Copy link
Owner Author

mpusz commented Feb 1, 2023

And what do you think about the ISO 80000 requirement of comparing and adding/subtracting quantities? Should we allow that or do exactly the opposite, which was suggested above? I have implemented the ISO's behavior so far but still struggle with what to do with conversions. I think we have the following options here:

Comparison and arithmetics:

  1. Follow the ISO and allow width + height but not allow frequency + radioactivity as those will be defined as different kinds.
  2. Implement strict arithmetics described above. With that width + height and frequency + radioactivity do not compile and we do not have to play with kinds at all to achieve that.

Conversions:
3. Interconvertibility across the same quantity branch
4. Implicit downcasting (length -> width) across the same quantity branch
5. Implicit upcasting (width -> length) across the same quantity branch

It seems that options 1. and 5. are the ISO's preferred approach, but that does not provide any type-safety. With that, I would prefer to abandon quantity types and quantity kinds at all, and just use dimensions. The only problem here is what @JohelEGP
mentioned some time ago that there is no such thing as a speed dimension, so naming things could be harder. Such behavior would be really easy to implement, document, and standardize.

I tried hard to achieve 1. and 3. as a compromise. However, those are inconsistent in mathematical ways, as pointed out above. I am not sure if we should explore this path more?

The most C++ type-safety provides 2. and 4. which is suggested above by @burnpanck. It is consistent as well but not compatible with ISO 80000.

@JohelEGP
Copy link
Collaborator

JohelEGP commented Feb 1, 2023

Yeah, you're right. Does the implicit conversion generalizes to kinds (such that a path length implicitly converts to a distance, which is a logic error if the path length is not a distance, i.e. the shortest path length between two points in a metric space) or is it limited somehow?

@JohelEGP
Copy link
Collaborator

JohelEGP commented Feb 1, 2023

  1. Implicit downcasting (length -> width) across the same quantity branch
  2. Implicit upcasting (width -> length) across the same quantity branch

It seems that options 1. and 5. are the ISO's preferred approach, but that does not provide any type-safety.

The most C++ type-safety provides 2. and 4. which is suggested above by @burnpanck.

It seems to me you have the type-safety backwards. 4 is not type safe because a length isn't always a width, but 5 is type safe because a width is always a length.

@mpusz
Copy link
Owner Author

mpusz commented Feb 1, 2023

It seems to me you have the type-safety backwards. 4 is not type safe because a length isn't always a width, but 5 is type safe because a width is always a length.

Agree. I maybe phrased my thoughts in a wrong way.

This is why I actually claim that we should provide interconvertibility. One is for usability and the other one is for type safety. And I hope we do not have to extend that to arithmetics and comparison so we can implement ISO's behavior. See TL;DR chapter of #426.

@burnpanck
Copy link
Contributor

Ok, I understand now better what you are trying to achieve. My proposal was geared at clarity and consistency, by using the same set of rules for conversion and arithmetics/comparison. I also applied those same set of rules both to frequency vs. radioactivity (which ISO explicitly states as separate kinds), as well as for width vs. height, which are both of the same kind in ISO. My approach distinguished the latter case from the former only through hierarchy, i.e. by making width and height some sort of sub-kinds which still are both length but not compatible with each other.

I believe clarity and consistency are very valuable, and we should be careful about introducing more concepts, because it makes it even more difficult for us to just even define the behaviour, let alone verify it and communicate it to the users.

Let me think a little more if I can formalise the rules that you have been suggesting under the assumption that conversion may have different rules than compatibility for algebra and comparison, so that we can make width and height the same kind (i.e. equivalent for comparison and algebra purposes), but not equivalent for conversion.

@mpusz
Copy link
Owner Author

mpusz commented Feb 1, 2023

My proposal was geared at clarity and consistency, by using the same set of rules for conversion and arithmetics/comparison.

I love it. I would be for it if only we could make it consistent with ISO and also type-safe. However, the more experience we get, the more it looks like we need some compromise here or be not consistent with ISO 80000 rules.

Let me think a little more if I can formalise the rules that you have been suggesting under the assumption that conversion may have different rules than compatibility for algebra and comparison, so that we can make width and height the same kind (i.e. equivalent for comparison and algebra purposes), but not equivalent for conversion.

Thanks! I tried to state such rules in #426, but maybe we can find something even better.

@burnpanck
Copy link
Contributor

burnpanck commented Feb 1, 2023

Ok, let me recap/restate what we believe to be true and the goal that we want to achieve.

Assumptions:

  • A1) We want to model ISO kinds exactly; ISO explicitly speaks about compatibility for comparison, but given that A > B <-> A - B > 0, I believe that compatibility for addition should be exactly the same. We will have to define the result of such an operation.
  • A2) ISO obviously does not speak about convertibility in the C++ sense, so we may want separate rules here.

For comparison/addition:

  • B1) Different kinds should be incompatible for addition and comparison:frequency + radioactivity should be disallowed.
  • B2) With a suitable concept of quantity "types", different "types" that are of the same "kind" should be compatible for comparison and algebra by definition of same kind. Assuming width and height are such examples, then width + height should be allowed. The only reasonable result of that operation would be a general length then.

For conversion:

  • C1) Whenever two things are incompatible for comparison, they should never be implicitly convertible in either direction. (Otherwise I think that would be unintuitive to a C++ programmer: if I can implicitly convert A to B, then I would expect that the comparison between the two would behave as if one of then is implicitly converted to the other and then compared). Thus, by B1), quantities of different kinds never convert to each other.
  • C2) The converse shall not generally be true: width and height are compatible for comparison (in this hypothetical "ideal" design), but they should never convert implicitly, because they are on separate branches of the "quantity type tree" within a single "kind".
  • C3) There is no consensus on conversion along the "vertical" direction in the "quantity type tree":
    • "implicit narrowing" (length to width): @mpusz and @burnpanck are in favour of allowing that, @JohelEGP is against (correct, or did I misunderstand?).
    • "implicit widening" (width to length): @mpusz wants to allow that, I'm undecided yet. @JohelEGP seems to be in favour, as this is the usual "covariant" compatibility implied by a C++ type hierarchy ("type safety" - though I'm not sure that this term has any meaning here on it's own, nor that we should necessarily represent the "quantity type hierarchy" as a hierarchy of quantity<...> types in C++. IMHO we should rather try to understand what conversion behaviour best models physical quantities.)

Do we all agree on that?

@mpusz
Copy link
Owner Author

mpusz commented Feb 1, 2023

I agree with all of the above :-)

We want to model ISO kinds exactly

Probably it would be good to do so unless we decide to ignore ISO 80000. One thing to note here is that ISO does not specify which quantities are of different kinds. It just provides a few examples and claims that the division into kinds is to some extent arbitrary.

"type safety" - though I'm not sure that this term has any meaning here

By "type safety" I mean that we should not be allowed to call void foo(quantity<isq::width> w, quantity<isq::height> h); with reversed order of arguments. This in my opinion is the best we can do in those circumstances but, of course, I am open to some better proposals.

@JohelEGP
Copy link
Collaborator

JohelEGP commented Feb 1, 2023

  • "implicit narrowing" (length to width)

I wouldn't call that narrowing. It's implicitly adding semantics to a value by type conversion, from a more generic type to a more specific type.

@burnpanck
Copy link
Contributor

Also, please clarify what your feeling towards path_length and distance is. I assume they are of the general kind of "length", so comparison to width and height should be allowed. So among those four, are they all on separate branches , or are path_length and distance "closer" in some way. (Ideally, draw me the hierarchy).

@mpusz
Copy link
Owner Author

mpusz commented Feb 1, 2023

This is my understanding

        length
   /      |          \
width   height    path_length
                       |
                   distance

at least this is how I implemented it here: https://github.com/mpusz/units/blob/v2_framework/src/systems/isq/include/mp_units/systems/isq/space_and_time.h.

@burnpanck
Copy link
Contributor

burnpanck commented Feb 1, 2023

"implicit narrowing" (length to width)

I wouldn't call that narrowing. It's implicitly adding semantics to a value by type conversion, from a more generic type to a more specific type.

Ok, then let's not call it narrowing. @JohelEGP Please confirm what type of "vertical" conversions you would like to allow, and which you don't. My understanding is you want to allow width to length but not the other way around?

@JohelEGP
Copy link
Collaborator

JohelEGP commented Feb 1, 2023

I agree with all of the above :-)

We want to model ISO kinds exactly

I agree, too.

or are path_length and distance "closer" in some way.

See #405 (comment).

@mpusz
Copy link
Owner Author

mpusz commented Feb 4, 2023

Now, which level of detail can we imagine for such quantities? The user might want to distinguish

I think that is more about the representation type rather than a quantity type/kind. Altough some quantities are explicitly speciffied by ISO as vectors or tensors (all the rest are scalars). For example, there is a position vector quantity of kind length that is explicitly defined as a "vector quantity".

@mpusz
Copy link
Owner Author

mpusz commented Feb 4, 2023

I also can't see how such a hierarchy would work in practice if users can add arbitrary new strong quantity types which would then have to be properly located in the type hierarchy tree again by the user.

Yes, this might be a potential issue. However, a user does not have to use the definitions of the ISQ system of quantites (specified by the ISO 80000). For specific needs, a project-specific system of quantities can be created either form scratch or by building on top of only some selected quantities of ISQ.

@mpusz
Copy link
Owner Author

mpusz commented Feb 4, 2023

now Imagine that we Not only want to distinguish between width and height but also between width in Frame A and width in Frame B, same for height.

Then it would be possible to call this method with width in Frame A and height in Frame B as these are more specialized than width and height and I guess they satisfy the width and height Concept.

This is my intent. I will write about it soon below 😉

@mpusz
Copy link
Owner Author

mpusz commented Feb 4, 2023

However, does it make sense to ask whether width and height in different frames are square? I would say this is a clear logic error if it were callable like this.

Does it make sense to call std::sort(it1, it2); on two iterators from two different containers? 😛 Sometimes we can't enforce anything. If a user has specific ocnstraints about its hierarchy, custom functions should be provided.

@mpusz
Copy link
Owner Author

mpusz commented Feb 4, 2023

OK, so here is where I am right now with my prototyping. As we agreed before and what was clearly stated by @Quuxplusone, we have two different categories of quantities.

Quantities of a specific type

  • For example length, width, height, radius, path length, distance, etc
  • Such quantities form a hierarchy
  • They should be implicitly convertible upwards to other compatible quantities. By that I mean that every width is a length, every distance is a (shortest) path length which by itself is also a length.
  • Downcasting on compatible quantities should be explicit as not every length is a width.
  • Compatible quantities are the ones that can be found on the same hierarchy branch. width is a length and height is a length so those pairs are compatible. However, width is not a height so such conversion should not be allowed even with explicit cast.

Quantities of a specific kind

  • They form logical sets of quantity types.
  • In a hierarchy of quantities of a specific type such a set could be represented as a subtree starting from some node. The root node of the hierarchy always starts a new kind and spans across entire tree - this is a quantity of a unique dimension. However, for some quantities it make sense to define more constrained subtree. For example frequency, activity, and modulation rate are all quantities of the 1 / dim_time dimension but probably such quantities should not be possible to be added, subtracted, or compared. Another example here could be energy and moment of force both having the same dimension again.
  • Quantity of a specific kind can be implicitly converted to any quantity type in its set.

I played with many ideas in the code and this is what I ended up with:

quantity type quantity kind
C++ type quantity<isq::width[m]> , quantity<isq::length[m]> quantity<si::metre>, quantity<kind_of<isq::length>[m]>
instance creation 42 * isq::width[m], isq::length(42 * m) 42 * m, 42 * kind_of<isq::length>[m], kind_of<isq::length>(42 * m), kind_of<get_kind(isq::width)>(42 * m)>

A user in most cases will probably just use the first option to create an instance or type of a quantity kind. The other verbose options will be needed for:

  • natural units
  • cases when a user do not want to use/print a dedicated named unit (for example if a user wants to create a quantity of frequency kind but use 1/s as a unit then the following will be needed kind_of<isq::frequency>(42 * (1/s)))

As ISO states quantities of the same kind should be comparable, addable, and subtractable. I provided some examples about why I think it is useful in examples in the posts above. However, I think that the two categories of quantities should behave differently here.

A width + 3 m is a quantity of width

quantity_of<isq::width> auto w = isq::width(42 * m) + 3 * m;

This is the case when we are adding a quantity kind to a specific quantity type. Such a quantity kind behaves as any quantity type from the set of quantities of the same kind.

A width + height is a quantity of length

quantity<isq::width[m]> w = isq::width(42 * m) + isq::height(42 * m);  // Compile-time error as the result is a quantity
                                                                       // of length which is not implicitly convertible
                                                                       // to width

Adding or subtracting quantities of specific types results in the closest common node/base in the hierarchy. It might be the root of the subtree (i.e. length like above) but does not have to be:

quantity_of<isq::path_length> auto p = isq::distance(42 * m) + isq::path_length(42 * m);

To summarize the above, kind_of<isq::length> and isq::length are two different categories and behave differently:

quantity_of<isq::width> auto q1 = isq::width(42 * m) + 123 * m;    // the same result for 123 * kind_of<isq::length>[m]
quantity_of<isq::length> auto q2 = isq::width(42 * m) + isq::length(123 * m);

@mpusz
Copy link
Owner Author

mpusz commented Feb 4, 2023

Please let me know how you like the above and share your thoughts and suggestions. I would like to ensure that we end up with the best possible design here that is easy to use, easy to document and understand, type-safe, and free of surprises. If we can't achieve all of those, we have to find the best compromise.

@dwith-ts
Copy link

dwith-ts commented Feb 4, 2023

The first line of
quantity_of<isq::width> auto q1 = isq::width(42 * m) + 123 * m; // the same result for 123 * kind_of<isq::length>[m]
quantity_of<isq::length> auto q2 = isq::width(42 * m) + isq::length(123 * m);

is ok from my point of view. For the 2nd line this seems ok when written exactly like this where you nicely specify the type of width and length and also the concept for the resulting value.
However, out in the wild (in real codebases) it is often not obvious what types the 2 arguments have (might be badly named variables) and the resulting type could just be deduced by auto because the author is lazy (in reality all these names are longer due to additional namespaces) or it is directly returned from a method with auto deduced return type.

If we were to write
auto q2 = a.as_kind() + b.as_kind();
Instead then it is much easier to directly grasp what is happening here, we strip off parts of the strong type safety and only care about the dimension + unit of the quantity.

The same ambiguity might also arise with the first code line but there it is less problematic.

@dwith-ts
Copy link

dwith-ts commented Feb 4, 2023

Regarding quantities of the same type: what is the goal of the library, do we see the types as a fixed set or are these open for extension?

I presume they should be open for extension. Now even the ISO itself calls the categorization they list as somewhat arbitrary and from my experience with such strong types I would definitely want to use a different set of types for e.g. the length kinds.

=> users probably rarely want to use these predefined types like width, height. Instead, they either only work with lengths or they crate their own hierarchy.

This means that we have to assume that there is more fine-grained hierarchy and if this already leads to inconsistent code as in the is_squareexample then we might have a problem with how we model the domain (just my opinion here).

Taking a closer look at your comment

Does it make sense to call std::sort(it1, it2); on two iterators from two different containers? 😛 Sometimes we can't enforce anything. If a user has specific ocnstraints about its hierarchy, custom functions should be provided.

here I think that the proper concept would require that the 2 iterators are from the same container. Of course this cannot easily be modeled in C++, but that would be the “true” concept here.

If we now use concepts for quantities that are designed for a hierarchy of types and it already breaks down with a small extension where we distinguish between quantities in different coordinate frames (which is a reasonable hierarchy) as seen by the is_square method then this (IMHO) calls this approach into question.

@mpusz
Copy link
Owner Author

mpusz commented Feb 4, 2023

However, out in the wild (in real codebases) it is often not obvious what types the 2 arguments have (might be badly named variables) and the resulting type could just be deduced by auto because the author is lazy (in reality all these names are longer due to additional namespaces) or it is directly returned from a method with auto deduced return type.

Anyway, it will be a quantity of isq::length type and not a quantity of a kind. So for example this will fail:

auto q2 = isq::width(42 * m) + isq::length(123 * m);
quantity<isq::width> q3 = q2;  // does not compile

If we were to write
auto q2 = a.as_kind() + b.as_kind();
Instead then it is much easier to directly grasp what is happening here, we strip off parts of the strong type safety and only care about the dimension + unit of the quantity.

The above is incorrect. The q2 is not a quantity of a kind but a quantity of a type isq::length. Thanks to that q3 does not compile. If q2 was indeed a quantity of kind_of<isq::length> as you state above, then the q3 would compile which I think is really wrong.

@mpusz
Copy link
Owner Author

mpusz commented Feb 4, 2023

I presume they should be open for extension

You are right.

Now even the ISO itself calls the categorization they list as somewhat arbitrary

This is not true. The quantity types they provided are strict definitions so it is not arbitrary. The thing that is arbitrary is the division of types into kinds. ISO does not provide any definitions for such divisions besides some examples in the description part.

@mpusz
Copy link
Owner Author

mpusz commented Feb 4, 2023

users probably rarely want to use these predefined types like width, height. Instead, they either only work with lengths or they crate their own hierarchy.

And that is a really reasonable way to do if a project has such constraints. In such case a user may provide something like:

namespace my_namespace {

inline constexpr auto width = quantity_spec<isq::length, is_kind<width>> {} width;
inline constexpr auto height = quantity_spec<isq::length, is_kind<width>> {} height;

}

and with the above width will not be possible to be added or compared to height.

@dwith-ts
Copy link

dwith-ts commented Feb 4, 2023

The above is incorrect. The q2 is not a quantity of a kind but a quantity of a type isq::length. Thanks to that q3 does not compile. If q2 was indeed a quantity of kind_of<isq::length> as you state above, then the q3 would compile which I think is really wrong.

Yes, you caught my on my imprecise terminology here and the as_kind name is not a good name anyways. I now realized that you consider everything a type, even a quantity of length.

So what is missing for me is an intermediate level of type-safety other than the types that only compares dimension (and potentially unit). Then a user could explicitly ask to switch calculations to that level and ignore the additional quantity type information. This would make it always explicit on which level of type-safety we are.

As a summary we would have 3 different levels of safety:

  • plain scalars (or whatever the underlying type is), meaning almost no type safety
  • Dimension / unit-aware calculus which only considers the dimension / unit
  • Full quantity type calculus where only identical types can be added and it would always be explicit where we are.

We already say that the user has to explicitly request this when we want to operate on the least typesafe underlying type, this does not work with implicit conversions. Why not require the same thing when going from the most typesafe level to the middle level?

Just my thoughts based on how we use these things in our codebase. I think it is always valuable to think about what an inexperienced / lazy programmer would produce with a library and also design for this. Otherwise we just keep on adding more sharp edges in C++ ;-)

@mpusz
Copy link
Owner Author

mpusz commented Feb 4, 2023

So what is missing for me is an intermediate level of type-safety other than the types that only compares dimension

This would allow adding and comparing frequency, activity, and modulation rate in a single expression as well as energy and movement of force in another one. Is it what you mean here? I think you really mean a "quantity kind" here and not its dimension. So we can compare and add all types of lengths in one expression. This is what is required by ISO 80000, and that is why this is the default in the current design.

Mutually comparable quantities are called quantities of the same kind.

Two or more quantities cannot be added or subtracted unless they belong to the same category of mutually
comparable quantities. Hence, quantities on each side of an equal sign in an equation must also be of the
same kind.

Quantities that are of the same kind (e.g., length) have the same dimension, even if they are originally expressed in different units (such as yards and metres). If quantities have different dimensions (such as length vs. mass), they are of different kinds[4][6] and cannot be compared

Quantities that are comparable are said to be of the same kind[4] and are instances of the same general quantity. Hence, diameters and heights are quantities of the same kind, being instances of the general quantity length.

There is also an interesting quote about units:

If a particular instance of a quantity of a given kind is chosen as a reference quantity called the unit, then any other quantity of the same kind can be expressed in terms of this unit, as a product of this unit and a number.

This is why the V2 can define units as:

inline constexpr struct second : named_unit<"s", kind_of<isq::time>> {} second;
inline constexpr struct metre : named_unit<"m", kind_of<isq::length>> {} metre;

Providing kind_of is required for base units and optional for derived units:

inline constexpr struct hertz : named_unit<"Hz", 1 / second, kind_of<isq::frequency>> {} hertz;

@mpusz
Copy link
Owner Author

mpusz commented Feb 4, 2023

Full quantity type calculus where only identical types can be added and it would always be explicit where we are

Yes, we are missing such a level of safety. I am not sure if and how to support that.

@mpusz
Copy link
Owner Author

mpusz commented Feb 4, 2023

Maybe something like this?

auto q = strict<isq::width>(42 * m) + strict<isq::height>(42 * m); // Compile-time error

Alternatively, we could make the third mode (strict) the default and then provide weak<QuantitySpec> specifier for the second level of safety? But that would be incompatible with ISO 80000.

@dwith-ts
Copy link

dwith-ts commented Feb 4, 2023

Maybe it makes sense to take a step back…

Can you maybe elaborate a bit why you are putting so much emphasis on exactly following ISO 80000?

Is it because

  • there were comments in the committee that this should be followed?
  • You presume that this makes it more likely to get ihr through the committee?
  • you think that this is the best solution?

If you don’t want to answer that or not publicly then this is also fine, just let me know. I am currently trying to see what we deem the best technical solution without restricting the direction to much with such constraints. Afterwards it still makes sense to compare this with ISO and see how this fits together.

In my understanding there is no requirement from ISO to make their mentioned quantity types like radius, width, height actually distinct types in the C++ type system. This could just as well be represented with different variable names that then are automatically comparable because they are of the same C++ type, couldn’t it?

And as it seems that there is a requirement from ISO to make the types frequency and activity distinct types that cannot be compared, couldn’t this be handled by the mentioned 3rd layer of type safety where only quantities of the same type are comparable?

I just don’t think we are getting much in terms of type safety from using distinct C++ types for width and height if they are comparable anyways. The real big additional benefit (compared to just modeling quantity kinds) in my opinion comes from the 3rd layer of type-safety which makes distinct types incompatible by default.

@dwith-ts
Copy link

dwith-ts commented Feb 4, 2023

Maybe something like this?

auto q = strict<isq::width>(42 * m) + strict<isq::height>(42 * m); // Compile-time error

Alternatively, we could make the third mode (strict) the default and then provide weak<QuantitySpec> specifier for the second level of safety? But that would be incompatible with ISO 80000.

Yes, having the strict mode as default is what I would wish for. Otherwise I (personally) would rather stick with just one C++ type per quantity kind and be done with it. The definition of quantity types for width, height from ISO and their operations is IMHO just a mushy gray area that is difficult to implement and does not add much value.

But I think I’ve distracted the discussion enough for now, thanks a lot for your detailed explanations so far @mpusz , have a great weekend!

@mpusz
Copy link
Owner Author

mpusz commented Feb 4, 2023

I think that one the most important benefit of having this hierarchy over one type per kind are strongly-typed conversions.

void f1(quantity<isq::length[m]>, quantity<isq::length[m]>);
void f2(quantity<isq::width[m]>, quantity<isq::height[m]>);

f1(42 * m, 42 * m);. // Ok
f2(42 * m, 42 * m);. // Ok
f1(isq::height(42 * m), isq::width(42 * m)); // Ok
f2(isq::height(42 * m), isq::width(42 * m)); // Error

@mpusz
Copy link
Owner Author

mpusz commented Feb 4, 2023

Yes, having the strict mode as default is what I would wish for.

If we decide this is the best solution then we can go this way. But first I would like to hear some more opinions on the above options.

@dwith-ts
Copy link

dwith-ts commented Feb 4, 2023

I think that one the most important benefit of having this hierarchy over one type per kind are strongly-typed conversions.

void f1(quantity<isq::length[m]>, quantity<isq::length[m]>);
void f2(quantity<isq::width[m]>, quantity<isq::height[m]>);

f1(42 * m, 42 * m);. // Ok
f2(42 * m, 42 * m);. // Ok
f1(isq::height(42 * m), isq::width(42 * m)); // Ok
f2(isq::height(42 * m), isq::width(42 * m)); // Error

Wouldn’t then most programmers pick the versions with only f1(42 * m, 42 * m); f2(42 * m, 42 * m) because they are shorter? This version again does not protect against mixing up the order. At the call site such a call is just as if there were no type safety at all.

@chiphogg
Copy link
Collaborator

chiphogg commented Feb 4, 2023

I think that one the most important benefit of having this hierarchy over one type per kind are strongly-typed conversions.

void f1(quantity<isq::length[m]>, quantity<isq::length[m]>);
void f2(quantity<isq::width[m]>, quantity<isq::height[m]>);

f1(42 * m, 42 * m);. // Ok
f2(42 * m, 42 * m);. // Ok
f1(isq::height(42 * m), isq::width(42 * m)); // Ok
f2(isq::height(42 * m), isq::width(42 * m)); // Error

Wouldn’t then most programmers pick the versions with only f1(42 * m, 42 * m); f2(42 * m, 42 * m) because they are shorter? This version again does not protect against mixing up the order. At the call site such a call is just as if there were no type safety at all.

I think the intent was that the height and width versions would typically be pre-existing variables of that type, not literal values created inline with the call.

@dwith-ts
Copy link

dwith-ts commented Feb 4, 2023

Ok, that makes sense then!

@Quuxplusone
Copy link

FWIW, I find the code snippets hard to understand for two reasons (besides that I don't know this library at all ;)) —

  • I see both quantity<foo> x and quantity_of<foo> auto x: so this is a library of concrete types like absl::Duration, but also parameterized types (like std::time_point<foo>), but also a library of concepts (like I-don't-know-what)? FWIW, my mental model of how I'd like to use a units library exactly matches absl::Duration: it's just a thin wrapper around a double, but strongly typed, and with factories to conveniently apply scaling factors (e.g. my::Feet(3) == my::Yards(1)). (See the Godbolt below.)
  • I believe I understand correctly now that isq::length is a completely different sort of animal from isq::width — namely the former is a general quantity and the latter is a directed quantity — but the continual use of "length" and "width" in example code is unnecessarily confusing to the reader IMO, because it's too easy in English to think of "length and width" as if they were two coequal directed quantities of a rectangle or a box. Could @mpusz suggest some less confusable identifiers for use in examples? E.g. width is to isq::length as ____ is to isq::mass? I don't think anyone would casually mistake isq::mass for a directed quantity.

Alternatively: I see in https://raw.githubusercontent.com/mpusz/units/master/docs/framework/arithmetics.rst that there is some sort of convention of using affixes _kind and dim_: that doc uses rate_of_climb_kind, dim_length. Now, dim_length exactly matches my original usage of dimension to mean "the kind of thing that length and mass are." However, @mpusz, you then claimed that kind is a synonym for dimension! If I accept that, then I certainly can't refer to rate_of_climb_kind: "rate of climb" is clearly a quantity, whose dimension (or kind) is "length / time". "Rate of climb" itself is not a kind; rather, it is a quantity which shares the same kind (namely "length / time") with many other quantities, such as "airspeed" and "rate of descent."

@mpusz writes:

So we can compare and add all types of lengths in one expression. This is what is required by ISO 80000

I think you're reading too much C++ jargon into ISO 80000's English. I think it would be more pragmatically useful to read "comparable" as "able to be compared." It doesn't need to mean specifically "directly supports C++'s < operator with no type-casts involved on either side."

bool is_square(quantity_of<isq::width> auto w, quantity_of<isq::height> auto h) { return w == h; }

strikes me as just as unacceptably non-type-safe as

bool is_cromulent(quantity_of<isq::airspeed> auto as, quantity_of<isq::rate_of_descent> auto rod) { return as == rod; }

(By the way, per my first bullet, I'm aware that I don't understand the programming model here. These functions are templates, which AFAIC means maybe the == will compile and maybe it won't; we can't possibly tell until we instantiate the template with some particular types. Could we stick to non-template function with concrete types, like

bool is_cromulent2(quantity<isq::airspeed> as2, quantity<isq::rate_of_descent> rod2) { return as2 == rod2; }

or would that be a pointlessly unrealistic usage of your library as you envision it?)

Your next example is similarly over-templated for my mental model:

quantity_of<isq::length> auto gift_wrapping_paper_length(quantity_of<isq::width> auto w, quantity_of<isq::height> auto h) {
  return 2 * w + 2 * h + min(w, h);
}

Notice that std::min actually requires pw and ph to have the same type or else deduction fails, so the idea of mixed-mode arithmetic between widths and heights doesn't seem sustainable even in this simple example. (Unless you propose to supply a new-and-improved min implementation in your units library!)

I ended up getting horribly nerdsniped by this, so here's a Godbolt sketch of how I'd personally do it, again heavily inspired by the very concrete style of absl::Duration.
https://godbolt.org/z/Y7zaTGKeY
I know you can't do it exactly this way (hard-coded double as the representation type, impossible to add new dimensions/kinds), but FWIW, this is basically the style that I'm picturing in my head and implicitly comparing everything else to, and if I misinterpret something you're describing, I'll likely be erring in this direction.

@mpusz
Copy link
Owner Author

mpusz commented Feb 4, 2023

see both quantity x and quantity_of auto x: so this is a library of concrete types like absl::Duration, but also parameterized types (like std::time_point), but also a library of concepts (like I-don't-know-what)?

Yes, we do have quantity and quantity_point class templates to model affine space. quantity_of<> (or QuantityOf<> in my current not yet pushed code) is a concept that verifies if the given quantity is consistent with the second argument being one of dimension, quantity_spec, or reference.

I believe I understand correctly now that isq::length is a completely different sort of animal from isq::width — namely the former is a general quantity and the latter is a directed quantity

Actually, not 😉 Both isq::length and isq::width are strong quantity types where isq::width derives from isq::length. This means that every width is a length, but not the other way around. Think about it like a tree of quantity types of the same dimension. Additionally, isq::length is the name of the quantity kind that gathers all quantities of length, including the length itself, width, height, ... into a set of quantities that are comparable. In the library specifier kind_of can be used to say I am referring to kind rather than to a strongly typed quantity.

So isq::length, and isq::width are strongly types quantities, and kind_of<isq::length> and kind_of<get_kind(isq::width)> are referring to the same quantity kind of length.

Alternatively: I see in https://raw.githubusercontent.com/mpusz/units/master/docs/framework/arithmetics.rst that there is some sort of convention of using affixes kind and dim: that doc uses rate_of_climb_kind, dim_length

This was V1, and this was wrong, not scalable, and not consistent with ISO 80000. In V2, we are trying to do better, hence this discussion...

Could we stick to non-template function with concrete types, like

bool is_cromulent2(quantity<isq::airspeed> as2, quantity<isq::rate_of_descent> rod2) { return as2 == rod2; }

Yes, we can, but the above is incorrect because it does not specify a unit. With quantity_of concept, I accept quantity of every unit of this kind and any representation type as well. That is why I tend to mix the usage of strong types and concepts to show how things interact and how consistent they are. For an example code in V1 see https://godbolt.org/z/bcb87Kvea. Length in V1 is the same as QuantityOf<isq::length> in V2 (we want to minimize the number of definitions in the library). Such a generic function does not convert the quantity to a specific unit and back all the time which is faster and does not introduce the narrowing of values.

Notice that std::min actually requires pw and ph to have the same type or else deduction fails, so the idea of mixed-mode arithmetic between widths and heights doesn't seem sustainable even in this simple example. (Unless you propose to supply a new-and-improved min implementation in your units library!)

Well, this was a simplification, and that is why I did not provide std:: before min(). I could depend on ADL here.

@mpusz
Copy link
Owner Author

mpusz commented Feb 8, 2023

Just a short update. I am attending now the ISO C++ Commitee meeting in Issaquah. Through the last 2 weeks I tried to make covariant case work really hard but I failed:

  • the design was too complicated with two categories of quantities (Interconvertibility of derived quantities #427 (comment))
  • I ended up creating a lot of hardcoded kinds in the ISQ hierarchy even though the division "can be arbitrary" (my choices probably would not be OK for others)
  • some units serve more than one quantity kind (heat vs energy vs enthalpy for J, permeance vs inductance for T, conductance vs admittance vs susceptance for S, ...)
  • I needed to make thermodynamic_temperature and Celsius_themperature different kinds to serve K and deg_C

Now I am going to try the contravariant approach...

@mpusz
Copy link
Owner Author

mpusz commented Feb 8, 2023

Regarding the contravariant approach. Even though width + 3 m seems tempting, how to calculate mechanical_energy = potential_energy + kinetic_energy?

@Quuxplusone
Copy link

@mpusz wrote:

Even though width + 3 m seems tempting, how to calculate mechanical_energy = potential_energy + kinetic_energy?

FWIW, in my mental model / toy implementation, that looks isomorphic to the gift-wrap example:

    struct potential_energy : quantity<joules> {};
    struct kinetic_energy : quantity<joules> {};
    struct mechanical_energy : quantity<joules> {};

    potential_energy myPotentialEnergy = meters_per_s2(9.8) * myMass * myHeight;
    kinetic_energy myKineticEnergy = 0.5 * myMass * mySpeed * mySpeed;
    mechanical_energy myTotalEnergy = mechanical_energy(myPotentialEnergy) + mechanical_energy(myKineticEnergy);

where each strong type is castable to any other strong type with the same dimension/kind, but never implicitly convertible.
Or if all three of these variables have the same type, then the math isn't mixed-mode and there's no trouble at all, right?:

    struct energy : quantity<joules> {};
    energy myPotentialEnergy = meters_per_s2(9.8) * myMass * myHeight;
    energy myKineticEnergy = 0.5 * myMass * mySpeed * mySpeed;
    energy myMechanicalEnergy = myPotentialEnergy + myKineticEnergy;

Here's the bigger problem I'm just realizing now: My first snippet above wants meters_per_s2(G) * myMass * myHeight to be implicitly convertible to potential_energy. That's easy, if the multiplication operation returns me a suitcase full of unmarked joules (the unit by itself, with no semantic quantity attached). But then I would be able to write essentially mixed-mode things like myKineticEnergy += meters_per_s2(G) * myMass * myHeight or even myKineticEnergy += myPotentialEnergy * meters(1) / meters(1), or which seems dumb. This is similar to @burnpanck's "Trivial choice 1: Dimensional analysis only": it looks stronger on the surface, but as soon as you do any math, you're right back to weak dimension-only types. I have no idea how to get around this, other than to push the problem onto the programmer and say "well, use more helper functions" (Godbolt):

    struct potential_energy : quantity<joules> {};
    struct kinetic_energy : quantity<joules> {};
    potential_energy PEOfMassAtHeight(quantity_of<dim_mass> auto m, quantity_of<dim_length> auto h) {
        return meters_per_s2(9.8) * m * h;
    }
    potential_energy myPotentialEnergy = PEOfMassAtHeight(myMass, myHeight);  // OK
    kinetic_energy myKineticEnergy = PEOfMassAtHeight(myMass, myHeight);  // ill-formed, no implicit conversion

(quantity_of<dim_mass> auto is pretty gross, but I did that to myself by deliberately making kilograms a factory and not a type. I thought that was a good idea because "people shouldn't be storing raw kilograms in variables," but maybe for function parameters that turns out to be a good idea. Godbolt. Yeah, that looks fine, right?)

@mpusz
Copy link
Owner Author

mpusz commented Jun 15, 2023

Done in V2.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants