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 那樣複製完整的輸入迭代器,但又需要多次迭代輸入資料,這是一個理想的選擇。此函式對於 listReadVisits 輸入,會如預期般工作,因為它們是容器。它適用於任何遵循迭代器協定的容器型別。

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 為空,則使用 sequencemessage 的值印出一條格式化的訊息。
  • else:: 如果 values 不為空,則執行以下程式碼塊。
  • values_str = ', '.join(str(x) for x in values): 將 values 元組中的所有值轉換為字串,然後用逗號和空格連線起來。
  • print('%s: %s: %s' % (sequence, message, values_str)): 使用 sequencemessage 和連線的 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_difftime_diff 的值呼叫 flow_rate 函式,並將結果指定給 flow 變數。
  • print('%.3f kg per second' % flow): 使用格式化字串印出流速,保留三位小數。

玄貓認為,關鍵字引數是Python中一個非常實用的特性,可以提高程式碼的可讀性和靈活性。在設計函式時,應充分考慮使用關鍵字引數,以提高程式碼的品質。