1 Overview
1.1 Monadic Interface
In functional programming languages, the Monadic interface is a fundamental concept or mechanism for abstracting computational logic, expressing side effects, and controlling flow. This is because functions cannot produce side effects, such as IO, state modification, or throwing exceptions. However, in certain cases, such as reading and writing files, error handling, maintaining mutable state, or even using random numbers, functions that carry “context” or have “side effects” are needed. The monadic interface is typically used to express such context-carrying computations.
The monadic interface primarily addresses two issues: how to place a regular value into a computational context, and how to combine two context-carrying functions. With the evolution of the C++ standard, the capabilities of generics have been continuously enhanced, making the implementation of these two points no longer an unattainable goal. The following table provides a simple summary:
| Requirement |
C++ Corresponding Concept |
| Type Constructor |
C++ Class Template |
|
>>= Combination Operation |
std::bind or Chained Calls |
| Function |
std::function or Lambda |
|
do Syntax Sugar (Haskell) |
C++ Style Chained Calls |
For specific semantic elements, C++ also has some corresponding constructs. For example, the Maybe type in Haskell or Rust corresponds to std::optional<T> in C++. Similarly, Haskell’s Either e corresponds to std::expected<T, E> in C++ 23.
1.2 Intuitive Experience with std::optional
C++ 23’s std::optional already supports the monadic interface, making it a very natural monadic container. We can understand this intuitively through a simple example:
std::optional<int> my_div(int x, int y) { if (y == 0) return std::nullopt; return x / y;}int main() { auto result = std::optional(100) .and_then([](int x) { return my_div(x, 5); }) .and_then([](int x) { return my_div(x, 2); }); if (result) std::cout << "Result = " << *result << "\n"; else std::cout << "Division failed\n";}
The std::optional container encapsulates a context that expresses “value/presence” using the and_then() chained call to implement function composition. std::optional also provides or_else(), which can combine error handling into the context function. Additionally, it offers transform(), corresponding to Haskell or Rust’s fmap, for operations on the value within the container.
2 Implementation of Monadic Containers
2.1 Minimal Monadic Interface
To implement a minimal Monadic interface, that is, to make Monadic<T> a monadic container, it must include at least two types of special functions:
-
One type of function that elevates any value of type T to the type Monadic<T>, i.e., implements the conversion from T to Monadic<T>;
-
The other type is the bind function, which is responsible for applying a function that implements the conversion from T to Monadic<T’> to a Monadic<T>, modifying the value wrapped in Monadic<T>. The result is equivalent to: Monadic<T> -> (T -> Monadic<T’>) -> Monadic<T’> (the transformation in the parentheses is determined by the bind function).
Both types of functions can have multiple instances, but at least one of each must exist. From the perspective of C++ implementation, the first function is relatively simple, whether designed as a global function or a static member function of a class, it is easy to implement the conversion from T to Monadic<T>, for example, using a global function:
template<typename T>Monadic<T> ConstructM(T value);
The second function requires some type deduction from the compiler, as it needs to support chained binding, so the return type of the binding function must be deduced, which requires some tricks. In summary, a minimal Monadic<T> container should look like this:
template<typename T>class Monadic {public: // Place value into Monadic container static Monadic<T> val(T value); // Implement chained binding template<typename F> auto bind(F&& f) -> decltype(f(std::declval<T>()));};
2.2 A Complete Example
We know that Rust has a Result type, and we can mimic that by implementing a Result in C++. Result represents the outcome of an operation, which can either be a value of type T or an error of type E, represented by two template parameters.
template<typename T, typename E>class Result {public: Result(T val) { m_data = val; } Result(E err) { m_data = err; } bool is_ok() const { return std::holds_alternative<T>(m_data); } const T&& value() const { return std::get<T>(m_data); } const E&& error() const { return std::get<E>(m_data); } static Result Ok(T val) { return Result(std::move(val)); } static Result Err(E err) { return Result(std::move(err)); } template<typename F> auto and_then(F&& f) const -> decltype(f(std::declval<T>())) { if (is_ok()) return f(value()); return decltype(f(std::declval<T>()))::Err(error()); } template<typename F> auto transform(F&& f) const -> Result<decltype(f(std::declval<T>())), E> { if (is_ok()) return Result::Ok(f(value())); return Result::Err(error()); }private: std::variant<T, E> m_data;};
Since the container holds two values, two elevation functions are needed. Result has two static member functions, Ok() and Err(), which implement the elevation from T to Result<T’, E> and from E to Result<T, E’>. Following the naming conventions of the C++ standard library, we name our bind function and_then(). The and_then() function provides a binding effect in a chained call style. If the current container holds a valid value, it calls the binding function to perform the operation T -> Monadic<T’>. If the current container holds an error E, it uses the static Result::Err() to construct a Result<T’, E> type container, initializing it with the current error value and returning the value of this container.
Next, we will demonstrate the usage of Result with an example.
Result<int, std::string> ParseInt(std::string s) { try { return Result<int, std::string>::Ok(std::stoi(s)); } catch (...) { return Result<int, std::string>::Err("invalid integer"); }}Result<int, std::string> Half(int x) { return (x % 2 == 0) ? Result<int, std::string>::Ok(x / 2) : Result<int, std::string>::Err("not divisible by 2");}int main() { auto r = ParseInt("42") .and_then(Half) .and_then(Half); if (r.is_ok()) std::cout << "Result = " << r.value(); else std::cout << "Error: " << r.error();}
The ParseInt() function converts a string to an integer. If the conversion is successful, the value of m_data is 42; otherwise, it is “invalid integer”. When m_data is “invalid integer”, the result of the two chained and_then() calls will pass this error state to the result r. When m_data holds an integer, such as 42 in this example, the first binding to Half() is OK, changing its value to 21. However, during the second binding, it cannot be divided evenly, so the returned value of m_data is the string “not divisible by 2”.
C++ 23 has a corresponding std::expected, which provides bind functions such as and_then, or_else, and transform. Everyone can explore this further.
2.3 Adding Some “Sugar”
Many people do not like C++’s chained call style, so they have thought of various methods for operator overloading, such as overloading the | operator to replace and_then or or_else. Reference [4] provides a method of overloading the >>= operator in conjunction with lambda, making the code look somewhat like Haskell. Interested readers can explore its code.
3 Conclusion
Designing containers that support the Monadic interface or using standard library Monadic containers can eliminate unnecessary if-else logic code, such as using std::expected to handle return values. Combined with chained calls to compose functions, it can provide more expressive code, or in other words, the intent of the code is easier to understand.
References
[1] https://en.cppreference.com/w/cpp/utility/optional.html
[2] https://en.cppreference.com/w/cpp/utility/expected.html
[3] https://stackoverflow.com/questions/39725190/monad-interface-in-c
[4] https://github.com/Corristo/functionalCpp
[5] P2505R5: Monadic Functions for std::expected
[6] P0798: Monadic Functions for std::optional
[7] Rust Result: https://doc.rust-lang.org/std/result/enum.Result.html
Information, Code, and Previous ArticlesC++ 20 Designated InitializersC++ Covariant Return TypesC++ Size and ssize FunctionsC++ Latch and BarrierC++ SemaphoreC++ Lock() and Try_lock() FunctionsC++ Various LocksC++ Various std::mutexC++ SpanC++ Copy ElisionC++ Time Library Part Eight: Format and FormattingC++ 20 PIC++ [[noreturn]] SpecifierC++ How to Declare Lambda as FriendC++ Cache Line InterfaceC++ Clamp FunctionC++ Three and Five RulesC++ Strict Aliasing RulesC++ Construct_at FunctionContent and Notification Summary