What is the best approach for using std::ranges/std::views with std::expected in C++23?

What is the best approach for using std::ranges/std::views with std::expected in C++23?


8

Let me describe a scenario, first we have a function which returns us some sort of data which we can’t be sure about the validity of, i.e.

auto random_predicate() -> bool
{
    int v = uniform_dist(e1); // uniform distribution 1 to 100
    return (v % 5);
}

where uniform_dist() is an appropriately seeded uniform distribution, and we have an enum class which we shall use for error handling, i.e.

enum class Error
{
    ValueError
};

Then we perform some sort of views-based processing which uses random_predicate() from within an operation, as follows:

std::vector<int> vs{1,2,3,4,5};

auto result = vs
   | views::filter([](int i){ return i % 2; })
   | views::transform([](int i) -> std::expected<int, Error> {
      auto v = random_predicate();
      if (v) return std::unexpected<Error>(Error::ValueError);
      else return i * i; 
   });

So, by the end of this operation, we can assert

static_assert(
    std::is_same_v<
        std::decay_t<std::ranges::range_value_t<result>>, 
        std::expected<int, Error>
    >
)

will in fact be true.

The question is, then what? We know have a view of std::expected values which we need to resolve to either: an error type which propagates up the call stack, or a view of the success type (i.e. a view of int in the above example (with all elements not a multiple of 5!))


My solution

My solution is to simply check if each element is in error and then if there are no errors transform the result to the desired view, so something like

template<typename T>
static auto has_error(const std::expected<T, Error>& e){ return !e.has_value(); };

auto f(const std::vector<int>& vs)
{    
    auto c = vs
        | views::filter([](int i){ return i % 2; })
        | views::transform([](int i) -> std::expected<int, Error> {
            auto v = random_predicate();
            if (v) return std::unexpected<Error>(Error::ValueError);
            else return i * i; 
        });

    if (auto v = ranges::find_if(c, has_error<int>); v != c.end()) 
    {
        return (*v).error();
    }
    else 
    {
        return c | views::transform([](auto&& e){ return e.value(); });
    }
}

But then we run into the problem that the function cannot deduce the return type to be std::expected<T, Error> where T is the type of a container with elements of type (in the case of the above example) int. And well, I dont even know what to write for T here, so my question is how should this be implemented?

godbolt: https://godbolt.org/z/Wfjr8o3qM


Alternatively, I’m interested in hearing how others may approach this problem in a better way all together?

Thanks

Edit: I suppose, you dont really want to return a view of some elements as it may lead to a dangling view? In that case, is it best to just use ranges::to<T>() when returning from a function?

Share
Improve this question

2

  • Definitely not an easy question. If performance is not an issue and you can use external libraries, stackoverflow.com/questions/67716780/… suggests to use any_view from ranges-v3. Regarding your edit, I think that as long as your original data is valid in the scope of where you use the views it should be fine (like with the code on SO, not with the one in the Godbolt)

    – Alex

    11 hours ago


  • Thank you, yes, I updated the question to move the vector out of the function for that very reason, but forgot to update the godbolt! Thank you for the link.

    – Jonah F

    11 hours ago

3 Answers
3

Reset to default


4

In order to determine the appropriate return type of your function f, you can make use of the std::ranges::range_value_t and std::ranges::range_reference_t type traits, as well as the std::conditional_t type trait.

First, you can use std::ranges::range_value_t<decltype(c)> to obtain the value type of the range returned by your views-based processing. This should be std::expected<int, Error>.

Then, you can use std::ranges::range_reference_t<decltype(c)> to obtain the reference type of the range. This should be std::expected<int, Error>& if the range is mutable, or const std::expected<int, Error>& if the range is const.

Finally, you can use std::conditional_t to conditionally select the return type based on whether there are any errors in the range. Here’s an example implementation of f that makes use of these type traits:

template <typename Range>
auto f(Range&& range)
    -> std::conditional_t<
        std::ranges::any_of(range, [](auto&& e) { return !e.has_value(); }),
        std::ranges::range_value_t<decltype(range)>::error_type,
        std::vector<std::ranges::range_value_t<decltype(range)>::value_type>
    >
{
    using value_type = std::ranges::range_value_t<decltype(range)>::value_type;
    using error_type = std::ranges::range_value_t<decltype(range)>::error_type;
    
    auto c = std::forward<Range>(range)
        | views::filter([](int i){ return i % 2; })
        | views::transform([](int i) -> std::expected<value_type, Error> {
            auto v = random_predicate();
            if (v) return std::unexpected<Error>(Error::ValueError);
            else return i * i; 
        });
        
    if (auto v = ranges::find_if(c, has_error<value_type>); v != c.end()) 
    {
        return (*v).error();
    }
    else 
    {
        return c | views::transform([](auto&& e){ return e.value(); })
                 | ranges::to<std::vector<value_type>>();
    }
}

In this implementation, the return type is selected based on whether there are any errors in the range, using std::ranges::any_of. If there are errors, the return type is error_type (which is equivalent to std::expected<value_type, Error>::error_type). Otherwise, the return type is a std::vector of the success type (value_type). Note that the views::transform operation is used to convert the range of std::expected values to a range of success values before converting to a std::vector.

Share
Improve this answer

3

  • I Like the ideas here. Although, there is a problem. You decide the return type based on whether the input range has any errors i.e. std::ranges::any_of(range, [](auto&& e) { return !e.has_value(); }), however, the return is actually dependent on whether the range c (from inside the function scope) encounters any errors, right? I understand you cant deduce the return type based on c, but this function does something slightly different to what I was looking for.

    – Jonah F

    11 hours ago


  • My compiler does not want to reproduce this code atm, but I am wondering if there would be an issue if the values of the range were not known at compile time? How would the return type be defined?

    – Alex

    11 hours ago


  • MikaDior: Perhaps you could provide a demo in some online compiler?

    – Ted Lyngmo

    11 hours ago


2

Another option to still be able to return the range:

auto f(const std::vector<int> &vs) {
    auto c = vs
             | views::filter([](int i) { return i % 2; })
             | views::transform([](int i) -> std::expected<int, Error> {
        auto v = random_predicate();
        if (v) return std::unexpected<Error>(Error::ValueError);
        else return i * i;
    });

    auto values = c | views::transform([](auto &&e) { return e.value(); });

    using success_t = decltype(values);
    using ret_t = std::expected<success_t, Error>;

    if (auto v = ranges::find_if(c, has_error<int>); v != c.end()) {
        return ret_t(std::unexpected<Error>((*v).error()));
    } else {
        return ret_t(values);
    }
}

It uses the fact that values as it is a view is computed lazily. So if it is not returned, values will never be computed, and we can use it to determine the return type. Next step is to ensure all returns are wrapped into the ret_t type so that auto can guess correctly.

Note: both in this answer and your original question, this only works in the original range can be iterated on multiple time (can’t remember the name of this concept sorry)

Share
Improve this answer

2

  • 1

    I like this answer, I think it checks all the boxes, thank you.

    – Jonah F

    11 hours ago

  • 1

    "both in this answer and your original question, this only works in the original range can be iterated on multiple time" Namely, ranges::forward_range.

    – 康桓瑋

    8 hours ago


0

The first thing to note is that since the lambda passed into views::transform uses random_predicate() internally, it will produce different results each time it is invoked, which violates equality-preserving, so the lambda does not meet the regular_invocable required by transform_view, you’ve got a UB.


As this answer illustrates, suppose you have a range of std::expected<int, E>, you may want to collect them into std::expected<std::vector<int>, E>, which is quite useful (That’s why ranges::to does not constrain the return type to be a range in the end).

Although it is still not possible to get the "correct" behavior described by the answer via ranges::to<expected> for the current standard, it is not difficult to implement that:

template<ranges::input_range R>
  requires is_expected<ranges::range_value_t<R>>
auto to_expected(R&& r) {
  using expected_type = ranges::range_value_t<R>;
  using value_type = expected_type::value_type;
  using error_type = expected_type::error_type;
  using return_type = std::expected<std::vector<value_type>, error_type>;

  auto values = r
    | views::take_while([](const auto& e) { return e.has_value(); })
    | views::transform([](const auto& e) { return e.value(); });

  // for simplicity, only use vector to store the result
  std::vector<value_type> v;
  auto [it, out] = ranges::copy(values, std::back_inserter(v));
  if (it.base() == r.end()) // all success
    return return_type(std::move(v));
  // return the first error
  return return_type(std::unexpect, (*it.base()).error());
};

Then in your example, you can pass the range of std::expected<int, E> into this function to get a std::expected<std::vector<int>, E>

auto f() {
  std::vector<int> vs{1,2,3,4,5};
  auto c = vs | views::filter([](int i){ return i % 2; });

  // Fix views::transform
  std::vector<std::expected<int, Error>> r;
  for (int i : c) {
    if (random_predicate())
      r.emplace_back(std::unexpected<Error>(Error::ValueError));
    else
      r.emplace_back(i * i);
  }

  return to_expected(r);
}

which ensures we only traverse the original range once, making it work even if it is an input_range.

Demo

Share
Improve this answer



Your Answer


Post as a guest

Required, but never shown


By clicking тАЬPost Your AnswerтАЭ, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged

or ask your own question.

Leave a Reply

Your email address will not be published. Required fields are marked *