C++ Pattern: Deriving From std::variant

I am a big fan of sum types for expressive programming. They provide an elegant way to encode mutually exclusive data types in a single field. While not provided by the language itself, the C++ standard library offers us std::variant. Since there is no language-level pattern matching construct, interacting with variants can be less than ergonomic. One way to mitigate this is inheriting from std::variant and creating useful domain-specific access methods. This article discusses a few different ways of deriving from std::variant that might be useful and/or interesting.

A Result Type

To start off we’ll create a ~15 line class derived from std::variant that fulfills the basics of a result type (something like C++23 std::expected but available in C++17 and above). This is a nice way to encapsulate success and failures types in a united interface. Implementation:

#include <variant>

template<typename T, typename Error>
class Result : public std::variant<T, Error> {
public:
  using std::variant<T, Error>::variant;
  using std::variant<T, Error>::operator=;
  
    Error* error() { return std::get_if<Error>(this); }
    const Error* error() const { return std::get_if<Error>(this); }
    T* value() { return std::get_if<T>(this); }
    const T* value() const { return std::get_if<T>(this); }
    T* operator->() { return value(); }
    const T* operator->() const { return value(); }
    operator bool() const { return error() == nullptr; }
};

We derive from std::variant to take advantage of the ergonomic constructors and assignment operators (meaning we don’t have to implement them for each type and each reference type; doing this correctly is tedious). After this, we add two convenient methods to access the underlying types (along with const overloads). And finally, define operator-> to allow access to the underlying success type value and operator bool to determine whether it holds the success type (along with const overloads).

To demonstrate basic usage we can create a small type hierarchy with success and failure types:

struct SuccessResult {
  int date;
  double time;
};
struct ErrorResult {
  std::string message;
};
using ProcessResult = Result<SuccessResult, ErrorResult>;

Then we create a function to demonstate it’s usage:

ProcessResult process(std::string_view input) {
  return ErrorResult{ "Not implemented" };
}

And finally we call the function and write some code that observes the return value:

const auto result = process("Hello");
if (!result) {
  std::cout << "Error: " << result.error()->message << std::endl;
} else {
  std::cout << "Date: " << result->date << " Time: " << result->time << std::endl;
}

Overall, this approach is highly ergonomic, concise and clear. The call site is easily understood since operator bool cleanly checks the status, operator -> eliminates unnecessary intermediate variables and method calls. The error() access method is also self-explanatory.

Data Or Pointer to Data

Another potentially useful class we can build is DataOrPointer. This is a class that either holds a type or a pointer to that type. Again we provide ergonomic access methods and operators:

template<typename T>
class DataOrPointer : public std::variant<T, T*> {
public:
  using std::variant<T, T*>::variant;
  using std::variant<T, T*>::operator=;
  operator const T&() const {
    if (auto value = std::get_if<T>(this); value) {
      return *value;
    }
    return *std::get<T*>(*this);
  }
};

You can use this if you want to provide a single return type to a function that takes a T as an argument and may or may not return a newly constructed T object after testing some conditions. If constructing T is expensive, this approach can be a clean way to achieve the objective. A real-world example of this could be normalizing two BigFloats to use the same exponent before operating on them. If the target exponent is equal to the current exponent, it would be a waste to build a new copy (In this case we can’t mutate the original numbers).

DataOrPointer<const UnsignedBigFloat> usingExponent(const UnsignedBigFloat& value, int64_t exponent)  {
    if (exponent == value._exponent) {
        return DataOrPointer<const UnsignedBigFloat>(&value);
     }

    auto copy = value;
    copy._mantissa.timesTenToThe(exponent - value._exponent);
    copy._exponent = exponent;
    return std::move(copy);
}

Multi-Type Reference

I will admit the following example is almost too esoteric to be useful, but I have actually reached for this once before.

The challenge: create a reference that can bind to one of multiple types, performance not being critical and reducing code duplication being the main objective.

Solution:

template<typename... Ts>
class MultiTypeReference : public std::variant<Ts*...> {
public:
  using std::variant<Ts*...>::variant;

  template<typename T>
  auto& operator=(T value) {
    std::visit([&](auto& pointer) { *pointer = value; }, *this);
    return *this;
  }

  template<typename T>
  operator T() {
    return std::visit([&](auto& pointer) { return T(*pointer); }, *this);
  }

Yes, this works. Yes, it’s weird. No, I don’t encourage you to use it. However, it is an interesting case study and hopefully gets you thinking about how to stretch the use of std::variant. Here’s how one would actually use it:

struct Data {
   bool useFieldA;
   int32_t A;
     int64_t B;
};

MultiTypeReference<int32_t, int64_t> getRelevantField(Data& data) {
    return data.useFieldA ?
          MultiTypeReference<int32_t, int64_t>(data.A) :
          MultiTypeReference<int32_t, int64_t>(data.B);
}

void assignOne(Data& one) {
  getRelevantField(one) = 1;
}

At the end of the day, yes, this simply hides the conditional access and assignment behind some abstractions. But in use, it behaves exactly how we want it: a reference to one of multiple types.

Epilogue

The reason I included the word ‘Pattern’ in the title is because these ideas can be extended to theoretically endless types and custom access methods.

For examples, you could build a result type with three different possible types and provide access methods for them (removing operator->). Or perhaps you provide a transform method that takes a functor and changes the value to hold a different type after transforming the current type. Or even just provide custom comparison operators (Which could be necessary for sorting different numeric types; imagine you want to sort a vector of regular int and BigInt references stored within a variant-type).

In review, the key tools to leverage are:

  1. Using the constructors and assignment operators provided by std::variant.
  2. Defining named access methods for different types.
  3. Providing an operator-> for a success or special type.
  4. Providing conversion operators for syntax-less unpacking.

I hope my discussion of these concepts was useful or at least interesting. Thanks for reading! Subscribe via RSS or LinkedIn.