Skip to content

best::tap::operator[] index mismatch #16

@fennewald

Description

@fennewald

There are two issues with best::tap::operator[] -- a typo that prevents use, and a lifetime issue.

Here is the current implementation, for reference.

best/best/func/tap.h

Lines 178 to 201 in 3475255

template <typename Guard, typename Cb>
constexpr auto tap<Guard, Cb>::operator[](auto&& arg) const& {
return best::tap([&](auto&& arg) -> decltype(auto) {
return ((BEST_FWD(arg))->**this)[BEST_FWD(arg)];
});
}
template <typename Guard, typename Cb>
constexpr auto tap<Guard, Cb>::operator[](auto&& arg) & {
return best::tap([&](auto&& arg) -> decltype(auto) {
return ((BEST_FWD(arg))->**this)[BEST_FWD(arg)];
});
}
template <typename Guard, typename Cb>
constexpr auto tap<Guard, Cb>::operator[](auto&& arg) const&& {
return best::tap([&](auto&& arg) -> decltype(auto) {
return ((BEST_FWD(arg))->*BEST_MOVE(*this))[BEST_FWD(arg)];
});
}
template <typename Guard, typename Cb>
constexpr auto tap<Guard, Cb>::operator[](auto&& arg) && {
return best::tap([&](auto&& arg) -> decltype(auto) {
return ((BEST_FWD(arg))->*BEST_MOVE(*this))[BEST_FWD(arg)];
});
}

First

There is a name collision for arg -- the lambda argument shadows the function argument. This prevents any actual use. The solution is straightforward: change one of the names, add a few tests to prevent any future issues.

Second

We run into some weird lifetime issues here. In general, operator[] returns reference types. The decltype(auto) signature preserves the reference. This means that code like this:

best::test Tap = [](auto& t) {
  // Generates a vector of multiples of n, to the requested length
  best::tap multiples = [](int&& n){
    return [&](int len){
      best::vec<int> res;
      for (int i=1; i<len; ++i) {
        res.push(n * i);
      }
      return res;
    };
  };

  // generates the first 10 multiples, and then selects the second
  t.expect_eq((2->*multiples(10))[1], 4); // succeeds
  t.expect_eq(2->*multiples(10)[1], 4); // fails
};

Will return a dangling reference to the objects on the stack of the lambda we just left:

 TEST: _ZN4best8tap_test3TapE ]
failed expect_eq() at best/func/tap_test.cc:45
expected these values to be equal:
  1696621669
  4

There are scenarios where this behavior is valid. If the tap produces, say, a reference to a static object, than references into that container are presumably also static, and valid. However, I believe the average case is a stack-scoped return from.

The documentation alludes to oddities with this behavior, but stops short of fully describing the behavior:

->* also binds stronger than operator() and operator[]. However, the vanilla tap, best::tap, overloads operator() and operator[] to give the illusion otherwise: foo->*my_tap(bar) will be parsed as operator->*(foo, my_tap.operator()(bar)): using () or [] on a tap will "bind" those arguments, returning a new tap that "does what you expect", such that foo->*my_tap(bar) is almost (foo->*my_tap)(bar).

As far as resolution, I am happy to submit a PR, and have two proposed resolutions:

Option 1: Leave it alone

In short, just fix the argument naming, and add a sentence in the documentation for best::tap explaining the behavior outlined above. This maintains maximal flexibility for the user, even if it is a bit of a foot-gun.

Option 2: Prefer return-by-value

We can also just update the return types of the function to return a best::unref<decltype(/* the entire function body */)>. Returning-by-value, I believe, will present a more ergonomic behavior for library users. It does, however, introduce 'hidden' costs(an implicit copy constructor call), and prevent more advanced users for intentionally returning references.

I will submit a PR for whichever option you prefer, @mcy.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions