3
\$\begingroup\$

(Please note: the post about the compose implementation announced below is now available.)

This is about decorating callables by making their argument and return value to be a std::optional. Therefore I created the template function make_skippable that takes a callable and returns a wrapping lambda having an optional argument and an optional return value.

Here a simple example of how to use this function:

auto callable = [](auto arg) { return arg; };
auto skippable_callable = make_skippable(callable);
std::optional<int> res1 = skippable_callable(static_cast<int>(1));
std::optional<int> res2 = skippable_callable(std::optional<int>{2});
std::optional<int> res3 = skippable_callable(std::optional<int>{});

The purpose of this functionality is to allow callables to be composed as a chain, whereby in the event that a callable doesn’t produce a result (by returning a std::nullopt), the chain breaks by skipping the processing of the subsequent callables (unless the callable is able to handle a std::nullopt). I will follow up on the compose implementation later in a separate review request.

Below you will find the detailed requirements that the make_skippable implementation should fulfil:

  • wrap a callable regardless of whether it already has an optional argument or an optional return value
  • in case a nullopt argument occurs, only skip a callable that has a value argument, instead execute a callable that has an optional argument by forwarding the nullopt
  • support all kinds callables having a single argument, including generic lambdas
  • support perfect forwarding regarding argument and callable object
  • c++17

Here the implementation itself:

template <typename>
struct IsOptional : std::false_type {};
template <typename T>
struct IsOptional<std::optional<T>> : std::true_type {};

template <typename TArg>
auto ensure_optional(TArg&& arg) {
  if constexpr (IsOptional<std::decay_t<TArg>>::value) {
    return std::forward<TArg>(arg);
  } else {
    return std::make_optional(std::forward<TArg>(arg));
  }
}

template <typename TFn>
auto make_skippable(TFn&& fn) {
  return [fn = std::forward<TFn>(fn)](auto&& optional_or_value) {
    auto&& optional_arg = ensure_optional(std::forward<decltype(optional_or_value)>(optional_or_value));
    using OptionalArg = decltype(optional_arg);

    constexpr bool invoke_with_optional = std::is_invocable_v<TFn, OptionalArg>;
    if constexpr (invoke_with_optional) {
      auto&& res = fn(std::forward<OptionalArg>(optional_arg));
      return ensure_optional(std::forward<decltype(res)>(res));
    }

    const bool invoke_with_value = optional_arg.has_value();
    if (invoke_with_value) {
      auto&& value = std::forward<OptionalArg>(optional_arg).value();
      auto&& res = fn(std::forward<decltype(value)>(value));
      return ensure_optional(std::forward<decltype(res)>(res));
    }

    // skip fn
    using ValueArg = typename std::decay_t<OptionalArg>::value_type;
    using Res = typename std::invoke_result<TFn, ValueArg>::type;
    if constexpr (IsOptional<Res>::value) {
      return Res{};
    } else {
      return std::optional<Res>{};
    }
  };
}

Kudos to Jonathan Boccara who inspired me with his blog post The Optional Monad In C++, Without the Ugly Stuff - Fluent C++.

Happy to receive any kind of feedback :-)

This godbolt link refers to the implementation shown above and does also include tests.

\$\endgroup\$

2 Answers 2

3
\$\begingroup\$

Design review

This was a neat idea in 2017. I don’t think it’s really got the same shine in 2024.

More recent C++ offers more and better options

There are more “Maybe” types

In C++17, std::optional was the only type that modelled “Maybe”; that is, a type that either holds a value, or “None”. Because it was the only game in town, it was abused. It was quite often used as a return type, where the “None” meant there was an error. Boccara actually makes that mistake in his post. He talks about “failable” functions… but returning an empty optional is not the correct way to indicate a failure. If a function returns an empty optional, that implies that the function succeeded… but just didn’t have any data to return. (For example, imagine a function get_nickname() that queries a database to get a person’s nickname; returning an empty optional would imply that everything succeeded, but the person just had no nickname.)

As of C++23, we now have std::expected. This is the correct way to return a value-or-error. (In fact, get_nickname() should probably return expected<optional<string>, ...>. If the expected has a value, then the query succeeded… but the result might still be “no nickname” (an empty optional).)

And there are even more “Maybe”-like types proposed for C++26, like std::nullable_view and std::maybe_view.

So it is no longer safe to consider std::optional to be the only vocabulary type for modelling “Maybe”.

You may object that because you are targeting C++17, those types are not relevant. But “targeting C++17” does not mean “ignoring everything that comes after”. If a library worked for C++17, and only C++17, and failed to work for any later standard, then it would be of little use.

So at least you should consider making make_skippable() more generic, so that it can work with other “Maybe” types.

Monads are the future

As of C++23, std::optional got monadic operations. These can not only do what make_skippable() does more easily, they are far, far more powerful.

I took your Godbolt example, and tweaked it a bit. Mostly, I just changed the compiler(s) and flags to be more up-to-date. (I also had to add default constructors for NonCopyableArg and NonCopyableFn.) Here is the modified Godbolt: https://godbolt.org/z/oaMzGo5P4.

Then I took your tests, and replaced every call to a “skippable” with either an unwrapped direct call to the original lambda, or a monadic chain. I could have done all with an unwrapped direct call to the original lambda, or all with monadic operations, but I mixed half-and-half just for kicks. Here is the C++23 version: https://godbolt.org/z/jjzaxGYbo.

Not only is the code simpler, take a look at the codegen. GCC is unable to strip away the optional wrapping in the make_skippable() version… but in the monadic version, the optionals are almost completely gone (some remain because they are returned, but even then, GCC recognized that they never fail, and stripped out the bad_optional_access). Clang does a decent job with both versions, but better with the monadic version.

And the monadic interface is much more flexible. For example, it has .or_else(), to even allow handling the empty case.

Is wrapping necessary?

So, ultimately, all make_skippable() does is take any unary callable, and make sure both the argument and return type are std::optionals.

But… does that make sense?

What is the point of wrapping arguments?

So let’s say you have a function auto f(int) -> std::string. What… exactly… is gained by wrapping the int in an optional?

The logic seems to be that auto f_wrapped(std::optional<int>) -> std::optional<std::string> is easier to compose than auto f(int) -> std::string. However, the monadic operations show that is not true:

auto result =
    get_optional_int()
        .transform(f)
        .and_then(to_upper)
    ;

// ... works just as well as:
auto result =
    get_optional_int()
        .and_then(f_wrapped)
        .and_then(to_upper)
    ;

// ... except the latter has more complexity because it has to check
// the optional int *twice*.

Let me put this another way. Consider the implementation of make_skippable():


template <typename TFn>
auto make_skippable(TFn&& fn) {
  return [fn = std::forward<TFn>(fn)](auto&& optional_or_value) {
    auto&& optional_arg = ensure_optional(std::forward<decltype(optional_or_value)>(optional_or_value));
    using OptionalArg = decltype(optional_arg);

    constexpr bool invoke_with_optional = std::is_invocable_v<TFn, OptionalArg>;
    if constexpr (invoke_with_optional) {
      auto&& res = fn(std::forward<OptionalArg>(optional_arg));
      return ensure_optional(std::forward<decltype(res)>(res));
    }

    const bool invoke_with_value = optional_arg.has_value();
    if (invoke_with_value) {
      auto&& value = std::forward<OptionalArg>(optional_arg).value();
      auto&& res = fn(std::forward<decltype(value)>(value));
      return ensure_optional(std::forward<decltype(res)>(res));
    }

    // skip fn
    using ValueArg = typename std::decay_t<OptionalArg>::value_type;
    using Res = typename std::invoke_result<TFn, ValueArg>::type;
    if constexpr (IsOptional<Res>::value) {
      return Res{};
    } else {
      return std::optional<Res>{};
    }
  };
}

The logic is:

template <typename TFn>
auto make_skippable(TFn&& fn)
{
    return [fn = std::forward<TFn>(fn)](auto&& optional_or_value)
    {
        // Wrap value in optional.

        // If function takes optional argument:
        //  *  Call function.
        // Else:
        //  *  If the optional has a value:
        //      -  Unwrap value.
        //      -  Call function.

        // If we get here, function does not take optional, and
        // optional was empty, so return nullopt.
    };
}

See the problem? If you are given a value (and not an optional), you wrap the value, do a pointless test (because you know the optional has a value), then unwrap it.

Let’s flip the logic around:

template <typename TFn>
auto make_skippable(TFn&& fn)
{
    return [fn = std::forward<TFn>(fn)](auto&& optional_or_value)
    {
        // If argument is not optional:
        //  *  Call function.
        // Else:
        //  *  If function takes optional argument:
        //      -  Call function.
        //  *  Else:
        //      -  If the optional has a value:
        //          o  Unwrap value.
        //          o  Call function
        //      -  Else:
        //          o  Return nullopt.
    };
}

If the argument isn’t an optional, just call the function. Either the function takes a straight T, or an optional<T>. Doesn’t matter. If it takes a T, then… well, it has a T. If it takes a optional<T>, the copy/move construction from T is implicit, so… it just works.

If the argument is an optional, then if the function takes an optional, just pass it through. If it doesn’t then you do the test, and only conditionally call the function.

Like so:

template <typename TFn>
auto make_skippable(TFn&& fn)
{
    return [fn = std::forward<TFn>(fn)](auto&& arg)
    {
        using Arg = decltype(arg);

        if constexpr (not IsOptional<std::remove_cvref_t<Arg>>::value)
        {
            return ensure_optional(fn(std::forward<Arg>(arg)));
        }
        else
        {
            if constexpr (std::is_invocable_v<TFn, Arg>)
            {
                return ensure_optional(fn(std::forward<Arg>(arg)));
            }
            else
            {
                if (optional_or_value)
                    return ensure_optional(fn(std::forward<Arg>(arg).value()));
                else
                    return decltype(ensure_optional(fn(std::forward<Arg>(arg).value()))){};
            }
        }
    };
}

The key point is that you never need to wrap the argument. If it’s an optional, it’s already wrapped. And if it’s not, wrapping is pointless: either the function will take the argument directly, or it will be implicitly wrapped.

What is the point of wrapping return values?

Wrapping the argument is unnecessary because it will be implicitly wrapped if needed. But what about the return value? Well, there is a different problem there.

Consider the function auto f(int) -> string. If you wrap the return type, you get: auto g(int) -> optional<string>. Is this an improvement?

No, in fact it is a terrible idea. Because the function auto f(int) -> string is safe and easy to use. It’s just auto s = f(42); and then s is always safe to use. But auto g(int) -> optional<string> is more complex and dangerous, because you always have to do: auto temp = g(42); if (temp) { auto s = *temp; /* !!! */ }, and s is only safe to use in the scope marked !!!.

So the mere act of wrapping a function makes using the result:

  • slower
  • more complex
  • more dangerous

… and the benefit is… composability?

It had better be really nice composability.

Unconditional wrapping is inflexible

make_skippable() unconditionally wraps return values, even when there is no possibility of failure. Even if the function is auto f(optional<T>) -> U, meaning I am 100% guaranteed to get a U result, no matter what, even if there is no T, make_skippable() will turn that into optional<U>, with all the extra complexity and cost that brings with it.

As written, make_skippable() also unconditionally wraps the argument, but I showed that doesn’t need to be the case. There is another, deeper problem though.

When I have an int or a “Maybe(int)”… that is, optional<int>, and I am applying some function f to it that returns double or a “Maybe(double)” (optional<double>), there are several possible outcomes:

f Value argument (int) Maybe argument (optional<int>)
Always gives result double optional<double>
May not give result optional<double> ???

What should I get in that last quadrant? make_skippable()’s answer is optional<double>. That might be right, but consider the following program:

#include <optional>
#include <type_traits>

auto f(int) -> std::optional<double>
{
    return 1.0;
}

auto g(int) -> std::optional<double>
{
    return 2.0;
}

auto main() -> int
{
    auto v1 = std::optional{1}
        .transform(f)
    ;

    auto v2 = std::optional{1}
        .and_then(g)
    ;

    static_assert(std::is_same_v<decltype(v1), std::optional<std::optional<double>>>);
    static_assert(std::is_same_v<decltype(v2), std::optional<double>>);
}

Note that when I use .transform(), the return type is always wrapped, even if it is already an optional. With .and_then() the return type is never wrapped (but it must always be an optional).

I can choose whether or not I want optional<double> or optional<optional<double>> simply by selecting which monadic operation to apply.

I might want optional<optional<double>>, because it gives me more information. For example, if .has_value() is false, then I know the original optional<int> argument was nullopt… but if .has_value() is true but .value().has_value() is false, then I know the original argument had an int value, but f() did not give a result.

Note that I can’t get plain double, because these are monadic operations, meaning the argument is always (effectively) optional<int>. This is different from the situation with make_skippable(), where sometimes the argument is int, and the extra wrapping is pointless. If the function is auto func(int) -> double, then it makes sense to wrap the result if the argument is optional<int> (this is .transform()), but it is silly to wrap the return value if the argument is int.

So with optional’s monadic operations, I can chose whether I want optional<double> or optional<optional<double>>. (But I can’t choose just double, because we’re only starting with optional<int>, not int. If I wanted it, I would get double by just not wrapping the known int in an optional to begin with.)

So with make_skippable(), how could I choose whether I want double, optional<double>, or optional<optional<double>>?

Conclusion

This idea was great back in 2017… nowadays I’m not so sure.

What might save it is the composability, which you promise “later”. It would have to be pretty good to beat the composability of the monadic operations… but that could happen.

Without considering composability, what make_skippable() amounts to is a wrapper function of dubious utility.

  • It makes no sense to wrap non-optional arguments.
  • It makes no sense to wrap non-optional return values.

The only argument for either of those things is… something-something composition. So I can’t really say with any certainty whether this design is actually worthwhile or not, without seeing the composition in action.

\$\endgroup\$
4
  • \$\begingroup\$ Thank you very much for the review. You raised a lot of meaningful questions related to the overall composition design that actually made me think about the design again. I think you're right that it won't shine very brightly, but I hope it will do my job. As I said, I will post it soon. Sorry for putting you off ;-) \$\endgroup\$
    – mahush
    Commented Mar 4 at 16:49
  • \$\begingroup\$ I very much welcome your suggestion to avoid unnecessary wrapping of arguments in std::optional \$\endgroup\$
    – mahush
    Commented Mar 4 at 16:54
  • \$\begingroup\$ Great that you highlight std::expected, std::nullable_view and std::maybe_view \$\endgroup\$
    – mahush
    Commented Mar 4 at 17:42
  • \$\begingroup\$ “returning an empty optional is not the correct way to indicate a failure”: I agree very much with that. For that reason I choose the term skippable. So instead of emphasising that a function might fail with a std::nullopt return value, I wanted to focus on subsequent function being skipped when only a std::nullopt argument is available \$\endgroup\$
    – mahush
    Commented Mar 4 at 17:43
1
\$\begingroup\$

Here comes a self-answer in order to share the code improvements I made based on feedback from indi and subsequently based on my second thoughts on the overall design. In consequence I decided to make following changes regarding the make_skippable implementation:

  • Removed Support for Callables Accepting std::optional: As indi pointed out, supporting callables that accept a std::optional as argument raises the question how to make them skippable in a reasonable way (see his question “What should I get in that last quadrant?”). Ease of use is a core goal here, so to keep it simple I decided to even not raise this question and therefore dropped support for callables accepting a std::optional. A static_assert was added accordingly. To address this, a DisablingOptionalArgumentFn helper template has been introduced, enabling users to adapt generic lambdas for use with make_skippable, thereby raising awareness of this design choice. For usage see following example:

    auto lambda = DisablingOptionalArgumentFn([](auto arg) { return arg; });
    auto skippable_lambda = make_skippable(lambda);
    
  • No Wrapping of Arguments; Expecting Optional Arguments Directly: That is much more straightforward, especially in the context of composition. The basic idea here is, if skippable callables are composed and they always return std::optionals, the input naturally already is an std::optional (Unless it’s the very first callable in the chain, but that’s a corner case that can easily be handled when actually composing). This design decision is supported by a static_assert that ensures the argument is of an optional type, enhancing error output for a smoother user experience.

  • Removed ensure_optional in Favor of EnsureOptional Type Traits: This shift towards a trait-based approach contributes to a cleaner and more straightforward implementation of the new make_skippable function.

  • Disallowing Callables Returning void: Semantically, it doesn't really make sense to wrap a void in a std::optional (probably that is why it’s also not supported technically). Alternatively, it is also not an option to simply forward a void instead of wrapping it because this way we would miss the information if a skip has taken place. In fact, std::expected would solve that problem, but unfortunately, it is not available for C++17. However, void-returning callables are incompatible with the pipeline paradigm that is about data flow. Thus, callables returning void are disallowed via static_assert.

  • Improved Error Output for Unsupported Argument Types: To further enhance usability, the implementation now features better error output when a callable wrapped with make_skippable is invoked with an unsupported argument type. This is achieved through static_assert.

  • Added mutable to Support State-Modifying Callables: The addition of the mutable keyword allows make_skippable to support callables, such as lambdas or functors, that modify their own state. This change broadens the applicability of make_skippable, accommodating a wider range of use cases by enabling stateful operations.

  • Adjusted Unit Tests: According to the changes, I adjusted the unit tests that you can find below in the godbolt link.

Here the updated implementation:

// A type trait that checks if a given type is std::optional.
template <typename>
struct IsOptional : std::false_type {};
template <typename T>
struct IsOptional<std::optional<T>> : std::true_type {};

// A type trait that wraps a given type in std::optional if it isn't already.
template <typename T>
struct EnsureOptional {
  using Type = std::optional<T>;
};
template <typename U>
struct EnsureOptional<std::optional<U>> {
  using Type = std::optional<U>;
};

// A type trait poviding the return type of TFn including compile-time checks
template <typename TFn, typename TArg, bool IsValue = std::is_invocable_v<TFn, TArg>>
struct InvokeResult {
  static_assert(std::is_invocable_v<TFn, TArg>, "Callable TFn does not support arguments of type TArg");
  static_assert(not std::is_invocable_v<TFn, std::optional<TArg>>,
                "Callable TFn may not support an argument of type std::optional");
  using Type = typename std::invoke_result<TFn, TArg>::type;
  static_assert(not std::is_void_v<Type>, "Result of TFn may not be void");
};

// Transforms a given function (Fn) into another one (FnSkippable). FnSkippable expectes Fn's argument wrapped in a
// std::optional. Moreover, FnSkippable returns Fn's result wrapped into std::optional, unless it is not already.
// When calling FnSkippable, Fn is just executed unless FnSkippable is called with a std::nullopt. In this case, Fn is
// not executed (skipped) and FnSkippable simply returns a std::nullopt.
template <typename TFn>
auto make_skippable(TFn&& fn) {
  return [fn = std::forward<TFn>(fn)](auto&& optional_arg) mutable {
    using OptionalArg = std::decay_t<decltype(optional_arg)>;
    using ValueArg = typename std::decay_t<typename OptionalArg::value_type>;
    using FnResult = typename InvokeResult<TFn, ValueArg>::Type;
    using OptionalFnResult = typename EnsureOptional<FnResult>::Type;

    if (optional_arg.has_value()) {
      auto&& value = std::forward<OptionalArg>(optional_arg).value();
      return OptionalFnResult{fn(std::forward<decltype(value)>(value))};
    }

    // skip fn
    return OptionalFnResult{std::nullopt};
  };
}

// Helper template to remove support for optional arguments for TFn
template <typename TFn>
struct DisablingOptionalArgumentFn {
  explicit DisablingOptionalArgumentFn(TFn&& fn) : m_fn{std::forward<TFn>(fn)} {}

  template <typename TArg, typename = std::enable_if_t<not IsOptional<TArg>::value>>
  auto operator()(TArg&& arg) {
    return m_fn(std::forward<TArg>(arg));
  }

private:
  TFn m_fn;
};

You can find the updated implementation along with unit tests on godbolt.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.