I’m posting this in the spirit of “the best way to find a right answer is to post a wrong answer.”
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!
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.
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.
Goes all the way back to 1960s with Lisp, they were implemented with pointers. It’s a stupidly simple implementation so it got popular 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
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, .headmust 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.
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.
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.
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.splitAtScala 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
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.