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
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.
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
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:
optional destructors are changed accordingly. There we replace
std::is_trivially_destructible_v with our shiny new concept
1 2 3 4 5 6 7 8
With that change, we now have two concepts in place
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.
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.
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.
- 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
!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 Twitter or via email. In case, you like a more detailed introduction into Concepts tell me about it.