PEP572:賦值表達式(海象符)


PEP572:賦值表達式(海象符)

閱讀 PEP 是理解 Python 特性的絕好方式。Python 3.8 引入了賦值表達式,它是什麼?怎麼用?有什麼限制?話不多說,直接看 PEP。


一、簡介


本提案建議在 Python 中增加 <strong>:= 運算符,使我們可以在表達式中直接賦值給變量。

增加這個運算符後,字典推導式的計算順序也將作出調整,從而確保 鍵 的計算先於 值 的計算(因為 鍵 的值可能會被綁定在一個變量名稱上,用於 值 的計算)。

在本提案的討論過程中,:= 被非正式地稱為“<strong>海象符”("the walrus operator")。帶這種運算符的表達式,正式名稱是“<strong>賦值表達式”("Assignment Expressions",即本提議的標題),有時也被稱為“<strong>命名錶達式”("Named Expressions",例如 CPython 實現中即以此作為內部名稱)。



二、必要性說明


命名某個表達式的結果是編程中的重要一環,使我們只需記住一個簡單的名稱,而不是一長串的表達式,並且也容易複用。目前,Python 只能在賦值聲明中進行命名,因而在列表推導式或一些其它場景下,就無法進行命名。

另外,在交互式 debug 過程中,命名某個大型表達式的一部分可以幫助我們做一些深入的檢查。如果無法獲取表達式的局部結果,就往往需要在調試過程中重構代碼;通過賦值表達式,這些重構將被幾個簡單的 := 替代。

由於不再需要重構代碼,我們在調試過程中不經意地改變代碼邏輯的幾率也降低了(調試過程中的重構,是導致 <strong>海森堡Bug <heisenbugs> 的常見原因),同時讓我們更容易向別的程序員解釋程序邏輯。

(譯註:所謂 <strong>Heisenbugs,就是當我們調試的時候,這個 bug 會莫名其妙地消失,命名取自 維爾納·海森堡 提出的量子力學觀察者效應:觀察系統的行為將不可避免地將改變其狀態。)


2.1 使用真實代碼進行討論的重要性

在本提案的討論過程中,許多人(不管是支持者還是反對者)都有一種使用過度簡化,或者過度複雜的例子的傾向。

使用過度簡化的例子時,往往讓人感覺是在吹毛求疵,或者可以直接反駁“我反正是絕不會寫出這樣的代碼來的”。而使用過度複雜的例子時,也容易讓人感覺含混不清。

當然,這兩種例子依然是有意義的:它們可以幫助我們澄清一些語義學的上的概念。因此,我們還是會用到一些這樣的示例。

不論如何,討論中使用的例子,最好還是來自真實的代碼。也就是說,來自大大小小的真實應用,並且在寫這些代碼時,還沒有考慮到本提案的存在。

<strong>Tim Peters 檢查了他自己的代碼庫,找出許多(在他看來)可以通過賦值表達式寫得更清楚的案例,他的最終結論是:本提案確實可以,雖然在比較小的程度上,改進不少代碼。

使用真實代碼的另一個好處是,我們可以間接地觀察程序員們對緊湊的理解。<strong>Guido van Rossum

檢查了 Dropbox 的代碼庫,發現程序員們更傾向於少寫一些代碼行,而不是縮短每行代碼的長度。

比方說,Guido 發現,有些程序員寧肯重複地寫幾個短表達式,導致程序變慢,也不願多寫一行代碼。例如,與其寫這樣的代碼:

<code>match = re.match(data)group = match.group(1) if match else None/<code>

程序員更喜歡這樣寫:

<code>group = re.match(data).group(1) if re.match(data) else None/<code>

另一種情況是,程序員有時寧肯多跑一些代碼,也不願多寫一層縮進:

<code>match1 = pattern1.match(data)match2 = pattern2.match(data)if match1:    result = match1.group(1)elif match2:    result = match2.group(2)else:    result = None/<code>

在上面的代碼中,match2 在 match1 已經 match 的時候依然會 match,實際上是沒有必要的,更高效的寫法應該是:

<code>match1 = pattern1.match(data)if match1:    result = match1.group(1)else:    match2 = pattern2.match(data)    if match2:        result = match2.group(2)    else:        result = None/<code>

三、句法與語義


在可以使用 Python 表達式的大多數地方,都可以使用命名錶達式。具體形式為 NAME := expr ,expr 是一個有效的 Python 表達式,NAME 是一個標識符。

命名錶達式的值與對應表達式是一樣的,只是可以同時賦值給某個變量:

<code># 正則匹配if (match := pattern.search(data)) is not None:    # Do something with match# 迭代器循環while chunk := file.read(8192):   process(chunk)# 重用一個計算複雜的變量[y := f(x), y**2, y**3]# 重用推導式過濾器中的計算結果filtered_data = [y for x in data if (y := f(x)) is not None]/<code>


3.1 例外情況

賦值表達式不能用於一些特定場景,主要是為了避免語義混淆:

  • 不能用於直接的賦值聲明,除非用括號括起來。例如:
<code>y := f(x)  # 錯誤(y := f(x))  # 正確,但不推薦/<code>

這個設定主要是幫助大家區別 賦值聲明 與 賦值表達式 ——任何情況下,它們中最多隻有一個符合語法規範。

  • 不能用於直接的賦值聲明的右側,除非用括號括起來。例如:
<code>y0 = y1 := f(x)  # 錯誤y0 = (y1 := f(x))  # 正確,但不鼓勵/<code>

理由同上。

  • 不能用於調用函數時的關鍵字參數,除非用括號括起來。例如:
<code>foo(x = y := f(x))  # 錯誤foo(x=(y := f(x)))  # 正確,但很奇怪/<code>

這個設定主要是為了避免一些容易引起混淆的代碼,並且獲取函數參數的過程本身已經很複雜了。

  • 不能用於函數參數的默認值,除非用括號括起來。例如:
<code>def foo(answer = p := 42):  # 錯誤    ...def foo(answer=(p := 42)):  # 正確,但有點醜陋    .../<code>

函數參數的具體語法對很多用戶來說已經很難理解了(例如,可變對象作為參數默認值等),因此,避免賦值表達式再來添亂,並且也與前一個設定相呼應。

  • 不能用於函數參數的類型註解,除非用括號括起來:
<code>def foo(answer: p := 42 = 5):  # 錯誤    ...def foo(answer: (p := 42) = 5):  # 正確,但可能沒人會這麼寫    .../<code>

理由與前面兩點的理由相似,各種各樣的 : 和 = 堆在一起,影響代碼可讀性。

  • 不能用於匿名函數,除非用括號括起來。例如:
<code>(lambda: x := 1) # 錯誤lambda: (x := 1) # 正確,但好像沒什麼用(x := lambda: 1) # 正確lambda line: (m := re.match(pattern, line)) and m.group(1) # 正確/<code> 

在匿名函數的最外層命名一個變量沒有意義,因為無法使用這個變量。為了複用這個變量,總是要加一個括號的,因此,這個設定應該不會影響到大家的代碼。

  • 在 f-strings 格式化中使用賦值表達式時,必須使用括號。例如:
<code>>>> f'{(x:=10)}'  # 正確,使用了賦值表達式'10'>>> x = 10>>> f'{x:=10}'    # 正確,正常使用格式化定義,將 '=10' 作為格式化參數'        10'/<code>

這也意味著,在 f-string 中,帶 := 不一定就是賦值表達式。f-string 使用 : 傳遞格式化參數,為了向後兼容,這裡的賦值表達式必須使用括號括起來。當然,這種用法並不推薦。


3.2 作用域

賦值表達式並不會引入新的作用域。大多數情況下,它所在的作用域是很明確的:就是當前作用域,如果這個作用域中使用了 nolocal 或 global 變量,賦值表達式也可以使用。而一個匿名函數(雖然是匿名的,但也是一個函數)本身也會引入一個作用域。

但有一種特殊情況,列表、集合、字典推導式與生成器表達式(一下統一稱為推導式)中的賦值表達式,作用域為這些推導式所在的作用域,並且可以使用原作用域中的 nolocal 或 global 變量。為了更好地支持這一規則,遞歸推導式中的賦值表達式,作用域在最外層推導式所在的作用域。當然,如果最外層推導式是在一個匿名函數中的話,賦值表達式的作用域就是這個匿名函數自身的作用域。

這樣設計有兩個目的,一是使我們能方便地調用 any() 或 all() 函數,例如:

<code>if any((comment := line).startswith('#') for line in lines):    print("First comment:", comment)else:    print("There are no comments")if all((nonblank := line).strip() == '' for line in lines):    print("All lines are blank")else:    print("First non-blank line:", nonblank)/<code>

二是使我們能很容易地計算推導式中的累計狀態,例如:

<code># 計算列表推導式中的累計和total = 0partial_sums = [total := total + v for v in values]print("Total:", total)/<code>

當然,賦值表達式中的標識符名稱不能與推導式所用的變量名稱相同。因為推導式本身所用的變量,作用域只在推導式中,而命名錶達式中的標識符,作用域在最外層推導式所在的作用域中,兩者相同必然會產生衝突。

例如,[i := i+1 for i in range(5)] 是錯誤的,推導過程中所用的變量名 i 作用域在推導式中,而 i := 部分的 i 的作用域並不侷限於這個推導式。同樣,以下這些示例也都是錯誤的:

<code>[[(j := j) for i in range(5)] for j in range(5)] # 錯誤[i := 0 for i, j in stuff]                       # 錯誤[i+1 for i in (i := stuff)]                      # 錯誤/<code>

就以上示例來說,技術上,我們也可以為它們設計一個統一的語法規則,但很難說這種規則在實踐中有什麼用處。因此,內核實現中,遇到這些場景,會直接拋出 SyntaxError。

這個限制即使在賦值表達式並不會被執行時也是生效的:

<code>[False and (i := 0) for i, j in stuff]     # 錯誤[i for i, j in stuff if True or (j := 1)]  # 錯誤/<code>

對於推導式中的推導部分(第一個 for 之前的部分)或過濾器部分( if 之後,任意嵌套的 for 之前的部分),不能重名的限制只針對推導式中的迭代變量。如果在這些地方有匿名函數,則由於匿名函數引入了新的作用域,因此依然可以無限制地使用賦值表達式。

由於內核實現上的設計限制(符號表分析器 symbol table analyser 很難判斷推導式最左側的迭代部分是否與其它部分重用名稱 ),推導式的迭代部分完全禁用命名錶達式( in 之後,並在可能的 if 或 for 之前的部分):

<code>[i+1 for i in (j := stuff)]                    # 錯誤[i+1 for i in range(2) for j in (k := stuff)]  # 錯誤[i+1 for i in [j for j in (k := stuff)]]       # 錯誤[i+1 for i in (lambda: (j := stuff))()]        # 錯誤/<code>

另外一個特例就是,如果推導式在一個類作用域中,並且其中的賦值表達式的賦值結果也在這個類作用域中,也會拋出 SyntaxError:

<code>class Example:    [(j := i) for i in range(5)]  # 錯誤/<code>

(這個特例是由推導式所創建的隱式函數作用域導致的——目前還沒有讓函數直接調用該函數所在的類作用域中的變量的運行時機制,並且我們也無意於增加這種機制。如果之後這個問題解決了,針對賦值表達式的這個限制也可能會取消。請注意,在推導式中無法使用其所在的類作用域中所定義的變量,是一個已經存在的問題。)

(譯註:這個問題有歷史原因,與生成器表達式的設計有關,想要理解具體是什麼問題可以參考 stackover上的回答,想要理解這樣設計的原因,可以參考 PEP289,之後有機會的話,也會翻譯推薦給大家。)

參考附錄 B ,可以看到一些將推導式轉換為等效代碼,從而繞過命名衝突的例子。


3.3 := 運算符的優先級

:= 的優先級高於逗號,低於其它所有操作符,包括 or,and,以及條件表達式(A if C else B)。如前文所說,:= 永遠不會與 = 比較優先級(除非通過括號分隔開了)。

:= 可直接用於函數的位置參數,但不能用於關鍵字參數。

以下例子或許有助於我們理解這些規則:

<code># 錯誤x := 0# 替代寫法(x := 0)# 錯誤x = y := 0# 替代寫法x = (y := 0)# 正確len(lines := f.readlines())# 正確foo(x := 3, cat='vector')# 錯誤foo(cat=category := 'vector')# 替代寫法foo(cat=(category := 'vector'))/<code>

以上大多數所謂“正確”的寫法都是不推薦的寫法,因為閱讀代碼的人往往一掃而過,可能容易看混。但在一些簡單場景中還是可以使用的:

<code># 正確if any(len(longline := line) >= 100 for line in lines):    print("Extremely long line:", longline)/<code>

本提案推薦大家在 := 兩側分別留一個空格,正如 PEP8 對 = 作為賦值符號時的建議一樣。當然,在指定關鍵字參數時,= 的兩側不用留空格 : )


3.4 計算順序的調整

為確保語法定義精確,計算順序也需要被精確定義。技術上說,計算順序不是一個新問題,因為函數調用過程可能本身就要有一些控制。Python 已有的規則是,子表達式會逐步從左往右計算。賦值表達式使我們在函數調用過程中進行控制的需要更明確了,因此,我們對當前計算順序做了一個調整:

在字典推導式 {X: Y for ...} 中,按原來的規則,Y 是先於 X 計算的,我們建議讓 X 的計算先於 Y。(其實,在形如 {X: Y} 或 dict((X, Y) for ...) 的字典創建過程中,X 的計算就是先於 Y 的,我們只是把同樣的規則也推廣到字典推導式中。)


最重要的區別是,:= 是一個表達式,因此可以被用於很多賦值聲明不能使用的場景,包括匿名函數與推導式。

反過來說,賦值表達式也不能支持一些賦值聲明的特性:

  • 不直接支持多個對象賦值:
<code>x = y = z = 0  # 等效代碼: (z := (y := (x := 0)))/<code>
  • 不支持非名稱的賦值對象:
<code># 無對應的等效代碼a[i] = xself.rest = []/<code>
  • 對逗號的運算優先級不同:
<code>x = 1, 2  # x 為 (1, 2)(x := 1, 2)  # x 為 1/<code>
  • 不支持迭代器拆包(包括常規形式與擴展形式):
<code># 等效代碼需要加括號loc = x, y  # 等效代碼 (loc := (x, y))info = name, phone, *rest  # 等效代碼 (info := (name, phone, *rest))# 無等效代碼px, py, pz = positionname, phone, email, *other_info = contact/<code>
  • 不支持行內類型註釋:Inline type annotations are not supported:
<code># 最接近的等效代碼是單獨聲明 "p: Optional[int]" 然後賦值p: Optional[int] = None/<code>
  • 不支持增量賦值:
<code>total += tax  # 等效代碼 (total := total + tax)/<code>

四、使用示例


4.1 標準庫中的使用示例


site.py

env_base 只在這個判斷語句中使用,因此直接放到 if 之後:

  • 原代碼:
<code>env_base = os.environ.get("PYTHONUSERBASE", None)if env_base:    return env_base/<code>
  • 改進後:
<code>if env_base := os.environ.get("PYTHONUSERBASE", None):    return env_base/<code>


_pydecimal.py

取消 if 語句的嵌套,減少一層縮進:

  • 原代碼:
<code>if self._is_special:    ans = self._check_nans(context=context)    if ans:        return ans/<code>
  • 改進後:
<code>if self._is_special and (ans := self._check_nans(context=context)):    return ans/<code>


copy.py

避免 if 語句的多層嵌套。(本例還可以參考附錄 A )

  • 原代碼:
<code>reductor = dispatch_table.get(cls)if reductor:    rv = reductor(x)else:    reductor = getattr(x, "__reduce_ex__", None)    if reductor:        rv = reductor(4)    else:        reductor = getattr(x, "__reduce__", None)        if reductor:            rv = reductor()        else:            raise Error(                "un(deep)copyable object of type %s" % cls)/<code>
  • 改進後:
<code>if reductor := dispatch_table.get(cls):    rv = reductor(x)elif reductor := getattr(x, "__reduce_ex__", None):    rv = reductor(4)elif reductor := getattr(x, "__reduce__", None):    rv = reductor()else:    raise Error("un(deep)copyable object of type %s" % cls)/<code>


datetime.py

tz 只在 s += tz 中使用,把賦值放到 if 語句中使作用域更明確。

  • 原代碼:
<code>s = _format_time(self._hour, self._minute,                 self._second, self._microsecond,                 timespec)tz = self._tzstr()if tz:    s += tzreturn s/<code>
  • 改進後:
<code>s = _format_time(self._hour, self._minute,                 self._second, self._microsecond,                 timespec)if tz := self._tzstr():    s += tzreturn s/<code>


sysconfig.py

在 while 語句調用 fp.readling(),在 if 語句調用 match() ,使代碼更緊湊:

  • 原代碼:
<code>while True:    line = fp.readline()    if not line:        break    m = define_rx.match(line)    if m:        n, v = m.group(1, 2)        try:            v = int(v)        except ValueError:            pass        vars[n] = v    else:        m = undef_rx.match(line)        if m:            vars[m.group(1)] = 0/<code>
  • 改進後:
<code>while line := fp.readline():    if m := define_rx.match(line):        n, v = m.group(1, 2)        try:            v = int(v)        except ValueError:            pass        vars[n] = v    elif m := undef_rx.match(line):        vars[m.group(1)] = 0/<code>


4.2 簡化列表推導式

通過獲取過濾器計算結果,可以更高效地進行列表推導:

<code>results = [(x, y, x/y) for x in input_data if (y := f(x)) > 0]/<code>

類似地,可以引入賦值表達式,使子表達式在主表達式中複用:

<code>stuff = [[y := f(x), x/y] for x in range(5)]/<code>

注意,在以上兩個例子中,變量 y 的作用域都是推導式所在的作用域(即與 results 或 stuff 為同一個作用域)。


4.3 獲取條件計算結果Capturing condition values

賦值表達式可用於獲取 if 或 while 語句中的條件計算結果:

<code># 循環交互while (command := input("> ")) != "quit":    print("You entered:", command)# 獲取正則表達式的 match 結果# 可以查看 Lib/pydoc.py 中的更多示例if match := re.search(pat, text):    print("Found:", match.group(0))# 把 match 賦值放在 elif 語句中,避免了多層縮進elif match := re.search(otherpat, text):    print("Alternate found:", match.group(0))elif match := re.search(third, text):    print("Fallback found:", match.group(0))# 讀取 socket 數據,直到遇到空字符串:while data := sock.recv(8192):    print("Received data:", data)/<code>

在 while 循環中,賦值表達式往往可以避免無限循環的引入。用戶可以直接調用函數作為循環條件,並在之後的循環體中使用函數調用的結果。


4.4 Fork

一個來自 UNIX 底層的示例:

<code>if pid := os.fork():    # Parent codeelse:    # Child code/<code>

五、代碼風格建議


有些地方可以等效地使用賦值表達式與賦值聲明,那麼,應該優先使用哪一種呢?我們有以下兩條建議:

  1. 如果可以,優先使用賦值聲明,它可以更清楚地表明意圖。
  2. 如果使用賦值表達式可能導致計算順序不明確,應重構為使用賦值聲明的代碼。

(譯註:本提案還有 3 個附錄,本文已經較長,之後再翻譯推薦給大家,請多多見諒!)


分享到:


相關文章: