多數命令式(Imperative)語言的求值策略,是立刻求值(Eager evaluation),也就是運算式在被指定至變數時,就馬上算出數值。
習於使用這些傳統語言的開發者,對於惰性求值(Lazy evaluation)的概念與應用,相對來說較不熟悉。若能認識惰性求值,在適當場合善用對應的語法或程式庫,對於效能的改進將會有極大幫助。
在Python中,有個yield語法可運用在函式中,呼叫包括yield的函式時,實際上並非執行函式內容,而是傳回產生器(Generator),產生器的__next__被呼叫時,才會執行原本包括yield的流程。
單看流程定義的話,yield很像是return可傳回值,不過傳回值後,流程會離開函式回到呼叫__next__的客戶端流程,客戶端再度呼叫產生器的__next__時,流程又會回到函式中yield處,並承接先前狀態繼續執行。可是這語法有什麼好處呢?應用之一是產生無限長的自然數列:
def natural(x):
while True:
yield x; x += 1
假設呼叫natural(1)後會傳回產生器,並指定給gen變數,next(gen)就會呼叫gen的__next__方法,此時執行natural函式定義的流程,並於yield時傳回1,客戶端再呼叫next(gen)就會繼續x += 1的流程,並再度回到迴圈開頭,執行yield傳回2,依此類推。
建立無限長自然數的list,Python是不可能,然而用yield建立產生器,就可產生下個自然數。
Python還可使用for表達式(Comprehension)建立產生器,如(get_person_from_database(id) for id in ids)會建立產生器,而[get_person_from_database(id) for id in ids]會產生list,若像前者建立產生器並指定person_gen,以下的程式寫法,將比直接產生list有效率:
for person in person_gen:
if(person.luckyNumber == generatedLuckyNumber):
return person
上面的for in迴圈,會呼叫產生器的__next__,此時get_person_from_database才會實際執行。
查詢資料庫是個耗時工作,若原本的ids有1,000個,假設首次迭代就找到幸運兒,後面節省999次資料庫查詢,提供捷徑(Short-circuit)運算可能性。
迭代器與產生器的不同
Python使用者可能會聯想到迭代器,正如我先前專欄〈從具體到抽象的迭代器〉中談過的,迭代器可尋訪一組資料,在Python中可實作物件的__iter__方法傳回迭代器,通常使用iter函式來呼叫物件的__iter__方法,迭代器與產生器使用相同的協定,也就是迭代器也具有__next__方法。
事實上,可搭配for in迴圈的,是具有__iter__方法的物件,迴圈會透過__iter__取得迭代器,再於迴圈中呼叫迭代器的__next__方法。前段介紹的產生器就實作了__iter__方法,因此可搭配for in迴圈進行迭代。
既然Python中的產生器與迭代器,都是具有__next__方法的物件,那為何要區分?
直接以迭代器概念,也可實現無限長自然數列或是捷徑運算不是嗎?畢竟使用產生器搭配for in迴圈時,最後也是呼叫迭代器__next__方法時,將之委託給產生器的__next__方法罷了。
就Python而言,主要程式碼在表述能力上的問題,Python提供了yield與for表達式來建立產生器,可使得程式碼在流程表述上更為清楚。在沒有產生器語法的語言,像Java,確實可實作迭代器模式,令迭代器具備產生器的行為。
實際上,在JDK8設計與Lambda語法搭配的Collection API時,2012年4月草案曾考慮在Iterable定義filter、map等方法,令他們傳回Iterable物件,而這些物件的iterator方法傳回的Iterator實作,將具有產生器功能,只不過,相對直接提供語法建立產生器來說,使用迭代器來實作產生器概念時,程式碼著實刁鑽許多。
以產生器實現惰性求值
先前實現無限長自然數列,或是捷徑運算的範例,實際上都是產生器於惰性求值的應用。惰性求值常見於函數式程式語言,運算式不會在被指定給變數時立即求值,而是真正要取用值時才運算。
例如,Java若有addOne方法,可將傳入List中的元素都加1,再傳回新List,那麼addOne(addOne(addOne(list)))時將發生3次走訪List的動作,過程中最右邊的addOne會產生List,傳給中間的addOne,而該addOne又產生新List,傳給最右的addOne,然後這addOne產生最後的List。
然而在Haskell中,執行addOne $ addOne $ addOne [1, 2, 3, 4, 5]時,最右邊的addOne並不會馬上求值,而是當最左邊addOne在第一個元素上加1時,中間addOne才會要求最右邊addOne,提供第一個元素,因此,整個過程完成只需產生一個數列,數列走訪只會發生一次,由此改進效率。
簡單來說,在Haskell這類支援惰性求值的語言,每個運算式都是產生器,並不需要特別語法。
Python的運算式不支援惰性求值,然而提供yield與for表達式來建立產生器,在實現惰性求值時,甚至可以不影響原本流程表述方式。如Python中addOne(addOne(addOne(list)))時,想有Haskell中類似的惰性求值效果,可定義addOne函式:
def addOne(lt):
return (ele + 1 for ele in lt)
這方式Python開發者都很熟悉,只不過將[]改為(),但addOne(addOne(addOne(list)))最後傳回的還是產生器,因此得搭配next函式或for in語法來逐一取得元素,或使用list函式從產生器建立list物件,用set函式建立set物件等。
實際上,Python有些函式的結果就是產生器,像map、filter,傳回map與filter實例,而非list。
區分職責與語義
Python由於支援產生器的語法,因此產生器與迭代器在外觀與職責算是清楚,然而在沒有這類支援的Java中,雖可使用迭代器來實現產生器,職責卻混淆不清。2012年12月的草案提出新的Stream物件來擔負產生器職責。相對Collection以iterator產生Iterator物件,stream方法用來產生Stream物件,本身是循序概念,上頭定義的filter、map等方法,也都傳回Stream物件,如同Python中map、filter函式會傳回map、filter物件,Stream做filter、map是惰性求值。
iterator傳回迭代器,stream傳回Stream產生器,語義明確,而對於平行處理的支援,則可以使用Collection的parallelStream方法,傳回支援平行處理的Stream物件。
將產生器與迭代器的職責區隔後,就能明瞭,在stream方法呼叫後,接下來都是中介操作,若要取得最後成果,必然得透過某個方法,就像Python用list、set等函式,從產生器建立list、set物件等;然而,與其在Stream設計一堆toList、toSet、toCollection方法,JDK8定義collect方法接受Collector物件,透過該物件告知collect方法,如何收集產生器中取得的物件,並以指定型態傳回。
現在不少程式庫或語言納入產生器的實現概念,例如不支援產生器語法的JavaScript有個Lazy.js程式庫,就致力於JavaScript中實現惰性求值,JavaScript是動態語言,因而實現之後在應用上算是簡潔;實際上JavaScript 1.7定義yield可建立產生器,可惜目前JavaScript各執行環境支援程度不一。
無論如何,產生器在現代開發中非罕見,認識並理解實現的概念,有助於善用相關程式庫或語法。
專欄作者
熱門新聞
2024-11-25
2024-11-25
2024-11-25
2024-11-25
2024-11-15
2024-11-15