Logo

Blog


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
template<typename T>
class optional
{
public:
  optional() = default;

  1 Destruct T, if it is not trivially destructible
  ~optional() requires(not std::is_trivially_destructible_v<T>)
  {
    if(has_value) { value.as()->~T(); }
  }

  2 For trivially destructible types use the defaulted dtor
  ~optional() = default;

private:
  storage_t<T> value;  3 Storage for the wrapped value
  bool         has_value;
};

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
struct COMLike {
  ~COMLike() {}    1 Make it not default destructible
  void Release();  2 Release all data

  // Some data fields
};

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
template<typename T>
concept HasRelease = requires(T t)
{
  t.Release();
};

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
1 Only if not trivially destructible
~optional() requires(not std::is_trivially_destructible_v<T>)
{
  if(has_value) { value.as()->~T(); }
}

2 If not trivially destructible and has Release method
~optional() requires(not std::is_trivially_destructible_v<T> &&
                     HasRelease<T>)
{
  if(has_value) {
    value.as()->Release();
    value.as()->~T();
  }
}

3 For trivially destructible types use the defaulted dtor
~optional() = default;

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:

C++20 Concepts subsume.

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:

C++20 Concepts subsume for not trivially destructible.

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
template<typename T>
concept TriviallyDestructible = std::is_trivially_destructible_v<T>;

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
1 Only if not trivially destructible
~optional() requires(not TriviallyDestructible<T>);

2 If not trivially destructible and has Release method
~optional() requires(not TriviallyDestructible<T>) && HasRelease<T>;

3 For trivially destructible types use the defaulted dtor
~optional() = default;

With that change, we now have two concepts in place TriviallyDestructible and HasRelease. Mapping this to the former boolean algebra, we get the following:

C++20 Concepts subsume for trivially destructible.

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
template<typename T>
concept NotTriviallyDestructible = not std::is_trivially_destructible_v<T>;

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
1 Only if not trivially destructible
~optional() requires NotTriviallyDestructible<T>;

2 If not trivially destructible and has Release method
~optional() requires NotTriviallyDestructible<T>&& HasRelease<T>;

3 For trivially destructible types use the defaulted dtor
~optional() = default;

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

  1. 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.
  2. Concepts can subsume other concepts.
  3. To identify if two concepts are the same, the compiler verifies that both originate from the same source location.
  4. In Concepts terms an expression is a thing which defines a source location.
  5. 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 Twitter or via email. In case, you like a more detailed introduction into Concepts tell me about it.

Andreas