Python 的切片為什麼不會索引越界?

豌豆花下貓發表於2021-12-20

切片(slice)是 Python 中一種很有特色的特性,在正式開始之前,我們先來複習一下關於切片的知識吧。

切片主要用於序列物件中,按照索引區間擷取出一段索引的內容。

切片的書寫形式:[i : i+n : m] ;其中,i 是切片的起始索引值,為列表首位時可省略;i+n 是切片的結束位置,為列表末位時可省略;m 可以不提供,預設值是 1,不允許為 0,當 m 為負數時,列表翻轉。

切片的基本含義是:從序列的第 i 位索引起,向右取到後 n 位元素為止,按 m 間隔過濾

下面是一些很有代表性的例子,基本涵蓋了切片語法的使用要點:

# @Python貓
li = [1, 4, 5, 6, 7, 9, 11, 14, 16]

# 以下寫法都可以表示整個列表,其中 X >= len(li)
li[0:X] == li[0:] == li[:X] == li[:] == li[::] == li[-X:X] == li[-X:]

li[1:5] == [4,5,6,7] # 從1起,取5-1位元素
li[1:5:2] == [4,6] # 從1起,取5-1位元素,按2間隔過濾
li[-1:] == [16] # 取倒數第一個元素
li[-4:-2] == [9, 11] # 從倒數第四起,取-2-(-4)=2位元素
li[:-2] == li[-len(li):-2] == [1,4,5,6,7,9,11] # 從頭開始,取-2-(-len(li))=7位元素

# 步長為負數時,列表先翻轉,再擷取
li[::-1] == [16,14,11,9,7,6,5,4,1] # 翻轉整個列表
li[::-2] == [16,11,7,5,1] # 翻轉整個列表,再按2間隔過濾
li[:-5:-1] == [16,14,11,9] # 翻轉整個列表,取-5-(-len(li))=4位元素
li[:-5:-3] == [16,9] # 翻轉整個列表,取-5-(-len(li))=4位元素,再按3間隔過濾

# 切片的步長不可以為0
li[::0]  # 報錯(ValueError: slice step cannot be zero)

像 C/C++、Java 和 JavaScript 等語言,雖然也支援某些“切片”功能,例如擷取陣列或字串的片段,但是,它們並沒有一種在語法層面上的通用性支援。

根據維基百科資料,Fortran 是最早支援切片語法的語言(1966),而 Python 則是最具代表性的語言之一。

主要程式語言對切片的支援

另外,像 Perl、Ruby、Go 和 Rust 等語言,雖然也有切片,但都不及 Python 那樣靈活和自由(因為它支援 step、負數索引、預設索引)。

程式語言中切片語法的形式

切片的基本用法就能夠滿足大部分的需求,但是,Python 切片還有一些進階的用法,例如:切片佔位符用法(可實現列表的賦值、刪除與拼接操作)、自定義物件實現切片功能、迭代器切片(itertools.islice())、檔案物件切片等等。關聯閱讀:Python進階:全面解讀高階特性之切片!

關於切片的介紹與溫習,就到這裡了。

下面進入文章標題的問題:Python 的切片語法為什麼不會出現索引越界呢?

當我們根據單個索引進行取值時,如果索引越界,就會得到報錯:“IndexError: list index out of range”。

>>> li = [1, 2]
>>> li[5]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: list index out of range

對於一個非空的序列物件,假設其長度為 length,則它有效的索引值是從 0 到(length - 1)。如果把負數索引也考慮進去,則單個索引值的有效區間是 [-length, length - 1] 閉區間。

但是,當 Python 切片中的索引超出這個範圍時,程式並不會報錯。

>>> li = [1, 2]
>>> li[1:5]  # 右索引超出
[2]
>>> li[5:6]  # 左右索引都超出
[]

其實,對於這種現象,官方文件中有所介紹:

The slice of s from i to j is defined as the sequence of items with index k such that i <= k < j. If i or j is greater than len(s), use len(s). If i is omitted or None, use 0. If j is omitted or None, use len(s). If i is greater than or equal to j, the slice is empty.

也就是說:

  • 當左或右索引值大於序列的長度值時,就用長度值作為該索引值;
  • 當左索引值預設或者為 None 時,就用 0 作為左索引值;
  • 當右索引值預設或者為 None 時,就用序列長度值作為右索引值;
  • 當左索引值大於等於右索引值時,切片結果為空物件。

對照上面的例子,可以得到:

>>> li = [1, 2]
>>> li[1:5]  # 等價於 li[1:2]
[2]
>>> li[5:6]  # 等價於 li[2:2]
[]

歸結起來一句話:Python 直譯器把可能導致索引越界的操作給遮蔽了,你的寫法可以很自由,但是最終的結果會被死死限制在合法的索引區間內。

對於這個現象,我其實是有點疑惑的,為什麼 Python 不直接報索引越界呢,為什麼要修正切片的邊界值,為什麼一定要返回一個值呢,即便這個值可能是個空序列?

當我們使用“li[5:6]”時,至少在字面意義上想表達的是“取出索引從 5 到 6 所對應的值”,就像是在說“取出書架上從左往右數的第 6 和 7 本書”。

如果程式是如實地遵照我們的指令的話,它就應該報錯,就應該說:對不起,書架上的書不夠數。

實話說,我並沒有查到這方面的解釋,這篇文章也不是要給大家科普 Python 在設計上有什麼獨到的見解。恰恰相反,這篇文章的主要目的之一是希望得到大家的回覆解答。

在 Go 語言中,遇到同樣的場景時,它的做法是報錯“runtime error: slice bounds out of range”。

在 Rust 語言中,遇到同樣的場景時,它的做法是報錯“byte index 5 is out of bounds of ......”。

在其它支援切片語法的語言中,也許還有跟 Python 一樣的設計。但是,我還不知道有沒有(學識淺薄)……

最後,繼續回到標題中的問題“Python 的切片為什麼不會索引越界”。我其實想問的問題有兩個:

  • 當切片語法中的索引超出邊界時,為什麼 Python 還能返回結果,返回結果的計算原理是什麼?
  • 為什麼 Python 的切片語法要允許索引超出邊界呢,為什麼不設計成丟擲索引錯誤?

對於第一個問題的回答,官方文件已經寫得很明白了。

對於第二個問題,本文暫時沒有答案。

也許我很快就能找到答案,但是,也可能需要很久。不管如何,本文先到此為止了。

如果你喜歡研究 Python 設計上的小細節,感興趣探求“為什麼”問題的解答,歡迎關注“Python為什麼”系列文章。

推薦閱讀最受大家喜歡的往期話題:

(1)Python 為什麼推薦蛇形命名法?

(2)Python 為什麼用 # 號作註釋符?

(3)Python 之父為什麼嫌棄 lambda 匿名函式?

(4)Python 為什麼不支援 switch 語句?

(5)Python 疑難問題:[] 與 list() 哪個快?為什麼快?快多少呢?

(6)Python 為什麼不支援 i++ 自增語法,不提供 ++ 操作符?

本文屬於“Python為什麼”系列(Python貓出品),該系列主要關注 Python 的語法、設計和發展等話題,以一個個“為什麼”式的問題為切入點,試著展現 Python 的迷人魅力。所有文章將會歸檔在 Github 上,專案地址:https://github.com/chinesehuazhou/python-whydo

相關文章