Skip to Content
Course content

240: Writing Custom Concepts

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've been working on a logging utility for a project lately, and I ran into a classic C++ frustration. I wanted a generic function that could take any "Entity" object—whether it's a User, a Transaction, or a SystemLog—and print it to the console. Naturally, I started with a template.

template <typename T>
void log_entity(const T& entity) {
    std::cout << "Log Entry: " << entity << "\n";
}

Dealing with the Template Wall

This looks fine on paper. But the second I tried to pass a std::vector<int> into log_entity, the compiler absolutely lost its mind. I got a 40-line error message essentially telling me that std::ostream doesn't know how to handle a vector.

The problem is that T is too generic. I don't want any type; I want a type that is actually printable. Now, I could use std::is_convertible or some SFINAE magic, but that's the old way. Since we're in C++20, I want the compiler to tell me "Hey, this type doesn't meet my requirements" before it even tries to instantiate the function body.

Drafting a Basic Requirement

I'll start by defining a concept. I want to ensure that the expression std::cout << value is valid. I'll call this Printable. Here is my first attempt:

template <typename T>
concept Printable = requires(T a) {
    { std::cout << a };
};

Wait, that didn't compile. The compiler is complaining that the expression { std::cout << a } doesn't return a type that's being checked. In a requires expression, if I wrap something in curly braces, I'm telling C++: "This expression must be valid, and I might care about its return type." Since I just care that it works, I can actually just list the expression without the braces, or I can specify the return type. Let's go with the simpler version:

template <typename T>
concept Printable = requires(T a) {
    std::cout << a;
};

Now I can apply this to my function. Instead of typename T, I'll use Printable auto. It's much cleaner.

void log_entity(Printable auto const& entity) {
    std::cout << "Log Entry: " << entity << "\n";
}

Now, when I pass that std::vector in again, the error is a single line: constraints not satisfied. Much better. But I realized I need more. A "Loggable Entity" in my system shouldn't just be printable; it should also have a get_id() method that returns something convertable to a string.

Layering Constraints

This is where custom concepts get powerful. I can combine requirements. I want my Loggable concept to require the Printable concept and a specific member function.

template <typename T>
concept Loggable = Printable<T> && requires(T a) {
    { a.get_id() } -> std::convertible_to<std::string>;
};

Look at that last line. I'm using a compound requirement. I'm not just saying a.get_id() must exist; I'm saying the result of that call must be convertible to a std::string. If I have a class that returns an int for the ID, this concept will fail unless I provide a way to make that int a string.

Putting it to the Test

Let's see if this actually holds up. I'll create a User class and a BrokenEntity class to see the difference.

struct User {
    std::string get_id() const { return "user_123"; }
    friend std::ostream& operator<<(std::ostream& os, const User& u) {
        return os << "User(123)";
    }
};

struct BrokenEntity {
    // Missing get_id()
    friend std::ostream& operator<<(std::ostream& os, const BrokenEntity& b) {
        return os << "Broken";
    }
};

void log_entity(Loggable auto const& entity) {
    std::cout << "[" << entity.get_id() << "] " << entity << "\n";
}

If I call log_entity(User{}), it works perfectly. If I call log_entity(BrokenEntity{}), the compiler stops me immediately. It tells me BrokenEntity doesn't satisfy Loggable because it doesn't have a get_id() method. I've effectively moved the "debugging" phase from runtime (or 100-line template errors) to the very moment I wrote the function call.




📋 Practical Task

Implementing a "Numeric Range" Concept

You are building a statistics library. You need a function called calculate_variance that only works with types that behave like numbers. Specifically, your custom concept NumericRange must ensure that the type T satisfies the following:

  • It must support the addition operator (+).
  • It must support the multiplication operator (*).
  • It must be std::equality_comparable (you can use the standard concept for this).
  • It must have a member function to_double() that returns a double.

Your Task:

  1. Define the NumericRange concept using a requires expression.
  2. Create a struct DataPoint that satisfies this concept.
  3. Create a struct InvalidPoint that fails the concept (e.g., missing the to_double() method).
  4. Write a function void print_variance(NumericRange auto val) that simply calls val.to_double() and prints it.
  5. Ensure your code compiles when calling print_variance with a DataPoint, and identify why it would fail with InvalidPoint.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.