Skip to content

物件的參照

若修改某個可變物件的內容,該物件仍是原本的同一個物件,只是其中的某個位置或內容改變。

若對名稱使用 =,不是把舊物件本身在所有地方一起替換掉,而是讓這個名稱改為參照右側運算得到的物件。


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

這裡的 KL1 都是紙筆標籤。

它們並不是物件的正式名稱,也不一定是 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 保存的參照改變。

因為 adata 都參照 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,但其中仍放入原本 L1L2 的參照。

輸出:

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] 保存的參照被替換。

社群中常用 isid() 驗證這種情況:外層串列不同,但某些內層串列仍可能是同一個物件。(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]

函式結束後,區域名稱 xdata 不再使用;外部仍是:

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) 社群常用 isid() 比較 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 ab 參照同一物件
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

因為 ab 分別參照兩個不同的串列物件,只是內容相同。

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 的參照放入三次。

最核心的判斷原則:

先確認完整程式的初始參照關係,再找出左側真正指定的是「名稱」還是「容器中的某一格」,最後判斷右側運算式是否建立新物件。


參考資料