Python 中的 range 函数为什么要设计成「左闭右开」的样子,这是有啥用意吗?

AI解码师,w_ferrari787381610

刚学 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 的时候,就感觉不奇怪了,四十多年前就被论证清楚了。