物件的參照
若修改某個可變物件的內容,該物件仍是原本的同一個物件,只是其中的某個位置或內容改變。
若對名稱使用 =,不是把舊物件本身在所有地方一起替換掉,而是讓這個名稱改為參照右側運算得到的物件。
1. 核心觀念
在 Python 中,可以使用以下模型理解資料:
名稱或容器中的位置 ──→ 物件
例如,完整程式如下:
a = [1, 2, 3]
為了紙筆分析,可以替串列物件加上標籤 L1:
L1 = 串列物件 [1, 2, 3]
名稱 a ──→ L1
a 是 Python 程式中的名稱;L1 是為了畫圖方便而加上的紙筆標籤,不是程式中真正宣告的變數。
Python 中每個物件都有自己的 Type(型別)、Value(值)及 Identity(身分)。名稱會綁定物件,串列的項目也會參照物件。(Python documentation)
2. 紙筆標籤的使用方式
2.1 完整範例
items = [0, 0, 0]
為了分析,可以在紙上標記:
K = 整數物件 0
L1 = 串列物件 [0, 0, 0]
items ──→ L1
L1[0] ──→ K
L1[1] ──→ K
L1[2] ──→ K
這裡的 K、L1 都是紙筆標籤。
它們並不是物件的正式名稱,也不一定是 Python 程式中真正存在的變數。
2.2 若程式中真的有名稱 k
完整程式:
k = 0
items = [k, k, k]
可以畫成:
K = 整數物件 0
L1 = 串列物件 [0, 0, 0]
名稱 k ──→ K
名稱 items ──→ L1
L1[0] ──→ K
L1[1] ──→ K
L1[2] ──→ K
要注意:
L1[0] 不是先指向名稱 k,再由 k 指向 K。
正確模型是:
名稱 k ──→ K
L1[0] ──→ K
名稱 k 與串列格子 L1[0] 是兩個不同的參照位置,只是當下都參照同一個整數物件。
3. = 的真正意義
= 是 Assignment(指定)。
基本流程是:
1. 先計算右側運算式
2. 取得右側運算結果所代表的物件
3. 將該物件指定給左側目標
左側目標可能是:
名稱
串列索引
巢狀串列索引
物件屬性
3.1 = 本身不保證建立新物件
完整程式:
a = [1, 2, 3]
b = a
print(a is b)
紙筆模型:
L1 = 串列物件 [1, 2, 3]
a ──→ L1
b ──→ L1
b = a 沒有複製串列,也沒有建立新串列,只是讓 b 參照 a 當時所參照的同一個物件。
輸出:
True
3.2 右側運算式可能建立新物件
完整程式:
a = [1, 2, 3]
b = a[:]
print(a is b)
print(a == b)
紙筆模型:
L1 = 串列物件 [1, 2, 3]
L2 = 串列物件 [1, 2, 3]
a ──→ L1
b ──→ L2
右側的 a[:] 建立新的外層串列 L2,= 再讓名稱 b 參照 L2。
輸出:
False
True
因此應記成:
=負責指定;是否建立新物件,要看右側運算式。
Python 的普通指定與 Augmented Assignment(增強指定)有不同規則;增強指定可能直接修改原物件。(Python documentation)
4. 名稱與串列索引是不同的指定目標
4.1 左邊是名稱
完整程式:
data = [10]
data = [20]
print(data)
紙筆模型:
L1 = [10]
L2 = [20]
第一行後:
data ──→ L1
第二行後:
data ──→ L2
被改變的是:
名稱 data 的參照
原本的 L1 沒有被改成 [20];只是名稱 data 改為參照另一個串列。
4.2 左邊是串列索引
完整程式:
data = [10]
data[0] = 20
print(data)
紙筆模型:
L1 = [10]
data ──→ L1
data[0] ──→ L1[0]
L1[0]:10 → 20
被改變的是:
L1 的索引 0
輸出:
[20]
官方文件將 s[i] = x 描述為以 x 替換可變序列 s 的第 i 個項目。(Python documentation)
5. 修改物件內容與重新指定名稱
這是物件參照中最重要的差別。
5.1 修改物件內容:Mutation
完整程式:
a = [10]
data = a
data[0] = 15
print(a)
print(data)
print(a is data)
執行指定前:
L1 = [10]
a ──→ L1
data ──→ L1
追蹤左側:
data ──→ L1
data[0] ──→ L1[0]
因此真正被修改的是:
L1[0]:10 → 15
執行後:
L1 = [15]
a ──→ L1
data ──→ L1
輸出:
[15]
[15]
True
L1 仍然是原本同一個串列物件,只是索引 0 保存的參照改變。
因為 a、data 都參照 L1,所以兩者都看得到改變。
5.2 重新指定名稱:Rebinding
重新指定名稱是指:
讓某個名稱改為參照另一個物件。
它不是修改舊物件,也不是把舊物件在所有參照位置一起替換。
5.2.1 完整程式
a = [15]
data = a
data = data + [99]
print(a)
print(data)
print(a is data)
5.2.2 執行前
L1 = [15]
a ──→ L1
data ──→ L1
此時:
a is data
為 True。
5.2.3 先計算等號右側
要執行的程式是:
data = data + [99]
Python 先計算:
data + [99]
右側的 [99] 先建立一個新的串列。紙筆上標記為:
L2 = [99]
因為 data 目前參照 L1,所以串接運算相當於:
L1 + L2
串列的普通 + 會產生新的串接串列。紙筆上標記為:
L3 = [15, 99]
此時:
L1 = [15]
L2 = [99]
L3 = [15, 99]
原本的 L1 沒有被加入 99。
5.2.4 再指定左側名稱
右側得到 L3 後,概念上再執行:
data = L3
因此:
執行前:
data ──→ L1
執行後:
data ──→ L3
完整結果:
a ──→ L1 = [15]
data ──→ L3 = [15, 99]
輸出:
[15]
[15, 99]
False
5.2.5 舊串列沒有被全面替換
錯誤理解:
a、data 都自動改為參照 L3
正確理解:
a ──→ L1
data ──→ L3
只有左側名稱 data 被重新指定。
5.2.6 = 本身不是新串列的建立者
在:
data = data + [99]
中:
[99]
→ 建立 L2
data + [99]
→ 建立串接結果 L3
data = L3
→ 讓名稱 data 改為參照 L3
因此:
=負責指定參照;右側運算式決定是否產生新物件。
5.3 Mutation 與 Rebinding 完整對照
修改物件內容
完整程式:
a = [15]
data = a
data[0] = 20
print(a)
print(data)
參照變化:
執行前:
a ─┐
data ─┴──→ L1 = [15]
執行後:
a ─┐
data ─┴──→ L1 = [20]
輸出:
[20]
[20]
重新指定名稱
完整程式:
a = [15]
data = a
data = data + [99]
print(a)
print(data)
參照變化:
執行前:
a ─┐
data ─┴──→ L1 = [15]
執行後:
a ─────→ L1 = [15]
data ─────→ L2 = [15, 99]
輸出:
[15]
[15, 99]
最短記法:
data[0] = ...
→ 修改 data 所參照物件的內容
data = ...
→ 修改名稱 data 指向哪個物件
6. 整數也是物件,但整數不可變
6.1 完整程式
x = 10
y = x
x = x + 5
print(x)
print(y)
紙筆標籤:
K = 整數物件 10
M = 整數物件 15
執行 x = x + 5 前:
x ──→ K
y ──→ K
右側:
x + 5
得到整數物件 15,也就是紙筆標籤 M。
接著只重新指定名稱 x:
x:K → M
執行後:
x ──→ M
y ──→ K
輸出:
15
10
不是把整數物件 10 修改成 15,而是讓名稱 x 改為參照另一個整數物件。
整數是 Immutable Object(不可變物件),無法像串列一樣原地修改內容。
7. 淺層複製:a[:]
7.1 完整程式
a = [[1, 2], [3, 4]]
b = a[:]
print(a is b)
print(a[0] is b[0])
print(a[1] is b[1])
使用紙筆標籤:
L1 = [1, 2]
L2 = [3, 4]
L3 = [L1, L2]
L4 = [L1, L2]
a ──→ L3
b ──→ L4
a[:] 建立新的外層串列 L4,但其中仍放入原本 L1、L2 的參照。
輸出:
False
True
True
完整切片可建立 Shallow Copy(淺層複製):外層容器是新的,但其中的巢狀物件仍可能共用。(Python documentation)
8. 淺層複製中的兩種指定
8.1 完整程式
a = [[1, 2], [3, 4]]
b = a[:]
b[0][0] = 9
b[1] = [7, 8]
print(a)
print(b)
建立完成後:
L1 = [1, 2]
L2 = [3, 4]
L3 = [L1, L2]
L4 = [L1, L2]
a ──→ L3
b ──→ L4
8.2 執行 b[0][0] = 9
追蹤左側:
b ──→ L4
b[0] ──→ L1
b[0][0] ──→ L1[0]
所以真正被修改的是:
L1[0]:1 → 9
修改後:
L1 = [9, 2]
因為 a[0]、b[0] 都參照 L1,所以兩者都看得到改變。
8.3 執行 b[1] = [7, 8]
右側建立新串列:
L5 = [7, 8]
追蹤左側:
b ──→ L4
b[1] ──→ L4[1]
真正發生的是:
原本:L4[1] → L2
後來:L4[1] → L5
最後:
L3 = [L1, L2]
L4 = [L1, L5]
a ──→ L3
b ──→ L4
輸出:
[[9, 2], [3, 4]]
[[9, 2], [7, 8]]
L2 本身沒有被修改,只有 L4[1] 保存的參照被替換。
社群中常用 is 或 id() 驗證這種情況:外層串列不同,但某些內層串列仍可能是同一個物件。(Stack Overflow)
9. 二維串列與 *
9.1 完整程式
grid = [[0] * 3] * 3
grid[1][2] = 7
print(grid)
9.2 建立內層串列
右側內部先計算:
[0] * 3
紙筆模型:
K = 整數物件 0
L1 = [K, K, K]
也就是:
L1 = [0, 0, 0]
9.3 建立外層串列
接著外層再重複 L1 的參照:
L2 = [L1, L1, L1]
grid ──→ L2
因此:
grid[0] ─┐
grid[1] ─┼──→ L1
grid[2] ─┘
外層三格不是參照三個不同串列,而是三次參照同一個 L1。(Python documentation)
9.4 執行 grid[1][2] = 7
紙筆上將整數物件 7 標記為 M:
M = 整數物件 7
追蹤左側:
grid[1] ──→ L1
grid[1][2] ──→ L1[2]
真正修改的是:
L1[2]:K → M
所以:
L1 = [K, K, M]
= [0, 0, 7]
三個 grid[i] 都參照同一個 L1,因此輸出:
[[0, 0, 7], [0, 0, 7], [0, 0, 7]]
不是三個整數 0 一起被修改,而是三列都看到同一個被修改過的串列 L1。
9.5 建立彼此獨立的列
完整程式:
grid = [[0] * 3 for _ in range(3)]
grid[1][2] = 7
print(grid)
每一輪串列推導式都重新建立一個內層串列:
L1 = [0, 0, 0]
L2 = [0, 0, 0]
L3 = [0, 0, 0]
L4 = [L1, L2, L3]
grid ──→ L4
輸出:
[[0, 0, 0], [0, 0, 7], [0, 0, 0]]
10. 函式參數也是名稱
10.1 完整程式
def change(x, data):
x = x + 5
data[0] = data[0] + 5
data = data + [99]
n = 10
a = [10]
change(n, a)
print(n)
print(a)
10.2 進入函式時
紙筆標籤:
K = 整數物件 10
L1 = 串列物件 [10]
呼叫函式前:
n ──→ K
a ──→ L1
進入函式後:
外部:
n ──→ K
a ──→ L1
函式內:
x ──→ K
data ──→ L1
函式參數名稱不會自動複製傳入的物件。
10.3 執行 x = x + 5
右側得到整數物件 15,紙筆上標記為 M:
M = 整數物件 15
只重新指定區域名稱 x:
x:K → M
外部名稱 n 仍然參照 K:
n ──→ K
所以 n 仍是 10。
10.4 執行 data[0] = data[0] + 5
追蹤左側:
data ──→ L1
data[0] ──→ L1[0]
所以:
L1[0]:K → M
結果:
L1 = [15]
因為:
a ──→ L1
data ──→ L1
所以外部名稱 a 也看到 [15]。
10.5 執行 data = data + [99]
右側先建立串接結果:
L2 = [99]
L3 = L1 + L2
L3 = [15, 99]
接著只重新指定區域名稱 data:
data:L1 → L3
最後:
外部:
n ──→ K = 10
a ──→ L1 = [15]
函式內:
x ──→ M = 15
data ──→ L3 = [15, 99]
函式結束後,區域名稱 x、data 不再使用;外部仍是:
n = 10
a = [15]
輸出:
10
[15]
11. append()、普通 +、+= 的差別
11.1 append():修改原串列
完整程式:
a = [1, 2, 3]
b = a
c = a
b.append(4)
print(a)
print(b)
print(c)
print(a is b, a is c)
執行前:
L1 = [1, 2, 3]
a ─┐
b ─┼──→ L1
c ─┘
b.append(4) 直接修改 b 所參照的 L1:
L1:[1, 2, 3] → [1, 2, 3, 4]
執行後:
a ─┐
b ─┼──→ L1 = [1, 2, 3, 4]
c ─┘
輸出:
[1, 2, 3, 4]
[1, 2, 3, 4]
[1, 2, 3, 4]
True True
Python 文件將 append() 定義為把一個項目加入現有串列尾端。(Python documentation)
11.2 普通 +:建立新串列,再重新指定名稱
完整程式:
a = [1, 2, 3]
b = a
c = a
c = c + [4]
print(a)
print(b)
print(c)
print(a is b, a is c)
執行前
L1 = [1, 2, 3]
a ─┐
b ─┼──→ L1
c ─┘
計算右側 c + [4]
右側 [4] 先建立一個串列:
L2 = [4]
因為 c 參照 L1,所以:
c + [4]
= L1 + L2
普通串列 + 建立新的串接結果:
L3 = [1, 2, 3, 4]
原本的 L1 仍是:
L1 = [1, 2, 3]
指定左側名稱 c
右側完成後:
c:L1 → L3
執行後:
a ─┐
b ─┴──→ L1 = [1, 2, 3]
c ─────→ L3 = [1, 2, 3, 4]
輸出:
[1, 2, 3]
[1, 2, 3]
[1, 2, 3, 4]
True False
因此:
c = c + [4]
不是修改 L1,而是建立新串列後重新指定名稱 c。
11.3 串列的 +=:原地擴充原串列
完整程式:
a = [1, 2, 3]
b = a
c = a
c += [4]
print(a)
print(b)
print(c)
print(a is b, a is c)
執行前
L1 = [1, 2, 3]
a ─┐
b ─┼──→ L1
c ─┘
執行 c += [4]
右側 [4] 會建立一個暫時串列:
L2 = [4]
但對內建 list 而言,+= 會原地擴充左側原串列:
L1:[1, 2, 3] → [1, 2, 3, 4]
名稱 c 仍然參照 L1:
a ─┐
b ─┼──→ L1 = [1, 2, 3, 4]
c ─┘
輸出:
[1, 2, 3, 4]
[1, 2, 3, 4]
[1, 2, 3, 4]
True True
增強指定與普通指定不完全等價;在可能的情況下,增強指定可執行原地操作。對內建串列而言,+= 會原地擴充串列。(Python documentation) 社群常用 is 或 id() 比較 list.__iadd__() 與 list.__add__() 的差異。(Stack Overflow)
11.4 三種操作完整比較
| 完整操作 | 原本的 L1 是否被修改 |
名稱是否改指向新串列 | 其他參照 L1 的名稱是否看得到 |
|---|---|---|---|
b.append(4) |
是 | 否 | 會 |
c = c + [4] |
否 | c 會改指向新串列 |
不會 |
c += [4](內建 list) |
是 | 否,仍指向原串列 | 會 |
12. 完整比較表
| 完整程式中的操作 | 真正被修改的目標 | 是否建立新串列 | 共用原串列的其他名稱是否看得到 |
|---|---|---|---|
a = [1, 2]; b = a |
名稱 b |
否 | a、b 參照同一物件 |
a = [1, 2]; b = a[:] |
名稱 b |
是,建立新外層串列 | 巢狀物件仍可能共用 |
a = [10]; a[0] = 15 |
原串列的索引 0 |
右側視運算而定 | 會看到原串列內容改變 |
a = [1]; a.append(2) |
原串列內容 | 否 | 會 |
a = [1]; a.extend([2]) |
原串列內容 | 右側 [2] 會建立串列 |
會 |
a = [1]; b = a; b = b + [2] |
名稱 b |
是,串接結果為新串列 | a 仍指向舊串列 |
a = [1]; b = a; b += [2] |
內建串列的內容 | 右側 [2] 會建立串列;左側原地擴充 |
會 |
row = [0]; grid = [row] * 3 |
建立新的外層串列 | 建立外層,但不複製 row |
三格共用 row |
grid[i][j] = x |
grid[i] 所參照內層串列的索引 j |
右側視運算而定 | 所有共用該內層串列者都看得到 |
13. is 與 ==
13.1 ==:比較值或內容
完整程式:
a = [1, 2]
b = [1, 2]
print(a == b)
輸出:
True
兩個串列的內容相同。
13.2 is:比較是否為同一個物件
完整程式:
a = [1, 2]
b = [1, 2]
print(a is b)
輸出:
False
因為 a 與 b 分別參照兩個不同的串列物件,只是內容相同。
13.3 同時比較
完整程式:
a = [1, 2]
b = a
c = [1, 2]
print(a == b, a is b)
print(a == c, a is c)
輸出:
True True
True False
物件參照題目中,可使用 is 協助判斷是否共用同一物件。
14. 判斷指定敘述的步驟
每次遇到指定敘述,可以依序回答以下問題。
14.1 左側是什麼?
完整範例:
data = [10]
data[0] = 20
第一行左側是名稱:
data
第二行左側是串列索引:
data[0]
14.2 左側最終指向哪個位置?
完整範例:
grid = [[0, 0, 0]]
grid[0][2] = 7
追蹤:
grid[0] → 第一個內層串列
grid[0][2] → 第一個內層串列的索引 2
所以被修改的是該內層串列的索引 2。
14.3 右側是否建立新物件?
完整範例一:
a = [1, 2]
b = a
b = a 不會複製串列。
完整範例二:
a = [1, 2]
b = a[:]
a[:] 建立新的外層串列。
完整範例三:
a = [1, 2]
b = a + [3]
a + [3] 建立新的串接串列。
14.4 舊物件是否仍被其他名稱參照?
完整範例:
a = [1]
data = a
data = [2]
print(a)
print(data)
參照變化:
執行 data = [2] 前:
a ─┐
data ─┴──→ L1 = [1]
執行後:
a ─────→ L1 = [1]
data ─────→ L2 = [2]
輸出:
[1]
[2]
L1 仍然存在,因為名稱 a 還參照它。
15. 最後記憶口訣
名稱 = 物件
→ 改的是名稱的參照。
串列[索引] = 物件
→ 改的是串列裡的那一格。
append、extend、內建串列的 +=
→ 修改原串列。
串列 + 串列
→ 建立新的串接串列。
a[:]
→ 建立新的外層串列,但巢狀物件仍可能共用。
[row] * 3
→ 不會複製 row,只會把 row 的參照放入三次。
最核心的判斷原則:
先確認完整程式的初始參照關係,再找出左側真正指定的是「名稱」還是「容器中的某一格」,最後判斷右側運算式是否建立新物件。