SpECTRE  v2024.03.19
Protocols

Overview of protocols

Protocols are a concept we use in SpECTRE to define metaprogramming interfaces. A variation of this concept is built into many languages, so this is a quote from the Swift documentation:

‍A protocol defines a blueprint of methods, properties, and other requirements that suit a particular task or piece of functionality. The protocol can then be adopted by a class, structure, or enumeration to provide an actual implementation of those requirements. Any type that satisfies the requirements of a protocol is said to conform to that protocol.

You should define a protocol when you need a template parameter to conform to an interface. Here is an example of a protocol that is adapted from the Swift documentation:

namespace protocols {
/*
* \brief Has a name.
*
* Requires the class has these member functions:
* - `name`: Returns the name of the object as a `std::string`.
*/
struct Named {
template <typename ConformingType>
struct test {
// Try calling the `ConformingType::name` member function
using name_return_type = decltype(std::declval<ConformingType>().name());
// Check the return type of the `name` member function
static_assert(std::is_same_v<name_return_type, std::string>,
"The 'name' function must return a 'std::string'.");
};
};
} // namespace protocols

The protocol defines an interface that any type that adopts it must implement. For example, the following class conforms to the protocol we just defined:

class Person : public tt::ConformsTo<protocols::Named> {
public:
// Function required to conform to the protocol
std::string name() const { return first_name_ + " " + last_name_; }
private:
// Implementation details of the class that are irrelevant to the protocol
std::string first_name_;
std::string last_name_;
public:
Person(std::string first_name, std::string last_name)
: first_name_(std::move(first_name)), last_name_(std::move(last_name)) {}
};
std::string name()
Return the result of the name() member of a class. If a class doesn't have a name() member,...
Definition: PrettyType.hpp:733
Indicate a class conforms to the Protocol.
Definition: ProtocolHelpers.hpp:22

The class indicates it conforms to the protocol by (publicly) inheriting from tt::ConformsTo<TheProtocol>.

Once you have defined a protocol, you can check if a class conforms to it using the tt::assert_conforms_to or tt::conforms_to metafunctions:

// SFINAE-friendly version:
constexpr bool person_class_is_named =
tt::conforms_to_v<Person, protocols::Named>;
// Assert-friendly version with more diagnostics:
static_assert(tt::assert_conforms_to_v<Person, protocols::Named>);

Note that checking for protocol conformance is cheap, so you may freely use protocol conformance checks in your code.

This is how you can write code that relies on the interface defined by the protocol:

template <typename NamedThing>
std::string greet(const NamedThing& named_thing) {
// Make sure the template parameter conforms to the protocol
static_assert(tt::assert_conforms_to_v<NamedThing, protocols::Named>);
// Now we can rely on the interface that the protocol defines
return "Hello, " + named_thing.name() + "!";
}

Checking for protocol conformance here makes it clear that we are expecting a template parameter that exposes the particular interface we have defined in the protocol. Therefore, the author of the protocol and of the code that uses it has explicitly defined (and documented!) the interface they expect. And the developer who consumes the protocol by writing classes that conform to it knows exactly what needs to be implemented.

Note that the tt::conforms_to metafunction is SFINAE-friendly, so you can use it like this:

template <typename Thing,
std::string greet_anything(const Thing& /*anything*/) {
return "Hello!";
}
template <typename NamedThing,
std::string greet_anything(const NamedThing& named_thing) {
return greet(named_thing);
}
typename Requires_detail::requires_impl< B >::template_error_type_failed_to_meet_requirements_on_template_parameters Requires
Express requirements on the template parameters of a function or class, replaces std::enable_if_t
Definition: Requires.hpp:67

The tt::conforms_to metafunction only checks if the class indicates it conforms to the protocol. Where SFINAE-friendliness is not necessary prefer the tt::assert_conforms_to metafunction that triggers static asserts with diagnostic messages to understand why the class does not conform to the protocol.

We typically define protocols in a file named Protocols.hpp and within a protocols namespace, similar to how we write tags in a Tags.hpp file and within a Tags namespace. The file should be placed in the directory associated with the code that depends on classes conforming to the protocols. For example, the protocol Named in the example above would be placed in directory that also has the greet function.

Protocol users: Conforming to a protocol

To indicate a class conforms to a protocol it (publicly) inherits from tt::ConformsTo<TheProtocol>. The class must fulfill all requirements defined by the protocol. The requirements are listed in the protocol's documentation.

Any class that indicates it conforms to a protocol must have a unit test to check that it actually does. You can use the tt::assert_conforms_to metafunction for the test:

static_assert(tt::assert_conforms_to_v<Person, protocols::Named>);

Protocol authors: Writing a protocol

To author a new protocol you implement a class that provides a test metafunction and detailed documentation. The test metafunction takes a single template parameter (typically named ConformingType) and checks that it conforms to the requirements laid out in the protocol's documentation. Its purpose is to provide diagnostic messages as compiler errors to understand why a type fails to conform to the protocol. You can use static_asserts or trigger standard compiler errors where appropriate. See the protocols defined above for examples.

Occasionally, you might be tempted to add template parameters to a protocol. In those situations, add requirements to the protocol instead and retrieve the parameters from the conforming class. The reason for this guideline is that conforming classes will always inherit from tt::ConformsTo<Protocol>. Therefore, any template parameters of the protocol must also be template parameters of their conforming classes, which means the protocol can just require and retrieve them. For example, we could be tempted to follow this antipattern:

// Don't do this. Protocols should not have template parameters.
template <typename NameType>
struct NamedAntipattern {
template <typename ConformingType>
struct test {
// Check that the `name` function exists _and_ its return type
using name_return_type = decltype(std::declval<ConformingType>().name());
static_assert(std::is_same_v<name_return_type, NameType>,
"The 'name' function must return a 'NameType'.");
};
};

However, instead of adding template parameters to the protocol we should add a requirement to it:

// Instead, do this.
struct NamedWithType {
template <typename ConformingType>
struct test {
// Use the `ConformingType::NameType` to check the return type of the `name`
// function.
using name_type = typename ConformingType::NameType;
using name_return_type = decltype(std::declval<ConformingType>().name());
static_assert(std::is_same_v<name_return_type, name_type>,
"The 'name' function must return a 'NameType'.");
};
};

Classes would need to specify the template parameters for any protocols they conform to anyway, if the protocols had any. So they might as well expose them:

struct PersonWithNameType : tt::ConformsTo<protocols::NamedWithType> {
using NameType = std::string;
std::string name() const;
};

This pattern also allows us to check for protocol conformance first and then add further checks about the types if we wanted to:

static_assert(
tt::assert_conforms_to_v<PersonWithNameType, protocols::NamedWithType>);
static_assert(
std::is_same_v<typename PersonWithNameType::NameType, std::string>,
"The `NameType` isn't a `std::string`!");

Protocol authors: Testing a protocol

Protocol authors should provide a unit test for their protocol that includes an example implementation of a class that conforms to it. The protocol author should add this example to the documentation of the protocol through a Doxygen snippet. This gives users a convenient way to see how the author intends their interface to be implemented.

Protocols and C++20 "Constraints and concepts"

A feature related to protocols is in C++20 and goes under the name of constraints and concepts. Every protocol defines a concept, but it defers checking its requirements to the unit tests to save compile time. In other words, protocols provide a way to indicate that a class fulfills a set of requirements, whereas C++20 constraints provide a way to check that a class fulfills a set of requirements. Therefore, the two features complement each other. Once C++20 becomes available in SpECTRE we can either gradually convert our protocols to concepts and use them as constraints directly if we find the impact on compile time negligible, or we can add a concept that checks protocol conformance the same way that tt::conforms_to_v currently does (i.e. by checking inheritance).