Python面試必備一之迭代器、生成器、淺複製、深複製

XHunter發表於2024-04-05

本文首發於公眾號:Hunter後端

原文連結:Python面試必備一之迭代器、生成器、淺複製、深複製

這一篇筆記主要介紹 Python 面試過程中常被問到的一些問題,比如:

  1. Python 中的迭代器和生成器是什麼,有什麼作用
  2. Python 中不可變型別有哪些
  3. 在 Python 函式中,傳遞引數傳遞的是什麼,值還是引用
  4. 將一個列表或者字典傳入函式,在函式內部對其進行修改,會影響函式外部的該變數嗎
  5. Python 中的深複製和淺複製是什麼,怎麼用,區別是什麼

針對以上問題,本篇筆記將詳細闡述其原理,並用示例來對其進行解釋,本篇筆記目錄如下:

  1. 迭代器
  2. 生成器
  3. Python 中的可變與不可變型別
  4. Python 的函式引數傳遞
  5. 淺複製、深複製

1、迭代器

1. 迭代

在 Python 中,對於列表(list)、元組(tuple)、集合(set)等物件,我們可以透過 for 迴圈的方式拿到其中的元素,這個過程就是迭代。

2. 可迭代物件

在 Python 裡,所有的資料都是物件,其中,可以實現迭代操作的資料就稱其為可迭代物件。

比如前面介紹的列表,元組,集合,字串,字典都是可迭代物件。

如果要判斷一個物件是否是可迭代物件,可以透過與 typing.Iterable 來進行比較:

from typing import Iterable

print(isinstance([1, 2, 3], Iterable))  # True
print(isinstance((1, 2, 3), Iterable))  # True
print(isinstance({1, 2, 3}, Iterable))  # True
print(isinstance({"a": 1, "b": 2}, Iterable))  # True
print(isinstance("asdsad", Iterable))  # True

3. 迭代器

我們可以將一個可迭代物件轉換成迭代器,所謂迭代器,就是內部含有 __iter____next__ 方法的物件,它可以記住遍歷位置,不會像列表那樣一次性全部載入。

迭代器有什麼好處呢,正如前面所言,因為不用一次性全部載入物件,所以可以節約記憶體,我們可以透過 next() 方法來逐個訪問物件中的元素。

我們可以使用 iter() 方法來將一個可迭代物件轉換成迭代器。

1) 建立迭代器

我們可以透過 iter() 函式來將可迭代物件轉換成迭代器:

s = [1, 2, 3]
s_2 = iter(s)

2) 判斷物件是否是迭代器

迭代器的型別是 typing.Iterator,我們可以透過 isinstance() 函式來進行判斷。

注意: 這裡進行測試的 Python 版本是 3.11,所以需要從 typing 中載入 Iterator,如果是之前的某個版本,應該從 collections 模組中載入。

from typing import Iterator
 
isinstance(s, Iterator)  # False
isinstance(s_2, Iterator)  # True

3) 訪問迭代器

我們可以透過 next() 函式來訪問迭代器:

s = [1, 2, 3]
s_2 = iter(s)
next(s_2)  # 1
next(s_2)  # 2
next(s_2)  # 3
next(s_2)  # raise StopIteration

訪問迭代器的時候需要注意下,如果使用 next() 函式訪問到物件的末尾還接著訪問的話,會引發 StopIteration 的異常。

我們可以透過 try-except 的方式來捕獲:

s = [1, 2, 3]
s_2 = iter(s)

while True:
    try:
        print(next(s_2))
    except StopIteration:
        print("訪問結束")
        break

2、生成器

生成器也是一種迭代器,它也可以使用 next() 方法逐個訪問生成器中的元素,並且能夠實現惰性計算,延遲執行以達到節省記憶體的目的。

1. 生成器的建立

可以使用兩種方式建立生成器,一種是使用小括號 () 操作列表生成式,一種是使用 yield 來修飾。

1) 使用列表生成式建立生成器

x = (i for i in range(10))
print(type(x))  # generator

前面介紹了生成器也是一種迭代器,下面可以進行驗證操作:

from typing import Iterator
print(isinstance(x, Iterator))  # True

而生成器本身的型別為 Generator,也可以透過 typing 模組引入:

from typing import Generator
print(isinstance(x, Generator))  # True

2) 使用 yield 欄位建立生成器

如果要使用 yield 來建立生成器,則需要將其放置在函式內,以下是一個示例:

def test_yield(n):
    for i in range(n):
        yield i

x = test_yield(8)
print(type(x))  # <class 'generator'>

print(next(x))  # 0

在這裡,yield 相當於 return 一個值,並且記住這個位置,在下次迭代時,程式碼從 yield 的下一條語句開始執行。

2. 生成器的使用

前面介紹了生成器就是一種迭代器,所以可以使用迭代器的方式來訪問生成器,比如 for 迴圈,next() 方法等。

3. 生成器的應用示例

下面介紹兩個運用生成器的例項,一個是用於斐波那契數列,一個是按行讀取檔案。

1) 斐波那契數列

使用生成器來操作斐波那契數列,其函式操作如下:

def fibonacci(max_number):
    n, a, b = 0, 0, 1
    while n < max_number:
        yield b
        a, b = b, a + b
        n += 1

for i in fibonacci(6):
    print(i)

2) 讀取檔案

如果有一個大檔案,我們也可以使用生成器的方式來逐行讀取檔案:

def read_file(path):
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            yield line

path = "path/to/file"
for line in read_file(path):
    print(line.strip())

4. 迭代器與生成器的異同

首先,生成器本身就是一個迭代器,所以生成器具有迭代器的所有優點,比如不用一次性載入全部物件,節約記憶體。

不同點在於兩者的建立方式是不一樣的,而且使用 yield 構成生成器的應用程度是更廣泛的。

3、Python 中的可變與不可變型別

首先,Python 中資料型別的可變與不可變的定義為當我們修改了它的值後它對應的記憶體地址是否變化。

如果一個資料型別,它的值修更改後,它的記憶體地址發生了改變,那麼我們稱其為不可變型別。

相反,如果我們修改某個資料型別的值後,記憶體地址沒有發生變化,那麼則稱其為可變型別。

我們可以這樣理解,對於同一個記憶體地址而言,如果可以修改變數的值,那麼它就是可變型別,否則是不可變型別。

1. 不可變型別

Python 中不可變的資料型別有 int、string、tuple、bool 等,示例如下:

s = 1
print(id(s))  # 140713862796072

s = 2
print(id(s))  # 140713862796104

上面的兩次輸出可以看到 s 這個變數的記憶體地址在值修改後就變化了。

2. 可變型別

Python 中可變的資料型別有 list、set、dict,這些資料型別在修改原值後,其記憶體地址不變,因此屬於可變型別。

s = [1,2,3]
print(id(s))  # 2116182318592

s.append(4)
print(id(s))  # 2116182318592

4、Python 的函式引數傳遞

這裡的問題其實是在 Python 中,我們往函式里傳引數時,是值傳遞還是引用傳遞。

所謂的值傳遞,就是把引數的值做一個複製,把複製的值傳到函式內。

所謂的引用傳遞,就是把引數的記憶體地址直接傳到函式內。

那麼在 Python 裡,函式的傳參到底是哪一種呢,我們可以來做個實驗:

def test(a):
    print(id(a))

a = 1
print(id(a))  # 140713862796072
test(a)  # 140713862796072

a = [1, 2, 3]
print(id(a))  # 2116183414208
test(a)  # 2116183414208

可以看到,不管是不可變型別還是可變型別,我們傳入函式內部的變數的記憶體地址和外部變數的記憶體地址都是一樣的,因此,在 Python 中,函式的傳參都是傳遞的變數的引用,即變數的記憶體地址

可變型別與不可變型別的區別

這裡需要注意的一點,對於可變型別和不可變型別,當我們在函式內對其修改後,其是否會影響到外部變數呢,我們還是可以接著做一個測試,這裡對於兩種型別分別進行測試。

先做不可變型別的測試:

def test_1(a):
    print(f"函式內部修改前,a 的地址為: {id(a)}")
    a = 2
    print(f"函式內部修改後,a 的地址為: {id(a)}")

a = 1
print(f"呼叫函式前,a 的地址為:{id(a)}")

test_1(a)
print(f"函式外 a 的值是:{a},地址為:{id(a)}")

這裡輸出的資訊如下:

呼叫函式前,a 的地址為:140713862796072
函式內部修改前,a 的地址為: 140713862796072
函式內部修改後,a 的地址為: 140713862796104
函式外 a 的值是:1,地址為:140713862796072

在這裡可以看到,雖然函式傳參傳入的是變數的引用,即記憶體地址,但因為它是不可變型別,所以對其修改後,函式內部相當於是對其重新申請了一個記憶體地址進行操作,但是不會影響函式外部原有的記憶體地址。

接下來測試一下可變資料型別:

def test_2(l):
    print(f"函式內部修改前,l 的地址為: {id(l)}")
    l.append(3)
    print(f"函式內部修改後,l 的地址為: {id(l)}")

l = [1, 2]
print(f"呼叫函式前,l 的地址為:{id(l)}")

test_2(l)
print(f"函式外 l 的值是:{l},地址為:{id(l)}")

其輸出的資訊如下:

呼叫函式前,l 的地址為:2116196122176
函式內部修改前,l 的地址為: 2116196122176
函式內部修改後,l 的地址為: 2116196122176
函式外 l 的值是:[1, 2, 3],地址為:2116196122176

這裡可以看到,函式內外 l 變數的地址都是不變的,但因為是可變型別,所以在函式內部修改了變數的值以後,並沒有重新分配記憶體,所以在函式外部 l 變數同步被影響。

那麼在函式內部對傳入的可變型別變數進行任何操作都會影響到函式外部嗎?

不一定,這裡提供一個示例:

def test_3(l):
    print(f"修改變數前,l 的地址為:{id(l)}")
    l = l + [3]
    print(f"修改變數後,l 的地址為:{id(l)}")
    
l = [1, 2]
print(f"呼叫函式前,l 的地址為:{id(l)}, 值為:{l}")
test_3(l)
print(f"呼叫函式後,l 的地址為:{id(l)},值為:{l}")

它的輸出的資訊如下:

呼叫函式前,l 的地址為:2116183414208, 值為:[1, 2]
修改變數前,l 的地址為:2116183414208
修改變數後,l 的地址為:2116200373376
呼叫函式後,l 的地址為:2116183414208,值為:[1, 2]

可以看到,在函式內部,對可變型別進行了操作之後,它的記憶體地址有所變化,而且修改後不會影響到原始變數。

這是因為在函式內部執行的操作是 l = l + [3],這個操作的本質並不是直接對變數的值進行修改,而是新建一個記憶體地址,然後對這個變數進行重新賦值,所以這個操作的 l 與函式傳入的變數 l 已經不是同一個變數了,因此不會影響到外部的變數。

多說一句,可變型別變數的這個操作其實就跟不可變型別的變數的重新賦值是同一個意義:

a = 1
a = 2

這裡其實也是因為對 a 進行了新的記憶體空間申請,然後重新賦值。

5、淺複製、深複製

1. 概念

在 Python 中,如果是不可變物件,比如 string,int 等,變數間的複製效果都是一致的,都會重新獲取一個記憶體地址,重新賦值,複製前後兩個變數不再相關。

而如果是可變物件,比如 list,set,dict 等,就需要區分淺複製和深複製。

淺複製的操作過程:為新變數重新分配記憶體地址,新變數的元素與原始變數的元素地址還是一致的。

但是如果原始變數的元素是不可變型別,那麼修改原始變數或新變數的元素之後,不會引起兩個變數的同步變化。

如果修改的是變數元素的可變型別,而可變型別進行修改後,其記憶體地址不會變的,則會引起兩個變數的同步變化。

深複製的操作過程:為新變數重新分配記憶體地址,建立一個物件,如果原始變數的元素中有巢狀的可變型別,那麼則會遞迴的將其中的全部元素都複製到新變數,複製過程結束之後,新變數與原始變數沒有任何關聯,只是簡單的值相等而已。

上面這兩個概念可能聽起來比較繞,接下來我們用示例來對其進行展示。

2. 淺複製

1) 元素為不可變型別

淺複製的操作使用 copy 模組,引入和使用如下:

import copy
l1 = [1, 2, 3]
l2 = copy.copy(l1)

這裡使用元素為不可變型別的 dict 進行示例展示:

d1 = {"a": 1, "b": 2}
d2 = copy.copy(d1)

print(f"d1 的地址為:{id(d1)}")
print(f"d2 的地址為:{id(d2)}")

print(f"d1 a 的地址為:{id(d1['a'])}")
print(f"d2 a 的地址為:{id(d2['a'])}")

它的資訊輸出如下:

d1 的地址為:2116196027264
d2 的地址為:2116200318400
d1 a 的地址為:140713862796072
d2 a 的地址為:140713862796072

可以看到,進行淺複製後,兩個變數的記憶體地址是不一樣的,但是內部的元素的地址都還是一樣的。

而如果對其元素的值進行更改,因為元素是不可變型別,所以更改之後其內部元素的地址也會不一樣:

d2["a"] = "2"
print(f"d1 的值為:{d1}")
print(f"d2 的值為:{d2}")

print(f"d1 a 元素的地址為:{id(d1['a'])}")
print(f"d2 a 元素的地址為:{id(d2['a'])}")

其輸出的內容如下:

d1 的值為:{'a': 1, 'b': 2}
d2 的值為:{'a': '2', 'b': 2}
d1 a 元素的地址為:140713862796072
d2 a 元素的地址為:140713862839480

2) 元素為可變型別

當需要複製的可變物件的元素也是可變型別的時候,比如,列表內巢狀了列表或者字典,或者字典內巢狀了列表或者字典,以及集合的相關巢狀,對其進行淺複製後,因其巢狀的元素是可變型別的,所以在對內部元素進行修改後,元素的記憶體地址還是會指向同一個,所以對外展示的影響就是,原始變數和新變數會同步更新資料。

接下來我們以字典內巢狀列表為例進行示例展示:

d1 = {"a": 1, "b": [1, 2]}
d2 = copy.copy(d1)

print(f"d1 的地址為:{id(d1)}, d1 的 b 元素的地址為:{id(d1['b'])}")
print(f"d2 的地址為:{id(d2)}, d2 的 b 元素的地址為:{id(d2['b'])}")

其輸出內容如下:

d1 的地址為:2116201415808, d1 的 b 元素的地址為:2116195489024
d2 的地址為:2116183354816, d2 的 b 元素的地址為:2116195489024

這裡可以看到 d1 和 d2 的記憶體地址是不一樣的,但是內部的 b 元素的記憶體地址一致。

接下來我們對 d2 的 b 列表進行修改,再來看一看兩者的地址和 d1 以及 d2 的值:

d2["b"].append(3)

print(f"d1 的值為:{d1}, d1 的 b 元素的地址為:{id(d1['b'])}")
print(f"d2 的值為:{d2}, d2 的 b 元素的地址為:{id(d2['b'])}")

其輸出內容如下:

d1 的值為:{'a': 1, 'b': [1, 2, 3]}, d1 的 b 元素的地址為:2116195489024
d2 的值為:{'a': 1, 'b': [1, 2, 3]}, d2 的 b 元素的地址為:2116195489024

可以看到,對 d2 修改 b 元素的值後,也同步反映到了 d1 上。

總結: 綜上,可以看到,在淺複製中,如果元素是不可變物件,那麼修改原始變數或新變數後,不會引起兩者的同步變化,如果元素是可變物件,那麼修改原始變數或者新變數後,則會引起兩者的同步變化。

3. 深複製

相對於淺複製而言,深複製的操作要簡單許多,不管元素是可變物件還是不可變物件,進行深複製後,原始變數和新變數從外到內都是不一樣的記憶體空間,而且修改任意一個都不會引起同步變化。

程式碼示例如下:

import copy

d1 = {"a": 1, "b": [1, 2]}
d2 = copy.deepcopy(d1)

d2["b"].append(3)

print(f"d1 的值為:{d1},d1 的 b 元素地址為:{id(d1['b'])}")
print(f"d2 的值為:{d2},d2 的 b 元素地址為:{id(d2['b'])}")

其輸出內容如下:

d1 的值為:{'a': 1, 'b': [1, 2]},d1 的 b 元素地址為:2116199853248
d2 的值為:{'a': 1, 'b': [1, 2, 3]},d2 的 b 元素地址為:2116199512896

根據輸出可以看到,它的內容是符合我們前面對其的解釋的。

4. 總結

一般來說,如果沒有特殊需求,不需要原始變數與新變數之間有所關聯的話,建議使用深複製,因為淺複製的內部元素的關聯性,在實際程式設計中很容易造成資料混亂。

以上就是本次 Python 面試知識的全部內容,下一篇將介紹 Python 中的 lambda 表示式、函式傳參 args 和 kwargs 以及垃圾回收機制等。

如果想獲取更多後端相關文章,可掃碼關注閱讀:
image

相關文章