Python進階 - 閉包與裝飾器

一、閉包

1. 什麼是閉包?

在一個函數,如下面的函數所示:

def outside_function():
	temp = "Hello"
	print(temp)

我們的程序在運行過這個函數之後,因爲temp變量只是一箇中間過程的變量,不再被程序需要了,因此其佔用的內存會被釋放。也就是說,在一個函數執行完成後,函數作用域中的變量會被釋放內存。

然而,如果我們在一個函數中再定義另一個函數:

def outside_function():
	temp = "Hello"
	print("This is outside function")
	def inner_function():
		print("This is inner function")
		print(temp)
	return inner_function

由於在Python中一切皆爲對象,函數也能夠作爲對象被return關鍵字返回,此時在外層函數中定義的inner_function()函數叫做一個閉包。

在計算機科學中,閉包(英語:Closure),又稱詞法閉包(Lexical Closure)或函數閉包(function closures),是引用了自由變量的函數。這個被引用的自由變量將和這個函數一同存在,即使已經離開了創造它的環境也不例外。所以,有另一種說法認爲閉包是由函數和與其相關的引用環境組合而成的實體。閉包在運行時可以有多個實例,不同的引用環境和相同的函數組合可以產生不同的實例。

也就是說,在上面的代碼中,在正常運行中,運行完outside_function函數後,temp變量的內存會被釋放。但因爲inner_function函數使用了temp變量,且inner_functionoutside_function函數的返回值。也就意味着,如果用戶調用outside_function函數,就可以獲得inner_function函數的對象,並以此來調用inner_function函數。可是此時如果temp變量隨着outside_function函數的執行完成而釋放內存,那麼當inner_function函數執行時,就無法再執行和temp有關的指令了。因此,temp變量和inner_function函數被綁定在一起,不會在內存中被釋放了。

一個函數要成爲閉包,必須滿足三個條件:

  1. 要成爲閉包的函數嵌套在另外一個函數中
  2. 閉包函數使用了外層函數的變量,該變量叫做自由變量(free variable)
  3. 閉包函數是外層函數的返回值
2. nonlocal關鍵字

需要注意的是,在內層函數中,並不能改變自由變量的值,且每個閉包並不是對應着同一個自由變量。

def outside_function():
	str = "Hello"
	def inner_function():
		str = "Hi"
	print("Before inner function str = %s" % str)
	inner_function()
	print("After inner function str = %s" % str)
	return inner_function

上面的代碼中,雖然在inner_function函數中改變了str變量的值,但前後打印的結果仍然是Hello,而不會變成Hi。如果要在inner_function()中修改str的值,需要添加nonlocal關鍵字,如下所示:

def outside_function():
	str = "Hello"
	def inner_function():
		nonlocal str
		str = "Hi"
	print("Before inner function str = %s" % str)
	inner_function()
	print("After inner function str = %s" % str)
	return inner_function

這次運行的結果就會變成

# 運行結果
Hello
Hi

我們還需要注意的是,每一個閉包的對象對應的是不同的自由變量,例如下面的例子中,我們首先定義一個閉包函數:

def outside_function():
	test_list = []
	def inner_function(name):
		test_list.append(len(test_list) + 1)
		print("%s %s" % (name, test_list))
	return inner_function

上面的嵌套函數中,外層函數定義了一個列表test_list,內層函數每次會將其添加一個新的數字(它的長度+1)。根據上面閉包的定義,函數嵌套關係存在,內層函數調用了外層函數作用域內的對象,外層函數的返回值是內層函數的對象,因此該內層函數是一個閉包。當上面的函數運行下面的程序片段時:

inner_function1 = outside_fuction()
inner_function2 = outside_fuction()
inner_function1("inner_function1")
inner_function1("inner_function1")
inner_function1("inner_function1")
inner_function1("inner_function1")
inner_function2("inner_function2")
inner_function2("inner_function2")
inner_function2("inner_function2")

我們可以獲得的結果爲:

# 運行結果
inner_function1 [1]
inner_function1 [1,2]
inner_function1 [1,2,3]
inner_function1 [1,2,3,4]
inner_function2 [1]
inner_function2 [1,2]
inner_function2 [1,2,3]

可以看到,inner_function1inner_function2並不是向同一個test_list中添加整形數值。也就是說,每個閉包對象對應的是不同的自由變量。

二、自由變量

自由變量並不是只在閉包中出現,在普通的函數中也很常見

i = 3
def f(j):
	return i*j

自由變量指的是函數在函數內部的命名域(namespace)沒有找到聲明的變量,此時Python編譯器會在函數外,也就是全局的變量找到該變量的聲明。該變量叫做自由變量,也就是上端代碼中的i

i = 3
print(f(2))
i = 2
print(f(2))

# 運行結果
6
4

可以看到,非閉包的自由變量,我們對其修改數值,可以改變函數的運行結果

然而,如果是閉包的自由變量,我們修改自由變量的值,卻無法修改函數運行的結果

def outside_function():
	i = 3
	def inside_function(j):
		return i*j
	return inside_function

以上的函數來運行下面的代碼段:

inside_function = outside_function()
print(inside_function(2))
i = 100
print(inside_function(2))

兩次獲得的結果都是6,可見改變i的值並不會影響inside_function的運行;原因很明顯,因爲我們在全局作用域中的i變量和outside_function作用域中的i變量並不是一個變量,因此我們雖然可以修改全局作用域中的i變量,卻無法修改函數內部的i變量

三、裝飾器

Python的裝飾器本質上是一個函數或一個類,功能是在不需要修改原代碼的基礎上添加新的功能。裝飾器應用的功能有很多,一般有插入日誌、性能測試、事務處理、緩存、權限校驗等場景,裝飾器是解決這類問題的絕佳設計。有了裝飾器,我們可以將與函數本身無關的代碼抽離出來,並把這部分代碼也實現重用。

我們首先定義一個函數,這個函數是判斷一個給定的數字是否是素數:

def isPrime(num):
	if num == 2:
		return True
	else:
		for i in range(2,num):
			if num % i == 0:
				return False
		return True

現在我們想看看這個函數執行的時間是多少,該如何實現呢?一種方法是直接在原函數上進行修改:

import time

def isPrime(num):
	tic = time.clock()
	if num == 2:
		return True
	else:
		for i in range(2,num):
			if num % i == 0:
				toc = time.clock()
				print("運行時間爲%.2f" % (toc-tic))
				return False
		toc = time.clock()
		print("運行時間爲%.2f" % (toc-tic))
		return True

但是這樣的缺點很明顯,我們的函數isPrime本來是判斷給定數字是否是素數的,現在混入了大量無關的time模塊的代碼。在這裏使用嵌套的函數,即可把time模塊與函數本身的功能分離開:

def count_time(num):
	tic = time.clock()
	flag = isPrime(num)
	toc = time.clock()
	print("運行時間爲%.2f" % (toc-tic))
	return flag

def isPrime(num):
	if num == 2:
		return True
	else:
		for i in range(2,num):
			if num % i == 0:
				return False
		return True

if __name__ == "__main__":
	wrapper = count_time(10)

但是這樣,我們的count_time函數就和isPrime函數綁定了,但也許我們的其他函數也有這樣的需求,這樣設計的結果使得代碼中產生大量的count_time函數,依舊造成了大量重複的代碼。如果我們把需要記錄時間的函數作爲一個對象傳入到count_time函數中,那麼我們就可以實現對任何函數實現記錄運行時間的功能了。

def count_time(func,*args,**kwargs):
	tic = time.clock()
	result = func(*args, **kwargs)
	toc = time.clock()
	print("運行時間爲%.2f" % (toc-tic))
	return result
	# 如果沒有返回值會返回None

在這裏,我們可以應用到第一部分所講的閉包,來把功能封裝到一個內層函數中,並把func的參數傳到內層函數中。當我們需要調用內層函數時,就調用count_time函數返回內層函數對象。這樣,count_time函數只需要傳遞一個需要調用的函數對象func,便可以返回內層函數了,這也就是裝飾器的思想:

def count_time(func):
	# 內層函數
	def wrapper(*args, **kwargs):
		tic = time.clock()
		result = func(*args,**kwargs)
		toc = time.clock()
		print("運行時間爲%.2f" % (toc-tic))
		return result
	return wrapper

從代碼來看,首先函數形成嵌套,且內層函數wrapper調用了外層函數count_time作用域內的對象func,且外層函數的返回值是內層函數,因此根據前文我們對閉包的判斷條件,該函數形成了閉包。

那麼在調用的時候,我們該如何使用呢?按照正常的閉包用法,我們應該:

num = 10
wrapper = count_time(isPrime)
result = wrapper(num)

也就是說,我們每次調用函數,都需要調用一次count_time函數來獲取內層函數,並將func對象與wrapper函數進行綁定;並且,我們需要對返回的內部函數進行命名,如果將其命名爲wrapper的話,不同的函數之間會造成混淆,如果將其命名爲isPrime的話,又會與原函數造成混淆。如果我們能直接調用原函數就好了!

在Python的裝飾器中,就提供了這樣的語法來幫助我們。當我們定義isPrime的時候,如果我們需要對其進行記錄時間的操作,在定義完count_timewrapper閉包函數後,我們只需要對isPrime函數添加@count_time註解即可

@count_time
def isPrime(num):
	if num == 2:
		return True
	else:
		for i in range(2,num):
			if num % i == 0:
				return False
		return True

這樣,在調用的時候,只需要直接調用isPrime函數即可,即

num = 10
result = isPrime(num)

並且依舊可以實現記錄時間的功能

四、Python的內置裝飾器

Python提供了三個內置的裝飾器,分別爲@property@classmethod@staticmethod

1. @property

@Property裝飾器可以把一個類中的方法變爲同名的屬性,主要應用在類的定義中。

例如,我們定義一個Student類,其中有一個分數屬性score,那麼從外部可以直接訪問並修改,導致了學生的分數可以任意的修改,十分不安全。在Java或其他面嚮對象語言中,我們的解決方法是把score設置爲私有屬性,並設置setter來更新數據,設置getter來訪問數據,在Python中,我們也可以做相同的操作,且在更新成績時判斷,輸入的值是否爲0-100之間的數值

class Student:
	def __init__(self,score)
		self._score = score

	def getScore(self):
		return self._score

	def setScore(self,score):
		if 0 <= score <= 100:
			self._score = score
		else:
			raise ValueException("成績必須在0到100之間!")

在Python中,通過使用提供的@property裝飾器,我們可以實現通過直接訪問來修改數值,並仍然讓數據可控

class Student:
	def __init__(self,score)
		self._score = score

	@property
	def score(self):
		return self._score

	@score.setter
	def score(self,value):
		if 0 <= score <= 100:
			self._score = value
		else:
			raise ValueException("成績必須在0到100之間!")

我們對變量的getter添加@property裝飾器,添加後可以繼續對另一個同名函數添加@score.setter裝飾器來定義修改數據的方式

我們通過直接訪問參數即可,並且可以對輸入的數據進行把控

s = Student(80)
s.score = 60
print(s.score)
s.score = 9999

運行的結果爲

60
Traceback (most recent call last):
  ...
ValueError: 成績必須在0到100之間!

當然,@score.setter裝飾器不是必須添加的,如果不實現同名方法,並添加setter裝飾器的註解的話,該屬性就會變爲只讀屬性,不能從外界被訪問

例如,一個長方形的屬性有長和寬,還有屬性周長和麪積。一個長方形在被創造的時候長和寬就被確定了,因此修改周長和麪積是沒有意義的,我們就可以把周長和麪積不添加setter的裝飾器,讓其作爲只讀屬性

class Rectangle:
	def __init__(self,length,width):
		self._length = length
		self._width = width

	@property
	def area(self):
		return self._length * self._width

	@property
	def perimeter(self):
		return 2 * (self._width + self._length)

在我們使用的時候,便可以直接通過方法名來訪問參數了:

r1 = Rectangle(8,6)
print(r1.area)
print(r1.perimeter)
r1.area = 80

會有運行結果:

48
28
Traceback (most recent call last):
  File "test.py", line 17, in <module>
    r1.area = 80
AttributeError: can't set attribute
2. @classmethod

@classmethod裝飾器會讓一個方法返回其所在的類本身。由於Python不支持構造函數的重載,因此,@classmethod裝飾器可以實現構造函數的重載;@classmethod還可以替代工廠方法,讓普通類具有其它語言中工廠類的功能

@classmethod裝飾器所修飾的方法不需要對象實例化,也不需要self參數,而需要代表自身類的cls參數,當然,cls只是一個約定俗成的名字,也可以叫this或任何其他名字。這類方法可以在實例化之前就被調用,因爲有時在實例化之前可能需要調用該類的一些方法,該方法的返回值會影響類的實例化過程。

就像前面所說的,@classmethod可以實現構造方法的重構:

class Date:
	def __init__(self,year = 0,month = 0, day = 0):
		self.year = year
		self.month = month
		self.day = day

	@classmethod
	def construct_from_string(cls,date_string):
		year, month, day = date_string.split("-")
		return cls(year,date,string)

@classmethod還可以在類實例化之前對類做出一些判斷,來影響之後的實例化過程。例如我們的模塊在實例化之前,首先需要判斷和其他模塊的版本是否兼容:

class Module:
	def __init__(self,compatible_version):
		self.compatible_version = compatible_version

	@classmethod
	def isCompatible(cls,other_version):
		if other_version in compatible_version:
			return True
		else: 
			return False

@classmethod的第三個功能和類的繼承有關。通常,我們在子類中可以重寫一個方法,來添加基類中沒有的功能。但是如果我們的基類想針對不同的子類在某些方法中做出不同的處理,則需要添加@classmethod裝飾器:

class Person:
	def __init__(self,name):
		self.name = name

	def introduce(self):
		print("My name is %s" % self.name)

	@classmethod
	def introduce_job(cls):
		if cls.__name__ == "Police":
			print("I'm a police.")
		elif cls.__name__ == "Doctor":
			print("I'm a doctor")
	
class Police(Person):
	pass
class Doctor(Person):
	pass

下面我們分別實例化Police類和Doctor類,來調取introduce_job方法:

police = Police("Bob")
doctor = Doctor("Peter")
police.introduce()
police.introduce_job()
doctor.introduce()
doctor.introduce_job()

運行結果爲:

My name is Bob
I'm a police.
My name is Peter
I'm a doctor
3. @staticmethod

@staticmethod裝飾器會把一個方法標記爲靜態方法,即不需要實例化,直接調用類名來訪問的方法;在Python中,實例化的對象也可以調用靜態方法。由於在面向對象編程中比較常見,不再贅述了。

靜態方法不需要傳self參數或cls參數,只傳入方法需要的參數即可

class Test:

	@staticmethod
	def average(arr):
		return sum(arr) / len(arr)

arr = [1,2,3,4,5,6,7,2,3,4,2,3]
print(Test.average(arr))

test = Test()
print(test.average(arr))

兩種方式調用都會返回相同的結果3.5。

參考資料

[1] Python: 從閉包到裝飾器
[2] 理解Python閉包概念
[3] Python系列之閉包
[4] Python 3函數自由變量的大坑
[5] Python小技巧:裝飾器(Decorator)
[6] Python中內置裝飾器的使用
[7] 使用@property
[8] Python @classmethod
[9] Python進階(六):@classmethod和@staticmethod


我和幾位大佬建立了一個微信公衆號,歡迎關注後查看更多技術乾貨文章
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章