Implementing Result cleanly and efficently
C++ can represent a Result type just as cleanly and efficiently as haskell. Like Haskell, C++ has true sum types, and we can encapsulate the entirety of their functionality with a tagged union. In addition, by taking advantage of implicit construction, we can represent Success and Failure as types instead of static member functions (this makes things a lot cleaner).
Defining Success and Failure
These are really simple. They're just wrapper classes, so we can implement them as aggregates. In addition, using C++17's template deduction guides, we won't have to specify the template parameters for Failure and Success. Instead, we'll just be able to write Success{10}, or Failure{"Bad arg"}.
template <class F>
class Failure {
public:
F value;
};
template<class F>
Failure(F) -> Failure<F>;
template <class S>
class Success {
public:
S value;
// This allows chaining from an initial Success
template<class Fun>
auto operator>>(Fun&& func) const {
return func(value);
}
};
template <class S>
Success(S) -> Success<S>;
Defining Result
Result is a sum type. That means it can be either a success or a failure, but not both. We can represent this with a union, which we'll tag with a was_success bool.
template < class S, class F>
class Result {
union {
Success<S> success;
Failure<F> failure;
};
bool was_success = false; // We set this just to ensure it's in a well-defined state
public:
// Result overloads 1 through 4
Result(Success<S> const& s) : success(s), was_success(true) {}
Result(Failure<F> const& f) : failure(f), was_success(false) {}
Result(Success<S>&& s) : success(std::move(s)), was_success(true) {}
Result(Failure<F>&& f) : failure(std::move(f)), was_success(false) {}
// Result overloads 5 through 8
template<class S2>
Result(Success<S2> const& s) : success{S(s.value)}, was_success(true) {}
template<class F2>
Result(Failure<F2> const& f) : failure{F(f.value)}, was_success(false) {}
template<class S2>
Result(Success<S2>&& s) : success{S(std::move(s.value))}, was_success(true) {}
template<class F2>
Result(Failure<F2>&& f) : failure{F(std::move(f.value))}, was_success(false) {}
// Result overloads 9 through 10
Result(Result const&) = default;
Result(Result&&) = default;
template<class S2>
Result<S2, F> operator&&(Result<S2, F> const& res) {
if(was_success) {
return res;
} else {
return Failure{failure};
}
}
template<class Fun, class Ret = decltype(valueOf<Fun>()(success.value))>
auto operator>>(Fun&& func) const
-> Ret
{
if(was_success) {
return func(success.value);
} else {
return failure;
}
}
~Result() {
if(was_success) {
success.~Success<S>();
} else {
failure.~Failure<F>();
}
}
};
Explaining Result(...)
A result is either constructed from a success or a failure.
- Overloads 1 through 4 just handle the basic copy and move construction from
Success and Failure objects;
- Overloads 5 trhough 8 handle the case where we want to do an implicit conversion (as in the case of a string literal to a
std::string.
- Overloads 9 and 10 handle move and copy construction of
Result.
Explaining operator>>
This is pretty similar to your implementation of operator>>=, and i'll explain the reasoning behind my changes.
template<class Fun, class Ret = decltype(valueOf<Fun>()(success.value))>
auto operator>>(Fun&& func) const
-> Ret
{
if(was_success) {
return func(success.value);
} else {
return failure;
}
}
Why not use std::function? std::function is a type-erasing wrapper. That means that it uses virtual function calls under the hood, which slow things down. By using an unconstrained template, we make it much easier for the compiler to optimize stuff.
Why use >> instead of >>=? I used >> because >>= has weird behavior since it's an assignment operator. The statement a >>= b >>= c is actually a >>= (b >>= c), which is not what we intended.
What does class Ret = decltype(valueOf<Fun>()(success.value)) do? That defines a template parameter which is defaulted to the return type of the function you pass. This enables us to avoid using std::function while also allowing us to use lambdas.
Explaining ~Result()
Because Result contains a union, we have to manually specify how to destruct it. (Classes containing Result won't have to do this - once we specify it inside Result, everything behaves like normal). This is pretty straight-forward. If it contains the Success object, we destroy that one. Otherwise, we destroy the failure one.
// Result class
~Result() {
if(was_success) {
success.~Success<S>();
} else {
failure.~Failure<F>();
}
}
Updating your example for my implementation
Now that we've written a Result class, we can update your definitions of triple and main.
New definition of triple
Pretty straight-forward; we just replaced your success and failure functions with the Success and Failure types.
auto triple(int val) -> Result<int, std::string>
{
if (val < 100) {
return Success{val * 3};
} else {
return Failure{"can't go over 100"};
}
}
New definition of main
I added a print function so we can actually see some output. It's just a lambda. There are two computations done, one for ans and the other for ans2. The one for ans prints 18, because triple doesn't push the number over 100, but the one for ans2 doesn't print anything because it results in a failure.
int main(int argc, char* argv[])
{
auto print = [](auto value) -> Result<decltype(value), std::string> {
std::cout << "Obtained value: " << value << '\n';
return Success{value};
};
auto ans = Success{2} >> triple >> triple >> print;
auto ans2 = Success{2} >> triple >> triple >> triple >> triple >> triple >> print;
}