Skip to content

Past the end, an array gives nil, not an error

Level: 101 · a Python programmer's first week with Ruby arrays

One line: a[10] on a four-element array is nil, a[5] = 99 pads the gap with nil, and a slice is a[1..2] (end included) or a[1, 2] (start and length) — where Python raises IndexError on both the read and the write and has one slice form, a[1:3], whose stop is excluded.

A Ruby Array is the same kind of thing as a Python list: an ordered, growable, heterogeneous sequence, indexed from zero, with negative indexes counting from the end. The contracts differ at the edges. Reading past either end returns nil rather than raising; fetch is the method that raises, and it takes a default. Writing past the end grows the array and fills the gap with nil — but writing before the start (d[-5] = 1 on four elements) is an IndexError, because there is no gap to fill there. A slice has two spellings, a[start..end] with a Range and a[start, length], and its own edge rule: a slice that starts exactly at the end is [], one that starts past it is nil. Two more things a Python reader should carry over: Array.new(3, []) stores one default object in all three slots, exactly as [[]] * 3 does, and the block form Array.new(3) { [] } is the fresh-object form, exactly as the comprehension is; and push, << and unshift return the array, so they chain, where Python's append and insert return None.

Verified output of arrays_and_negative_indexes_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. a = [10, 20, 30, 40]; a.length               4
 2. a[0], a[-1]  (negative: from the end)        [10, 40]
 3. a[10], a[-10]  (past either end)             [nil, nil]
 4. a.fetch(10)  (the raising form)              IndexError: index 10 outside of array bounds: -4...4
 5. a.fetch(10, :none)                           :none
 6. a[1..2]  (two dots: end included)            [20, 30]
 7. a[1...3]  (three dots: end excluded)         [20, 30]
 8. a[1..]  (endless range: to the end)          [20, 30, 40]
 9. a[1, 2]  (start, length)                     [20, 30]
10. a[4..], a[5..]  (slice at / past the end)    [[], nil]
11. b[5] = 99 on a 4-element copy (pads)         [10, 20, 30, 40, nil, 99]
12. d = a.dup; d[-5] = 1  (before the start)     IndexError: index -5 too small for array; minimum: -4
13. a.first(2), a.last(2), [].first              [[10, 20], [30, 40], nil]
14. a.values_at(0, 2, 10)                        [10, 30, nil]
15. Array.new(3, []) then x[0] << 1  (shared)    [[1], [1], [1]]
16. Array.new(3) { [] } then y[0] << 1 (fresh)   [[1], [], []]
17. nested.flatten, nested.flatten(1)            [[1, 2, 3, 4], [1, 2, [3, [4]]]]
18. c = [1, 2]; c.push(3) << 4  (returns c)      [1, 2, 3, 4]
19. c.pop, c.shift, then c                       [4, 1, [2, 3]]
20. c.unshift(0)  (returns c)                    [0, 2, 3]
21. [*a, 5], [*1..3], f(*a) with def f(*args)    [[10, 20, 30, 40, 5], [1, 2, 3], [10, 20, 30, 40]]
22. a - [20], a & [20, 99], a | [99]             [[10, 30, 40], [20], [10, 20, 30, 40, 99]]

Reading the output

Rows 1–5 are indexing. a[-1] is the last element, a[10] and a[-10] are both nil, and fetch(10) is the raising form, whose message names the bounds -4...4; fetch(10, :none) returns the default instead.

Rows 6–10 are slicing. a[1..2] and a[1...3] are the same two elements, because two dots include the end and three exclude it; a[1..] runs to the end; a[1, 2] means "two elements starting at 1", not "1 to 2". Row 10 is the edge rule: a slice starting at index 4 of a four-element array is [], and one starting at 5 is nil.

Rows 11–12 are writing. Assigning to index 5 grows the copy to six elements with a nil in slot 4; assigning to index −5 of a fresh four-element copy raises, with a message that names the minimum index.

Rows 13–14: first(n) and last(n) are the slices you reach for most, [].first is nil like every other miss, and values_at picks several indexes at once, returning nil for a missing one.

Rows 15–16 are the shared-default trap. Array.new(3, []) evaluates [] once and stores that one array three times, so appending through x[0] shows up in every slot. Array.new(3) { [] } runs the block once per slot.

Row 17: flatten recurses all the way down and flatten(1) one level.

Rows 18–20: push (and its alias <<) and unshift return the array, so c.push(3) << 4 is one expression; pop and shift return the element they removed.

Row 21: * splats an array into a literal or an argument list, and def f(*args) collects the arguments back into an Array. Row 22: -, & and | are set-like operators on arrays, keeping the left operand's order.

Compared with Python

The Python twin asks the same twenty-two questions of a list. The reads and writes that gave nil or padded in Ruby raise IndexError in Python (rows 3, 4, 11, 12, 13 and 14), so there is no fetch — plain indexing already is the raising form, and a default needs an if (row 5). Slicing has one spelling, a[start:stop] with the stop excluded (rows 6–9), and a slice that starts anywhere past the end is [], never None (row 10). The shared-default trap is the same trap under a different spelling, [[]] * 3 (rows 15–16). Python has no flatten (row 17: a recursive helper, or itertools.chain.from_iterable for one level). append, extend and insert return None, so they do not chain (rows 18 and 20), and *args collects into a tuple, not a list (row 21). list - list is a TypeError; the set-like operators need a set (row 22).

Verified output of arrays_and_negative_indexes_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. a = [10, 20, 30, 40]; len(a)                 4
 2. a[0], a[-1]  (negative: from the end)        [10, 40]
 3. a[10], a[-10]  (past either end)             IndexError, IndexError
 4. a[10] is already the raising form            IndexError
 5. a[10] if len(a) > 10 else 'none'             'none'
 6. a[1:3]  (stop excluded; no inclusive form)   [20, 30]
 7. a[1:3]  (the only slice form)                [20, 30]
 8. a[1:]  (to the end)                          [20, 30, 40]
 9. a[1:1 + 2]  (no start, length form)          [20, 30]
10. a[4:], a[5:]  (slice at / past the end)      [[], []]
11. b[5] = 99 on a 4-element copy                IndexError
12. d = a[:]; d[-5] = 1  (before the start)      IndexError
13. a[:2], a[-2:], [][0]                         [10, 20], [30, 40], IndexError
14. itemgetter(0, 2)(a); with 10 too             (10, 30), IndexError
15. [[]] * 3 then x[0].append(1)  (shared)       [[1], [1], [1]]
16. [[] for _ in range(3)] then y[0]... (fresh)  [[1], [], []]
17. flatten(nested) helper, chain one level      [[1, 2, 3, 4], [1, 2, [3, [4]]]]
18. c = [1, 2]; c.append(3), c.extend([4]), c    [None, None, [1, 2, 3, 4]]
19. c.pop(), c.pop(0), then c                    [4, 1, [2, 3]]
20. c.insert(0, 0), then c                       [None, [0, 2, 3]]
21. [*a, 5], [*range(1, 4)], f(*a) with *args    [[10, 20, 30, 40, 5], [1, 2, 3], (10, 20, 30, 40)]
22. a - [20]; sorted(set(a) - {20})              TypeError, [10, 30, 40]

The Python library's Slicing is not indexing ↗ measures the same two contracts on str — s[n] may raise, s[:n] may not — and what that does to text.

Try it

  1. Write rotate_right(a, k) that returns a copy of a rotated right by k places using only negative-index slices and the splat — no Array#rotate — and make it work for k = 0, for k larger than the size and for negative k. Check every result against a.rotate(-k).
  2. Write chunks(a, n) that splits an array into arrays of at most n elements with a[i, n] in a loop that stops when the slice comes back nil, then compare its result with a.each_slice(n).to_a — and explain why the loop must test for nil rather than for [] (row 10 is the hint).
Solution to 1

arrays_and_negative_indexes_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: rotate an array right by k places with negative slices and the splat,
# for any integer k, without Array#rotate -- then check against a.rotate(-k).

def rotate_right(a, k)
  return a.dup if a.empty?
  k %= a.size                  # 0, k > size and negative k all reduce to 0...size
  [*a[-k..], *a[...-k]]        # the last k elements first, then everything before them
end

a = [1, 2, 3, 4, 5]
(-2..7).each do |k|
  mine = rotate_right(a, k)
  verdict = mine == a.rotate(-k) ? "== a.rotate(-k)" : "DIFFERS from a.rotate(-k)"
  printf("k = %2d  %-16s %s\n", k, mine.inspect, verdict)
end
puts "empty: #{rotate_right([], 3).inspect}"

Verified output of arrays_and_negative_indexes_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

k = -2  [3, 4, 5, 1, 2]  == a.rotate(-k)
k = -1  [2, 3, 4, 5, 1]  == a.rotate(-k)
k =  0  [1, 2, 3, 4, 5]  == a.rotate(-k)
k =  1  [5, 1, 2, 3, 4]  == a.rotate(-k)
k =  2  [4, 5, 1, 2, 3]  == a.rotate(-k)
k =  3  [3, 4, 5, 1, 2]  == a.rotate(-k)
k =  4  [2, 3, 4, 5, 1]  == a.rotate(-k)
k =  5  [1, 2, 3, 4, 5]  == a.rotate(-k)
k =  6  [5, 1, 2, 3, 4]  == a.rotate(-k)
k =  7  [4, 5, 1, 2, 3]  == a.rotate(-k)
empty: []

See also