Skip to content

Two dots include the end, three dots exclude it

Level: 101 · a Python programmer who knows range and slices

One line: 1..5 is five numbers and 1...5 is four; a Range can be endless (1..), beginless (..5), made of Strings ("a".."e") or of Floats; and cover? asks "between the ends?" where include? asks "one of the elements?" — Python's range is always half-open, always integers, and a sequence you can index.

A Range is an object with a begin, an end and an exclude_end? flag — it is not a list of its elements, and it stays a Range until something asks it to iterate. That is why 1.. can exist (there is no end to store), why to_a on it raises, and why (1..10**12).sum returns at once: for Integer ends, sum uses the arithmetic-series formula instead of looping (Not machine-checked here.). The same object serves four jobs: slicing (a[1..2], s[..3]), case/when (whose === is cover?), iteration (each, step, map), and membership. Membership has two spellings that agree on numbers and disagree on Strings: cover? compares against the two ends with <=>, so ("a".."z").cover?("bb") is true because "a" <= "bb" <= "z"; include? walks the elements for a String range, so ("a".."z").include?("bb") is false — but for a numeric range include? is cover?, which is how (1..10).include?(5.5) is true. A Range is frozen, cannot be indexed, and compares by its ends rather than its elements.

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

 1. (1..5).to_a, (1...5).to_a                                [[1, 2, 3, 4, 5], [1, 2, 3, 4]]
 2. (1..5).size, (1...5).size, (1..5).class                  [5, 4, Range]
 3. ("a".."e").to_a, ("a".."e").to_a.join                    [["a", "b", "c", "d", "e"], "abcde"]
 4. (1..).first(3), (1..).end  (endless)                     [[1, 2, 3], nil]
 5. (..5).include?(3), (..5).cover?(9)  (beginless)          [true, false]
 6. (1..).to_a                                               RangeError: cannot convert endless range to an array
 7. (1..10).step(3).to_a, ((1..10) % 3).to_a                 [[1, 4, 7, 10], [1, 4, 7, 10]]
 8. (1.0..2.0).step(0.5).to_a  (a Float range)               [1.0, 1.5, 2.0]
 9. ("a".."z"): include?("bb"), cover?("bb"), === "bb"       [false, true, true]
10. (1..10).include?(5.5), (1..10).to_a.include?(5.5)        [true, false]
11. case 42 when 1..10 / when 11..100 / else                 "medium"
12. a[1..2], a[1...-1], a[..1], a[2..], "hello"[1..3]        [[20, 30], [20, 30, 40], [10, 20], [30, 40, 50], "ell"]
13. (1..100).sum, (1..10**12).sum  (a formula, not a loop)   [5050, 500000000000500000000000]
14. (1...5).last, (1...5).last(1), (1...5).max               [5, [4], 4]
15. (5..1).to_a, 5.downto(1).to_a                            [[], [5, 4, 3, 2, 1]]
16. (1..5) == (1...6), (1..5).to_a == (1...6).to_a           [false, true]
17. (1..5)[1]  (a Range is not indexable)                    NoMethodError: undefined method '[]' for an instance of Range
18. 7.clamp(1..5), (1..5).cover?(2..3), (1..5).frozen?       [5, true, true]

Reading the output

Rows 1–3: .. keeps the end and ... drops it, so the sizes are 5 and 4; a String range runs from "a" to "e" — it steps with succ (Not machine-checked here.).

Rows 4–6: an endless range has end nil and can still answer first(3); a beginless one answers include? and cover? by comparing against its one end; asking the endless one for to_a is a RangeError.

Rows 7–8: step(3) and its alias % produce every third element (an Enumerator::ArithmeticSequence), and a Float range steps by 0.5.

Rows 9–10 are the two membership questions. On "a".."z", cover?("bb") and === "bb" are true and include?("bb") is false; on 1..10, include?(5.5) is true because a numeric range answers by comparison, while the same question of the array (1..10).to_a is false.

Rows 11–13: when 11..100 matches 42 through ===; ranges slice arrays and strings, with ..1 and 2.. for the open ends; the two sums come from the formula — the second, (1..10**12).sum, would not return in any reasonable time as a loop.

Row 14 is a small trap: (1...5).last is 5, the stored end, even though the end is excluded — last(1) and max iterate and give 4.

Rows 15–18: 5..1 is empty rather than descending (downto counts down); 1..5 and 1...6 are different Ranges with the same elements; (1..5)[1] is a NoMethodError; clamp takes a range, cover? accepts a whole range, and every Range is frozen.

Compared with Python

The twin asks range, itertools.count and slices the same eighteen questions. range(1, 6) is the only spelling — half-open, integer-only (range(1.0, 2.0) is a TypeError, row 8), with the step as a third argument (row 7). There are no character ranges (row 3 builds the letters from code points or takes them from string.ascii_lowercase), no endless range (itertools.count is the endless iterator, row 4, and list(count(1)) would never return, so row 6 does not run it (Not machine-checked here.)), and no beginless range (row 5 is a comparison). Membership in a range is integer membership: 5.5 is not in range(1, 11) but 5.0 is (row 10), and the two-spellings question of row 9 becomes in on a list against a chained comparison. match has no range pattern, so row 11 uses guards. sum(range(...)) iterates, so the twin prints the formula for 10**12 instead of waiting (row 13). Where Ruby's Range refuses indexing, Python's range is a full sequence: it has [-1], slices to another range (rows 14 and 17), and compares as a sequence, so range(0) == range(1, 1) is true (row 16) where (1..5) == (1...6) was false. clamp is min(max(x, lo), hi) (row 18).

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

 1. list(range(1, 6)), list(range(1, 5))  (half-open only)   [[1, 2, 3, 4, 5], [1, 2, 3, 4]]
 2. len(range(1, 6)), len(range(1, 5)), type(range(1, 6))    [5, 4, range]
 3. [chr(c) for c in range(97, 102)], ascii_lowercase[:5]    [['a', 'b', 'c', 'd', 'e'], 'abcde']
 4. list(islice(count(1), 3)); count(1) has no end           [[1, 2, 3], None]
 5. 3 <= 5, 9 <= 5  (no beginless range: a comparison)       [True, False]
 6. list(count(1))  (not run: it would never return)         -
 7. list(range(1, 11, 3))  (the step is the third argument)  [1, 4, 7, 10]
 8. range(1.0, 2.0); [1 + i * 0.5 for i in range(3)]         TypeError, [1.0, 1.5, 2.0]
 9. "bb" in letters (a list), "a" <= "bb" <= "z"             [False, True]
10. 5.5 in range(1, 11), 5.0 in range(1, 11)                 [False, True]
11. match 42: case n if n in range(1, 11) / range(11, 101)   'medium'
12. a[1:3], a[1:-1], a[:2], a[2:], "hello"[1:4]              [[20, 30], [20, 30, 40], [10, 20], [30, 40, 50], 'ell']
13. sum(range(1, 101)); n * (n + 1) // 2 for n = 10**12      [5050, 500000000000500000000000]
14. range(1, 5)[-1], range(1, 5)[-1:], max(range(1, 5))      [4, range(4, 5), 4]
15. list(range(5, 1)), list(range(5, 0, -1))                 [[], [5, 4, 3, 2, 1]]
16. range(1, 6) == range(1, 6, 1), range(0) == range(1, 1)   [True, True]
17. range(1, 6)[1], range(1, 6)[1:3]  (range is a sequence)  [2, range(2, 4)]
18. min(max(7, 1), 5), set(range(2, 4)) <= set(range(1, 6))  [5, True]

The Python library's Slicing is not indexing ↗ is the page on s[:n], the slice that never raises.

Try it

  1. Write to_ranges(ints) that compresses a sorted array of integers into inclusive ranges — [1, 2, 3, 5, 7, 8, 9] becomes [1..3, 5..5, 7..9] — and expand(ranges) that turns them back, then check the round trip on a few inputs including [] and a single element. slice_when is the shortcut.
  2. Write page_ranges(total, per_page) that returns [1..10, 11..20, 21..25] for (25, 10), twice: once from 1.step(total, per_page) and once from (1..total).each_slice(per_page). Then explain why the last range must end at total and not at 21 + per_page - 1.
Solution to 1

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

# Kata: compress a sorted list of integers into inclusive ranges, then expand
# the ranges back and check the round trip.

def to_ranges(ints)
  ints.slice_when { |a, b| b != a + 1 }.map { |run| run.first..run.last }
end

def expand(ranges) = ranges.flat_map(&:to_a)

[[1, 2, 3, 5, 7, 8, 9], [4], [], [1, 3, 5], [10, 11, 12, 13]].each do |ints|
  ranges = to_ranges(ints)
  verdict = expand(ranges) == ints ? "round trip ok" : "ROUND TRIP FAILED"
  printf("%-24s -> %-28s %s\n", ints.inspect, ranges.inspect, verdict)
end

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

[1, 2, 3, 5, 7, 8, 9]    -> [1..3, 5..5, 7..9]           round trip ok
[4]                      -> [4..4]                       round trip ok
[]                       -> []                           round trip ok
[1, 3, 5]                -> [1..1, 3..3, 5..5]           round trip ok
[10, 11, 12, 13]         -> [10..13]                     round trip ok

See also