Calling tail on an empty list should produce an empty list

I’m posting this in the spirit of “the best way to find a right answer is to post a wrong answer.” :grinning_face:

I find it unintuitive that calling .tail on an empty list throws an exception. It’s been suggested to me that, by design, .drop(1) doesn’t throw so that you can have both behaviours, and I can see that calling tail on an empty list is technically an invalid operation.

However, it would never occur to me to substitute tail with drop(1) in order to get a different outcome, and I find throwing an exception here to be a surprising behaviour. I believe that if people really cared about the length of their list that much, they’d do a manual length check or test the requirement by some other means.

So, in the name of usability and the ‘Principle of least astonishment‘, and I’d like to propose that instead of throwing an `UnsupportedOperationException`, this holds true:

`List().tail == List()`

I look forward to someone educating me on why this doesn’t work. Thanks!

to me, i think tail as primarily a list operation mainly for use in a recursive algorithm to “shrink” the list - and all other uses are invalid :slight_smile:

2 Likes

I think that’s all I use it for too, and I suspect that’s the only reason I haven’t tripped over this more often, because it’s always guarded by a terminating empty case test.

Nonetheless, it’s still a surprise whenever I re-learn this behaviour exists every few years. :joy:

Update: I re-learnt about it this time because of a Scala Native issue: 3.3.4: tail of empty list · Issue #22258 · scala/scala3 · GitHub

i think tail as primarily a list operation mainly for use in a recursive algorithm to “shrink” the list - and all other uses are invalid

Thinking about that statement a little further: I think it’s fine, that’s your interpretation of the syntax/operation. I just don’t read it that way. To me it’s ‘take everything but the head, if there is one’ and so it equivalent to drop(1), though perhaps the underlying process is different.

A quick search suggests that Haskell and OCaml do the same thing as Scala though (?), so maybe this is just standard practice and I need to change my thinking… but I reserve the right to personally find it weird. :smiley:

Goes all the way back to 1960s with Lisp, they were implemented with pointers. It’s a stupidly simple implementation so it got popular :smiley: The ML family took the inspiration from there, and Scala inherited ideas from Standard ML (such as the unit value ()).

❯ sml
Standard ML of New Jersey v110.79 [built: Mon Apr 22 10:14:55 2024]
- val x: int list = [];
val x = [] : int list
- tl x;

uncaught exception Empty
  raised at: smlnj/init/pervasive.sml:211.19-211.24
1 Like

There was discussion about which API should fail fast. (I can’t locate it.)

That was about boundary tests for drop, slice, and so on.

I think it comes down to which algorithms are you trying to fail fast on.

My possibly faulty memory is that the counterargument is that drop(1) should fail on empty because it is an indexing error. Of course, the consensus went the other way.

I don’t recall anyone asking for a forgiving tail.

my point is that when would you really ever want to drop just one element so much that you hard code it to specifically .drop(1) unless you are doing some step by step iteration, and in that case it is only efficient for list,

to expand my analogy - drop(n) is for some generic n - i.e. split the collection at index n, again drop(1) over and over is terrible unless its a list or iterator

head and tail are a pair. On an empty list, .head must throw an exception, so for consistency, tail does too. If the list doesn’t have a head, it can’t be said to have a tail either.

Also, throwing is good because you find out about the problem (an unexpectedly empty list) immediately. Returning an empty list risks delaying an eventual failure until a much later point in time, and by then identifying the root cause may be difficult. References: Fail-fast system - Wikipedia , https://stackoverflow.com/questions/2807241/what-does-the-expression-fail-early-mean-and-when-would-you-want-to-do-so

Regardless, the drop(1) behavior is of course sometimes useful. (But not that often, as Jamie has indicated.)

7 Likes

Consistency with it’s opposite is an argument I can buy, thanks for that.

I understand fail-fast systems, it just hadn’t occurred to me that that might be the generally preferred failure mode. I suppose you have to gate one behaviour or another: You’re either guarding against empty lists so that you can safely call tail, or you’re guarding against unexpectedly empty lists …so that you can call tail. Put that way, the former does seem more reasonable.

In any event, my curiosity has been satisfied, thank you all for your comments. :folded_hands:

1 Like

In SML, take(n) and drop(n) throw an exception when n is more than the length of the list. I find both intuitions reasonable, either “you cannot take/drop from an empty list” or “when a list is empty, taking/dropping isn’t going to make it less empty”.
Maybe it’s a case of different uses? take(n) with the intent of takeExactly(n) should only be used on lists with at least n elements and throw an exception otherwise; take(n) with the intent of takeAtMost(n) is always possible and should not throw. I don’t know if it’s worth having two distinct library functions, though.

1 Like

i’d say that consistency with List destructuring (pattern matching) is also key here for building intuition. it should be possible to refactor between them without change of meaning, i.e.

val head :: tail = makeList()
if (someCondition(tail)) {
  return
}
// should behave same as
val list = makeList()
val tail = list.tail
if (someCondition(tail)) {
  return
}
val head = list.head
// i.e. should fail in exactly the same scenarios

analogously (in terms of refactoring), String.splitAt Scala Standard Library 2.13.4 - scala.collection.StringOps is documented as “Splits this string into two at a given position. Note: c splitAt n is equivalent to (c take n, c drop n).” so all three methods should throw an exception for the same values of n. therefore:

val (earlier, later) = makeList().splitAt(n)
// should behave same as
val list = makeList()
val earlier = list.take(n)
val later = list.drop(n)
// i.e. should fail in exactly the same scenarios
2 Likes

I think the answer is that a function should behave in such a way that it benefits the most amount of people / codebases.

In a typical / average codebase, calling .tail on an empty list is a bug, therefore throwing is a thing to do for the greatest good. Similarly, it was intuitively determined that drop(10) on a smaller collection is more likely simplify the codebase and very rarely a bug condition.

One notable exception that I keep warning about is the zip() function when the lists are of unequal size. In my whole career I never intentionally wanted a shorter collection. Because we obviously cannot change behavior of existing zip function, I hope we get overloaded zipAll function that throws if collections are not of equal size.

2 Likes

Aha! So that is the root cause of this evil choice. To me, () looks like an empty Tuple and the unit value should better have been called unit.

Funny thing is, unit is actually just an size-zero Tuple. They are equivalent. Or maybe I have just been doing too much Haskell in my college years.

Unfortunately not, see this contribution from sjrd.

1 Like

related (from this deck):

2 Likes

In Haskell….

1 Like