Python 迭代器提供彈性資料處理能力,但若函式內需重複遍歷迭代器,則可能遭遇迭代器耗盡問題,導致非預期結果。例如,計算數字百分比的函式,若使用迭代器作為輸入,sum() 函式會先耗盡迭代器,後續迴圈將無資料可處理。
為解決此問題,可將迭代器內容複製至列表,確保每次遍歷皆使用完整資料,但此法可能造成記憶體負擔。另一種方法是傳遞產生迭代器的函式,每次呼叫皆產生新迭代器,避免資料耗盡,但使用 lambda 函式略顯繁瑣。更優雅的解法是建立容器類別,實作迭代器協定,讓每次迴圈皆產生新的迭代器物件,確保資料完整性,缺點是可能重複讀取資料。最後,可採用防禦性程式設計,檢查輸入是否為迭代器,若為迭代器則丟擲錯誤,避免非預期行為,適用於需多次迭代與不希望複製迭代器內容的場景。
Python迭代器陷阱:如何避免函式重複取用問題 - 玄貓觀點
在Python中,迭代器(Iterator)是個強大的工具,但若使用不當,可能導致程式出現難以預期的錯誤。今天,玄貓要來分享一個關於Python迭代器的常見陷阱,以及如何透過正確的設計來避免它。
迭代器的隱藏危機:重複取用問題
想像一下,你寫了一個函式,需要多次遍歷輸入的資料。如果輸入是一個迭代器,每次遍歷實際上都會消耗掉迭代器中的元素。當函式再次嘗試遍歷時,迭代器可能已經耗盡,導致結果不正確。
讓玄貓用一個實際的例子來說明。假設我們需要計算一組數字的百分比:
def normalize(numbers):
total = sum(numbers)
result = []
for value in numbers:
percent = 100 * value / total
result.append(percent)
return result
這個 normalize 函式首先計算數字總和,然後計算每個數字的百分比。如果我們直接傳入一個列表,一切都很好:
visits = [15, 35, 80]
percentages = normalize(visits)
print(percentages)
# 輸出: [11.538461538461538, 26.923076923076923, 61.53846153846154]
但如果我們傳入一個迭代器呢?例如,從檔案讀取資料的生成器:
def read_visits(data_path):
with open(data_path) as f:
for line in f:
yield int(line)
# 建立一個包含一些數字的臨時檔案
path = '/tmp/my_numbers.txt'
with open(path, 'w') as f:
f.write('15\n35\n80\n')
it = read_visits(path)
percentages = normalize(it)
print(percentages)
# 輸出: []
結果是空的!這是因為 sum(numbers) 已經消耗了迭代器中的所有元素,導致後面的 for 迴圈沒有任何資料可以遍歷。
解法一:複製迭代器內容
要解決這個問題,最簡單的方法就是顯式地將迭代器的內容複製到一個列表中。這樣,我們就可以多次遍歷這個列表,而不會影響原始的迭代器。
def normalize_copy(numbers):
numbers = list(numbers) # 複製迭代器
total = sum(numbers)
result = []
for value in numbers:
percent = 100 * value / total
result.append(percent)
return result
it = read_visits(path)
percentages = normalize_copy(it)
print(percentages)
# 輸出: [11.538461538461538, 26.923076923076923, 61.53846153846154]
這個方法簡單有效,但有個明顯的缺點:如果迭代器中的資料量很大,複製操作可能會消耗大量記憶體。
解法二:使用產生新迭代器的函式
另一種方法是接受一個函式,每次呼叫這個函式都會傳回一個新的迭代器。這樣,每次需要遍歷資料時,我們都可以獲得一個全新的迭代器。
def normalize_func(get_iter):
total = sum(get_iter()) # 新的迭代器
result = []
for value in get_iter(): # 新的迭代器
percent = 100 * value / total
result.append(percent)
return result
percentages = normalize_func(lambda: read_visits(path))
print(percentages)
# 輸出: [11.538461538461538, 26.923076923076923, 61.53846153846154]
這個方法避免了複製大量資料,但使用 lambda 函式的方式略顯笨拙。
解法三:實作迭代器協定
更優雅的解決方案是提供一個新的容器類別,實作迭代器協定。迭代器協定定義了Python的 for 迴圈如何遍歷容器型別的內容。
當Python看到 for x in foo 這樣的陳述式時,它實際上會呼叫 iter(foo)。iter 內建函式會呼叫 foo.__iter__ 特殊方法。__iter__ 方法必須傳回一個迭代器物件(它本身實作了 __next__ 特殊方法)。然後,for 迴圈會重複呼叫迭代器物件上的 next 內建函式,直到迭代器耗盡(並引發 StopIteration 異常)。
實際上,你可以透過將 __iter__ 方法實作為生成器,來為你的類別實作所有這些行為。
class ReadVisits(object):
def __init__(self, data_path):
self.data_path = data_path
def __iter__(self):
with open(self.data_path) as f:
for line in f:
yield int(line)
這個新的容器型別可以在不修改的情況下,正確地傳遞給原始函式。
visits = ReadVisits(path)
percentages = normalize(visits)
print(percentages)
# 輸出: [11.538461538461538, 26.923076923076923, 61.53846153846154]
這之所以有效,是因為 normalize 中的 sum 方法會呼叫 ReadVisits.__iter__ 來分配一個新的迭代器物件。用於正規化數字的 for 迴圈也會呼叫 __iter__ 來分配第二個迭代器物件。每個迭代器都會獨立前進和耗盡,確保每次唯一的迭代都能看到所有輸入資料值。這種方法的唯一缺點是它會多次讀取輸入資料。
解法四:防禦性程式設計
既然我們知道容器是如何運作的,我們可以編寫函式來確保引數不只是迭代器。協定規定,當迭代器傳遞給 iter 內建函式時,iter 將傳回迭代器本身。相反,當容器型別傳遞給 iter 時,每次都會傳回一個新的迭代器物件。因此,你可以測試輸入值的這種行為,並引發 TypeError 來拒絕迭代器。
def normalize_defensive(numbers):
if iter(numbers) is iter(numbers): # 一個迭代器 — 不好!
raise TypeError('Must supply a container')
total = sum(numbers)
result = []
for value in numbers:
percent = 100 * value / total
result.append(percent)
return result
如果你不想像上面的 normalize_copy 那樣複製完整的輸入迭代器,但又需要多次迭代輸入資料,這是一個理想的選擇。此函式對於 list 和 ReadVisits 輸入,會如預期般工作,因為它們是容器。它適用於任何遵循迭代器協定的容器型別。
visits = [15, 35, 80]
normalize_defensive(visits) # 沒有錯誤
visits = ReadVisits(path)
normalize_defensive(visits) # 沒有錯誤
如果輸入是可迭代的但不是容器,該函式將引發異常。
it = iter(visits)
normalize_defensive(it)
# 輸出: TypeError: Must supply a container
使用變數位置引數減少視覺雜訊
接受可選的位置引數(通常稱為星號引數,指的是引數的傳統名稱 *args)可以使函式呼叫更清晰,並減少視覺雜訊。
例如,假設你想記錄一些除錯資訊。使用固定數量的引數,你需要一個接受訊息和值列表的函式。
def log(message, values):
if not values:
print(message)
else:
values_str = ', '.join(str(x) for x in values)
print('%s: %s' % (message, values_str))
log('My numbers are', [1, 2])
log('Hi there', [])
# 輸出:
# My numbers are: 1, 2
# Hi there
Python可變引數的隱憂:玄貓的程式碼最佳化之路
在Python中,使用*args可以讓函式接收不定數量的位置引數,這在某些情況下非常方便。但玄貓發現,這種靈活性背後隱藏著一些潛在的問題,需要謹慎處理。
1. 靈活的可變引數:*args 的妙用
有時候,我們希望函式能夠接收任意數量的引數。例如,一個日誌函式,可以接收不同數量的數值進行記錄:
def log(message, *values):
if not values:
print(message)
else:
values_str = ', '.join(str(x) for x in values)
print('%s: %s' % (message, values_str))
log('玄貓的數字是', 1, 2)
log('嗨,大家好')
內容解密
log(message, *values): 定義一個名為log的函式,它接受一個必需的位置引數message和任意數量的其他位置引數*values。if not values:: 檢查values是否為空。如果為空,則意味著除了message之外沒有其他引數被傳遞。print(message): 如果values為空,則僅印出message。else:: 如果values不為空,則執行以下程式碼塊。values_str = ', '.join(str(x) for x in values): 將values中的所有值轉換為字串,然後用逗號和空格連線起來。print('%s: %s' % (message, values_str)): 印出message,後跟一個冒號和空格,然後是連線的values字串。log('玄貓的數字是', 1, 2): 呼叫log函式,傳遞訊息 “玄貓的數字是” 以及兩個額外的引數 1 和 2。log('嗨,大家好'): 呼叫log函式,只傳遞訊息 “嗨,大家好”。
當我們已經有一個列表,想要將其作為引數傳遞給函式時,可以使用*運算元:
favorites = [7, 33, 99]
log('玄貓喜歡的數字', *favorites)
內容解密
favorites = [7, 33, 99]: 建立一個名為favorites的列表,其中包含三個數字:7、33 和 99。log('玄貓喜歡的數字', *favorites): 呼叫log函式,傳遞訊息 “玄貓喜歡的數字” 以及解封裝的favorites列表作為額外的引數。星號*用於將列表解封裝為單獨的引數。
2. *args 的隱憂:玄貓的經驗分享
雖然*args很方便,但玄貓在實際專案中發現了兩個主要問題:
- 記憶體消耗:
*args會將所有引數轉換為一個元組。如果呼叫者使用生成器,生成器會被完整迭代,可能消耗大量記憶體,導致程式當機。
def my_generator():
for i in range(10):
yield i
def my_func(*args):
print(args)
it = my_generator()
my_func(*it)
內容解密
-
def my_generator():: 定義一個名為my_generator的生成器函式。 -
for i in range(10):: 迴圈 10 次,i的值從 0 到 9。 -
yield i: 產生i的值。每次迴圈迭代時,生成器都會產生一個新的值。 -
def my_func(*args):: 定義一個名為my_func的函式,它接受任意數量的位置引數,並將它們收集到一個名為args的元組中。 -
print(args): 印出args元組的內容。 -
it = my_generator(): 建立my_generator生成器的一個例項,並將其指定給變數it。 -
my_func(*it): 呼叫my_func函式,並使用解封裝運算元*將生成器it產生的所有值作為單獨的引數傳遞給它。 -
擴充套件性問題:如果未來需要在函式中新增位置引數,所有呼叫者都需要更新,否則可能會出現難以追蹤的錯誤。
def log(sequence, message, *values):
if not values:
print('%s: %s' % (sequence, message))
else:
values_str = ', '.join(str(x) for x in values)
print('%s: %s: %s' % (sequence, message, values_str))
log(1, '玄貓喜歡的數字', 7, 33) # 新的用法
log('玄貓喜歡的數字', 7, 33) # 舊的用法,會出錯
內容解密
def log(sequence, message, *values):: 定義一個名為log的函式,它接受一個名為sequence的引數,一個名為message的引數,以及任意數量的其他位置引數,這些引數被收集到一個名為values的元組中。if not values:: 檢查values元組是否為空。print('%s: %s' % (sequence, message)): 如果values為空,則使用sequence和message的值印出一條格式化的訊息。else:: 如果values不為空,則執行以下程式碼塊。values_str = ', '.join(str(x) for x in values): 將values元組中的所有值轉換為字串,然後用逗號和空格連線起來。print('%s: %s: %s' % (sequence, message, values_str)): 使用sequence、message和連線的values字串的值印出一條格式化的訊息。log(1, '玄貓喜歡的數字', 7, 33): 使用序列 1、訊息 “玄貓喜歡的數字” 以及值 7 和 33 呼叫log函式。log('玄貓喜歡的數字', 7, 33): 使用訊息 “玄貓喜歡的數字” 以及值 7 和 33 呼叫log函式。這將導致錯誤的輸出,因為 7 將被解釋為sequence引數的值。
3. 玄貓的建議:謹慎使用*args,善用關鍵字引數
*args適用於引數數量較少與固定的情況,主要為了程式碼的簡潔和可讀性。但玄貓建議,在以下情況下應避免使用*args:
- 當引數數量可能很大時,避免記憶體消耗。
- 當函式未來可能需要擴充套件時,避免破壞現有程式碼。
為了避免這些問題,可以使用僅限關鍵字引數(keyword-only arguments),詳見Item 21。
Python 關鍵字引數:玄貓的程式碼最佳化技巧
關鍵字引數是Python中一種強大的特性,它提供了靈活性和可讀性,讓程式碼更易於理解和維護。玄貓將分享如何利用關鍵字引數來最佳化程式碼。
1. 關鍵字引數的靈活性
在Python中,呼叫函式時可以透過位置傳遞引數,也可以透過關鍵字傳遞引數。
def remainder(number, divisor):
return number % divisor
assert remainder(20, 7) == 6
除了位置引數,我們還可以使用關鍵字引數:
remainder(20, 7)
remainder(20, divisor=7)
remainder(number=20, divisor=7)
remainder(divisor=7, number=20)
內容解密
remainder(number=20, divisor=7): 使用關鍵字引數呼叫remainder函式,明確指定number的值為 20,divisor的值為 7。remainder(divisor=7, number=20): 再次使用關鍵字引數呼叫remainder函式,但這次引數的順序顛倒了。number的值仍然是 20,divisor的值仍然是 7。
但需要注意,位置引數必須在關鍵字引數之前,與每個引數只能指定一次。
remainder(number=20, 7) # 錯誤:SyntaxError: non-keyword arg after keyword arg
remainder(20, number=7) # 錯誤:TypeError: remainder() got multiple values for argument ‘number’
內容解密
remainder(number=20, 7): 嘗試使用關鍵字引數number=20,然後使用位置引數 7 呼叫remainder函式。這會導致SyntaxError: non-keyword arg after keyword arg錯誤,因為在關鍵字引數之後不允許使用位置引數。remainder(20, number=7): 嘗試使用位置引數 20,然後使用關鍵字引數number=7呼叫remainder函式。這會導致TypeError: remainder() got multiple values for argument 'number'錯誤,因為引數number被指定了多次(一次透過位置,一次透過關鍵字)。
2. 關鍵字引數的優勢:玄貓的經驗之談
關鍵字引數提供了三個顯著的優勢:
- 提高程式碼可讀性:透過關鍵字,可以清晰地知道每個引數的含義,避免混淆。例如,
remainder(number=20, divisor=7)比remainder(20, 7)更易讀。 - 提供預設值:可以在函式定義中為關鍵字引數指定預設值,使得函式在某些情況下更易於使用。
- 擴充套件性:在不影響現有程式碼的情況下,可以輕鬆地增加新的關鍵字引數。
3. 例項:計算流速
假設我們要計算液體流入容器的流速。如果容器帶有刻度,我們可以透過測量不同時間點的重量差來計算流速。
def flow_rate(weight_diff, time_diff):
return weight_diff / time_diff
weight_diff = 0.5
time_diff = 3
flow = flow_rate(weight_diff, time_diff)
print('%.3f kg per second' % flow)
內容解密
def flow_rate(weight_diff, time_diff):: 定義一個名為flow_rate的函式,它接受兩個引數:weight_diff(重量差)和time_diff(時間差)。return weight_diff / time_diff: 傳回重量差除以時間差的結果,即流速。weight_diff = 0.5: 將weight_diff變數設定為 0.5。time_diff = 3: 將time_diff變數設定為 3。flow = flow_rate(weight_diff, time_diff): 使用weight_diff和time_diff的值呼叫flow_rate函式,並將結果指定給flow變數。print('%.3f kg per second' % flow): 使用格式化字串印出流速,保留三位小數。
玄貓認為,關鍵字引數是Python中一個非常實用的特性,可以提高程式碼的可讀性和靈活性。在設計函式時,應充分考慮使用關鍵字引數,以提高程式碼的品質。