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. Protocols are implemented as unary type traits. Here is an example of a protocol that is adapted from the Swift documentation:

namespace protocols {
namespace detail {
} // namespace detail
/*!
* \brief Has a name.
*
* Requires the class has these member functions:
* - name: Returns the name of the object as a std::string.
*/
template <typename ConformingType>
using Named = detail::is_name_callable_r<std::string, ConformingType>;
} // 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)) {}
};

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::conforms_to metafunction:

static_assert(tt::conforms_to_v<Person, protocols::Named>,
"The class does not conform to the protocol.");

Note that checking for protocol conformance is cheap, because the tt::conforms_to metafunction only checks if the class indicates it conforms to the protocol via the above inheritance. The rigorous test whether the class actually fullfills all of the protocol's requirements is deferred to its unit tests (see Protocol users: Testing protocol conformance). Therefore 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::conforms_to_v<NamedThing, protocols::Named>,
"NamedThing must be 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 also 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);
}

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: Testing protocol conformance

Any class that indicates it conforms to the protocol must test that it actually does using the test_protocol_conformance metafunction from tests/Unit/ProtocolTestHelpers.hpp:

static_assert(test_protocol_conformance<Person, protocols::Named>,
"Failed testing protocol conformance");

# Protocol authors: Protocols must be unary type traits

When you author a new protocol, keep in mind that protocols must be unary type traits. This means they take a single template parameter (typically named ConformingType) and inherit from std::true_type or std::false_type depending on whether the ConformingType fullfills the protocol's requirements. Make sure to implement the protocol in a SFINAE-friendly way. You may find the macros in Utilities/TypeTraits.hpp useful. For example, we use CREATE_IS_CALLABLE in the protocols above for testing the existence and return type of a member function.

Occasionally, you might be tempted to add additional template parameters to the protocol. In those situations, make the additional parameters part of your protocol instead. The reason for this guideline is that protocols will always be used as unary type traits when inheriting 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 check them.

For example, we could be tempted to follow this antipattern:

// Don't do this. Protocols should be _unary_ type traits.
template <typename ConformingType, typename NameType>
using NamedAntipattern =
// Check that the name function exists _and_ its return type
detail::is_name_callable_r<NameType, ConformingType>;

However, instead of making the protocol a non-unary template we should add a requirement to it:

namespace detail {
// Lazily evaluated so we can use ConformingType::NameType
template <typename ConformingType>
struct IsNameCallableWithType
: is_name_callable_r_t<typename ConformingType::NameType, ConformingType> {
};
} // namespace detail
template <typename ConformingType>
using NamedWithType =
// First check the class has a NameType, then use it to check the return
// type of the name function.
detail::IsNameCallableWithType<ConformingType>,

Classes would need to specify the additional 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::conforms_to_v<PersonWithNameType, protocols::NamedWithType>,
"The class does not conform to the protocol.");
static_assert(
cpp17::is_same_v<typename PersonWithNameType::NameType, std::string>,
"The NameType isn't a std::string!");

# Protocol authors: Testing a protocol

We are currently testing protocol conformance as part of our unit tests, so that the global tt::conforms_to convenience metafunction only needs to check if a type inherits off the protocol, but doesn't need to check the protocol's (possibly fairly expensive) implementation. This is primarily to keep compile times low, and may be reconsidered when transitioning to C++ "concepts". Full protocol conformance is tested in the test_protocol_conformance metafunction mentioned above.

To make sure their protocol functions correctly, protocol authors must test its implementation in a unit test (e.g. in a Test_Protocols.hpp):

static_assert(protocols::Named<Person>::value, "Failed testing the protocol");
static_assert(not protocols::Named<NotNamed>::value,
"Failed testing the protocol");

They should make sure to test the implementation with classes that conform to the protocol, and others that don't. This means the test will always include an example implementation of a class that conforms to the protocol, and the protocol author should add it 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).