[Python] 作用域與Closure(閉包)

Tsung-Yu
Tom’s blog
Published in
14 min readFeb 25, 2020

前言

前幾篇提到Python中的Decorator,其實隱含許多作用域以及閉包的概念,故另外寫成一篇來近一步討論這兩者。

First-class Function(頭等函式)

在了解Closure之前,要先知道Python中的First-class Function是什麼,First-class Function又可以被稱做頭等函數,或是頭等物件(First-class Object),Python裡的每個function都是first-class function
根據MDN的定義

A programming language is said to have First-class functions when functions in that language are treated like any other variable. For example, in such a language, a function can be passed as an argument to other functions, can be returned by another function and can be assigned as a value to a variable.

上面這段描述白話來說就是:

函數可以被當做參數傳遞、能夠作為函數回傳值、能夠被修改、能夠被賦值給一個變數。

範例

可被賦值給變數

def compare(m, n):
return m if m > n else n
func = compare # assign function物件給func
print(compare)
print(func)
print(compare(10, 20))
print(func(10, 20))
"""
結果
<function compare at 0x112441e60>
<function compare at 0x112441e60>
20
20
"""

由上面的範例來看,compare函數物件被賦值給變數func,print出的結果顯示compare和func指向同一個函數物件。

可作為參數傳遞

def square(x):
return x * x
def arr(f, items):
return [f(item) for item in items]
numbers = [1, 2, 3, 4, 5]total = arr(square, numbers)
print(total) # [1, 4, 9, 16, 25]

由上面的範例來看,函數square函數物件被當作arr函數的參數傳遞 ,隨後於arr中進行陣列處理。

可作為函數的回傳值

# 可作為函數的回傳值
def logger(msg):
def message():
print('Log:', msg)
return message
logWarning = logger('Warning')
logWarning() # Log: Warning

由上面的範例來看,在函數logger內部建立函數message,函數message內使用了logger傳入的參數msg,最後loggermessage函數作為回傳值,再assign給logWarning進行呼叫。

或是另外一個例子:

def html_tag(tag):
def wrap_text(text):
print('<{0}>{1}</{0}>'.format(tag, text, tag))
return wrap_text
h1 = html_tag('h1')
h1('This is a header') # <h1>This is a header</h1>
h1('This is a header, too') # <h1>This is a header, too</h1>
p = html_tag('p')
p('This is the first paragraph') # <p>This is the first paragraph</p>
p('This is the second paragraph') # <p>This is the second paragraph</p>

Python Scope(作用域)

對頭等函式有概念之後,再來談談Python的作用域。
Python的作用域規則(scope)規則叫做LEGB,scope在查找時,順序為 Local -> Enclosed -> Global -> Built-in

  • Local: 於function或是class內宣告的變數名
  • Enclosed: 位於巢狀層次的function結構,常用於Closure
  • Global: 最上層位於模組(module)的全域變數名稱
  • Build-in: 內建模組(module)的名稱,例如print, abs()這樣的函式等等
圖片源

The Python Tutorial裡面有更詳細的解釋。

  • the innermost scope, which is searched first, contains the local names
  • the scopes of any enclosing functions, which are searched starting with the nearest enclosing scope, contains non-local, but also non-global names
  • the next-to-last scope contains the current module’s global names
  • the outermost scope (searched last) is the namespace containing built-in names

Python的作用域有許多細節可以討論,為了縮短篇幅和挑幾個重點出來,主要區分為global(全域)、local(區域)變數和Enclosed Scope。

global(全域)變數

放在function外的變數

a = "hello a"
def scope1():
print(a)

scope1()
"""
執行結果:
hello a
"""

執行scope1(),要印出a變數的值時,若在scope1內找不到變數a,便會往外找,找到全域中宣告的a變數

local(區域)變數

在Python裡創建一個function,function內執行的區域稱作「local scope」,而建立區域變數最簡單的方式是於function中給定一個變數。一般來說,全域變數是無法被該function scope內重新定義的變數進行存取。

範例

假設有一變數a初始值為hello a,想要透過scope1()函數對a重新賦值

a = "hello a"
def scope1():
a = 1
scope1()
print(a)
"""
執行結果:
hello a
"""

上述結果告訴我們,a = 1無法對scope1()外的a重新賦值。

若要讓local scope內的變數讓外部進行存取,可以在目標變數的前面宣告一個global

a = "hello a"
def scope1():
global a
a = 1
scope1()
print(a)
"""
執行結果:
1
"""

特殊情況

在Python中,區域變數或是全域變數,兩者只能「選邊站」,不可以同時指定為區域變數及全域變數。

a = 5
def scope():
print(a)
a = 10
scope()

執行上述範例,會得到UnboundLocalError的錯誤資訊。

Enclosed Scope

依據巢狀層次從內到外搜尋,當搜尋到LEGB的E時,Python會從最近的 enclosing scope 向外找起,那這些enclosing scopes裡的所有變數,稱作non-local variable

# enclosure
def outer(a):
b = a
def inner():
c = 3
def inner_inner(b):
k = b+c
return b+c
return inner_inner
return inner
outcome = outer(5)
ans = outcome()
ans(3) # 6

以上述範例來說,bouter()的區域變數,cinner()的區域變數,由於離inner()最近的scope是outer所建立的,b又是於此scope被宣告,所以binner()non-local variable

再往下走,以inner_inner()來看,k為它的local variable ,值被assign為b+c,這時的b 並非被宣告在outer()scope裡,而是藉由參數傳遞的,也就是說,b屬於local variable。反之c則是被宣告在inner()的scope裡,對inner_inner()來說,是屬於non-local variable

Closure

前面提到很多關於頭等函式及作用域,可以開始進入正題: Closure

假設有個巢狀函式,最外層的函式把自己內層嵌套另外一個函式,將這個嵌套的函式作為回傳值傳遞出去,便會形成一個Closure

先看一段範例:

def student():
height = 170
weight = 60
def info():
print("my height is {}.".format(height))
print("my weight is {}.".format(weight))
return info
print(student)
print(student())
students = student()
students()
"""
回傳結果:
<function student at 0x112479440>
<function student.<locals>.info at 0x1124794d0>
my height is 170.
my weight is 60.
"""

上面的範例可以觀察出一個奇怪的點,一般情況下,function中內區域變數的生命週期(life cycle)會隨著function執行完畢而結束(變數的生命週期在這篇文章有提到),但是print出來的結果卻還可以讀取到heightweight兩個屬於student()scope的變數。

原因在於return info這個地方,info這個function趁著return的時候捕捉外層函式裡的變數,並偷渡進來自己的scope裡面。

被捕捉的變數便稱做「captured variable」,帶有captured variable的函式稱為closure。

查看Closure

若想知道閉包儲存多少物件,可以印出__closure__屬性查看資訊,__closure__會是一個唯讀屬性;印出的資料型態是tuple

def student():
height = 170
weight = 60
def info():
print("my height is {}.".format(height))
print("my weight is {}.".format(weight))
return info
students = student()print(student.__closure__) # None
print(students.__closure__) # (<cell at 0x112cb1d50: int object at 0x10d522670>, <cell at 0x112cb1d90: int object at 0x10d5218b0>)
print(type(students.__closure__)) # <class 'tuple'>
print(students.__closure__[0].cell_contents) # 170
print(students.__closure__[1].cell_contents) # 60

從上面的範例來看會發現,雖然對info來說,有heightweight兩個non-local variable,但因為info並未使用它們,所以這時student.__closure__的回傳值是None

再往下一步,對student進行呼叫,並assign給變數students,訪問__closure__屬性則會回傳 (<cell at 0x112cb1d50: int object at 0x10d522670>, <cell at 0x112cb1d90: int object at 0x10d5218b0>)這樣的物件資訊。

若要印出裡面的某個物件的話,如取得物件的值,跟tuple取值的方法相同,[]填入要索引的位置,如students.__closure__[0].cell_contents,回傳index=0的值。

Captured variables 如何賦值

如果要對Captured variables重新賦值的話,

def student():
height = 170
weight = 60
def info():
height += 1
weight -= 1
print("my height is {}.".format(height))
print("my weight is {}.".format(weight))
return info
students = student()
print(students()) # UnboundLocalError

執行上述範例後會看到預期不到的錯誤: UnboundLocalError

原因

在function scope中,當變數被賦值時,Python會自動將變數設定為區域變數(local variable)
回頭看上面的範例中,heightweight被重新賦值,兩者在info這個function scope判定為區域變數,但兩者找不到相對應的變數名。

在一般情況下,若想在某個function中assign新的值給先前宣告在全域變數(global scope)中的變數時,一樣也會報UnboundLocalError錯誤訊息。

a = 5
def scope():
a += 10
scope() # UnboundLocalError

解決方法

宣告nonlocal去操作captured variable。
來看範例:

def student():
height = 170
weight = 60
def info():
# nonlocal
nonlocal height
nonlocal weight
height += 1
weight -= 1
print("my height is {}.".format(height))
print("my weight is {}.".format(weight))
return info
students = student()
students()
"""
結果
my height is 171.
my weight is 59.
"""

加上nonlocal heightnonlocal weight後即可正常assign變數了哦!

captured variable在Python中並非區域或全域變數,所以只能用 nonlocal去宣告變數,才能進行其他操作。

Captured variables具獨立性

def student():
height = 170
weight = 60
def info():
# nonlocal
nonlocal height
nonlocal weight
height += 1
weight -= 1
print("my height is {}.".format(height))
print("my weight is {}.".format(weight))
return info
students1 = student()
students1()
students1()
students1()
print("\n--- students1 比較 students2 ---\n")
students2 = student()
students2()
"""
結果:
my height is 171.
my weight is 59.
my height is 172.
my weight is 58.
my height is 173.
my weight is 57.
--- students1 比較 students2 ---my height is 171.
my weight is 59.
"""

由上述例子可知:
即使students1持續將heightweight兩個Captured variables加總和遞減,另一個students2內的Captured variable完全不受影響,推論兩個closure function彼此獨立。

以上為關於作用域及Closure相關概念,如有錯誤之處,還請指教。

參閱

什麼是first-class function

聊聊 Python Closure

Python進階技巧 (4) — Lambda Function 與 Closure 之謎!

--

--