作者 | piglei(騰訊高級工程師)
轉載自騰訊技術工程知乎專欄
循環是一種常用的程序控制結構。我們常說,機器相比人類的最大優點之一,就是機器可以不眠不休的重復做某件事情,但人卻不行。而“ 循環 ”,則是實現讓機器不斷重復工作的關鍵概念。
在循環語法方面,Python 表現的即傳統又不傳統。它雖然拋棄了常見的 for(init;condition;incrment) 三段式結構,但還是選擇了 for 和 while 這兩個經典的關鍵字來表達循環。絕大多數情況下,我們的循環需求都可以用 for
雖然循環的語法很簡單,但是要寫好它確并不容易。在這篇文章里,我們將探討什么是“地道”的循環代碼,以及如何編寫它們。
什么是“地道”的循環?
“地道”這個詞,通常被用來形容某人做某件事情時,非常符合當地傳統,做的非常好。打個比方,你去參加一個朋友聚會,同桌的有一位廣東人,對方一開口,句句都是標準京腔、完美兒化音。那你可以對她說:“您的北京話說的真 地道 ”。
既然“地道”這個詞形容的經常是口音、做菜的口味這類實實在在的東西,那“地道”的循環代碼又是什么意思呢?讓我拿一個經典的例子來解釋一下。
如果你去問一位剛學習 Python 一個月的人:“如何在遍歷一個列表的同時獲取當前下標?”。他可能會交出這樣的代碼:
上面的循環雖然沒錯,但它確一點都不“地道”。一個擁有三年 Python 開發經驗的人會說,代碼應該這么寫:
for
?i,?
name
?
in
?enumerate(names):
????print(i,?
name
)
enumerate() 是 Python 的一個內置函數,它接收一個“可迭代”對象作為參數,然后返回一個不斷生成 (當前下標,當前元素) 的新可迭代對象。這個場景使用它最適合不過。
所以,在上面的例子里,我們會認為第二段循環代碼比第一段更“地道”。
因為它用更直觀的代碼,更聰明的完成了工作。
▌ enumerate() 所代表的編程思路
不過,判斷某段循環代碼是否地道,并不僅僅是以知道或不知道某個內置方法作為標準。我們可以從上面的例子挖掘出更深層的東西。
如你所見,Python 的 for 循環只有 for
這就引出了我的第一個建議。
建議1:使用函數修飾被迭代對象來優化循環
使用修飾函數處理可迭代對象,可以在各種方面影響循環代碼。而要找到合適的例子來演示這個方法,并不用去太遠,內置模塊 itertools 就是一個絕佳的例子。
簡單來說,itertools 是一個包含很多面向可迭代對象的工具函數集。我在之前的系列文章《容器的門道》里提到過它。
如果要學習 itertools,那么 Python 官方文檔 是你的首選,里面有非常詳細的模塊相關資料。但在這篇文章里,側重點將和官方文檔稍有不同。我會通過一些常見的代碼場景,來詳細解釋它是如何改善循環代碼的。
▌ 1. 使用 product 扁平化多層嵌套循環
雖然我們都知道“扁平的代碼比嵌套的好”。但有時針對某類需求,似乎一定得寫多層嵌套循環才行。比如下面這段:
def
?
find_twelve
(num_list1,?num_list2,?num_list3)
:
????
"""從?3?個數字列表中,尋找是否存在和為?12?的?3?個數
????"""
????
for
?num1?
in
?num_list1:
????????
for
?num2?
in
?num_list2:
????????????
for
?num3?
in
?num_list3:
????????????????
if
?num1?+?num2?+?num3?==?
12
:
????????????????????
return
?num1,?num2,?num3
對于這種需要嵌套遍歷多個對象的多層循環代碼,我們可以使用 product() 函數來優化它。product() 可以接收多個可迭代對象,然后根據它們的笛卡爾積不斷生成結果。
from
?itertools?
import
?product
def
?
find_twelve_v2
(num_list1,?num_list2,?num_list3)
:
????
for
?num1,?num2,?num3?
in
?product(num_list1,?num_list2,?num_list3):
????????
if
?num1?+?num2?+?num3?==?
12
:
????????????
return
?num1,?num2,?num3
相比之前的代碼,使用 product() 的函數只用了一層 for 循環就完成了任務,代碼變得更精煉了。
▌ 2. 使用 islice 實現循環內隔行處理
有一份包含 Reddit 帖子標題的外部數據文件,里面的內容格式是這樣的:
python-guide:?Python?best?practices?guidebook,?written?
for
?humans.
---
Python?
2
?Death?Clock
---
Run?any?Python?Script?with?an?Alexa?Voice?Command
---
<...?...>
可能是為了美觀,在這份文件里的每兩個標題之間,都有一個 "---" 分隔符。現在,我們需要獲取文件里所有的標題列表,所以在遍歷文件內容的過程中,必須跳過這些無意義的分隔符。
參考之前對 enumerate() 函數的了解,我們可以通過在循環內加一段基于當前循環序號的 if 判斷來做到這一點:
def
?
parse_titles
(filename)
:
????
"""從隔行數據文件中讀取?reddit?主題名稱
????"""
????
with
?open(filename,?
'r'
)?
as
?fp:
????????
for
?i,?line?
in
?enumerate(fp):
????????????
#?跳過無意義的?'---'?分隔符
????????????
if
?i?%?
2
?==?
0
:
????????????????
yield
?line.strip()
但對于這類在循環內進行隔行處理的需求來說,如果使用 itertools 里的 islice() 函數修飾被循環對象,可以讓循環體代碼變得更簡單直接。
islice(seq,start,end,step) 函數和數組切片操作( list[start:stop:step] )有著幾乎一模一樣的參數。如果需要在循環內部進行隔行處理的話,只要設置第三個遞進步長參數 step 值為 2 即可(默認為 1)。
from
?itertools?
import
?islice
def
?
parse_titles_v2
(filename)
:
????
with
?open(filename,?
'r'
)?
as
?fp:
????????
#?設置?step=2,跳過無意義的?'---'?分隔符
????????
for
?line?
in
?islice(fp,?
0
,?
None
,?
2
):
????????????
yield
?line.strip()
▌ 3. 使用 takewhile 替代 break 語句
有時,我們需要在每次循環開始時,判斷循環是否需要提前結束。比如下面這樣:
for
?user?
in
?users:
????
#?當第一個不合格的用戶出現后,不再進行后面的處理
????
if
?
not
?is_qualified(user):
????????
break
????
#?進行處理?...?...
對于這類需要提前中斷的循環,我們可以使用 takewhile() 函數來簡化它。 takewhile(predicate,iterable) 會在迭代 ? iterable ? 的過程中不斷使用當前對象作為參數調用 ? predicate ? 函數并測試返回結果,如果函數返回值為真,則生成當前對象,循環繼續。否則立即中斷當前循環。
使用 takewhile 的代碼樣例:
from
?itertools?
import
?takewhile
for
?user?
in
?takewhile(is_qualified,?users):
????
#?進行處理?...?...
itertools 里面還有一些其他有意思的工具函數,他們都可以用來和循環搭配使用,比如使用 chain 函數扁平化雙層嵌套循環、使用 zip_longest 函數一次同時循環多個對象等等。
篇幅有限,我在這里不再一一介紹。如果有興趣,可以自行去官方文檔詳細了解。
▌ 4. 使用生成器編寫自己的修飾函數
除了 itertools 提供的那些函數外,我們還可以非常方便的使用生成器來定義自己的循環修飾函數。
讓我們拿一個簡單的函數舉例:
def
?
sum_even_only
(numbers)
:
????
"""對?numbers?里面所有的偶數求和"""
????result?=?
0
????
for
?num?
in
?numbers:
????????
if
?num?%?
2
?==?
0
:
????????????result?+=?num
????
return
?result
在上面的函數里,循環體內為了過濾掉所有奇數,引入了一條額外的 if 判斷語句。如果要簡化循環體內容,我們可以定義一個生成器函數來專門進行偶數過濾:
def
?
even_only
(numbers)
:
????
for
?num?
in
?numbers:
????????
if
?num?%?
2
?==?
0
:
????????????
yield
?num
def
?
sum_even_only_v2
(numbers)
:
????
"""對?numbers?里面所有的偶數求和"""
????result?=?
0
????
for
?num?
in
?even_only(numbers):
????????result?+=?num
????
return
?result
將 numbers 變量使用 even_only 函數裝飾后, sum_even_only_v2 函數內部便不用繼續關注“偶數過濾”邏輯了,只需要簡單完成求和即可。
Hint:當然,上面的這個函數其實并不實用。在現實世界里,這種簡單需求最適合直接用生成器/列表表達式搞定:sum(numfornuminnumbersifnum%2==0)
建議2:按職責拆解循環體內復雜代碼塊
我一直覺得循環是一個比較神奇的東西,每當你寫下一個新的循環代碼塊,就好像開辟了一片黑魔法陣,陣內的所有內容都會開始無休止的重復執行。
但我同時發現,這片黑魔法陣除了能帶來好處, 它還會引誘你不斷往陣內塞入越來越多的代碼,包括過濾掉無效元素、預處理數據、打印日志等等。甚至一些原本不屬于同一抽象的內容,也會被塞入到同一片黑魔法陣內。
你可能會覺得這一切理所當然,我們就是迫切需要陣內的魔法效果。如果不把這一大堆邏輯塞滿到循環體內,還能把它們放哪去呢?
讓我們來看看下面這個業務場景。在網站中,有一個每 30 天執行一次的周期腳本,它的任務是是查詢過去 30 天內,在每周末特定時間段登錄過的用戶,然后為其發送獎勵積分。
代碼如下:
import
?time
import
?datetime
def
?
award_active_users_in_last_30days
()
:
????
"""獲取所有在過去?30?天周末晚上?8?點到?10?點登錄過的用戶,為其發送獎勵積分
????"""
????days?=?
30
????
for
?days_delta?
in
?range(days):
????????dt?=?datetime.date.today()?-?datetime.timedelta(days=days_delta)
????????
#?5:?Saturday,?6:?Sunday
????????
if
?dt.weekday()?
not
?
in
?(
5
,?
6
):
????????????
continue
????????time_start?=?datetime.datetime(dt.year,?dt.month,?dt.day,?
20
,?
0
)
????????time_end?=?datetime.datetime(dt.year,?dt.month,?dt.day,?
23
,?
0
)
????????
#?轉換為?unix?時間戳,之后的?ORM?查詢需要
????????ts_start?=?time.mktime(time_start.timetuple())
????????ts_end?=?time.mktime(time_end.timetuple())
????????
#?查詢用戶并挨個發送?1000?獎勵積分
????????
for
?record?
in
?LoginRecord.filter_by_range(ts_start,?ts_end):
????????????
#?這里可以添加復雜邏輯
????????????send_awarding_points(record.user_id,?
1000
)?
上面這個函數主要由兩層循環構成。外層循環的職責,主要是獲取過去 30 天內符合要求的時間,并將其轉換為 UNIX 時間戳。之后由內層循環使用這兩個時間戳進行積分發送。
如之前所說,外層循環所開辟的黑魔法陣內被塞的滿滿當當。但通過觀察后,我們可以發現 整個循環體其實是由兩個完全無關的任務構成的:“挑選日期與準備時間戳” 以及 “發送獎勵積分 ”。
▌ 復雜循環體如何應對新需求
這樣的代碼有什么壞處呢?讓我來告訴你。
某日,產品找過來說,有一些用戶周末半夜不睡覺,還在刷我們的網站,我們得給他們發通知讓他們以后早點睡覺。于是新需求出現了:“ 給過去 30 天內在周末凌晨 3 點到 5 點登錄過的用戶發送一條通知” 。
新問題也隨之而來。敏銳如你,肯定一眼可以發現,這個新需求在用戶篩選部分的要求,和之前的需求非常非常相似。但是,如果你再打開之前那團循環體看看,你會發現代碼根本沒法復用,因為在循環內部,不同的邏輯完全被 耦合 在一起了。??
在計算機的世界里,我們經常用“ 耦合 ”這個詞來表示事物之間的關聯關系。上面的例子中,“挑選時間”和“發送積分”這兩件事情身處同一個循環體內,建立了非常強的耦合關系。
為了更好的進行代碼復用,我們需要把函數里的“挑選時間”部分從循環體中解耦出來。而我們的老朋友,“ 生成器函數 ”是進行這項工作的不二之選。
▌ 使用生成器函數解耦循環體
要把 “挑選時間” 部分從循環內解耦出來,我們需要定義新的生成器函數 gen_weekend_ts_ranges(),專門用來生成需要的 UNIX 時間戳:
def
?
gen_weekend_ts_ranges
(days_ago,?hour_start,?hour_end)
:
????
"""生成過去一段時間內周六日特定時間段范圍,并以?UNIX?時間戳返回
????"""
????
for
?days_delta?
in
?range(days_ago):
????????dt?=?datetime.date.today()?-?datetime.timedelta(days=days_delta)
????????
#?5:?Saturday,?6:?Sunday
????????
if
?dt.weekday()?
not
?
in
?(
5
,?
6
):
????????????
continue
????????time_start?=?datetime.datetime(dt.year,?dt.month,?dt.day,?hour_start,?
0
)
????????time_end?=?datetime.datetime(dt.year,?dt.month,?dt.day,?hour_end,?
0
)
????????
#?轉換為?unix?時間戳,之后的?ORM?查詢需要
????????ts_start?=?time.mktime(time_start.timetuple())
????????ts_end?=?time.mktime(time_end.timetuple())
????????
yield
?ts_start,?ts_end
有了這個生成器函數后,舊需求“發送獎勵積分”和新需求“發送通知”,就都可以在循環體內復用它來完成任務了:
def
?
award_active_users_in_last_30days_v2
()
:
????
"""發送獎勵積分"""
????
for
?ts_start,?ts_end?
in
?gen_weekend_ts_ranges(
30
,?hour_start=
20
,?hour_end=
23
):
????????
for
?record?
in
?LoginRecord.filter_by_range(ts_start,?ts_end):
????????????send_awarding_points(record.user_id,?
1000
)
def
?
notify_nonsleep_users_in_last_30days
()
:
????
"""發送通知"""
????
for
?ts_start,?ts_end?
in
?gen_weekend_ts_range(
30
,?hour_start=
3
,?hour_end=
6
):
????????
for
?record?
in
?LoginRecord.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科技大本營轉載文章,轉載請聯系原作者)
◆
精彩推薦
◆
6月29-30日 ,2019以太坊技術及應用大會 特邀 以太坊創始人V神與以太坊基金會核心成員 ,以及海內外知名專家齊聚北京,聚焦前沿技術,把握時代機遇,深耕行業應用,共話以太坊2.0新生態。
掃碼或點擊閱讀原文,既享優惠購票!
推薦閱讀
-
Bert時代的創新:Bert在NLP各領域的應用進展 | 技術頭條
-
免費GPU哪家強?谷歌Kaggle vs. Colab
-
高能!8段代碼演示Numpy數據運算的神操作
-
Python編寫循環的兩個建議 | 鵝廠實戰
-
Lambda 表達式有何用處?
-
9年前他用1萬個比特幣買了兩個披薩, 9年后他把當年的代碼賣給了蘋果,成為了 GPU 挖礦之父
-
TIOBE 6月編程語言排行榜:Python 勢不可擋,或在四年之內超越Java、C
-
漫威金剛狼男主棄影炒幣了?

更多文章、技術交流、商務合作、聯系博主
微信掃碼或搜索:z360901061

微信掃一掃加我為好友
QQ號聯系: 360901061
您的支持是博主寫作最大的動力,如果您喜歡我的文章,感覺我的文章對您有幫助,請用微信掃描下面二維碼支持博主2元、5元、10元、20元等您想捐的金額吧,狠狠點擊下面給點支持吧,站長非常感激您!手機微信長按不能支付解決辦法:請將微信支付二維碼保存到相冊,切換到微信,然后點擊微信右上角掃一掃功能,選擇支付二維碼完成支付。
【本文對您有幫助就好】元
