1. 程式人生 > 其它 >方法value作用於物件range時失敗_Python入門教程 | 第 7 章 面向物件高階程式設計

方法value作用於物件range時失敗_Python入門教程 | 第 7 章 面向物件高階程式設計

技術標籤:方法value作用於物件range時失敗

第七章 面向物件高階程式設計

資料封裝、繼承和多型只是面向物件程式設計中最基礎的 3 個概念。在 Python 中,面向物件還有很多高階特性,允許我們寫出非常強大的功能。

我們會討論多重繼承、定製類、元類等概念。

7.1 使用 __slots__

正常情況下,當我們定義了一個 class,建立了一個 class 的例項後,我們可以給該例項繫結任何屬性方法,這就是動態語言的靈活性。先定義 class:

classStudent(object):
pass

然後,嘗試給例項繫結一個屬性:

>>>s=Student()
>>>s.name='Michael'#動態給例項繫結一個屬性
>>>print(s.name)
Michael

還可以嘗試給例項繫結一個方法:

>>>defset_age(self,age):#定義一個函式作為例項方法
...self.age=age
...
>>>fromtypesimportMethodType
>>>s.set_age=MethodType(set_age,s)#給例項繫結一個方法
>>>s.set_age(25)#呼叫例項方法
>>>s.age#測試結果
25

但是,給一個例項繫結的方法,對另一個例項是不起作用的:

>>>s2=Student()#建立新的例項
>>>s2.set_age(25)#嘗試呼叫方法
Traceback(mostrecentcalllast):
File"",line1,in
AttributeError:'Student'objecthasnoattribute'set_age'

為了給所有例項都繫結方法,可以給 class 繫結方法:

>>>defset_score(self,score):
...self.score=score
...
>>>Student.set_score=set_score

給 class 繫結方法後,所有例項均可呼叫:

>>>s.set_score(100)
>>>s.score
100
>>>s2.set_score(99)
>>>s2.score
99

通常情況下,上面的set_score方法可以直接定義在 class 中,但動態繫結允許我們在程式執行的過程中動態給 class 加上功能,這在靜態語言中很難實現。

但是,如果我們想要限制例項的屬性怎麼辦?比如,只允許對 Student 例項新增nameage屬性。

為了達到限制的目的,Python 允許在定義 class 的時候,定義一個特殊的__slots__變數,來限制該 class 例項能新增的屬性:

classStudent(object):
__slots__=('name','age')#用tuple定義允許繫結的屬性名稱

然後,我們試試:

>>>s=Student()#建立新的例項
>>>s.name='Michael'#繫結屬性'name'
>>>s.age=25#繫結屬性'age'
>>>s.score=99#繫結屬性'score'
Traceback(mostrecentcalllast):
File"",line1,in
AttributeError:'Student'objecthasnoattribute'score'

由於'score'沒有被放到__slots__中,所以不能繫結score屬性,試圖繫結score將得到AttributeError的錯誤。

使用__slots__要注意,__slots__定義的屬性僅對當前類例項起作用,對繼承的子類是不起作用的:

>>>classGraduateStudent(Student):
...pass
...
>>>g=GraduateStudent()
>>>g.score=9999

除非在子類中也定義__slots__,這樣,子類例項允許定義的屬性就是自身的__slots__加上父類的__slots__

7.2 使用 @property

在繫結屬性時,如果我們直接把屬性暴露出去,雖然寫起來很簡單,但是,沒辦法檢查引數,導致可以把成績隨便改:

s=Student()
s.score=9999

這顯然不合邏輯。為了限制 score 的範圍,可以通過一個set_score()方法來設定成績,再通過一個get_score()來獲取成績,這樣,在set_score()方法裡,就可以檢查引數:

classStudent(object):

defget_score(self):
returnself._score

defset_score(self,value):
ifnotisinstance(value,int):
raiseValueError('scoremustbeaninteger!')
ifvalue0orvalue>100:
raiseValueError('scoremustbetween0~100!')
self._score=value

現在,對任意的 Student 例項進行操作,就不能隨心所欲地設定 score 了:

>>>s=Student()
>>>s.set_score(60)#ok!
>>>s.get_score()
60
>>>s.set_score(9999)
Traceback(mostrecentcalllast):
...
ValueError:scoremustbetween0~100!

但是,上面的呼叫方法又略顯複雜,沒有直接用屬性這麼直接簡單。

有沒有既能檢查引數,又可以用類似屬性這樣簡單的方式來訪問類的變數呢?對於追求完美的 Python 程式設計師來說,這是必須要做到的!

還記得裝飾器(decorator)可以給函式動態加上功能嗎?對於類的方法,裝飾器一樣起作用。Python 內建的@property裝飾器就是負責把一個方法變成屬性呼叫的:

classStudent(object):

@property
defscore(self):
returnself._score

@score.setter
defscore(self,value):
ifnotisinstance(value,int):
raiseValueError('scoremustbeaninteger!')
ifvalue0orvalue>100:
raiseValueError('scoremustbetween0~100!')
self._score=value

@property的實現比較複雜,我們先考察如何使用。把一個 getter 方法變成屬性,只需要加上@property就可以了,此時,@property本身又建立了另一個裝飾器@score.setter,負責把一個 setter 方法變成屬性賦值,於是,我們就擁有一個可控的屬性操作:

>>>s=Student()
>>>s.score=60#OK,實際轉化為s.set_score(60)
>>>s.score#OK,實際轉化為s.get_score()
60
>>>s.score=9999
Traceback(mostrecentcalllast):
...
ValueError:scoremustbetween0~100!

注意到這個神奇的@property,我們在對例項屬性操作的時候,就知道該屬性很可能不是直接暴露的,而是通過 getter 和 setter 方法來實現的。

還可以定義只讀屬性,只定義 getter 方法,不定義 setter 方法就是一個只讀屬性:

classStudent(object):

@property
defbirth(self):
returnself._birth

@birth.setter
defbirth(self,value):
self._birth=value

@property
defage(self):
return2015-self._birth

上面的birth是可讀寫屬性,而age就是一個只讀屬性,因為age可以根據birth和當前時間計算出來。

小結:

@property廣泛應用在類的定義中,可以讓呼叫者寫出簡短的程式碼,同時保證對引數進行必要的檢查,這樣,程式執行時就減少了出錯的可能性。

練習:

請利用@property給一個Screen物件加上widthheight屬性,以及一個只讀屬性resolution

classScreen(object):

@property

defwidth(self):

returnself._width

@width.setter

defwidth(self,value):

ifnotisinstance(value,(int,float)):

raiseValueError('widthmustbeannumber!')

ifvalue0:

raiseValueError('widthmust>0')

self._width=value

@property

defheight(self):

returnself._height

@height.setter

defheight(self,value):

ifnotisinstance(value,(int,float)):

raiseValueError('heightmustbeannumber!')

ifvalue0:

raiseValueError('heightmust>0')

self._height=value

@property

defresolution(self):

returnself._height*self._width

測試:

#測試:
s=Screen()
s.width=1024
s.height=768
print('resolution=',s.resolution)
ifs.resolution==786432:
print('測試通過!')
else:
print('測試失敗!')

7.3 多重繼承

繼承是面向物件程式設計的一個重要的方式,因為通過繼承,子類就可以擴充套件父類的功能。

回憶一下Animal類層次的設計,假設我們要實現以下 4 種動物:

  • Dog - 狗狗;
  • Bat - 蝙蝠;
  • Parrot - 鸚鵡;
  • Ostrich - 鴕鳥。

如果按照哺乳動物和鳥類歸類,我們可以設計出這樣的類的層次:

ab8952be29de65c90d125596cb6c6edd.png
歸類

但是如果按照“能跑”和“能飛”來歸類,我們就應該設計出這樣的類的層次:

6178640090421604ca88a8bd2a18f025.png
歸類

如果要把上面的兩種分類都包含進來,我們就得設計更多的層次:

  • 哺乳類:能跑的哺乳類,能飛的哺乳類;
  • 鳥類:能跑的鳥類,能飛的鳥類。

這麼一來,類的層次就複雜了:

b48e0262e682979fa7c61b1d22c1eddf.png
image-20200811111248510

如果要再增加“寵物類”和“非寵物類”,這麼搞下去,類的數量會呈指數增長,很明顯這樣設計是不行的。

正確的做法是採用多重繼承。首先,主要的類層次仍按照哺乳類和鳥類設計:

classAnimal(object):
pass

#大類:
classMammal(Animal):
pass

classBird(Animal):
pass

#各種動物:
classDog(Mammal):
pass

classBat(Mammal):
pass

classParrot(Bird):
pass

classOstrich(Bird):
pass

現在,我們要給動物再加上RunnableFlyable的功能,只需要先定義好RunnableFlyable的類:

classRunnable(object):
defrun(self):
print('Running...')

classFlyable(object):
deffly(self):
print('Flying...')

對於需要Runnable功能的動物,就多繼承一個Runnable,例如Dog

classDog(Mammal,Runnable):
pass

對於需要Flyable功能的動物,就多繼承一個Flyable,例如Bat

classBat(Mammal,Flyable):
pass

通過多重繼承,一個子類就可以同時獲得多個父類的所有功能。

7.3.1 MixIn

在設計類的繼承關係時,通常,主線都是單一繼承下來的,例如,Ostrich繼承自Bird。但是,如果需要“混入”額外的功能,通過多重繼承就可以實現,比如,讓Ostrich除了繼承自Bird外,再同時繼承Runnable。這種設計通常稱之為 MixIn。

為了更好地看出繼承關係,我們把RunnableFlyable改為RunnableMixInFlyableMixIn。類似的,你還可以定義出肉食動物CarnivorousMixIn和植食動物HerbivoresMixIn,讓某個動物同時擁有好幾個 MixIn:

classDog(Mammal,RunnableMixIn,CarnivorousMixIn):
pass

MixIn 的目的就是給一個類增加多個功能,這樣,在設計類的時候,我們優先考慮通過多重繼承來組合多個 MixIn 的功能,而不是設計多層次的複雜的繼承關係。

Python 自帶的很多庫也使用了 MixIn。舉個例子, Python 自帶了TCPServerUDPServer這兩類網路服務,而要同時服務多個使用者就必須使用多程序或多執行緒模型,這兩種模型由ForkingMixInThreadingMixIn提供。通過組合,我們就可以創造出合適的服務來。

比如,編寫一個多程序模式的 TCP 服務,定義如下:

classMyTCPServer(TCPServer,ForkingMixIn):
pass

編寫一個多執行緒模式的 UDP 服務,定義如下:

classMyUDPServer(UDPServer,ThreadingMixIn):
pass

如果你打算搞一個更先進的協程模型,可以編寫一個CoroutineMixIn

classMyTCPServer(TCPServer,CoroutineMixIn):
pass

這樣一來,我們不需要複雜而龐大的繼承鏈,只要選擇組合不同的類的功能,就可以快速構造出所需的子類。

小結:

由於 Python 允許使用多重繼承,因此,MixIn 就是一種常見的設計。

只允許單一繼承的語言(如 Java)不能使用 MixIn 的設計。

7.4 定製類

看到類似__slots__這種形如__xxx__的變數或者函式名就要注意,這些在 Python 中是有特殊用途的。

__slots__我們已經知道怎麼用了,__len__()方法我們也知道是為了能讓 class 作用於len()函式。

除此之外,Python 的 class 中還有許多這樣有特殊用途的函式,可以幫助我們定製類。

7.4.1 __str__

我們先定義一個Student類,列印一個例項:

>>>classStudent(object):
...def__init__(self,name):
...self.name=name
...
>>>print(Student('Michael'))
<__main__.student>0x109afb190>

打印出一堆<__main__.student object at>,不好看。

怎麼才能列印得好看呢?只需要定義好__str__()方法,返回一個好看的字串就可以了:

>>>classStudent(object):
...def__init__(self,name):
...self.name=name
...def__str__(self):
...return'Studentobject(name:%s)'%self.name
...
>>>print(Student('Michael'))
Studentobject(name:Michael)

這樣打印出來的例項,不但好看,而且容易看出例項內部重要的資料。

但是細心的朋友會發現直接敲變數不用print,打印出來的例項還是不好看:

>>>s=Student('Michael')
>>>s
<__main__.student>0x109afb310>

這是因為直接顯示變數呼叫的不是__str__(),而是__repr__(),兩者的區別是__str__()返回使用者看到的字串,而__repr__()返回程式開發者看到的字串,也就是說,__repr__()是為除錯服務的。

解決辦法是再定義一個__repr__()。但是通常__str__()__repr__()程式碼都是一樣的,所以,有個偷懶的寫法:

classStudent(object):
def__init__(self,name):
self.name=name
def__str__(self):
return'Studentobject(name=%s)'%self.name
__repr__=__str__

7.4.2 __iter__

如果一個類想被用於for ... in迴圈,類似 list 或 tuple 那樣,就必須實現一個__iter__()方法,該方法返回一個迭代物件,然後,Python 的 for 迴圈就會不斷呼叫該迭代物件的__next__()方法拿到迴圈的下一個值,直到遇到StopIteration錯誤時退出迴圈。

我們以斐波那契數列為例,寫一個 Fib 類,可以作用於 for 迴圈:

classFib(object):
def__init__(self):
self.a,self.b=0,1#初始化兩個計數器a,b

def__iter__(self):
returnself#例項本身就是迭代物件,故返回自己

def__next__(self):
self.a,self.b=self.b,self.a+self.b#計算下一個值
ifself.a>100000:#退出迴圈的條件
raiseStopIteration()
returnself.a#返回下一個值

現在,試試把 Fib 例項作用於 for 迴圈:

forninFib():
print(n)

7.4.3 __getitem__

Fib例項雖然能作用於 for 迴圈,看起來和 list 有點像,但是,把它當成 list 來使用還是不行,比如,取第 5 個元素:

>>>Fib()[5]
Traceback(mostrecentcalllast):
File"",line1,in
TypeError:'Fib'objectdoesnotsupportindexing

要表現得像 list 那樣按照下標取出元素,需要實現__getitem__()方法:

classFib(object):
def__getitem__(self,n):
a,b=1,1
forxinrange(n):
a,b=b,a+b
returna

現在,就可以按下標訪問數列的任意一項了:

>>>f=Fib()
>>>f[0]
1
>>>f[1]
1
>>>f[2]
2
>>>f[3]
3
>>>f[10]
89
>>>f[100]
573147844013817084101

但是 list 有個神奇的切片方法:

>>>list(range(100))[5:10]
[5,6,7,8,9]

對於 Fib 卻報錯。原因是__getitem__()傳入的引數可能是一個 int,也可能是一個切片物件slice,所以要做判斷:

classFib(object):
def__getitem__(self,n):
ifisinstance(n,int):#n是索引
a,b=1,1
forxinrange(n):
a,b=b,a+b
returna
ifisinstance(n,slice):#n是切片
start=n.start
stop=n.stop
ifstartisNone:
start=0
a,b=1,1
L=[]
forxinrange(stop):
ifx>=start:
L.append(a)
a,b=b,a+b
returnL

現在試試 Fib 的切片:

>>>f=Fib()
>>>f[0:5]
[1,1,2,3,5]
>>>f[:10]
[1,1,2,3,5,8,13,21,34,55]

但是沒有對 step 引數作處理:

>>>f[:10:2]
[1,1,2,3,5,8,13,21,34,55,89]

也沒有對負數作處理,所以,要正確實現一個__getitem__()還是有很多工作要做的。

此外,如果把物件看成dict__getitem__()的引數也可能是一個可以作 key 的 object,例如str

與之對應的是__setitem__()方法,把物件視作 list 或 dict 來對集合賦值。最後,還有一個__delitem__()方法,用於刪除某個元素。

總之,通過上面的方法,我們自己定義的類表現得和 Python 自帶的 list、tuple、dict 沒什麼區別,這完全歸功於動態語言的“鴨子型別”,不需要強制繼承某個介面。

7.4.4 __getattr__

正常情況下,當我們呼叫類的方法或屬性時,如果不存在,就會報錯。比如定義Student類:

classStudent(object):

def__init__(self):
self.name='Michael'

呼叫name屬性,沒問題,但是,呼叫不存在的score屬性,就有問題了:

>>>s=Student()
>>>print(s.name)
Michael
>>>print(s.score)
Traceback(mostrecentcalllast):
...
AttributeError:'Student'objecthasnoattribute'score'

錯誤資訊很清楚地告訴我們,沒有找到score這個 attribute。

要避免這個錯誤,除了可以加上一個score屬性外,Python 還有另一個機制,那就是寫一個__getattr__()方法,動態返回一個屬性。修改如下:

classStudent(object):

def__init__(self):
self.name='Michael'

def__getattr__(self,attr):
ifattr=='score':
return99

當呼叫不存在的屬性時,比如score,Python 直譯器會試圖呼叫__getattr__(self, 'score')來嘗試獲得屬性,這樣,我們就有機會返回score的值:

>>>s=Student()
>>>s.name
'Michael'
>>>s.score
99

返回函式也是完全可以的:

classStudent(object):

def__getattr__(self,attr):
ifattr=='age':
returnlambda:25

只是呼叫方式要變為:

>>>s.age()
25

注意,只有在沒有找到屬性的情況下,才呼叫__getattr__,已有的屬性,比如name,不會在__getattr__中查詢。

此外,注意到任意呼叫如s.abc都會返回None,這是因為我們定義的__getattr__預設返回就是None。要讓 class 只響應特定的幾個屬性,我們就要按照約定,丟擲AttributeError的錯誤:

classStudent(object):

def__getattr__(self,attr):
ifattr=='age':
returnlambda:25
raiseAttributeError('\'Student\'objecthasnoattribute\'%s\''%attr)

這實際上可以把一個類的所有屬性和方法呼叫全部動態化處理了,不需要任何特殊手段。

這種完全動態呼叫的特性有什麼實際作用呢?作用就是,可以針對完全動態的情況作呼叫。

舉個例子:

現在很多網站都搞 REST API,比如新浪微博、豆瓣啥的,呼叫 API 的 URL 類似:

  • http://api.server/user/friends
  • http://api.server/user/timeline/list

如果要寫 SDK,給每個 URL 對應的 API 都寫一個方法,那得累死,而且,API 一旦改動,SDK 也要改。

利用完全動態的__getattr__,我們可以寫出一個鏈式呼叫:

classChain(object):

def__init__(self,path=''):
self._path=path

def__getattr__(self,path):
returnChain('%s/%s'%(self._path,path))

def__str__(self):
returnself._path

__repr__=__str__

試試:

>>>Chain().status.user.timeline.list
'/status/user/timeline/list'

這樣,無論 API 怎麼變,SDK 都可以根據 URL 實現完全動態的呼叫,而且,不隨 API 的增加而改變!

還有些 REST API 會把引數放到 URL 中,比如 GitHub 的 API:

GET/users/:user/repos

呼叫時,需要把:user替換為實際使用者名稱。如果我們能寫出這樣的鏈式呼叫:

Chain().users('michael').repos

就可以非常方便地呼叫 API 了。有興趣的童鞋可以試試寫出來。

7.4.5 __call__

一個物件例項可以有自己的屬性和方法,當我們呼叫例項方法時,我們用instance.method()來呼叫。能不能直接在例項本身上呼叫呢?在 Python 中,答案是肯定的。

任何類,只需要定義一個__call__()方法,就可以直接對例項進行呼叫。請看示例:

classStudent(object):
def__init__(self,name):
self.name=name

def__call__(self):
print('Mynameis%s.'%self.name)

呼叫方式如下:

>>>s=Student('Michael')
>>>s()#self引數不要傳入
MynameisMichael.

__call__()還可以定義引數。對例項進行直接呼叫就好比對一個函式進行呼叫一樣,所以你完全可以把物件看成函式,把函式看成物件,因為這兩者之間本來就沒啥根本的區別。

如果你把物件看成函式,那麼函式本身其實也可以在執行期動態創建出來,因為類的例項都是執行期創建出來的,這麼一來,我們就模糊了物件和函式的界限。

那麼,怎麼判斷一個變數是物件還是函式呢?其實,更多的時候,我們需要判斷一個物件是否能被呼叫,能被呼叫的物件就是一個Callable物件,比如函式和我們上面定義的帶有__call__()的類例項:

>>>callable(Student())
True
>>>callable(max)
True
>>>callable([1,2,3])
False
>>>callable(None)
False
>>>callable('str')
False

通過callable()函式,我們就可以判斷一個物件是否是“可呼叫”物件。

小結:

  • Python 的 class 允許定義許多定製方法,可以讓我們非常方便地生成特定的類。
  • 本節介紹的是最常用的幾個定製方法,還有很多可定製的方法,請參考Python的官方文件[1]

7.5 使用列舉類

當我們需要定義常量時,一個辦法是用大寫變數通過整數來定義,例如月份:

JAN=1
FEB=2
MAR=3
...
NOV=11
DEC=12

好處是簡單,缺點是型別是int,並且仍然是變數。

更好的方法是為這樣的列舉型別定義一個 class 型別,然後,每個常量都是 class 的一個唯一例項。Python 提供了Enum類來實現這個功能:

7.5.1 Enum

fromenumimportEnum

Month=Enum('Month',('Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'))

這樣我們就獲得了Month型別的列舉類,可以直接使用Month.Jan來引用一個常量,或者列舉它的所有成員:

forname,memberinMonth.__members__.items():
print(name,'=>',member,',',member.value)

value屬性則是自動賦給成員的int常量,預設從1開始計數。

如果需要更精確地控制列舉型別,可以從Enum派生出自定義類:

fromenumimportEnum,unique

@unique
classWeekday(Enum):
Sun=0#Sun的value被設定為0
Mon=1
Tue=2
Wed=3
Thu=4
Fri=5
Sat=6

@unique裝飾器可以幫助我們檢查保證沒有重複值。

訪問這些列舉型別可以有若干種方法:

>>>day1=Weekday.Mon
>>>print(day1)
Weekday.Mon
>>>print(Weekday.Tue)
Weekday.Tue
>>>print(Weekday['Tue'])
Weekday.Tue
>>>print(Weekday.Tue.value)
2
>>>print(day1==Weekday.Mon)
True
>>>print(day1==Weekday.Tue)
False
>>>print(Weekday(1))
Weekday.Mon
>>>print(day1==Weekday(1))
True
>>>Weekday(7)
Traceback(mostrecentcalllast):
...
ValueError:7isnotavalidWeekday
>>>forname,memberinWeekday.__members__.items():
...print(name,'=>',member)
...
Sun=>Weekday.Sun
Mon=>Weekday.Mon
Tue=>Weekday.Tue
Wed=>Weekday.Wed
Thu=>Weekday.Thu
Fri=>Weekday.Fri
Sat=>Weekday.Sat

可見,既可以用成員名稱引用列舉常量,又可以直接根據value的值獲得列舉常量。

7.6 使用元類

7.6.1 type()

動態語言和靜態語言最大的不同,就是函式和類的定義,不是編譯時定義的,而是執行時動態建立的。

比方說我們要定義一個Hello的 class,就寫一個hello.py模組:

classHello(object):
defhello(self,name='world'):
print('Hello,%s.'%name)

當 Python 直譯器載入hello模組時,就會依次執行該模組的所有語句,執行結果就是動態創建出一個Hello的 class 物件,測試如下:

>>>fromhelloimportHello
>>>h=Hello()
>>>h.hello()
Hello,world.
>>>print(type(Hello))
<class'type'>
>>>print(type(h))
<class'hello.Hello'>

type()函式可以檢視一個型別或變數的型別,Hello是一個class,它的型別就是type,而h是一個例項,它的型別就是class Hello

我們說class的定義是執行時動態建立的,而建立class的方法就是使用type()函式。

type()函式既可以返回一個物件的型別,又可以創建出新的型別,比如,我們可以通過type()函式創建出Hello類,而無需通過class Hello(object)...的定義:

>>>deffn(self,name='world'):#先定義函式
...print('Hello,%s.'%name)
...
>>>Hello=type('Hello',(object,),dict(hello=fn))#建立Helloclass
>>>h=Hello()
>>>h.hello()
Hello,world.
>>>print(type(Hello))
<class'type'>
>>>print(type(h))
<class'__main__.Hello'>

要建立一個class物件,type()函式依次傳入3個引數:

  1. class的名稱;
  2. 繼承的父類集合,注意Python支援多重繼承,如果只有一個父類,別忘了tuple的單元素寫法;
  3. class的方法名稱與函式繫結,這裡我們把函式fn繫結到方法名hello上。

通過type()函式建立的類和直接寫class是完全一樣的,因為Python直譯器遇到class定義時,僅僅是掃描一下class定義的語法,然後呼叫type()函式創建出class。

正常情況下,我們都用class Xxx...來定義類,但是,type()函式也允許我們動態創建出類來,也就是說,動態語言本身支援執行期動態建立類,這和靜態語言有非常大的不同,要在靜態語言執行期建立類,必須構造原始碼字串再呼叫編譯器,或者藉助一些工具生成位元組碼實現,本質上都是動態編譯,會非常複雜。

7.6.2 metaclass

除了使用type()動態建立類以外,要控制類的建立行為,還可以使用metaclass。

metaclass,直譯為元類,簡單的解釋就是:

當我們定義了類以後,就可以根據這個類創建出例項,所以:先定義類,然後建立例項。

但是如果我們想創建出類呢?那就必須根據metaclass創建出類,所以:先定義metaclass,然後建立類。

連線起來就是:先定義metaclass,就可以建立類,最後建立例項。

所以,metaclass允許你建立類或者修改類。換句話說,你可以把類看成是metaclass創建出來的“例項”。

metaclass是Python面向物件裡最難理解,也是最難使用的魔術程式碼。正常情況下,你不會碰到需要使用metaclass的情況,所以,以下內容看不懂也沒關係,因為基本上你不會用到。

我們先看一個簡單的例子,這個metaclass可以給我們自定義的MyList增加一個add方法:

定義ListMetaclass,按照預設習慣,metaclass的類名總是以Metaclass結尾,以便清楚地表示這是一個metaclass:

#metaclass是類的模板,所以必須從`type`型別派生:
classListMetaclass(type):
def__new__(cls,name,bases,attrs):
attrs['add']=lambdaself,value:self.append(value)
returntype.__new__(cls,name,bases,attrs)

有了ListMetaclass,我們在定義類的時候還要指示使用ListMetaclass來定製類,傳入關鍵字引數metaclass

classMyList(list,metaclass=ListMetaclass):
pass

當我們傳入關鍵字引數metaclass時,魔術就生效了,它指示Python直譯器在建立MyList時,要通過ListMetaclass.__new__()來建立,在此,我們可以修改類的定義,比如,加上新的方法,然後,返回修改後的定義。

__new__()方法接收到的引數依次是:

  1. 當前準備建立的類的物件;
  2. 類的名字;
  3. 類繼承的父類集合;
  4. 類的方法集合。

測試一下MyList是否可以呼叫add()方法:

>>>L=MyList()
>>>L.add(1)
>>L
[1]

而普通的list沒有add()方法:

>>>L2=list()
>>>L2.add(1)
Traceback(mostrecentcalllast):
File"",line1,in
AttributeError:'list'objecthasnoattribute'add'

動態修改有什麼意義?直接在MyList定義中寫上add()方法不是更簡單嗎?正常情況下,確實應該直接寫,通過metaclass修改純屬變態。

但是,總會遇到需要通過metaclass修改類定義的。ORM就是一個典型的例子。

ORM全稱“Object Relational Mapping”,即物件-關係對映,就是把關係資料庫的一行對映為一個物件,也就是一個類對應一個表,這樣,寫程式碼更簡單,不用直接操作SQL語句。

要編寫一個ORM框架,所有的類都只能動態定義,因為只有使用者才能根據表的結構定義出對應的類來。

讓我們來嘗試編寫一個ORM框架。

編寫底層模組的第一步,就是先把呼叫介面寫出來。比如,使用者如果使用這個ORM框架,想定義一個User類來操作對應的資料庫表User,我們期待他寫出這樣的程式碼:

classUser(Model):
#定義類的屬性到列的對映:
id=IntegerField('id')
name=StringField('username')
email=StringField('email')
password=StringField('password')

#建立一個例項:
u=User(id=12345,name='Michael',email='[email protected]',password='my-pwd')
#儲存到資料庫:
u.save()

其中,父類Model和屬性型別StringFieldIntegerField是由ORM框架提供的,剩下的魔術方法比如save()全部由metaclass自動完成。雖然metaclass的編寫會比較複雜,但ORM的使用者用起來卻異常簡單。

現在,我們就按上面的介面來實現該ORM。

首先來定義Field類,它負責儲存資料庫表的欄位名和欄位型別:

classField(object):

def__init__(self,name,column_type):
self.name=name
self.column_type=column_type

def__str__(self):
return''%(self.__class__.__name__,self.name)

Field的基礎上,進一步定義各種型別的Field,比如StringFieldIntegerField等等:

classStringField(Field):

def__init__(self,name):
super(StringField,self).__init__(name,'varchar(100)')

classIntegerField(Field):

def__init__(self,name):
super(IntegerField,self).__init__(name,'bigint')

下一步,就是編寫最複雜的ModelMetaclass了:

classModelMetaclass(type):

def__new__(cls,name,bases,attrs):
ifname=='Model':
returntype.__new__(cls,name,bases,attrs)
print('Foundmodel:%s'%name)
mappings=dict()
fork,vinattrs.items():
ifisinstance(v,Field):
print('Foundmapping:%s==>%s'%(k,v))
mappings[k]=v
forkinmappings.keys():
attrs.pop(k)
attrs['__mappings__']=mappings#儲存屬性和列的對映關係
attrs['__table__']=name#假設表名和類名一致
returntype.__new__(cls,name,bases,attrs)

以及基類Model

classModel(dict,metaclass=ModelMetaclass):

def__init__(self,**kw):
super(Model,self).__init__(**kw)

def__getattr__(self,key):
try:
returnself[key]
exceptKeyError:
raiseAttributeError(r"'Model'objecthasnoattribute'%s'"%key)

def__setattr__(self,key,value):
self[key]=value

defsave(self):
fields=[]
params=[]
args=[]
fork,vinself.__mappings__.items():
fields.append(v.name)
params.append('?')
args.append(getattr(self,k,None))
sql='insertinto%s(%s)values(%s)'%(self.__table__,','.join(fields),','.join(params))
print('SQL:%s'%sql)
print('ARGS:%s'%str(args))

當用戶定義一個class User(Model)時,Python直譯器首先在當前類User的定義中查詢metaclass,如果沒有找到,就繼續在父類Model中查詢metaclass,找到了,就使用Model中定義的metaclassModelMetaclass來建立User類,也就是說,metaclass可以隱式地繼承到子類,但子類自己卻感覺不到。

ModelMetaclass中,一共做了幾件事情:

  1. 排除掉對Model類的修改;
  2. 在當前類(比如User)中查詢定義的類的所有屬性,如果找到一個Field屬性,就把它儲存到一個__mappings__的dict中,同時從類屬性中刪除該Field屬性,否則,容易造成執行時錯誤(例項的屬性會遮蓋類的同名屬性);
  3. 把表名儲存到__table__中,這裡簡化為表名預設為類名。

Model類中,就可以定義各種操作資料庫的方法,比如save()delete()find()update等等。

我們實現了save()方法,把一個例項儲存到資料庫中。因為有表名,屬性到欄位的對映和屬性值的集合,就可以構造出INSERT語句。

編寫程式碼試試:

u=User(id=12345,name='Michael',email='[email protected]',password='my-pwd')
u.save()

輸出如下:

Foundmodel:User
Foundmapping:email==>
Foundmapping:password==>
Foundmapping:id==>
Foundmapping:name==>
SQL:insertintoUser(password,email,username,id)values(?,?,?,?)
ARGS:['my-pwd','[email protected]','Michael',12345]

可以看到,save()方法已經打印出了可執行的SQL語句,以及引數列表,只需要真正連線到資料庫,執行該SQL語句,就可以完成真正的功能。

不到100行程式碼,我們就通過metaclass實現了一個精簡的ORM框架,是不是非常簡單?

19929ca05008e9eef6dbc1f7b31d7162.png
image-20200817100522200

小結:

metaclass是Python中非常具有魔術性的物件,它可以改變類建立時的行為。這種強大的功能使用起來務必小心。

7.7 參考資料

  • [廖雪峰 - Python 3.x - 面向物件高階程式設計](

Reference

[1]

Python的官方文件: http://docs.python.org/3/reference/datamodel.html#special-method-names