Hacker News new | past | comments | ask | show | jobs | submit login
How to Use Monadic Operations for `std:optional` in C++23 (cppstories.com)
65 points by jandeboevrie on Sept 30, 2023 | hide | past | favorite | 52 comments



Monads without proper syntax (do-notation) are kinda ugly though

If I didn't learn monads with haskell before, I would think that monads is some useless boilerplate abstraction


https://godbolt.org/z/9zsesq5dh

you are welcome.

[support for all monads, or, you know, actual monad semantics left as an exercise to the reader]


The dot notation for chaining calls on monads works fine.


I quite enjoy the idea of monadic types, and have written (beaten?) some into languages that dont have native support, but I cant help but wonder if this (and when I see rusts too) approach of `.if_then`/`ok_or` `.or_else` etc actually improve readability?

It always seems to best suit languages that have built in type/pattern matching and you actually design your functions to behave differently given a type vs obscuring what is nested if/else statements? (I know Rust can pattern match.)

I think there is some readability improvements when you're able to turn `if & if & if x | else | else | y` into `if & if & if x | y` by letting the empty type "fall out", but as soon as you have something like `if(if(x | y) | z)` the fall out nature has less effect.


> I cant help but wonder if this (and when I see rusts too) approach of `.if_then`/`ok_or` `.or_else` etc actually improve readability?

They can be mis/overused, but I think the declarative aspect and ability to split processing into clear steps makes them very helpful. They also allow "ignoring" the negative side or easily shunting values there, which may require repeated conditional checking (and obscure the actual processing) in a language without monadic operations.

They're also very compositional, so you don't have to update every API e.g. Java needs both a `Map#get` and a `Map#getOrDefault`, and that still doesn't really cover the case where the default is expensive to compute (so you want it to be lazy). In Rust, `Map::get` just returns an `Option`, and `Option` supports the relevant services (`unwrap_or` and `unwrap_or_else` in this case), which also means it supports those services for every API which returns an option.


In C++ they’re important for safety, because dereference of an empty std::optional is a silent UB. The methods robustly handle the checks for you.


Nested .map in Option or Result in Rust are indeed ugly, but the try operator (?) makes up for it in my opinion


Tangentially related, but if you don't have access to c++23 or you don't like monadic properties, passing by reference and returning bool success can emulate or_else, transform, and_then with || &&

  bool fetchFromCache(int userId, UserProfile& profile);
  bool fetchFromServer(int userId, UserProfile& profile);

  UserProfile profile;
  if (!fetchFromCache(userId, profile) && !fetchFromServer(userId, profile)) {
    std::cout << "Failed to fetch user profile.\n";
    return;
  }
or just to prove a point, although even more unreadable than the monads:

  bool extractAge(const UserProfile& profile, int& age);

  UserProfile profile;
  int age;
  if (
    (!fetchFromCache(userId, profile) && !fetchFromServer(userId, profile))
    || !extractAge(profile, age)
  ) {
    std::cout << "Failed to determine user's age.\n";
    return;
  }
  
  int ageNext = age + 1;
This probably isn't a great example usecase for this, but its helpful when creating composable transform pipelines


In Python, it would be:

    user_id = 12345
    user = fetch_from_cache(user_id) or fetch_from_server(user_id)
    age_next = user and (user.age + 1)
    if age_next:
        print(f"Next year, {age_next} years old")
    else
        exit("Failed to determine next age")


Maybe I’m old and set in my ways but I don’t see where the monadic example provided is easier to read or interpret than the traditional example.


After reading this kind of code for a week it becomes as easy as anything else. As long as there’s no pyramids of doom (possible with plain if else, promise .then and async/await and this optional and_then) code will be easy to read.


That's true for many things we as humans can become accustomed to. But what exactly is the (hopefully quantifiable) improvement here? Otherwise, it's just a style option of preference.


If you use the optional type, then nothing. It’s just style. If you don’t use it and your compiler can tell you’re dereferencing a possible null value, then again you gain nothing.


https://news.ycombinator.com/item?id=37714144

This comment here, I think, has the answer. The inial returns from using the more functional and mathematical view of programs are smaller than that of traditional approach. But the former scales better if you invest more time in it, and eventually can give you much more.


I can't really think of a way in which I'd consider the monadic version better-or-equal than the "traditional approach". Except that maaaaybe

``` ageNext = age.transform([] (const int age) { return age + 1; }); ```

if equally readable to

``` if (age) { ageNext = *age + 1; } ```

Maaaaaybe!


For single operations, maybe (and still it's a style, you get used to it), but the value comes from chaining. For me, the user/ageNext example is much more readable with monads. That might also be because I'm used to monads with Rust.


I've seen early proposals of this and never been a big fan of the syntax, I'm disappointed it made it in to C++23. Monads can be useful if they enable composition across different concepts, but this is syntactic sugar that IMO can be expressed in a nicer way with an immediately evaluated lambda and early returns.

Compare

    const auto ageNext = fetchFromCache(userId)
        .or_else([&]() { return fetchFromServer(userId); })
        .and_then(extractAge)
        .transform([](int age) { return age + 1; });
with

    const auto ageNext = [&]() -> std::optional<int> {
      auto user = fetchFromCache(userId);
      if (!user) { user = fetchFromServer(userId); };
      if (!user) return std::nullopt;
      return extractAge(user) + 1;
    }();
If you allow GCC/Clang extensions, the first two lines in the lambda can be even turned into

      auto user = fetchFromCache(userId) ?: fetchFromServer(userId);


I've been using the equivalent function in Rust for some time with great success. I think it's a good addition to C++ as well, and I'm looking forward being able to use C++23 for that kind of things.


That looks like something I'd have concocted 20 years ago. Oh yeah let's chain expressions with functors in order to wrap the behaviour of a simple if statement. Campus party at 8.


Hardly anything new to anyone using ML or Lisp derived languages for the last half century, it is still quite new concept for most folks, specially in languages like C++.


The idea is to make the accesses safer, not necessarily more readable. The monadic operations make it impossible to access the value in a std::optional without first testing that it actually contains a value.


I understand the value of enforcing behaviours with types, but experience has taught me that this is a tooling issue, not something you have to wrap into a spaghetti mess every time you want to code an if.

Example: While programming kotlin, intellij idea warned me whenever I accessed a nullable object without a null check, and gave suggestions to convert types to non-nullable in order to avoid the checks altogether whenever appropriate.

I think there is value in keeping the code clean while keeping it correct at the same time, but that should be done at the compiler or linter level.


But code using these monadic operations can also be quite clean… toy example:

   auto askForUsername = [&] { … };
   auto lookupUserByName = [&](auto name) { … };
   auto printUserDetails = [&](auto userId) { … };
   
   auto details = 
     askForUsername()
       .and_then(lookupUserByName)
       .and_then(printUserDetails);


A linter or IDE can only "suggest" that you don't access null objects without a null check. You as the developer can choose to ignore the linter or IDE and write bad code anyway.

Enforcing behavior via the typing system prevents bad code from even compiling and running in the first place.

When you stop thinking of Optional<T> as a inconvenient wrapper clumsily wrapping a T and starting thinking of it as a first class datatype with its own members and methods, then it becomes a lot more clear to reason about the logic.


More categorical solutions looking for a problem.


The C++ standard standardizes existing practice; it's not a place for innovation.


The people writing the C++ standard seem to disagree.


I have been part of the C++ standards committee for 13 years.


Your comment has led me down a rabbit hole regarding the C++ standards commission and corrected various misconceptions I've held for a while.

You're right and I've made a fool of myself. My apologies.


You really did not. Common practice and innovation are the same thing. Look at lambdas. Then polymorphic lambdas. Then async. Then modules. Innovation is obviously getting in. Parent commenter was being a pedant. Doesn't matter if he's stroustrup himself.


Huh? Who says so?


I don’t like the so-called fluent interface. And control structures duplicated by methods are ugly as well. RIP command-query separation and clear OOP semantics.


Why don't they add a new interface which will essentially be Monad? Is the polymorphism story in C++ not good enough for practical use of it?


It's not that you couldn't do that, it's that polymorphism (in the interface/virtual functions sense) is only one of several paradigms for abstraction/code reuse in C++. And it is on the higher side of runtime cost among them.

Also, the type system in C++, despite all the template stuff, is not actually very good for serious functional programming. For example, when taking a function, there is no generic way to specify its signature in your own signature (and no, taking a std::function is not generic). `require` goes a long way though nowadays.


Yes requires is pretty powerful with std::invokable but if one of the parameters of your passed function is also generic then it gets more hairy.

template <typename T> requires std::invokable_r_v<int,int,int> int foo(T fn){ return fn(10, 20); }


int foo(invocable<int,int> auto fn){ return fn(10,20); }

But I think you wanted to show a different example.


I'm on mobile so maybe I mixed the concept and the trait but there should be an invocable_r version that constrains the return type in addition to invocable which just constrains the arguments.

And yes the inline version rather than requires is much nicer.


Oh, I see. Yes, out of the box there is a trait, but not a concept.


I'm surprised nobody mentioned co_await yet. This should be possible.

std::optional<int> foo();

And then

std::optional<int> bar(){

   int x = co_await foo();

   int y = co_await foo();

   return x+y;
}

No messy chaining or lambda pyramid of doom.

I'm surprised that the monadic ops and C++ co_await are not being aligned in the standard yet


It is doable, `folly::Expected` supports that: https://github.com/facebook/folly/blob/main/folly/test/Expec...


I'll have to check that out. Thanks


In that code from the article:

  const auto ageNext = fetchFromCache(userId)
        .or_else([&]() { return fetchFromServer(userId); })
        .and_then(extractAge)
        .transform([](int age) { return age + 1; });
what is the meaning of the & in the first lambda?


The & captures the `userId` by reference and makes it available in the lambda body.


The `&` captures the surrounding variables by reference. This means that the lambda will have access to variables defined in the scope where the lambda is declared, without making a copy of them. In this case, `userId` is captured by reference, so any changes to `userId` outside the lambda would be reflected inside the lambda as well.


It causes userId to be captured by reference. Compare with [=] which would capture by value.


Thank you grumbel and yrro, I get it. I'm used to write things like [&userId]() and was ignorant of "implicit" capture.

Is there a benefit to the explicit version or is this purely a stylistic matter?


Implicit capture may capture more than you intended to. Explicit will cause the compiler to alert you to other names you might not have realized you included. It also makes it harder for people modifying your code to mess it up.

That said, I usually use implicit because if I'm writing a lambda it's in a highly local context where a) it's just a shortcut, b) I know exactly what I'm doing, and c) it's not really meant for usability.

If you want to make the intent really explicit and rigid, it might even be better to use a structured capturing type with an operator(): `MyCapture{.x = x, .y = y}()` or whatever.


Implicit captures may not be obvious and may be accidental, leading to lifetime issues. Without a borrow checker like rust, it's safer to name them explicitly most of the time.


I wish we had the option to specify explicit captures in rust closures too. When you need it you need it, and it is good for clarity. The current "add `move` to switch from by-reference to by-value" is too coarse grained


Some time ago I was wondering what would be the best way to traverse a list of objects contained in optionals or unique_ptrs.

It seems like there is no way in C++ to express that i want to traverse the objects themselves and not the container objects. Hopefully that changes soon.


Good stuff but ugly.


What a time to be alive!




Consider applying for YC's Spring batch! Applications are open till Feb 11.

Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: