-
Notifications
You must be signed in to change notification settings - Fork 86
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
Comments
Is it better to be on the safe sound and disallow non-trivial conversions? |
@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:
In case when some combinations result in a lack of interconvertibility (but there is at least one valid combination) a Otherwise, the quantities should not be interconvertible. What do you think about that? |
Is that possible without using |
I hope so :-) I am going to compare 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 |
Maybe I'm misunderstanding something. Let's consider the given example:
A user can add a new quantity of dimension equal to those of |
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 So to make this rule happy two quantities of dimension length and two quantities of dimension time should be interconvertible. So
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. |
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 Given the constraint that we want to make Let us have a look at a quantity expression
|
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. |
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 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 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 |
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 onlyEvery quantity is equivalent/interconvertible with every other quantity of the same dimension. This is what most units libraries do. But it makes Trivial choice 2: "Extended dimensional analysis" onlyWe 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 Non-trivial choicesInstead, 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:
Consistency with quantity calculus leads to the following results for the examples provided in the first post:
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. |
An area has dimension L². Both 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 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.
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 Let me now discuss why I think that contravariance is in fact intuitively correct for the quantity hierarchy. Consider an expression That the converse does not lead to the expected result can be illustrated with the |
@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 However, ISO 80000 explicitly states in multiple places:
However, it also states:
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. 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 With the rules you described, it seems that 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.
Not necessary. Even in the C++ language, in most cases, we support only one implicit conversion step at a time. For example, if class
Sure, I am open to it. However, please keep the above
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.
I am not sure why they should be interconvertible as
Does it mean that energy and moment of force are equivalent as well? |
void boo(quantity<isq::height[m]>);
boo(42 * m); This is what I'm worried about. Conversion from length to height should be |
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. |
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:
Conversions: 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 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. |
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? |
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. |
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 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 |
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.
Thanks! I tried to state such rules in #426, but maybe we can find something even better. |
Ok, let me recap/restate what we believe to be true and the goal that we want to achieve. Assumptions:
For comparison/addition:
For conversion:
Do we all agree on that? |
I agree with all of the above :-)
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.
By "type safety" I mean that we should not be allowed to call |
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. |
Also, please clarify what your feeling towards |
This is my understanding
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. |
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 |
I agree, too.
See #405 (comment). |
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". |
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. |
This is my intent. I will write about it soon below 😉 |
Does it make sense to call |
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
Quantities of a specific kind
I played with many ideas in the code and this is what I ended up with:
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:
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 widthquantity_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 lengthquantity<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, 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); |
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. |
The first line of 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. If we were to write The same ambiguity might also arise with the first code line but there it is less problematic. |
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 Taking a closer look at your comment
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 |
Anyway, it will be a quantity of auto q2 = isq::width(42 * m) + isq::length(123 * m);
quantity<isq::width> q3 = q2; // does not compile
The above is incorrect. The |
You are right.
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. |
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. |
Yes, you caught my on my imprecise terminology here and the 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:
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++ ;-) |
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.
There is also an interesting quote about units:
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 inline constexpr struct hertz : named_unit<"Hz", 1 / second, kind_of<isq::frequency>> {} hertz; |
Yes, we are missing such a level of safety. I am not sure if and how to support that. |
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 |
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
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. |
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! |
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 |
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. |
Wouldn’t then most programmers pick the versions with only |
I think the intent was that the |
Ok, that makes sense then! |
FWIW, I find the code snippets hard to understand for two reasons (besides that I don't know this library at all ;)) —
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 @mpusz writes:
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
strikes me as just as unacceptably non-type-safe as
(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
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:
Notice that 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 |
Yes, we do have
Actually, not 😉 Both So
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...
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
Well, this was a simplification, and that is why I did not provide |
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:
Now I am going to try the contravariant approach... |
Regarding the contravariant approach. Even though |
@mpusz wrote:
FWIW, in my mental model / toy implementation, that looks isomorphic to the gift-wrap example:
where each strong type is castable to any other strong type with the same dimension/kind, but never implicitly convertible.
Here's the bigger problem I'm just realizing now: My first snippet above wants
( |
Done in V2. |
I just added a new discussion thread (#426) about the V2 design rules. As stated in TL;DR, the main idea is to:
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?
length * length
andarea
width * height
andarea
width
andheight
are interconvertible withlength
so this probably should be true as wellpath_length * distance
andpow<2>(path_length)
width * distance
andpath_length * width
distance
is interconvertible withpath_length
and not withwidth
altitude * distance
andpath_length * height
altitude
is interconvertible withheight
, anddistance
is interconvertible withpath_length
width * length
andlength * height
width
andheight
are interconvertible withlength
width
is not interconvertible withheight
length * distance
andpath_length * altitude
distance
is interconvertible withpath_length
andlength
is interconvertible withaltitude
path_length
is interconvertible withlength
and thendistance
is NOT interconvertible withaltitude
The text was updated successfully, but these errors were encountered: