元組與集合
元組(Tuple)
在開始之前,先不管 Tuple 是什麼東西或是怎麼翻譯,首先,你覺得 Tuple 該怎麼發音?曾經有人在 Twitter 上問 Python 的發明人 Guido 這個字應該怎麼唸,結果他的回答是:
Guido 說他星期一、三、五唸「吐波」,星期二、四、六唸「塔波」,星期日就不唸它。意思就是你想怎麼唸就怎麼唸,只要你自己或你的同事聽的懂就好。至於中文要翻譯成「元組」或「數組」就隨你開心,不過不管哪個翻譯我其實都認為不太容易從字面上理解,所以本書大部份會使用 Tuple 原文。
是說,Tuple 這個字是什麼意思?你可能會查到這個字在數學上代表「有限的有序數列」,但這個字怎麼來的?Tuple 這個字並不是 Python 發明的,這個字的起源跟拉丁文的數字有點關係。我們用英文數東西的時候,一個是 single,兩個是 couple 或 double,三個是 triple,到這裡這幾個字我們可能都還唸的出來,但接下來呢?quadruple、quintuple、sextuple、septuple、octuple、centuple 分別是四、五、六、七、八以及百倍,依此類推。而這些字結尾的 -ple
在拉丁文是 plus 或 more 的意思,所以 n-tuple 就是來自於這些詞的抽象化,而 Tuple 這個名字就是這樣來的。
冷知識講完了,讓我們回來看看 Python 裡的 Tuple。
建立 Tuple
Tuple 跟串列一樣也是一種有順序而且可以裝很多東西的資料結構,只是寫起來的樣子不一樣,使用上也有些不同的地方。雖然看起來跟串列有點像,但真的要比較的話,Tuple 的設計可能跟字串比較接近。在 Python 要建立一個 Tuple,最簡單的方式就是使用小括號 ( )
把東西包起來,裡面的元素用逗號分開,像這樣:
>>> location = (24.1234, 121.1234)
# 它是個 Tuple
>>> type(location)
<class 'tuple'>
或是可使用 tuple()
把像是串列或字串之類的可迭代物件轉換成 Tuple:
>>> tuple([1, 2, 3])
(1, 2, 3)
>>> tuple('hello')
('h', 'e', 'l', 'l', 'o')
效果一樣,只是寫法不同而已。跟串列一樣,Tuple 裡的資料沒有規定都要相同類型,你想放什麼就放什麼:
>>> medical_record = ('John Doe', 20, '202', True)
跟串列不同的地方除了寫起來的樣子不同之外,最明顯的差異就是 Tuple 是不可變的(Immutable)。這點跟字串的設計是一樣的,也就是說一旦 Tuple 裡的資料設定好之後就不能再改變了,不能新增、修改或移除裡面的元素。
雖然建立 Tuple 的時候並沒有限定元素個數,但如果裡面只打算裝一個元素的話,最後面要加上逗號,不然 Python 會把它當成其他的資料型態:
>>> this_is_not_tuple = ('hello')
>>> type(this_is_not_tuple)
<class 'str'>
# 只有一個的話要加上逗號
>>> only_one = ('hello',)
>>> type(only_one)
<class 'tuple'>
('hello')
其實就是字串,外面的小括號就只是一般的括號而已,只是個裝飾品,有加沒加效果都一樣。所以如果只有一個元素的 Tuple, 別忘了最後要加上逗號。
另外,如果你像我一樣喜歡偷懶,Tuple 的小括號也可以省略不寫,只要把每個元素用逗號隔開,Python 會自動把它當成 Tuple:
>>> numbers = 1450, 9527, 5566
>>> numbers
(1450, 9527, 5566)
>>> type(numbers)
<class 'tuple'>
不過這種寫法有沒有比較容易理解就看個人了,在我的認知裡小括號可以讓我更清楚知道這就是一整包的資料,所以我還是會習慣加上小括號。如果只有一個元素同樣也要加上逗號:
>>> numbers = 9527,
>>> numbers
(9527,)
看起來有點怪,感覺像一句話沒講完。再看看底下這個例子,你猜猜會是什麼結果:
>>> 1, 2 > 1, 3
這到底是兩個 Tuple 在互相比大小,還是一顆三個元素的 Tuple?執從之後會得到 (1, True, 3)
,這是因為大於小於的優先順序比逗號高,所以 2 > 1
先被計算,這很明顯就不是個應該省略小括號的例子。
看到這裡,Tuple 的小括號可以省略,但逗號卻不行,有沒有一種在 Tuple 的小括號只是配角,逗號才是主角的感覺。
常見操作
Tuple 跟串列一樣是有順序的,所以可透過索引值取得裡面的資料,用起來的手感跟串列一樣,索引值都是從 0 開始算:
>>> medical_record = ('John Doe', 20, '202', 'A')
>>> medical_record[0]
'John Doe'
>>> medical_record[1]
20
>>> medical_record[-1]
'A'
索引值從 0 開始,如果索引值是負的話就是從後面開始算,-1
就是最後一個,-2
就是倒數第二個,依此類推,如果拿超過索引值以外的資料會出現 IndexError
的錯誤,這些操作都跟串列一樣。不過因為 Tuple 是不可變的,所以如果想要改變裡面的元素會發生錯誤:
>>> medical_record = ('John Doe', 20, '202', 'A')
>>> medical_record[0] = 'Mary'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
你也許會想,誰說 Tuple 是不可變的,你看我這樣寫不就改變了嗎:
>>> t1 = (1, 2, 3)
>>> t1 += (4, 5, 6)
>>> t1
(1, 2, 3, 4, 5, 6)
嗯,如果你也這樣認為,那就表示你可能誤解了「不可變」的意思。上面這樣的寫法,其實並不是改變 Tuple 的內容,而是創建了一個全新的 Tuple,然後把它指定給原本的變數而已。這個行為叫做「重新指定(Reassignment)」,並不是修改 Tuple 的內容。
Tuple 是一種可迭代物件,所以迭代操作也都跟串列一樣,同樣使用 for ... in ...
的方式:
heroes = ("悟空", "鳴人", "魯夫")
for hero in heroes:
print(hero)
然後我們在串列章節介紹過的一些方法,像是內建函數 len()
或是物件身上的 .count()
以及 .index()
之類查詢的方法,Tuple 也都可以使用,這裡就不再贅述。不過因為 Tuple 是不可變動的,所以像是 .append()
、.insert()
、.remove()
這種會修改 Tuple 的方法就不能用了,使用會出現 AttributeError
的錯誤,就是告訴你 Tuple 沒有這些屬性或方法可以用。
雖說 Tuple 是不可變的,但如果在 Tuple 裡面放的資料是可變的資料型態,例如串列或字典,那麼裡面的串列或字典還是可以修改的,例如:
# 最後一個元素是串列
>>> hero_info = ('悟空', 100, ['克林', '達爾', '比克'])
# 修改最後一個元素裡的第 2 個元素
>>> hero_info[-1][1] = '魯夫'
>>> hero_info
('悟空', 100, ['克林', '魯夫', '比克'])
在上一章介紹到的可雜湊物件,如果 Tuple 裡面所有的元素都是不可變的,我們可以說這個 Tuple 完全是不可變的,它就可以是個可雜湊物件,也可以拿來當字典的 Key;反之如果 Tuple 裡面有可變的資料型態,進行雜湊計算的時候就會被抱怨了。
是說,大家不知道會不會覺得好奇,我們都已經有了串列了,為什麼還需要跟串列操作有七、八成像的 Tuple?Tuple 有什麼特別的地方或是有什麼好處?
先不說別的,光是不可變的資料型態本身就是好處,不過這對於初學者來說可能不太能理解不可變有什麼好處。因為不可變所以不會不小心被誰改到裡面的東西(除非裡面的元素本身是可變動的),這樣資料在各個函數之間被當參數傳來傳去的時候,不用擔心會被哪個函數改到裡面的值。
另外,效能上 Tuple 的效能也比串列好一些,待會就會說明。
複製 Tuple
在前面串列的章節曾經介紹過幾個方式可以用來複製串列:
- 使用
list()
函數 - 使用串列物件的
.copy()
方法 - 使用
copy
模組的copy()
函數 - 使用切片
Tuple 物件本身並沒有 .copy()
方法,不過上述其他手法都能用:
import copy
heroes = ("悟空", "鳴人", "魯夫")
clone_heroes1 = tuple(heroes) # 使用 tuple()
clone_heroes2 = copy.copy(heroes) # 使用 copy 模組的 copy() 函數
clone_heroes3 = heroes[:] # 使用切片
看起來沒什麼問題,但上面這些行為跟串列使用 list()
來進行複製是不同的,list()
會真的複製一份串列出來,但因為 Tuple 的設計是不可變的,所以 Python 不會也不需要真的去複製一份新的 Tuple,所以上面這些寫法都是回傳一個指向原本 Tuple 的參考而已:
print(clone_heroes1 is heroes) # True
print(clone_heroes2 is heroes) # True
print(clone_heroes3 is heroes) # True
也就是說,這些複製品根本不是什麼複製品,這跟原本的 Tuple 根本就是同一包東西!
《冷知識》空的 Tuple?
來來來,你猜猜看如果要建立一個空的 Tuple 該怎麼做?不要想太複雜,一個小括號就能搞定:
# 這是空的 Tuple
>>> t1 = ()
>>> type(t1)
<class 'tuple'>
# 用 tuple() 函數也可以
>>> t2 = tuple()
>>> type(t2)
<class 'tuple'>
有趣的是,一般的 Tuple 就算裡面的元素看起一樣,分別建立的 Tuple 就是不同的東西,但如果是空的 Tuple,因為空的就是空的,它跟柯南的真相一樣,只有一個:
# 有料的 Tuple
>>> t1 = (1, 2, 3)
>>> t2 = (1, 2, 3)
>>> t1 == t2
True
>>> t1 is t2
False
# 如果是空的 Tuple
>>> t1 = ()
>>> t2 = ()
>>> t1 == t2
True
>>> t1 is t2
True
Tuple 身為不可變的資料結構,只要建立之後就不能新增或修改,空的 Tuple 當然也不例外。所以,建立一個空的又不能改 Tuple 是有什麼用途?老實說用途不大,但還是有它可以派上用場的地方。舉個例子,像是用在函數的回傳值:
def get_hero(id):
if id == 0:
return ()
else:
# 經過漫長的查詢,終於找到英雄的資訊...
hero_info = ("悟空", 20, "[email protected]")
return hero_info
get_hero()
函數會根據 id 參數查詢資訊,如果 id 是 0 的話就回傳一個空的 Tuple,不然就回傳某位英雄的資料。你可能會好奇為什麼要回傳空的 Tuple,找不到資料的話,難道不能就回傳個 None
或 False
之類的嗎?當然可以,但我希望這個函數的回傳值可以維持一致性,不管找不找的到,回傳的資料型態都是 Tuple,不會因為找不到資料而改變回傳的資料型態,這時候就能使用空的 Tuple 當做找不到資料時候的回傳值。
Tuple 的效能
技術 上來說,因為 Tuple 是不可變的,所以它不用像串列一樣需要另外的空間來存放可能會變動的資料,白話的說就是比串列更省記憶體空間,效能上也會比串列好一點點,我們寫一小段程式來做個實驗:
from timeit import timeit
t_list = timeit("heroes = ['悟空', '鳴人', '魯夫']", number=10_000_000)
print("List:", t_list)
t_tuple = timeit("heroes = ('悟空', '鳴人', '魯夫')", number=10_000_000)
print("Tuple:", t_tuple)
timeit
是 Python 內建的模組,可以用來計算執行一段程式碼所需要的時間。這裡我分別只用串列和 Tuple 來做簡單的變數宣告,然後讓它執行一千萬次,看看執行的時間,在我的電腦結果是:
List: 0.31187591701745987
Tuple: 0.056503500032704324
而且 Tuple 在建立的時候佔用的資源也比串列少一點點:
import sys
the_list = list(range(10))
the_tuple = tuple(range(10))
print(sys.getsizeof(the_list)) # 136
print(sys.getsizeof(the_tuple)) # 120
sys
模組裡的 getsizeof()
函數可以用來計算物件佔用的記憶體大小,不同的電腦或作業系統執行的結果可能不太一樣,但 Tuple 佔用的記憶體空間通常都會比串列少一些。
技術上來說,串列跟 Tuple 都是連續的記憶體位置,串列可能會增加新的元素,原本的記憶體空間可能就會不夠放,得再去找更大的連續記憶體空間。如果每加一個元素就得再去找一塊新的連續記憶體空間的話也太辛苦了,所以 Python 在建立串列或是再去要新的記憶體空間的時候,會跟系統多要一點空間裝這些可能會新增的元素,就比較不用一直找新的位置。而 Tuple 的設計是不可變的,沒有新增元素的問題,所以需要的空間會比串列小一點。
從帳面上來看,Tuple 的效能的確是比串列來的好。Tuple 在寫起來以及用起來的手感都跟串列有點像,所以如果資料是不會變動的,在選擇資料型態的時候不妨先可考慮用 Tuple 來裝東西。
Tuple 推導式?
串列有串列推導式,字典有字典推導式,Tuple 呢?貌似也有類似的東西,但不太一樣...
>>> t1 = (item for item in range(5))
>>> t1
<generator object <genexpr> at 0x10041b280>
基本上就是把串列推導式的中括號 [ ]
改成小括號 ( )
而已,但大家還記得推導式的用途嗎?推導式就是在做資料轉換,把資料轉換成新的資料。不過不像其他推導式,像上面這樣的寫法並不是產生出一個新的 Tuple,而是得到一個「產生器(Generator)」物件。所以,像上面這樣用小括號寫出來有點像推導式的東西,並不是 Tuple 推導式,而是個「產生器表達式(Generator Expression)」,這寫法會產生一個產生器(有覺得唸起來很繞口嗎)。
我們這裡先不會提到太多關於產生器的細節,在第 11 章的函數進階篇有更多關於產生器詳細介紹,現在你暫時可以把產生器看成一個可迭代物件,所以可以用 for
迴圈把裡面的東西印出來看看:
items = (item for item in range(5))
for i in items:
print(i)
如果想把產生器物件轉換成真正的 Tuple 的話,可使用 tuple()
進行轉換:
>>> t1 = tuple(item for item in range(5))
>>> t1
(0, 1, 2, 3, 4)
因為 Tuple 是使用小括號包起來,所以再加上 for .. in..
這種類似推導式的寫法,有時候會被認為是 Tuple 推導式,但事實上並不是。但不管你認為它是或不是都沒關係,產生器表達式用到的機會相對比較少一點,而且也沒有串列推導式那麼直覺方便,不過產生器本身的設計倒是挺有趣的,關於產生器的細節會在後面章節再來跟大家介紹。
串列,還是 Tuple?
既然串列跟 Tuple 在操作上感覺很類似,好像差別就在寫法不太一樣以及不可變動而已,那麼該選哪一個呢?這沒有標準答案,我也看過工作很多年的工程師從頭到尾就只用串列也是過得好好的。對我來說,我認為 Tuple 並不只是「不可變的串列」而已,提供一些我的建議供大家參考:
- 第一條也是比較明顯的,如果確定裡面要裝的資料是不會變動的,選 Tuple,有可能需要變動的,選串列。不只因為 Tuple 的效能比串列好一些,同時從資料型態 本身也能知道這個容器裡裝的是不會變動的資料,比較不會發生意外。
- 如果是同質性(Homogeneous)的資料,例如都是數字或都是字串,我會選擇串列;異質性(Heterogeneous)的資料,例如裡面有數字、字串、字典等不同型態混著用的,我會選 Tuple。這不是硬性規定,只是我自己個人的習慣,但這點官方文件也有提到:「Tuples are immutable, and usually contain a heterogeneous sequence of elements...」。
集合
講到集合(Set),不知道以前在學校數學課有沒有學過這個東西?沒有也沒關係,我們來 Python 再學一次。在 Python 裡,集合跟串列、Tuple、字典一樣,也是一種資料型態,它跟我們以前在數學課學到的集合很像,而且有一些有趣的特性:
- 集 合裡的元素不會重複。
- 集合裡的元素沒有順序,所以取用集合裡的元素的時候不像串列或 Tuple 一樣使用索引值。
建立集合
在 Python 裡,集合的建立方式有兩種,一種是使用大括號 { }
:
>>> s1 = {1, 3, 3, 3, 3, 3, 2, 4}
>>> s1
{1, 2, 3, 4}
>>> type(s1)
<class 'set'>
直接用大括號 { }
滿直覺的,因為集合裡的元素不會重複,如果有重複的話會被濾掉。要注意的是,如果是使用大括號 { }
建立集合的話,裡面的元素不能是空的,因為 Python 會把它當成是空字典。所以如果要建立空集合,就要使用 set()
。set()
搭配可迭代物件,例如串列、元組、字串都可以:
# 用串列建立集合
>>> s2 = set([1, 3, 3, 3, 3, 3, 2, 4])
>>> s2
{1, 2, 3, 4}
# 用元組建立集合
>>> s3 = set((1, 3, 3, 3, 3, 3, 2, 4))
>>> s3
{1, 2, 3, 4}
# 用字串建立集合
>>> s4 = set('hello')
>>> s4
{'l', 'o', 'e', 'h'}
只要是可迭代物件都可以丟進 set()
來建立集合,不過從上面的字串建立集合的例子可以看到,集合裡的元素是不會重複的,所以即使字串裡有重複的字元,集合裡也只會有一個。所以如果你手上有個重複的串列,你想要把重複的元素刪掉,原本你可能得寫迴圈檢查是否有重複的元素,現在可以利用集合元素不會重複的特性,先把串列丟進 set()
函數裡,再把集合轉回串列:
>>> heroes = ['悟空', '鋼鐵人', '悟空', '魯夫', '蝙蝠俠', '魯夫']
>>> all_heroes = list(set(heroes))
>>> all_heroes
['魯夫', '悟空', '鋼鐵人', '蝙蝠俠']
因為集合沒有順序,所以透過這個過程得到的結果,元素的順序就不一定是我們原本的順序。如果不在乎順序,只是用來計算原本的串列或字串裡總共有幾個不同的元素就挺方便的。
另外,不是所有的元素都可以放進集合裡的,舉個例子,我試著把串列丟進集合裡:
>>> s5 = {[1, 2, 3], 4, 5}
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
Python 跟我們抱怨的錯誤訊息應該滿明顯的,元素本身如果不是「可雜湊(hashable)物件」就不能放進集合裡。至於什麼是可雜湊物件可見上個章節的介紹。
還記得我們在前面章節介紹過 nan
的概念嗎?在集合裡也可以放進去:
>>> { 1, 2, 3, float('nan') }
{1, 2, 3, nan}
雖然集合不會有重複的元素,但如果多放幾個 nan
呢?
>>> { 1, 2, 3, float('nan'), float('nan'), float('nan') }
{1, 2, 3, nan, nan, nan}
nan
的特色就是它不會等於任何數值,甚至包括它自己,因此在建立集合的時候,它會認為這裡沒有重複的 nan
,所以就會看到好幾個 nan
了。但這種放了好幾個 nan
的集合沒什麼用途,單純就只是好玩而已。