Python 工匠:編寫地道循環(huán)的兩個(gè)建議
今天依然推薦 Python 工匠系列文章,介紹編寫循環(huán)時(shí)需要注意的一些地方。
轉(zhuǎn)載來(lái)源
公眾號(hào):piglei
“閱讀本文大概需要 9?分鐘。
前言
循環(huán)是一種常用的程序控制結(jié)構(gòu)。我們常說(shuō),機(jī)器相比人類的最大優(yōu)點(diǎn)之一,就是機(jī)器可以不眠不休的重復(fù)做某件事情,但人卻不行。而“循環(huán)”,則是實(shí)現(xiàn)讓機(jī)器不斷重復(fù)工作的關(guān)鍵概念。
在循環(huán)語(yǔ)法方面,Python 表現(xiàn)的即傳統(tǒng)又不傳統(tǒng)。它雖然拋棄了常見(jiàn)的 for(init;condition;incrment) 三段式結(jié)構(gòu),但還是選擇了 for 和 while 這兩個(gè)經(jīng)典的關(guān)鍵字來(lái)表達(dá)循環(huán)。絕大多數(shù)情況下,我們的循環(huán)需求都可以用 for 來(lái)滿足, while 相比之下用的則更少些。
雖然循環(huán)的語(yǔ)法很簡(jiǎn)單,但是要寫好它確并不容易。在這篇文章里,我們將探討什么是“地道”的循環(huán)代碼,以及如何編寫它們。
什么是“地道”的循環(huán)?
“地道”這個(gè)詞,通常被用來(lái)形容某人做某件事情時(shí),非常符合當(dāng)?shù)貍鹘y(tǒng),做的非常好。打個(gè)比方,你去參加一個(gè)朋友聚會(huì),同桌的有一位廣東人,對(duì)方一開口,句句都是標(biāo)準(zhǔn)京腔、完美兒化音。那你可以對(duì)她說(shuō):“您的北京話說(shuō)的真地道”。
既然“地道”這個(gè)詞形容的經(jīng)常是口音、做菜的口味這類實(shí)實(shí)在在的東西,那“地道”的循環(huán)代碼又是什么意思呢?讓我拿一個(gè)經(jīng)典的例子來(lái)解釋一下。
如果你去問(wèn)一位剛學(xué)習(xí) Python 一個(gè)月的人:“如何在遍歷一個(gè)列表的同時(shí)獲取當(dāng)前下標(biāo)?”。他可能會(huì)交出這樣的代碼:
index =0for name in names:print(index, name)index +=1
上面的循環(huán)雖然沒(méi)錯(cuò),但它確一點(diǎn)都不“地道”。一個(gè)擁有三年 Python 開發(fā)經(jīng)驗(yàn)的人會(huì)說(shuō),代碼應(yīng)該這么寫:
for i, name in enumerate(names):print(i, name)
enumerate() 是 Python 的一個(gè)內(nèi)置函數(shù),它接收一個(gè)“可迭代”對(duì)象作為參數(shù),然后返回一個(gè)不斷生成 (當(dāng)前下標(biāo),當(dāng)前元素) 的新可迭代對(duì)象。這個(gè)場(chǎng)景使用它最適合不過(guò)。
所以,在上面的例子里,我們會(huì)認(rèn)為第二段循環(huán)代碼比第一段更“地道”。因?yàn)樗酶庇^的代碼,更聰明的完成了工作。
enumerate() 所代表的編程思路
不過(guò),判斷某段循環(huán)代碼是否地道,并不僅僅是以知道或不知道某個(gè)內(nèi)置方法作為標(biāo)準(zhǔn)。我們可以從上面的例子挖掘出更深層的東西。
如你所見(jiàn),Python 的 for 循環(huán)只有 for 這一種結(jié)構(gòu),而結(jié)構(gòu)里的前半部分 - 賦值給 item- 沒(méi)有太多花樣可玩。所以后半部分的 可迭代對(duì)象 是我們唯一能夠大做文章的東西。而以 enumerate() 函數(shù)為代表的“修飾函數(shù)”,剛好提供了一種思路:通過(guò)修飾可迭代對(duì)象來(lái)優(yōu)化循環(huán)本身。
這就引出了我的第一個(gè)建議。
建議1:使用函數(shù)修飾被迭代對(duì)象來(lái)優(yōu)化循環(huán)
使用修飾函數(shù)處理可迭代對(duì)象,可以在各種方面影響循環(huán)代碼。而要找到合適的例子來(lái)演示這個(gè)方法,并不用去太遠(yuǎn),內(nèi)置模塊 itertools 就是一個(gè)絕佳的例子。
簡(jiǎn)單來(lái)說(shuō),itertools 是一個(gè)包含很多面向可迭代對(duì)象的工具函數(shù)集。我在之前的系列文章《容器的門道》里提到過(guò)它。
如果要學(xué)習(xí) itertools,那么 Python 官方文檔 是你的首選,里面有非常詳細(xì)的模塊相關(guān)資料。但在這篇文章里,側(cè)重點(diǎn)將和官方文檔稍有不同。我會(huì)通過(guò)一些常見(jiàn)的代碼場(chǎng)景,來(lái)詳細(xì)解釋它是如何改善循環(huán)代碼的。
1. 使用 product 扁平化多層嵌套循環(huán)
雖然我們都知道“扁平的代碼比嵌套的好”。但有時(shí)針對(duì)某類需求,似乎一定得寫多層嵌套循環(huán)才行。比如下面這段:
def find_twelve(num_list1, num_list2, num_list3):"""從 3 個(gè)數(shù)字列表中,尋找是否存在和為 12 的 3 個(gè)數(shù)"""for num1 in num_list1:for num2 in num_list2:for num3 in num_list3:if num1 + num2 + num3 ==12:return num1, num2, num3
對(duì)于這種需要嵌套遍歷多個(gè)對(duì)象的多層循環(huán)代碼,我們可以使用 product() 函數(shù)來(lái)優(yōu)化它。product() 可以接收多個(gè)可迭代對(duì)象,然后根據(jù)它們的笛卡爾積不斷生成結(jié)果。
from itertools import productdef 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() 的函數(shù)只用了一層 for 循環(huán)就完成了任務(wù),代碼變得更精煉了。
2. 使用 islice 實(shí)現(xiàn)循環(huán)內(nèi)隔行處理
有一份包含 Reddit 帖子標(biāo)題的外部數(shù)據(jù)文件,里面的內(nèi)容格式是這樣的:
python-guide:Python best practices guidebook, written for humans.---Python2DeathClock---Run any PythonScriptwith an AlexaVoiceCommand---<......>
可能是為了美觀,在這份文件里的每?jī)蓚€(gè)標(biāo)題之間,都有一個(gè) "---" 分隔符。現(xiàn)在,我們需要獲取文件里所有的標(biāo)題列表,所以在遍歷文件內(nèi)容的過(guò)程中,必須跳過(guò)這些無(wú)意義的分隔符。
參考之前對(duì) enumerate() 函數(shù)的了解,我們可以通過(guò)在循環(huán)內(nèi)加一段基于當(dāng)前循環(huán)序號(hào)的 if 判斷來(lái)做到這一點(diǎn):
def parse_titles(filename):"""從隔行數(shù)據(jù)文件中讀取 reddit 主題名稱"""with open(filename,'r')as fp:for i, line in enumerate(fp):# 跳過(guò)無(wú)意義的 '---' 分隔符if i %2==0:yield line.strip()
但對(duì)于這類在循環(huán)內(nèi)進(jìn)行隔行處理的需求來(lái)說(shuō),如果使用 itertools 里的 islice() 函數(shù)修飾被循環(huán)對(duì)象,可以讓循環(huán)體代碼變得更簡(jiǎn)單直接。
islice(seq,start,end,step) 函數(shù)和數(shù)組切片操作( list[start:stop:step] )有著幾乎一模一樣的參數(shù)。如果需要在循環(huán)內(nèi)部進(jìn)行隔行處理的話,只要設(shè)置第三個(gè)遞進(jìn)步長(zhǎng)參數(shù) step 值為 2 即可(默認(rèn)為 1)。
from itertools import islicedef parse_titles_v2(filename):with open(filename,'r')as fp:# 設(shè)置 step=2,跳過(guò)無(wú)意義的 '---' 分隔符for line in islice(fp,0,None,2):yield line.strip()
3. 使用 takewhile 替代 break 語(yǔ)句
有時(shí),我們需要在每次循環(huán)開始時(shí),判斷循環(huán)是否需要提前結(jié)束。比如下面這樣:
for user in users:# 當(dāng)?shù)谝粋€(gè)不合格的用戶出現(xiàn)后,不再進(jìn)行后面的處理ifnot is_qualified(user):break# 進(jìn)行處理 ... ...
對(duì)于這類需要提前中斷的循環(huán),我們可以使用 takewhile() 函數(shù)來(lái)簡(jiǎn)化它。takewhile(predicate,iterable)會(huì)在迭代 iterable 的過(guò)程中不斷使用當(dāng)前對(duì)象作為參數(shù)調(diào)用 predicate 函數(shù)并測(cè)試返回結(jié)果,如果函數(shù)返回值為真,則生成當(dāng)前對(duì)象,循環(huán)繼續(xù)。否則立即中斷當(dāng)前循環(huán)。
使用 takewhile 的代碼樣例:
from itertools import takewhilefor user in takewhile(is_qualified, users):# 進(jìn)行處理 ... ...
itertools 里面還有一些其他有意思的工具函數(shù),他們都可以用來(lái)和循環(huán)搭配使用,比如使用 chain 函數(shù)扁平化雙層嵌套循環(huán)、使用 zip_longest 函數(shù)一次同時(shí)循環(huán)多個(gè)對(duì)象等等。
篇幅有限,我在這里不再一一介紹。如果有興趣,可以自行去官方文檔詳細(xì)了解。
4. 使用生成器編寫自己的修飾函數(shù)
除了 itertools 提供的那些函數(shù)外,我們還可以非常方便的使用生成器來(lái)定義自己的循環(huán)修飾函數(shù)。
讓我們拿一個(gè)簡(jiǎn)單的函數(shù)舉例:
def sum_even_only(numbers):"""對(duì) numbers 里面所有的偶數(shù)求和"""result =0for num in numbers:if num %2==0:result += numreturn result
在上面的函數(shù)里,循環(huán)體內(nèi)為了過(guò)濾掉所有奇數(shù),引入了一條額外的 if 判斷語(yǔ)句。如果要簡(jiǎn)化循環(huán)體內(nèi)容,我們可以定義一個(gè)生成器函數(shù)來(lái)專門進(jìn)行偶數(shù)過(guò)濾:
def even_only(numbers):for num in numbers:if num %2==0:yield numdef sum_even_only_v2(numbers):"""對(duì) numbers 里面所有的偶數(shù)求和"""result =0for num in even_only(numbers):result += numreturn result
將 numbers 變量使用 even_only 函數(shù)裝飾后, sum_even_only_v2 函數(shù)內(nèi)部便不用繼續(xù)關(guān)注“偶數(shù)過(guò)濾”邏輯了,只需要簡(jiǎn)單完成求和即可。
Hint:當(dāng)然,上面的這個(gè)函數(shù)其實(shí)并不實(shí)用。在現(xiàn)實(shí)世界里,這種簡(jiǎn)單需求最適合直接用生成器/列表表達(dá)式搞定:
sum(numfornuminnumbersifnum%2==0)
建議2:按職責(zé)拆解循環(huán)體內(nèi)復(fù)雜代碼塊
我一直覺(jué)得循環(huán)是一個(gè)比較神奇的東西,每當(dāng)你寫下一個(gè)新的循環(huán)代碼塊,就好像開辟了一片黑魔法陣,陣內(nèi)的所有內(nèi)容都會(huì)開始無(wú)休止的重復(fù)執(zhí)行。
但我同時(shí)發(fā)現(xiàn),這片黑魔法陣除了能帶來(lái)好處,它還會(huì)引誘你不斷往陣內(nèi)塞入越來(lái)越多的代碼,包括過(guò)濾掉無(wú)效元素、預(yù)處理數(shù)據(jù)、打印日志等等。甚至一些原本不屬于同一抽象的內(nèi)容,也會(huì)被塞入到同一片黑魔法陣內(nèi)。
你可能會(huì)覺(jué)得這一切理所當(dāng)然,我們就是迫切需要陣內(nèi)的魔法效果。如果不把這一大堆邏輯塞滿到循環(huán)體內(nèi),還能把它們放哪去呢?
讓我們來(lái)看看下面這個(gè)業(yè)務(wù)場(chǎng)景。在網(wǎng)站中,有一個(gè)每 30 天執(zhí)行一次的周期腳本,它的任務(wù)是是查詢過(guò)去 30 天內(nèi),在每周末特定時(shí)間段登錄過(guò)的用戶,然后為其發(fā)送獎(jiǎng)勵(lì)積分。
代碼如下:
import timeimport datetimedef award_active_users_in_last_30days():"""獲取所有在過(guò)去 30 天周末晚上 8 點(diǎn)到 10 點(diǎn)登錄過(guò)的用戶,為其發(fā)送獎(jiǎng)勵(lì)積分"""days =30for days_delta in range(days):dt = datetime.date.today()- datetime.timedelta(days=days_delta)# 5: Saturday, 6: Sundayif dt.weekday()notin(5,6):continuetime_start = datetime.datetime(dt.year, dt.month, dt.day,20,0)time_end = datetime.datetime(dt.year, dt.month, dt.day,23,0)# 轉(zhuǎn)換為 unix 時(shí)間戳,之后的 ORM 查詢需要ts_start = time.mktime(time_start.timetuple())ts_end = time.mktime(time_end.timetuple())# 查詢用戶并挨個(gè)發(fā)送 1000 獎(jiǎng)勵(lì)積分for record inLoginRecord.filter_by_range(ts_start, ts_end):# 這里可以添加復(fù)雜邏輯send_awarding_points(record.user_id,1000)
上面這個(gè)函數(shù)主要由兩層循環(huán)構(gòu)成。外層循環(huán)的職責(zé),主要是獲取過(guò)去 30 天內(nèi)符合要求的時(shí)間,并將其轉(zhuǎn)換為 UNIX 時(shí)間戳。之后由內(nèi)層循環(huán)使用這兩個(gè)時(shí)間戳進(jìn)行積分發(fā)送。
如之前所說(shuō),外層循環(huán)所開辟的黑魔法陣內(nèi)被塞的滿滿當(dāng)當(dāng)。但通過(guò)觀察后,我們可以發(fā)現(xiàn) 整個(gè)循環(huán)體其實(shí)是由兩個(gè)完全無(wú)關(guān)的任務(wù)構(gòu)成的:“挑選日期與準(zhǔn)備時(shí)間戳” 以及 “發(fā)送獎(jiǎng)勵(lì)積分”。
復(fù)雜循環(huán)體如何應(yīng)對(duì)新需求
這樣的代碼有什么壞處呢?讓我來(lái)告訴你。
某日,產(chǎn)品找過(guò)來(lái)說(shuō),有一些用戶周末半夜不睡覺(jué),還在刷我們的網(wǎng)站,我們得給他們發(fā)通知讓他們以后早點(diǎn)睡覺(jué)。于是新需求出現(xiàn)了:“給過(guò)去 30 天內(nèi)在周末凌晨 3 點(diǎn)到 5 點(diǎn)登錄過(guò)的用戶發(fā)送一條通知”。
新問(wèn)題也隨之而來(lái)。敏銳如你,肯定一眼可以發(fā)現(xiàn),這個(gè)新需求在用戶篩選部分的要求,和之前的需求非常非常相似。但是,如果你再打開之前那團(tuán)循環(huán)體看看,你會(huì)發(fā)現(xiàn)代碼根本沒(méi)法復(fù)用,因?yàn)樵谘h(huán)內(nèi)部,不同的邏輯完全被 耦合 在一起了。??
在計(jì)算機(jī)的世界里,我們經(jīng)常用“耦合”這個(gè)詞來(lái)表示事物之間的關(guān)聯(lián)關(guān)系。上面的例子中,“挑選時(shí)間”和“發(fā)送積分”這兩件事情身處同一個(gè)循環(huán)體內(nèi),建立了非常強(qiáng)的耦合關(guān)系。
為了更好的進(jìn)行代碼復(fù)用,我們需要把函數(shù)里的“挑選時(shí)間”部分從循環(huán)體中解耦出來(lái)。而我們的老朋友,“生成器函數(shù)”是進(jìn)行這項(xiàng)工作的不二之選。
使用生成器函數(shù)解耦循環(huán)體
要把 “挑選時(shí)間” 部分從循環(huán)內(nèi)解耦出來(lái),我們需要定義新的生成器函數(shù) gen_weekend_ts_ranges(),專門用來(lái)生成需要的 UNIX 時(shí)間戳:
def gen_weekend_ts_ranges(days_ago, hour_start, hour_end):"""生成過(guò)去一段時(shí)間內(nèi)周六日特定時(shí)間段范圍,并以 UNIX 時(shí)間戳返回"""for days_delta in range(days_ago):dt = datetime.date.today()+ datetime.timedelta(days=days_delta)# 5: Saturday, 6: Sundayif dt.weekday()notin(5,6):continuetime_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)# 轉(zhuǎn)換為 unix 時(shí)間戳,之后的 ORM 查詢需要ts_start = time.mktime(time_start.timetuple())ts_end = time.mktime(time_end.timetuple())yield ts_start, ts_end
有了這個(gè)生成器函數(shù)后,舊需求“發(fā)送獎(jiǎng)勵(lì)積分”和新需求“發(fā)送通知”,就都可以在循環(huán)體內(nèi)復(fù)用它來(lái)完成任務(wù)了:
def award_active_users_in_last_30days_v2():"""發(fā)送獎(jiǎng)勵(lì)積分"""for ts_start, ts_end in gen_weekend_ts_ranges(30, hour_start=20, hour_end=23):for record inLoginRecord.filter_by_range(ts_start, ts_end):send_awarding_points(record.user_id,1000)def notify_nonsleep_users_in_last_30days():"""發(fā)送通知"""for ts_start, ts_end in gen_weekend_ts_range(30, hour_start=3, hour_end=6):for record inLoginRecord.filter_by_range(ts_start, ts_end):notify_user(record.user_id,'You should sleep more')
總結(jié)
在這篇文章里,我們首先簡(jiǎn)單解釋了“地道”循環(huán)代碼的定義。然后提出了第一個(gè)建議:使用修飾函數(shù)來(lái)改善循環(huán)。之后我虛擬了一個(gè)業(yè)務(wù)場(chǎng)景,描述了按職責(zé)拆解循環(huán)內(nèi)代碼的重要性。
一些要點(diǎn)總結(jié):
使用函數(shù)修飾被循環(huán)對(duì)象本身,可以改善循環(huán)體內(nèi)的代碼
itertools 里面有很多工具函數(shù)都可以用來(lái)改善循環(huán)
使用生成器函數(shù)可以輕松定義自己的修飾函數(shù)
循環(huán)內(nèi)部,是一個(gè)極易發(fā)生“代碼膨脹”的場(chǎng)地
請(qǐng)使用生成器函數(shù)將循環(huán)內(nèi)不同職責(zé)的代碼塊解耦出來(lái),獲得更好的靈活性
看完文章的你,有沒(méi)有什么想吐槽的?請(qǐng)留言或者在 項(xiàng)目 Github Issues 告訴我吧。
附錄
題圖來(lái)源: Photo by Lai man nung on Unsplash
更多系列文章地址:https://github.com/piglei/one-python-craftsman
推薦閱讀
1
跟繁瑣的命令行說(shuō)拜拜!Gerapy分布式爬蟲管理框架來(lái)襲!
2
跟繁瑣的模型說(shuō)拜拜!深度學(xué)習(xí)腳手架 ModelZoo 來(lái)襲!
3
只會(huì)用Selenium爬網(wǎng)頁(yè)?Appium爬App了解一下
4??
媽媽再也不用擔(dān)心爬蟲被封號(hào)了!手把手教你搭建Cookies池
崔慶才
靜覓博客博主,《Python3網(wǎng)絡(luò)爬蟲開發(fā)實(shí)戰(zhàn)》作者
隱形字
個(gè)人公眾號(hào):進(jìn)擊的Coder


長(zhǎng)按識(shí)別二維碼關(guān)注
