當前位置:
首頁 > 科技 > Python編寫循環的兩個建議

Python編寫循環的兩個建議

作者 | piglei(騰訊高級工程師)

轉載自騰訊技術工程知乎專欄

循環是一種常用的程序控制結構。我們常說,機器相比人類的最大優點之一,就是機器可以不眠不休的重複做某件事情,但人卻不行。而「循環」,則是實現讓機器不斷重複工作的關鍵概念。

在循環語法方面,Python 表現的即傳統又不傳統。它雖然拋棄了常見的 for(init;condition;incrment) 三段式結構,但還是選擇了 for 和 while 這兩個經典的關鍵字來表達循環。絕大多數情況下,我們的循環需求都可以用 forin 來滿足, while 相比之下用的則更少些。

雖然循環的語法很簡單,但是要寫好它確並不容易。在這篇文章里,我們將探討什麼是「地道」的循環代碼,以及如何編寫它們。

什麼是「地道」的循環?

「地道」這個詞,通常被用來形容某人做某件事情時,非常符合當地傳統,做的非常好。打個比方,你去參加一個朋友聚會,同桌的有一位廣東人,對方一開口,句句都是標準京腔、完美兒化音。那你可以對她說:「您的北京話說的真地道」。

既然「地道」這個詞形容的經常是口音、做菜的口味這類實實在在的東西,那「地道」的循環代碼又是什麼意思呢?讓我拿一個經典的例子來解釋一下。

如果你去問一位剛學習 Python 一個月的人:「如何在遍歷一個列表的同時獲取當前下標?」。他可能會交出這樣的代碼:

index=

fornameinnames:

print(index,name)

index =1

上面的循環雖然沒錯,但它確一點都不「地道」。一個擁有三年 Python 開發經驗的人會說,代碼應該這麼寫:

fori,nameinenumerate(names):

print(i,name)

enumerate() 是 Python 的一個內置函數,它接收一個「可迭代」對象作為參數,然後返回一個不斷生成 (當前下標,當前元素) 的新可迭代對象。這個場景使用它最適合不過。

所以,在上面的例子里,我們會認為第二段循環代碼比第一段更「地道」。

因為它用更直觀的代碼,更聰明的完成了工作。

▌enumerate() 所代表的編程思路

不過,判斷某段循環代碼是否地道,並不僅僅是以知道或不知道某個內置方法作為標準。我們可以從上面的例子挖掘出更深層的東西。

如你所見,Python 的 for 循環只有 forin 這一種結構,而結構里的前半部分 - 賦值給 item- 沒有太多花樣可玩。所以後半部分的可迭代對象是我們唯一能夠大做文章的東西。而以 enumerate() 函數為代表的「修飾函數」,剛好提供了一種思路:通過修飾可迭代對象來優化循環本身。

這就引出了我的第一個建議。

建議1:使用函數修飾被迭代對象來優化循環

使用修飾函數處理可迭代對象,可以在各種方面影響循環代碼。而要找到合適的例子來演示這個方法,並不用去太遠,內置模塊 itertools 就是一個絕佳的例子。

簡單來說,itertools 是一個包含很多面向可迭代對象的工具函數集。我在之前的系列文章《容器的門道》里提到過它。

如果要學習 itertools,那麼 Python 官方文檔 是你的首選,裡面有非常詳細的模塊相關資料。但在這篇文章里,側重點將和官方文檔稍有不同。我會通過一些常見的代碼場景,來詳細解釋它是如何改善循環代碼的。

▌1. 使用 product 扁平化多層嵌套循環

雖然我們都知道「扁平的代碼比嵌套的好」。但有時針對某類需求,似乎一定得寫多層嵌套循環才行。比如下面這段:

deffind_twelve(num_list1, num_list2, num_list3):

"""從 3 個數字列表中,尋找是否存在和為 12 的 3 個數

"""

fornum1innum_list1:

fornum2innum_list2:

fornum3innum_list3:

ifnum1 num2 num3 ==12:

returnnum1, num2, num3

對於這種需要嵌套遍歷多個對象的多層循環代碼,我們可以使用 product() 函數來優化它。product() 可以接收多個可迭代對象,然後根據它們的笛卡爾積不斷生成結果。

fromitertoolsimportproduct

deffind_twelve_v2(num_list1, num_list2, num_list3):

fornum1, num2, num3inproduct(num_list1, num_list2, num_list3):

ifnum1 num2 num3 ==12:

returnnum1, num2, num3

相比之前的代碼,使用 product() 的函數只用了一層 for 循環就完成了任務,代碼變得更精鍊了。

▌2. 使用 islice 實現循環內隔行處理

有一份包含 Reddit 帖子標題的外部數據文件,裡面的內容格式是這樣的:

python-guide: Python best practices guidebook, writtenforhumans.

---

Python2Death Clock

---

Run any Python Script with an Alexa Voice Command

---

可能是為了美觀,在這份文件里的每兩個標題之間,都有一個 "---" 分隔符。現在,我們需要獲取文件里所有的標題列表,所以在遍歷文件內容的過程中,必須跳過這些無意義的分隔符。

參考之前對 enumerate() 函數的了解,我們可以通過在循環內加一段基於當前循環序號的 if 判斷來做到這一點:

defparse_titles(filename):

"""從隔行數據文件中讀取 reddit 主題名稱

"""

withopen(filename,"r")asfp:

fori, lineinenumerate(fp):

# 跳過無意義的 "---" 分隔符

ifi %2==:

yieldline.strip()

但對於這類在循環內進行隔行處理的需求來說,如果使用 itertools 里的 islice() 函數修飾被循環對象,可以讓循環體代碼變得更簡單直接。

islice(seq,start,end,step) 函數和數組切片操作( list[start:stop:step] )有著幾乎一模一樣的參數。如果需要在循環內部進行隔行處理的話,只要設置第三個遞進步長參數 step 值為 2 即可(默認為 1)。

fromitertoolsimportislice

defparse_titles_v2(filename):

withopen(filename,"r")asfp:

# 設置 step=2,跳過無意義的 "---" 分隔符

forlineinislice(fp,,None,2):

yieldline.strip()

▌3. 使用 takewhile 替代 break 語句

有時,我們需要在每次循環開始時,判斷循環是否需要提前結束。比如下面這樣:

foruserinusers:

# 當第一個不合格的用戶出現後,不再進行後面的處理

ifnotis_qualified(user):

break

# 進行處理 ... ...

對於這類需要提前中斷的循環,我們可以使用 takewhile() 函數來簡化它。takewhile(predicate,iterable)會在迭代iterable的過程中不斷使用當前對象作為參數調用predicate函數並測試返回結果,如果函數返回值為真,則生成當前對象,循環繼續。否則立即中斷當前循環。

使用 takewhile 的代碼樣例:

fromitertoolsimporttakewhile

foruserintakewhile(is_qualified, users):

# 進行處理 ... ...

itertools 裡面還有一些其他有意思的工具函數,他們都可以用來和循環搭配使用,比如使用 chain 函數扁平化雙層嵌套循環、使用 zip_longest 函數一次同時循環多個對象等等。

篇幅有限,我在這裡不再一一介紹。如果有興趣,可以自行去官方文檔詳細了解。

▌4. 使用生成器編寫自己的修飾函數

除了 itertools 提供的那些函數外,我們還可以非常方便的使用生成器來定義自己的循環修飾函數。

讓我們拿一個簡單的函數舉例:

defsum_even_only(numbers):

"""對 numbers 裡面所有的偶數求和"""

result =

fornuminnumbers:

ifnum %2==:

result = num

returnresult

在上面的函數里,循環體內為了過濾掉所有奇數,引入了一條額外的 if 判斷語句。如果要簡化循環體內容,我們可以定義一個生成器函數來專門進行偶數過濾:

defeven_only(numbers):

fornuminnumbers:

ifnum %2==:

yieldnum

defsum_even_only_v2(numbers):

"""對 numbers 裡面所有的偶數求和"""

result =

fornumineven_only(numbers):

result = num

returnresult

將 numbers 變數使用 even_only 函數裝飾後, sum_even_only_v2 函數內部便不用繼續關注「偶數過濾」邏輯了,只需要簡單完成求和即可。

Hint:當然,上面的這個函數其實並不實用。在現實世界裡,這種簡單需求最適合直接用生成器/列表表達式搞定:sum(numfornuminnumbersifnum%2==0)

建議2:按職責拆解循環體內複雜代碼塊

我一直覺得循環是一個比較神奇的東西,每當你寫下一個新的循環代碼塊,就好像開闢了一片黑魔法陣,陣內的所有內容都會開始無休止的重複執行。

但我同時發現,這片黑魔法陣除了能帶來好處,它還會引誘你不斷往陣內塞入越來越多的代碼,包括過濾掉無效元素、預處理數據、列印日誌等等。甚至一些原本不屬於同一抽象的內容,也會被塞入到同一片黑魔法陣內。

你可能會覺得這一切理所當然,我們就是迫切需要陣內的魔法效果。如果不把這一大堆邏輯塞滿到循環體內,還能把它們放哪去呢?

讓我們來看看下面這個業務場景。在網站中,有一個每 30 天執行一次的周期腳本,它的任務是是查詢過去 30 天內,在每周末特定時間段登錄過的用戶,然後為其發送獎勵積分。

代碼如下:

importtime

importdatetime

defaward_active_users_in_last_30days():

"""獲取所有在過去 30 天周末晚上 8 點到 10 點登錄過的用戶,為其發送獎勵積分

"""

days =30

fordays_deltainrange(days):

dt = datetime.date.today() - datetime.timedelta(days=days_delta)

# 5: Saturday, 6: Sunday

ifdt.weekday()notin(5,6):

continue

time_start = datetime.datetime(dt.year, dt.month, dt.day,20,)

time_end = datetime.datetime(dt.year, dt.month, dt.day,23,)

# 轉換為 unix 時間戳,之後的 ORM 查詢需要

ts_start = time.mktime(time_start.timetuple())

ts_end = time.mktime(time_end.timetuple())

# 查詢用戶並挨個發送 1000 獎勵積分

forrecordinLoginRecord.filter_by_range(ts_start, ts_end):

# 這裡可以添加複雜邏輯

send_awarding_points(record.user_id,1000)

上面這個函數主要由兩層循環構成。外層循環的職責,主要是獲取過去 30 天內符合要求的時間,並將其轉換為 UNIX 時間戳。之後由內層循環使用這兩個時間戳進行積分發送。

如之前所說,外層循環所開闢的黑魔法陣內被塞的滿滿當當。但通過觀察後,我們可以發現整個循環體其實是由兩個完全無關的任務構成的:「挑選日期與準備時間戳」 以及 「發送獎勵積分」。

▌複雜循環體如何應對新需求

這樣的代碼有什麼壞處呢?讓我來告訴你。

某日,產品找過來說,有一些用戶周末半夜不睡覺,還在刷我們的網站,我們得給他們發通知讓他們以後早點睡覺。於是新需求出現了:「給過去 30 天內在周末凌晨 3 點到 5 點登錄過的用戶發送一條通知」

新問題也隨之而來。敏銳如你,肯定一眼可以發現,這個新需求在用戶篩選部分的要求,和之前的需求非常非常相似。但是,如果你再打開之前那團循環體看看,你會發現代碼根本沒法復用,因為在循環內部,不同的邏輯完全被耦合在一起了。

在計算機的世界裡,我們經常用「耦合」這個詞來表示事物之間的關聯關係。上面的例子中,「挑選時間」和「發送積分」這兩件事情身處同一個循環體內,建立了非常強的耦合關係。

為了更好的進行代碼復用,我們需要把函數里的「挑選時間」部分從循環體中解耦出來。而我們的老朋友,「生成器函數」是進行這項工作的不二之選。

▌使用生成器函數解耦循環體

要把 「挑選時間」 部分從循環內解耦出來,我們需要定義新的生成器函數 gen_weekend_ts_ranges(),專門用來生成需要的 UNIX 時間戳:

defgen_weekend_ts_ranges(days_ago, hour_start, hour_end):

"""生成過去一段時間內周六日特定時間段範圍,並以 UNIX 時間戳返回

"""

fordays_deltainrange(days_ago):

dt = datetime.date.today() - datetime.timedelta(days=days_delta)

# 5: Saturday, 6: Sunday

ifdt.weekday()notin(5,6):

continue

time_start = datetime.datetime(dt.year, dt.month, dt.day, hour_start,)

time_end = datetime.datetime(dt.year, dt.month, dt.day, hour_end,)

# 轉換為 unix 時間戳,之後的 ORM 查詢需要

ts_start = time.mktime(time_start.timetuple())

ts_end = time.mktime(time_end.timetuple())

yieldts_start, ts_end

有了這個生成器函數後,舊需求「發送獎勵積分」和新需求「發送通知」,就都可以在循環體內復用它來完成任務了:

defaward_active_users_in_last_30days_v2():

"""發送獎勵積分"""

forts_start, ts_endingen_weekend_ts_ranges(30, hour_start=20, hour_end=23):

forrecordinLoginRecord.filter_by_range(ts_start, ts_end):

send_awarding_points(record.user_id,1000)

defnotify_nonsleep_users_in_last_30days():

"""發送通知"""

forts_start, ts_endingen_weekend_ts_range(30, hour_start=3, hour_end=6):

forrecordinLoginRecord.filter_by_range(ts_start, ts_end):

notify_user(record.user_id,"You should sleep more")

總結

在這篇文章里,我們首先簡單解釋了「地道」循環代碼的定義。然後提出了第一個建議:使用修飾函數來改善循環。之後我虛擬了一個業務場景,描述了按職責拆解循環內代碼的重要性。

一些要點總結:

使用函數修飾被循環對象本身,可以改善循環體內的代碼

itertools 裡面有很多工具函數都可以用來改善循環

使用生成器函數可以輕鬆定義自己的修飾函數

循環內部,是一個極易發生「代碼膨脹」的場地

請使用生成器函數將循環內不同職責的代碼塊解耦出來,獲得更好的靈活性

看完文章的你,有沒有什麼想吐槽的?請留言或者在 項目 Github Issues 告訴我吧。

附錄

題圖來源: Photo by Lai man nung on Unsplash

更多系列文章地址:https://github.com/piglei/one-python-craftsman

(*本文為 AI科技大本營轉載文章,轉載請聯繫原作者)

喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 AI科技大本營 的精彩文章:

李理:為什麼說人工智慧可以實現?
AI改寫《權游》結局,和編劇比誰更爛?

TAG:AI科技大本營 |