A task executor is given tasks that it runs. A while ago I designed a small concept of a task executor that replaces dynamic polymorphism with static polymorphism. But while switching from dynamic to static I lost an aspect about the type of the task that is being passed to the executor: its shape.
The dynamic approach requires an interface that describes exactly how a task must look: what method is required and what’s that method’s signature. For the static approach, I had nothing but a compile-time error if the task does not have a required method. The error message is good, I’m OK with what I get. But I don’t have a definition of what my constraints are for the task. I don’t have a concept of my requirement.
Before C++20, things were verbose and somewhat complicated. There are some ways to write requirements using SFINAE. But I feel they are just for the compilation to fail if they are not met. As for a human to understand them, they sure need more than a glance. It feels like before understanding the requirements of a type, you first need to understand how those requirements are implemented.
I tried a few implementations myself, but I could not get them exactly as I would like them to be. I’m having in my mind the simplicity that C++20 has on the concepts topic and I wanted to be around it. So… why not give C++20 a try? I never wrote C++20 more than a few experimental lines, so I wanted to see how I could implement my need.
A stripped off executor just for the sake of the example, with a task to be executed, would be:
int executor(int input, auto&& task) { return task.execute(input); } struct Task { int execute(int input) { return input + 1; } }; int main() { executor(1, Task{}); }
The requirements that need to be implemented by the task are:
-
- It must be a type with a method named
execute
. - The method accepts an integer argument
- and returns an integer.
- It must be a type with a method named
And I need to define a C++20 concept that requires a type T
representing the task and an int
which will be the input: I call the required execute
method on the task
object, with the integer argument, and I verify that the return type is an integer.
template <typename T> concept Task = requires(T&& task, int input) { { task.execute(input) } -> std::same_as<int>; };
Then the executor
must use the concept:
int executor(int input, Task auto&& task) { return task.execute(input); } // or template<typename T> requires Task<T> int executor(int input, T&& task) { return task.execute(input); } // or with custom error message template<typename T> int executor(int input, T&& task) { static_assert(Task, "the executor requires a task implementing the Task concept"); return task.execute(input); }
For a task that has a run
method instead of execute
struct InvalidTask { int run(int input) { return input + 2; } };
the compilation error is (along with other details):
error : use of function ‘int execute(int, auto : 1 &&)[with auto:1 = InvalidTask]’ with unsatisfied constraints
This gives semantic and compile-time enforcement: I can understand what I need and the compiler will let me know if I don’t have it.