← Back to list

Py立得 — mutable 與 immutable 物件

認識 Python 物件的 mutable 與 immutable 差異,從底層機制理解 Python script 中無法直覺理解的現象!

SleeperShark in ccClub · 2024-09-16 02:13 · 61 claps · 20.7 min read
#ccclub #mutable-objects #immutable-objects #py-立得 #python進階
Open on Medium ↗

Py立得 — mutable 與 immutable 物件

理解可變與不可變物件,寫出更 Pythonic 的程式碼

從這篇文章中,你將會學到:

  • immutable object 與 mutable object 的機制與差異
  • + 串接 string 和用 join 串接 string 在效率上的差異
  • 設計 function 的傳入參數時要注意是否為 mutable object
  • 設計 function 的參數的預設值時要注意是否為 mutable object

(感謝夥伴 Chloe Yueh 的美編支援)

在上一篇文章中談到了 Python 的所有資料都是由 object 構成,以及 variable 透過 address 找到儲存在 memory 的資料。 這次就延續這些基礎知識,來認識一個 Python 語言中很重要的概念:Mutable object 與 Immutable object

(沒聽過 object 跟address?快到 **這裡 **去複習一下吧!)

Immutable Object

Immutable Object 指的是那些一旦在 memory 中建立後,其 value 就無法改變的資料類別,比如下列資料型別:

  • int
  • float
  • string
  • tuple
  • frozenset

我們以最熟悉的 int 為例,當我們輸入 num = 1000 時,這個 num 變數所記錄的 int object value 就 1000,即使我們對 num 進行 加 / 減 / 乘 / 除 / 取餘數 任何操作,Python interpreter 不是對既有的 int object value 進行變更,而是建立一個新的 int object,並把計算後的結果儲存到新的 object 中。

讓我們來實際跑一下程式,用 id()確認變數的 memory address,證明 int 是 immutable object,以及計算後的結果,都是用新的物件來儲存

>> num = 1000

>> print(id(num))
134623788426288

# 對 num 進行加法,會產生一個新物件
>> print(num + 1000)
2000
>> print(id(num + 1000))
134623788426192
# 原本的 num 變數的 value, address 不變
>> print(num)
1000
>> print(id(num))
134623788426288

# 再試試看對 num 進行乘法
>> print(num * 3)
3000
>> print(id(num * 3))
134623788426256
>> print(id(num))
134623788426288

如果我們要儲存 immutable object 計算後的結果,就必須將結果 assign (賦值)給新的變數:

new_num = num + 1000

或是把結果 reassign 給原本的變數名稱:

num = num + 1000(或是 num += 1000)

你才能利用計算後的 int 數值。

我們再來看看對另一種 immutable 型別: string 進行操作的例子🌰:

>> name = "Alex"

>> print(id(name))
136059618945264

# 用 + 串接 string
>> name_shout = name + "!!!"

# name_shout 是一個新物件
>> print(name_shout)
Alex!!!
>> print(id(name_shout))
136059618945328

# 原有的 name 物件 value, address 不變
>> print(name)
Alex
>> print(id(name))
136059618945264

# 用 upper() 將string 改為大寫
upper_name = name.upper()

# upper_name 是一個新物件
>> print(upper_name)
ALEX
>> print(id(upper_name))
136059618945328

# 原有的 name 物件 value, address 不變
>> print(name)
Alex
>> print(id(name))
136059618945264

從上面的運行結果可以發現,無論是用 + 串接 string,或是執行 upper function 得到大寫的 string,都不影響原有的 name object!

如果嘗試對 immutable object 的 value 進行變動的話,則會跳出 Error:

>> club    = "ccClub"
>> club[3] = "c"

# Traceback (most recent call last):
#   File "/Users/leadbest/repo/pyTest/test_2.py", line 3, in <module>
#   club[3] = "c"
#    ~~~~^^^
# TypeError: 'str' object does not support item assignment

小結一下,關於 Immutable Object

  • object 在建立後,value 無法被改變。
  • 透過操作子/函式對 immutable object 進行任何計算操作,interpreter 都會建立一個新的 object 來儲存計算後的結果,不影響原有的 object。
  • 如果想要儲存計算後的結果,必須透過 assign 的方式,將計算後產生的新 object 的 address 綁定給變數。

Mutable Object

顧名思義,mutable object 就是那些在建立後,value 可以被改變的資料型別,如 list、dict、set,以及我們自定義的 class 等。

我們來以 list 為例🌰,透過 append、extend、pop、remove 進行操作時,改變的對象都是原有的 list object,裡面的 element 組成會根據調用的函數而改變:

>> course = ["Alex", "Mark", "Ethan"]

>> print(course)
['Alex', 'Mark', 'Ethan']

>> print(id(list))
132232573294592

# 透過 function 增加 list element
>> course.append("Judy")
>> course.extend(["May", "Steven"])

# 原有的 course object 的 value 改變
>> print(course)
['Alex', 'Mark', 'Ethan', 'Judy', 'May', 'Steven']
>> print(id(course))
132232573294592

# 透過 function 移除 list element
>> course.pop()
>> course.remove("Alex")

# 原有的 course object 的 value 改變
>> print(course)
['Mark', 'Ethan', 'Judy', 'May'
>> print(id(course))
132232573294592

從結果可以發現,不管是透過 append, extend 來新增 list 內的 element,或是用 pop, remove 移除 element,都沒有改變 course list 的 address,也就是說我們一直是對同一個 list object 進行操作

🔋 知識充電站 🔋

Python 官方文件中,如果你在函數說明中看到 in place (原地) 這個關鍵字,指的就是對 mutable object 本身進行操作,比如說 list.sort()list.reverse(),都是直接對原有的 list object 的 value 進行排序處理,而不是建立一個新的 list object 再排序。

要注意的是,雖然 list 是 mutable object,但不代表對 list 的每一種操作都是對原有的 object 操作,有些函式或操作子是透過建立新的 list、對新的 list 進行操作,回傳給變數的結果也是新的 list 的 address,比如以下幾個例子🌰:

>> fruits_a = ["pipeapple", "apple"]
>> fruits_b = ["tomato", "papaya"]

# 實驗一
# 用 + 連接兩個 list
>> fruits_all = fruits_a + fruits_b

# fruits_all 是一個新建立的物件
>> print(fruits_all)
['pipeapple', 'apple', 'tomato', 'papaya']
>> print(id(fruits_all))
135691873054848

# 原本的 fruits_a 和 fruits_b 物件沒有影響
>> print(fruits_a)
['pipeapple', 'apple']
>> print(id(fruits_a))
135691871142400

>> print(fruits_b)
['tomato', 'papaya']
>> print(id(fruits_b))
135691873089216

# 實驗二
# 用 sorted() 對 list 進行排序
>> sorted_fruits_a = sorted(fruits_a)

# sorted 會複製原本的 list,然後對新的 list 排序
>> print(sorted_fruits_a)
['apple', 'pipeapple']
>> print(id(sorted_fruits_a))
135691873109120

# 原本的 fruits_a 物件沒有影響
>> print(fruits_a)
['pipeapple', 'apple']
>> print(id(fruits_a))
135691871142400

# 實驗三
# list slicing 操作也是建立新物件來儲存分割後的結果
>> sliced_fruits_a = fruits_a[1:]
>> print(sliced_fruits_a)
['apple']
>> print(id(sliced_fruits_a))
135691873111808

# 原本的 fruits_a 物件沒有影響
>> print(fruits_a)
['pipeapple', 'apple']
>> print(id(fruits_a))
135691871142400

因此,在對 mutable object 進行操作時,要注意呼叫的操作方式是針對 mutable 物件本身進行異動、還是建立新的物件來儲存異動結果,根據自己程式的需求選擇適合的函式歐!

小結一下,關於 mutable object:

  • object 在建立後,value 可以被改變。
  • 以 list 為例,如果呼叫
  • list.append() - list.extend() - list.sort() - list.reverse() 這些函式的話,是對原有的 list object 的 value 進行更動。
  • 不是所有對 mutable object 的操作都是 in place 的,比如說
  • sorted(list) - list.copy() - 用 + 連接 list
  • 對 list 做 slicing 都會建立一個新的 list 物件,並回傳新物件的 address 給變數,原有的 list 物件的 value 則維持不變。

在認識了 mutable 與 immutable object 的機制後,就能解釋一些過去對你來說「知其然,不知其所以然」的 Python 規則,比如:

Concatenate String V.S. Join String

若想將 N 個字串組合成一個大字串,我們可以

  1. str_a + str_b + … 將一個一個字串相加
  2. 建立一個 list: str_list = [str_a, str_b, str_c …],再用 "".join(str_list) 得到組合後的結果

我們來簡單用 **timeit** 模組測試一下,這兩個不同的字串結合機制,在效率上的差異:

from timeit import Timer

# 用 + 連接 string
def generate_by_concat(times):
    def _inner():
        target = ""

        for i in range(times):
            target += "Hello!"
    return _inner

# 用 join 把 list 中的 string 串接在一起
def generate_by_join(times):
    def _inner():
        hello_list = list()

        for i in range(times):
            hello_list.append("Hello!")

        target = "".join(hello_list)
    return _inner

# 連接 10 個字串
print(Timer(generate_by_concat(10)).timeit(1))
# 1.8749997252598405e-06
print(Timer(generate_by_join(10)).timeit(1))
# 2.0410006982274354e-06

# 連接 10,000 個字串
print(Timer(generate_by_concat(10000)).timeit(1))
# 0.0005040830001235008
print(Timer(generate_by_join(10000)).timeit(1))
# 0.0002974579983856529

# 連接 10,000,000 個字串
print(Timer(generate_by_concat(10000000)).timeit(1))
# 0.524733666999964
print(Timer(generate_by_join(10000000)).timeit(1))
# 0.2807453749992419

從結果可以發現,當組合 10 個 “Hello!” 字串時,用 + 來建立會稍快一點; 但隨著組合字串的次數增加,反而是用 join 的方式會比較快,在次數達到 一千萬次時,用 + 連接 string 所花費的時間將近是 join 的兩倍了!

兩個方法在速度上的差異,關鍵就在計算對象是 mutable 還是 immutable object:

  • generate_by_concat 函式中,由於 string 是 immutable object,每多接一次字串: target += “Hello!”,就等於要新建立一個 string object 來儲存。 在這個函式中,如要連接 N 個 “Hello!” 字串,就會有建立 N 個 string object 的過程。
target = "Hello!"

target += "Hello!"
# target 建立一個新的 string 物件: "Hello!Hello!"

target += "Hello!"
# target 建立一個新的 string 物件: "Hello!Hello!Hello!"
  • generate_by_join 函式中,用 append 將字串加到既有的 hello_list 中,這個過程不用建立新物件;一直等到最後 join 的階段,才會將結合後的最終字串用新建立的 string object 儲存。 在這個函式中,無論要連接幾個 “Hello!” 字串,都只需要建立 2 個物件:儲存 string 的 list object,以及最後 join 結果的 string object。
target = ["Hello!"]

target.append("Hello!")
# target 是原本的 list 物件,value 是 ["Hello!", "Hello!"]

target.append("Hello!")
# target 是原本的 list 物件,value 是 ["Hello!", "Hello!", "Hellow!"]

final_string = "".join(target)
# 建立一個新的 string 物件: final_string,value 是 "Hello!Hello!Hello!"

當字串組合次數 N 較少時,由於初始建立 list 物件的成本比 string 物件高,因此 generate_by_concat 的速度要稍快一點;

然而,一旦 N 的數量上升,generate_by_concat 需要不斷建立 string object 的效能負擔就開始累積,所花費的時間會超過 generate_by_join

誰動了我的資料?!

當我第一次學到 global scope 與 local scope 的概念後,以為 function 內的變數與外面的變數是「彼此獨立的存在」,因此傳入 function 的 int, string 等資料,無論進行什麼操作,都不會影響到外面的參數,近比如下面的這個例子:

def do_something_to_the_num(num):
    num **= 2
    num += 100
    num *= 10
    num -= 1
    return num

num = 10
new_num = do_something_to_the_num(num)

print(num)
#10
print(new_num)
# 1999

然而,當我傳入 list 物件,並在 function 中對 list 物件進行操作時,卻驚訝的發現「我外面的 list 也發生變化了」!

from random import randint

def append_1_to_10(num_list):
    for i in range(1, 11):
        num_list.append(i)
    return num_list

num_list = []
new_list = append_1_to_10(num_list)

print(num_list)
# [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# !!! 我的 num_list 被偷加東西了!

在學會 address, immutable object, mutable object 觀念後,相信你已經猜到了這個現象的原因:

當我們將變數 num_list 傳入到 function 進行操作時,傳入的對象其實不是「一個 list copy」,而是傳入了 num_list 所綁定的 address,讓 function 中的 local 變數 num_list 也綁定了這個 address。

因此,在 append_1_to_10 中進行的 append 操作,實際上是依據 address 找到對應的 list object,把數字 1–10 加到 list 中。

至於為什麼範例一中的 num 變數沒有受到 function 的影響,則是因為 int 是 immutable object,在每一行計算過程中,都是用新產生的物件來儲存計算結果,因此 function 內的任何操作都不會影響到外面 num 物件所綁定的 int object address。

難以捉摸的 function default value

假設我們要設計一個 function:record_my_journey,來紀錄旅遊日誌。 這個函數需要有以下功能:

  • 可輸入兩個參數:旅遊地點 place string,以及記錄日誌 note dictionary
  • 輸入參數後,record_my_journey 會以 place 作為 key、當天的日期 string 作為 value,記錄在 note 中,並在最後回傳 note 變數
  • 使用者也可以選擇不傳入日誌,只帶入 place 參數,讓 function 自動產生一個全新的 note 回傳給使用者,作為記錄日誌的首章

根據上面的需求,我們可以這樣設計我們的 record_my_journey:

from datetime import date

def record_my_journey(place, note=dict()):
    note[place] = date.today().strftime('%Y-%m-%d')
    return note

接下來,我們實際使用這段 function 看看:

# 第一本 note 由預設值建立
>> my_note = record_my_journey('Taiwan')
>> print(my_note)
{'Taiwan': '2024-06-26'}

# 用第一本 note 寫下第二個旅遊紀錄
>> record_my_journey('Australia', my_note)
>> print(my_note)
{'Taiwan': '2024-06-26', 'Australia': '2024-06-26'}

# 第二本 note 也由預設值建立
>> my_note_2 = record_my_journey('India')
>> print(my_note_2)
{'Taiwan': '2024-06-26', 'Australia': '2024-06-26', 'India': '2024-06-26'}
# !!! 為什麼 my_note_2 會用到 my_note ???

# 確認一下 兩本 note 的物件
>> print(id(my_note))
138350073948160
>> print(id(my_note_2))
138350073948160
>> print(my_note is my_note_2)
True
# !!! 兩本 note 居然原來是一個物件 !!!

上面的程式碼中,原本我們預期在沒有帶任何 note 參數的情況下,my_note_2 應該要是一個由 record_my_journey 函式預設提供的新的 dict;

而實際的情況是,由 function 預設值產生的 my_note 與 my_note_2,兩個指向的是同一個 dict 物件 address: 138350073948160,後續對 my_note 與 my_note_2 進行的任何操作,都是對這個 dictionary 進行調整。

造成這個「預設物件複用」現象的原因,在於 “function” 本身也是一個 immutable object!

當 Python 編譯執行到 function 部分時,在 assign 預設值的 value 時,會在 memory 建立一個相對應的物件,並把物件的 address 指向給參數的預設值。 且因為 function 是 immutable object 的特性,預設值物件的 address value 無法被改變。

呼叫 function 時,如果對應參數沒有放入相對應的變數的話,function 就會使用在上述過程時建立的 default 物件 address,代入 function code block 中執行。

在上面的例子裡,dict 是 mutable object,而且對 dict 存入 key-value 是一個 in-place 的操作,不會生成新的 dict,所以上面 function 的寫法,無論在什麼地方呼叫,使用的預設值對象會一直是同一個 dict object。

因此,在設計帶有 mutable type 的參數時,務必要注意預設值的處理。 如果你想要有「若沒有提供對應的變數,則建立一個新的物件」的效果,應該要將程式改寫如下:

def record_my_journey(place, note=None):
    if note is None:
        note = dict()
        # function 的 code block 與參數宣告不同,會重新執行
        # 因此這裡的 dict() 會確實地建立一個新的 dict object

    note[place] = date.today().strftime('%Y-%m-%d')

    return note

my_note = record_my_journey('Taiwan')
print(my_note)
# {'Taiwan': '2024-06-26'}

my_note_2 = record_my_journey('India')
print(my_note_2)
# {'India': '2024-06-26'}

在這篇文章中,我們深入探討了immutable object 與 mutable object 的機制,以及這些特性會如何影響我們設計的 Python 程式碼。

如果你對這個主題有任何問題、或是其他想學習的 Python 知識,都歡迎你在留言區跟我們分享歐!

我們是 ccClub 團隊,致力於讓 Python 成為大家的第二外語,希望能用淺顯易懂、循序漸進的方式,帶領新手一步步跨入程式設計的世界。

如果你喜歡這篇文章,請給我們 1~20 個掌聲👏。 如果你喜歡「Py立得」的教學系列文,請給我們 31 個以上的掌聲👏。

Facebook: ccClub Python讀書會


메타데이터
post_id
e128f641eb6e
slug
py-instant-tips-mutable-immutable-e128f641eb6e
url
https://medium.com/ccclub/py-instant-tips-mutable-immutable-e128f641eb6e
canonical_url
https://medium.com/ccclub/py-instant-tips-mutable-immutable-e128f641eb6e
author_url
https://medium.com/@40243105s
status
ok
fetched_at
2026-07-13 06:23:13