Three slices. Two of them do exactly what they look like, and the third is the reason this exercise exists.
items = [1, 2, 3, 4, 5] items[-2:] # -> [4, 5] the last two items[-9:] # -> [1, 2, 3, 4, 5] asking past the start is fine count = 0 items[-count:] # -> [1, 2, 3, 4, 5] # because -0 is 0, and items[0:] is the whole list
- There is no negative zero to slice with
Integers have one zero, so -count with count == 0 is simply 0, and the slice means 'from the beginning' rather than 'from the end'. Every other value of count behaves; only this one flips the meaning of the expression. Guarding it before the slice is one line, and no amount of cleverness inside the slice will fix it.
- Slices are forgiving about the ends
items[-9:] on a five-item list gives all five rather than raising, because a slice clamps its bounds to the sequence — unlike items[-9], which raises IndexError. That forgiveness is why 'more than there are' needs no special handling here, and why slicing is usually the right tool for taking a window out of a list.
- A slice already returns a new list
items[-2:] builds a new list holding the same objects — so the caller can append to what you return without touching your input. It is a shallow copy: the items themselves are shared, so mutating one of them is still visible from both. For a list of numbers or strings that distinction never comes up; for a list of dictionaries it eventually will.
The one-line slice is nearly right. Try it with a count of zero before you decide it is finished — that is the case the whole exercise is about.
Two guards go in front of the slice: refuse a negative count, and answer a count of zero directly rather than letting it reach items[-0:].
if count < 0: raise ValueError(...). if count == 0: return []. Then return list(items[-count:]) — the list() is not strictly needed since a slice already copies, but it makes the promise explicit and keeps working if someone passes a tuple.
def take_last(items, count): if count < ____: raise ____(f’count cannot be negative, got {count}') if count == ____: return [] return list(items[____:])
def take_last(items, count):
if count < 0:
raise ValueError(f"count cannot be negative, got {count}")
# -0 is 0, so items[-count:] would be the whole list here.
if count == 0:
return []
return list(items[-count:])
Most comments explaining what a line does are noise. This one explains why a line *exists* — without it, the zero check looks like defensive clutter and the next person deletes it, reintroducing a bug that no ordinary reading of the slice would reveal. Comment the surprises, not the syntax.
Both special cases are settled before the interesting expression runs, so the last line means one thing and needs no conditions inside it. The alternative — items[len(items) - count:] with a max() to keep it from going negative — handles zero correctly and is markedly harder to read, which is a trade most people make in the wrong direction.
You could treat a negative count as zero and return an empty list. That is a defensible API and it hides the caller’s arithmetic mistake forever, because -1 and 0 then produce the same output. A count is a quantity; a negative quantity is a bug upstream, and the kindest thing to do with it is say so.
If you are repeatedly taking the last few of a list that keeps growing, do not keep the whole list — collections.deque(maxlen=n) throws away the old items as new ones arrive, so 'the last n' is simply what it holds, at constant memory. That is the right structure for a rolling log, a recent-items panel or a moving window. This function is for when you already have the full list and want a piece of it.
- Write take_first alongside it and notice which of the two guards it still needs.
- Rebuild it with deque(items, maxlen=count) and work out what that does when count is zero — the answer is not the same trap, but it is worth knowing.
- Add a step, so 'every second item of the last ten' is one call, and see how quickly the argument list stops being readable.