Home > Software engineering >  C Concepts: exactly how strictly are the coditions in a 'require' clause enforced?
C Concepts: exactly how strictly are the coditions in a 'require' clause enforced?

Time:11-03

Here I detail a MWE of what seems to be a quirk of the requires clause used in a concept. What I want is a concept that indicates whether or not a certain function type is callable with a series of arguments. I realize that this is is provided by std::invocable, but what I have here will illustrate the point.

Consider the following concept:

template <typename func_t, typename... args_t>
concept callable = requires(const func_t& f, const args_t&... args) {f(args...);};

This is fairly straigtforward: if I have a func_t, can I call it with args_t...? By my unserstanding, the concept should evaluate to true provided that calling the function with the provided arguments is a valid operation, including conversions. For example, if I have a lambda:

auto func = [](const double& i) -> void {};

Then both of the following concepts evaluate to true:

callable<decltype(func), int>    //true
callable<decltype(func), double> //true

This is seemingly because there is a conversion from int to double. This is fine, as this is behaviour I want in the project that made me discover this issue.

Now, I would like to call my lambda with a type that is a bit more complicated, something like the following:

auto func = [](const type1_t<space1>& t1) -> int {return 1;};

Consider the following types:

enum space {space1,space2};

template <const space sp> struct type2_t{};

template <const space sp> struct type1_t
{
    type1_t(){}

    template <const space sp_r>
    type1_t(const type2_t<sp_r>& t2){}
};

Here we can convert type2_t to type1_t regardless of the template parameters, owing to the constructor template in type1_t. Under these conditions, the following concepts evaluate to true:

callable<decltype(func), type1_t<space1>> //true
callable<decltype(func), type2_t<space1>> //true
callable<decltype(func), type2_t<space2>> //true

Let's say I don't want any conversion between types that have different space parameters. There are a couple of ways to do this, but I will choose to use a requires clause on the type1_t constructor:

template <const space sp_r>
requires (sp_r == sp)
type1_t(const type2_t<sp_r>& t2)
{
    //all other code remains unchanged.
}

After this chance, I get the following evaluations:

callable<decltype(func), type1_t<space1>> //true
callable<decltype(func), type2_t<space1>> //true
callable<decltype(func), type2_t<space2>> //false

This is the behaviour I expect, as the code in the requires clase of the concept no longer compiles.

Now, let's say that I remove the requires clause in the constructor of type1_t, and that the constructor now calls a member function called dummy_func:

template <const space sp> struct type1_t
{
    type1_t(){}

    template <const space sp_r>
    void dummy_func(const type2_t<sp_r>& t2){}

    template <const space sp_r>
    type1_t(const type2_t<sp_r>& t2)
    {
        dummy_func(t2);
    }
};

The constructor remains virtually unchanged so the concepts all evaluate to true once again:

callable<decltype(func), type1_t<space1>> //true
callable<decltype(func), type2_t<space1>> //true
callable<decltype(func), type2_t<space2>> //true

The strange behaviour comes when we introduce a requires clause on dummy_func:

template <const space sp_r>
requires (sp_r == sp)
void dummy_func(const type2_t<sp_r>& t2){}

With this clause, I expect the following concept evaluations:

callable<decltype(func), type1_t<space1>> //true
callable<decltype(func), type2_t<space1>> //true
callable<decltype(func), type2_t<space2>> //false

However, when I compile with the new clause, I actually get:

callable<decltype(func), type1_t<space1>> //true
callable<decltype(func), type2_t<space1>> //true
callable<decltype(func), type2_t<space2>> //true

This is strange to me as the following will compile:

auto func = [](const type1_t<space1>& t1) -> int {return 1;};
func(type1_t<space1>());

but this will not compile:

func(type2_t<space2>());

To me, this is contradictory with the concept callable<decltype(func), type2_t<space2>> evaluating to true, as I am directly using the body of code within the requires clause.

What is the source of this contradiction? Why is the compiler not fully checking the validity of the code within the requires clause of the concept?

Appendix

Two disclaimers:

  1. I am aware that I should be using std::invocable. The above is for illustration only. Note that the same problem arises when I use std::invocable.

  2. I can fix the issue by placing the constraint on the constructor of type1_t, but this is undesireable in my project.

For the full code that shows the issue, please refer to the following:

#include <iostream>
#include <concepts>

enum space
{
    space1,
    space2
};

template <typename func_t, typename... args_t>
concept callable = requires(const func_t& f, const args_t&... args) {f(args...);};

template <const space sp> struct type2_t{};

template <const space sp> struct type1_t
{
    type1_t(){}

    template <const space sp_r>
    requires (sp_r == sp)
    void dummy_func(const type2_t<sp_r>& t2){}

    template <const space sp_r>
    type1_t(const type2_t<sp_r>& t2)
    {
        dummy_func(t2);
    }
};

int main(int argc, char** argv)
{
    auto func = [](const type1_t<space1>& t1) -> int {return 1;};
    std::cout << callable<decltype(func), type1_t<space1>> << std::endl; //true
    std::cout << callable<decltype(func), type2_t<space1>> << std::endl; //true
    std::cout << callable<decltype(func), type2_t<space2>> << std::endl; //true, should be false!!
}

Note that I am using g 11.3 with the -std=c 20 flag.

CodePudding user response:

Constraints on functions constrain the function signature; they have nothing to do with the body of a function. Constraints don't care if the body would fail to compile; they only care about the validity of the constraints applied to that function's signature. So if you want a function to be constrained, you must put those constraints in that function's signature.

And yes, that does recursively propagate up the call graph. That's just something you have to deal with. This is one of the reasons why bundling arbitrary expression constraints into named concepts is useful.

  • Related