View.last versus View.apply behavior

Something I just noticed:

val v = List(1,2,3).view.map(f)
v.last // evaluates f 3 times
v(2) // evaluates f once

Since views are often use to avoid evaluating unnecessarily, would it be better to have v.last behave like v(2), even at the cost of a second traversal? Or is this by design, to leave users with a choice (and maybe some confusion)? Right now, last seems to rely on the default, iterator-based implementation.

2 Likes

The inconsistency surprises me too. Let me test my understanding…

I’m guessing that as List is a cons-cell implementation, and doesn’t carry any fancy direct-link-through-mutable-private reference to the last element around, then any access to the underlying element by the view has to involve iteration via recursive traversal (or direct recursive traversal).

Or do you expect .view to cache?

If I’m right so far, then it’s a choice as to whether you want the iteration do be done down in the underlying list prior to applying f to the last element, or as a ‘pull-through’ iterator that delegates to the underlying list iterator.

You would want a general mapped iterator to do pull-through so that .filter, .take etc don’t have to strictly evaluate the mapped elements. That doesn’t stop .last in the view implementation from being specialized to punch through to the underlying list, execute .last there and then apply f - and that is how .apply works already.

Looking at the library code briefly, it seems that .apply and .iterator are implemented as I imagined, and that .last just comes from the last-resort implementation up in Iterable, hence the inconsistency in applications of f.

You knew this already, of course.

Personally, I’d just call f(v.last) and have done with it if f is some monstrous performance drain. Or run f through a caching wrapper like Scalacache / Caffeine. Or use a Vector as the underlying collection, in which case it will evaluate f just once for .last and give near constant-time access for both .last and .apply(2).

It’s true that last never makes much sense on views anyway.

list.view.map(f).filter(p).headOption

is a more useful pattern than

list.view.map(f).filter(p).lastOption

for which everything has to be computed anyway.

Some behavior can still be hard to guess, though:

val v = List(1, 2, 3).view.map(f)
v.last         // evaluates f 3 times
v.reverse.head // evaluates f 3 times
v.reverse(0)   // evaluates f once!

(Which means that the documentation is incorrect. For SeqView.reverse, it states: “Even when applied to a view or a lazy collection it will always force the elements.”)

I guess none of this matters much as long as views are used in typical, useful patterns. It’s just there to trip teachers doing in-class demos :wink:

I feel that someone, somewhere will be composing a Scala technical interview question based on this discussion. Groan! :weary:

2 Likes