刚学 Python 那会儿也觉得非常别扭。
range(1, 8)
居然不包含 8?写代码的时候老是要 +1-1 的,烦得要死。直到后来读到 Dijkstra 在 1982 年写的一份手稿,才彻底想明白。
Dijkstra 是谁就不用多介绍了吧,最短路径算法那个。他专门手写过一份备忘录,标题叫《Why numbering should start at zero》,里面用一种极其严谨的方式,把”区间到底该怎么表示”这个问题给彻底终结了。(看到也有其他博主举这个例子,这里简单说)
他的推导过程大概是这样的——
要表示 2 到 12 这个序列,无非四种写法:
| 写法 | 区间 |
|---|---|
| a | 2 ≤ i < 13 |
| b | 1 < i ≤ 12 |
| c | 2 ≤ i ≤ 12 |
| d | 1 < i < 13 |
Dijkstra 逐一排除,过程堪称干净利落。
第一轮筛选:长度计算。 a 和 b 明显占优。因为
,
,上界减下界直接得到元素个数。而 c 和 d 你还得额外做一次加减运算才能算出来,在编程中是真的麻烦。
第二轮筛选:下界的选取。 自然数有最小值(0),但没有最大值。如果下界用严格小于(像 b 和 d 那样),那表示”从 0 开始的序列”时,你得写成
,也就是说你被迫引入了一个不属于自然数的值来描述自然数序列。这很不优雅。所以下界应该用
,a 和 c 胜出。
第三轮筛选:空序列的表示。 这个是致命的。假设我们选了 c,用
这种闭区间。那么当序列收缩为空的时候怎么写?
?上界比下界还小,这在语义上非常怪异。而如果用 a,空序列就是
,干干净净,
不存在,完事。
最终结果:a 以绝对优势胜出。 也就是左闭右开
。
再回到写代码的场景。左闭右开的好处:
长度计算零成本
r = range(3, 10)
len(r) # 就是 10 - 3 = 7
end 减 start 直接得到长度,不需要任何额外操作。如果是闭区间
,长度就是
,多出来的那个
看似微不足道,但在大量涉及区间运算的代码里,它是 off-by-one 错误的温床。
区间拼接无缝衔接,这一点是我个人认为最重要的。
假设要把一个列表切成两段:
data = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
first = data[:5] # [0, 1, 2, 3, 4]
second = data[5:] # [5, 6, 7, 8, 9]
前一段的终点就是后一段的起点,没有任何重叠或者遗漏,在闭区间下做不到——
和
在 5 这个位置上是重复的,你必须手动处理边界。
这个特性在分治算法、并行计算任务分片、数据分页这些场景里非常关键。你可以放心地用一个切分点把区间一分为二,不用担心边界元素被重复处理或者被漏掉。
空区间的表达自然
list(range(5, 5)) # []
就是空。不需要任何特殊标记,不需要用
或者
这种 hack 手段来表示”这里啥也没有”。
那么问题来了,
random.randint(1, 6)
为什么包含 6?
这其实是 Python 早期的一个历史遗留问题。
randint
的设计更多是出于对数学直觉的妥协——掷骰子嘛,你说”1 到 6 的随机数”,人的第一反应就是包含 6 的。但 Python 后来引入了
random.randrange()
,它就是左闭右开的,跟
range()
保持了一致。
randint
没改过来,纯粹是为了向后兼容,不想让已有代码炸掉。
所以严格来说,
randint
是个”设计失误被兼容性锁死”的例子,反过来恰恰证明了左闭右开才是这门语言真正推崇的约定。
其实左闭右开也不是只有 Python 有这样的设计。C++ 的 STL,
begin()
和
end()
就是左闭右开,
end()
指向的是最后一个元素的下一个位置。Java 的
substring(beginIndex, endIndex)
也是左闭右开。Go 的 slice 也是。Rust 的
0..n
也是
甚至早在 1980 年代,Xerox PARC 开发的Mesa 语言在讨论了所有可能的区间表示方式后,就已经明确选择了左闭右开,并且强烈建议不要使用其他三种。
所以当你下次写
range(1, 8)
然后发现它不包含 8 的时候,就感觉不奇怪了,四十多年前就被论证清楚了。