Type Traits

Author: thothonegan
Tags: development c++

Been working on some stuff involving c++'s type traits, so lets talk about them! They're kindof esoteric, but can help save a lot of debugging time and make your code harder to misuse.

Type Traits

So what are type traits? Essentially they're a collection of templates which are designed to tell you information about a type. For example, if you want to know if a type is an integral type:

std::is_integral::value; // true, int is an integer
std::is_integral::value; // false, string is not an integer

Theirs quite a list of different builtin traits. Some of the more useful ones allow you to tell if a type is Plain-Old Data (POD), can be constructed/copy constructed/moved, and if its a subclass of another class. So in what cases would this be useful? ``

Usage - Compile time assertions

You're creating a simple class like a Vector3D. While adding methods and stuff, you want to make sure you stay trivially copyable, which allows you to std::memcpy it around like a bag of bytes. But how can you guarantee someone doesn't sneak in a virtual method, or other things that break copying? Easy:

class Vector3D { /*...*/ };
static_assert (std::is_trivially_copyable::value, "Vector3D must be trivially copyable");

Now as part of compiling, it'll verify that it has that property.

Usage - SFINAE

SFINAE stands for Subsitution Failure Is Not An Error. When using function templates, it basically means that if it cannot match an instance: it will ignore it instead of creating a compile error. For example (from WolfCPlusPlus/TypeTraits.hpp where I add my own traits):

template 
struct InsertionOperatorResultType
{
    // if you can stream the source to the sink, return the type it returns
    // SFINAE - if its not streamable, its not an error - keep looking
    template 
    static auto check (LocalSink& sink, LocalSource& source) -> decltype(sink << source);
    
    // otherwise, return a failure structure
    static SubstituteFailure check(...);
    
    public:
        // choose the best check()
        using type = decltype(check(std::declval<Sink&>(), std::declval()));
};

So what does this have to do with type traits? Enter std::enable_if. std::enable_if lets you disable or enable a function based on SFINAE and type traits. For example, I have a resource manager which lets you load different resource types from disk such as images or text files. Every file it loads is required to be a subclass of Resource. However, you want it to return the type you plan on using it for, and not just the boring Resource class. So you create your template function:

template 
ResourceType* resourceWithPathID (PathID pathID);

Nice and easy right? Except, what happens if you do something like resourceWithPathID(path)? At best, it'll explode somewhere deep in the templates when the type doesnt match. At worst, it'll 'work' for some value of 'work' and you'll just get garbage back. So lets prevent it from taking any class that isn't a resource.

template ::value // ResourceType must be a subclass of Resource
    >::type* = nullptr
>
ResourceType* resourceWithPathID (PathID pathID);

If you try to pass it int now, it'll fail by pointing at the enable_if (see below for example output). There is a few other ways to use enable_if such as using it as the return type instead of a template parameter, but I'm a fan of this way because it keeps the checks seperate from the actual types. Still doesn't have the best error message, but its the best we can do, right?

C++TS - Concepts (Experimental)

One of the many improvements in progress for C++ is called Concepts. Concepts has been around a long time (pre C++11) and has changed a lot over time, but at the moment its still a seperate technical spec and not part of any main C++ standard or compiler (you can use GCC7 with -std=c++17 -fconcepts to try it experimentally). Concepts is a way to set requirements for a type directly, allowing for things such as better error messages and more flexibility then type_traits. I'm not going to go too deep into them here, but lets implement the same function with concepts instead of enable_if. Note that while compiler support for concepts exists, the STL hasn't been updated, so we have to define any concepts we use.

template 
concept bool IsBaseOf = std::is_base_of::value; // convert the type_trait into an equivilant concept

template  // no enable_if needed
requires IsBaseOf // instead, just a straightforward 'requires' line
ResourceType* resourceWithPathID (PathID pathID);

So its essentially the same, saved a little typing. Whats the big deal? Here's the error for enable_if if it fails on GCC7.

: In function 'Resource* callFail()':
:27:37: error: no matching function for call to 'resourceWithPathID(int)'
{ return resourceWithPathID (10); }
^
:21:15: note: candidate: template::value>::type*  > ResourceType* resourceWithPathID(PathID)
ResourceType* resourceWithPathID (PathID pathID);
^~~~~~~~~~~~~~~~~~
:21:15: note:   template argument deduction/substitution failed:
:18:20: error: no type named 'type' in 'struct std::enable_if'
>::type* = nullptr
^~~~~~~
:18:20: note: invalid template non-type parameter

Remember the failure is because we didnt pass a correct type to the function. The closest it gets is pointing to the enable_if, without any of idea of whats wrong with it. Here's the same error with the concepts code.

: In function 'Resource* callFail()':
:27:37: error: cannot call function 'ResourceType* resourceWithPathID(PathID) [with ResourceType = int; PathID = int]'
{ return resourceWithPathID (10); }
^
:21:15: note:   constraints not satisfied
ResourceType* resourceWithPathID (PathID pathID);
^~~~~~~~~~~~~~~~~~
:6:14: note: within 'template concept const bool IsBaseOf [with Parent = Resource; Child = int]'
concept bool IsBaseOf = std::is_base_of::value;
^~~~~~~~
:6:14: note: 'std::is_base_of::value' evaluated to false

Note that it pointed to the constraints not being satified, pointed exactly to the concept which failed, and even pointed out that is_base_of was the exact issue. And since we dont have enable_if as part of the "type", even the name of the function is nicer. Much better!

Concepts are still a bit of a ways off, but type_traits are usable today. They allow you to verify that types have specific properties, and even change your function behavior based on properties of the types (e.g. a stream operator which acts differently if a child type has its own stream operator, or a container which will ignore constructors if the type is trivially copyable).

Example Source

Some simple source used to test these examples. Try playing around with it on Godbolt using the "x86_64 gcc 7 (snapshot)" compiler with the extra flags "-std=c++17 -fconcepts".

#include 

// if 0, use type_traits : if 1, use concepts
#define USE_CONCEPTS 0

// redefine the type_trait as a concept
#if USE_CONCEPTS
template 
concept bool IsBaseOf = std::is_base_of::value;
#endif

// dummy types
class Resource {};
using PathID = int;

#if USE_CONCEPTS
template 
requires IsBaseOf
#else
template ::value // ResourceType must be a subclass of Resource
        >::type* = nullptr
    >
#endif
ResourceType* resourceWithPathID (PathID pathID);

Resource* callSuccess ()
{ return resourceWithPathID (10); }

Resource* callFail ()
{ return resourceWithPathID (10); }