C++20 Concepts: Subsumption rules
In episode 231 of C++ Weekly Multiple Destructors in C++20?! How and Why Jason told us about an optional
like class with two destructors. Thanks to Concepts, this is possible in C++20. The final result is equivalent to the following code
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
We have two destructors. The first destructor 1 is for the case the wrapped type has a destructor and is not trivially destructible. The trailing requires-clause in 1 checks for that condition. The second destructor 2 automatically kicks in, if 1 is false. This also makes this case somewhat simple. There is no need to put the inverted condition into a requires-clause of 2. Usually, I'm a supporter of expressive code and, at this point, would point out that putting the inverted condition on 2 makes things clear. Not this time. You will understand why at the end.
A third destructor is needed
Sometimes we have classes that have a destructor but have a Release
method in addition. Some of you are programming for Windows; others may know this pattern from other applications. The goal is that such an object holds data which it does not own exclusively and should survive a destructor call. We have to release this data explicitly by calling Release
. Let's call this COMLike
and create a minimal version without data members of such an struct
:
1 2 3 4 5 6 |
|
1 is there to make the type non-trivially destructible. We assume that in a complete implementation, COMLike
has data members and needs a destructor. With 2 there is the Release
method. Again the implementation does not matter for the purpose of this post.
What does COMLike
imply for the current optional
implementation? Well, one way is to say that it is totally fine, as we need to call Release
like without having the object wrapped into an optional
. Another way, the one I will use for this post is to say that as optional
owns this object, it should also ensure that releasing the data as well. A third option is to add a NTTP to optional
like CallReleaseOnDelete
and make the behavior depend on that parameter. As said, I like to keep it simple and go with option two. The destructor of optional
should call Release
, if the type has such a method.
Checking whether a type has a particular method calls for Concepts, we saw this in last month's post C++20 Concepts: Testing constrained functions. The approach is to create a new concept, HasRelease
which checks whether a given type has a method Release
. For simplicity, we leave it with that and ignore checking whether it returns void
or whatever return type is required. Such a concept is quickly written:
1 2 3 4 5 |
|
With that additional concept in our toolbox, we can think about the next step. It is C++, so there are several ways to create the desired behavior, that for a non-trivially destructible type with a Release
method, this method is called, and then the destructor of the type itself is called. The way I will use is to add another destructor.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
As we can see, this third destructor 2 is more or less a copy of the first one 1, with an additional constraint, HasRelease
, and the call to Release
. You probably have heard about the least and most constraint rule. While evaluating Concepts, the compiler identifies the most constraint method and chooses this method out of the others. With the current approach, it is clear that 2 is the most constraint, followed by 1 and 3. To be more precise, in this case, it is only about 2 and 1 as COMLike
is not trivially destructible. Nice case closed.
But wait, as good as this seems, it does not compile! The compiler cannot identify which one is the most constraint method. We never really talked about how the compiler does this. This is where things get complicated, but then you are reading a post about C++, wouldn't you disappointed if things were so easy?
Most and least constraint evaluation
The basics are that the compiler uses boolean algebra to identify the most constraint method. For that, we transform &&
to ∧ and ||
to ∨. For two conditions a and b the following is true:
These rules say that (1) the first a
is eliminated or subsumed by the rest of the term. For (2) only a
survives, because as soon as a
is true the value of b
doesn't matter. Here b
is subsumed. Now, if we apply these rules to the constraints in the optional
destructors 1 and 2, we get the following:
This shows that term (2) is the most constraint, as it contains the condition of (1) but has an additional one. With that 2 is the most constraint destructor. But wait, we concluded before that we like 2 to be the one that should handle the COMLike
type. By knowing the rules a bit more, nothing changes. Correct, but the rules are necessary; we are getting there.
Concept subsumption rules
First and most important, subsumption rules apply to Concepts only! Only one concept can subsume another concept. In the example of optional
above, we used a type-trait std::is_trivially_destructible_v
as constraint. We need to pack this type-trait into a concept:
1 2 |
|
Equipped with TriviallyDestructible
the optional
destructors are changed accordingly. There we replace std::is_trivially_destructible_v
with our shiny new concept TriviallyDestructible
.
1 2 3 4 5 6 7 8 |
|
With that change, we now have two concepts in place TriviallyDestructible
and HasRelease
. Mapping this to the former boolean algebra, we get the following:
Excellent, we have Concepts in place, and one term subsumes the other. Our beloved compiler is so happy about us now... If the compiler would just stop telling us that the code we passed for compilation has an error. It is still not possible for the compiler to identify the most constraint destructor. Really!? Yes, sorry.
Stay positive with your concepts
There is another rule about Concepts and subsumption. It is the way the compiler identifies and treats an expression forming a constraint. Here, expression does not refer to the Standard term expression. It stands for the source location of an expression. Concepts are only equal if they originate from the same source location.
Well, wait, TriviallyDestructible
exists only once, so both uses clearly refer to the same source location. Yes, that statement is absolutely true. However, the question is what is an expression or more accurately what is part of an expression? Those of you who looked closely at the last optional
version or at the boolean algebra noticed that not TriviallyDestructible<T>
is surrounded with parentheses. This has nothing to do with my style preference, this is the required syntax. The parentheses give us a glue. This entire (not TriviallyDestructible<T>)
is a single expression! Now, I think we can agree that the expression at 1 clearly has a different source location as 2. That is why the compiler still complains and is unable to identify the most constraint method.
The rule here is: stay positive with your concepts! Try to avoid negation of concepts, if you like to use subsumption rules. This is somewhat incompatible with the rule to keep the number of concepts low. On the other hand, may be, if we always assume that for a concept both forms exist, the negated one is prefixed with Not
we are still good with remembering only the positive concepts and can easily build the negative ones.
In code we need to make TriviallyDestructible
into the negated version NotTriviallyDestructible
and negate std::is_trivially_destructible_v
inside the concept definition.
1 2 |
|
The next step I think is obvious, we need to use NotTriviallyDestructible
and replace TriviallyDestructible
with it.
1 2 3 4 5 6 7 8 |
|
This code now compiles and does what we want. ´optional
now calls 2 for types which are non-trivially destructible and have a Release
method. Destructor 1 is picked for types which are non-trivially destructible but have no Release
method. And lastly objects which are trivially destructible call destructor 3 which is kindly provided by the compiler.
There is one more case, a type with Release
but which is trivially destructible. I leave updating the example to you. By now you know all the rules to do it.
Summary
- In requires-clauses we only need to specify a minimum of constraints. There is no need to add the negated constraints to other methods that should not match.
- Concepts can subsume other concepts.
- To identify if two concepts are the same, the compiler verifies that both originate from the same source location.
- In Concepts terms an expression is a thing which defines a source location.
- Parentheses and
not
/!
are part of an expression. They always lead to the definition of a new expression as they always have different source locations. This breaks subsumption
If you have other techniques or feedback, please reach out to me on X or via email. In case, you like a more detailed introduction into Concepts tell me about it.
Andreas