跟著官網學Python(10):類更多知識
"深入瞭解面向物件程式設計。"
01 面臨問題
繼續跟著官網學Python,第9章類。
類(Class)是很多面向物件程式語言中的一個關鍵概念,實現資訊封裝的基礎,類是一種使用者定義的引用資料型別,每個類包含資料說明和一組操作資料或傳遞訊息的函式,類的例項稱為物件。
上篇我們學習了名稱空間和作用域,瞭解名稱和物件,初探類學習了定義,非常簡單,當定義了一個類物件後,我們能幹嘛?
下面繼續。
02 怎麼辦
初探類
2) 類物件
類物件支援兩種操作:屬性引用和例項化。
屬性引用使用Python中所有屬性引用所使用的標準語法:obj.name
有效的屬性名稱是類物件被建立時存在於類名稱空間中的所有名稱,也包括函式名稱。
其實Python萬物皆物件,都可以合法的使用
obj.name
,只要name
是物件obj
名稱空間的合法名稱,比如上面scope_test
函式也可以.name
。那麼如何看一個物件(包括類)的名稱空間中的所有名稱呢?試試
dir(obj)
。如下定義測試類:
class MyClass: """A simple example class""" i = 12345 def f(self): return 'hello world'
MyClass.i
和MyClass.f
就是有效的屬性引用,將分別返回一個整數和一個函式物件。
類屬性也可以被賦值,因此可以通過賦值來更改MyClass.i
的值。__doc__
也是一個有效的屬性,將返回所屬類的文件字串: "A simple example class"。
好像其他語言的類只有被例項化後才能操作?
例項化使用函式表示法,可以把類物件視為是返回該類的一個新例項的不帶引數的函式。 如上述類x = MyClass()
會建立類的新例項並將此物件分配給區域性變數x
。
例項化操作(“呼叫”類物件)會建立一個空物件。 許多類喜歡建立帶有特定初始狀態的自定義例項。
為此類定義可能包含一個名為__init__()
def __init__(self):
self.data = []
當一個類定義了__init__()
方法時,類的例項化操作會自動為新建立的類例項發起呼叫__init__()
。
當然,__init__()
方法還可以有額外引數以實現更高靈活性。 在這種情況下,提供給類例項化運算子的引數將被傳遞給__init__()
。 如:
>>>
>>> class Complex:
... def __init__(self, realpart, imagpart):
... self.r = realpart
... self.i = imagpart
...
>>> x = Complex(3.0, -4.5)
>>> x.r, x.i
(3.0, -4.5)
注意類內部方法第一個引數都是self
,代表這個類自身,然後下面的賦值就顯而易見。
上面定義了一個複數類,__init()__
初始化函式接受兩個引數實部和虛部,然後分別賦值為類的r
和i
屬性(發現不用提前定義)。
然後初始化一個複數類例項Complex(3.0, -4.5)
賦值給x
,發現就是我們想要的複數,當然你可以繼續新增其他方法完善這個複數類。
3) 例項物件
例項物件做什麼? 例項物件理解的唯一操作是屬性引用。 有兩種有效的屬性名稱:資料屬性和方法。
資料屬性不需要宣告;像區域性變數一樣,它們將在第一次被賦值時產生。 例如,如果x
是上面建立的MyClass
的例項,則以下程式碼段將列印數值16
,且不保留任何追蹤資訊:
x.counter = 1
while x.counter < 10:
x.counter = x.counter * 2
print(x.counter)
del x.counter
就是類的資料屬性可以被修改和刪除。
另一類例項屬性引用稱為方法。 方法是“從屬於”物件(類例項)的函式。
在 Python 中,方法這個術語並不是類例項所特有的:其他物件也可以有方法。 例如,列表物件具有 append, insert, remove, sort 等方法。 然而,在以下討論中,我們使用方法一詞將專指類例項物件的方法,除非另外顯式地說明。
例項物件的有效方法名稱依賴於其所屬的類。 根據定義,一個類中所有是函式物件的屬性都是定義了其例項的相應方法。 因此在我們的示例中,x.f
是有效的方法引用,因為MyClass.f
是一個函式,而x.i
不是方法,因為MyClass.i
不是一個函式。 但是x.f
與MyClass.f
並不是一回事 --- 它是一個方法物件,不是函式物件。
有點繞,我不覺得這個概念暫時不理解不影響,反之知道例項.函式可以正確工作就好。
4) 方法物件
通常,方法在繫結後立即被呼叫x.f()
,在MyClass
示例中,這將返回字串'hello world'
。 但是,立即呼叫一個方法並不是必須的:x.f
是一個方法物件,它可以被儲存起來以後再呼叫。 例如:
xf = x.f
while True:
print(xf())
將一直列印Hello world
。
當一個方法被呼叫時到底發生了什麼?
你可能已經注意到上面呼叫x.f()
時並沒有帶引數,雖然f()
的函式定義指定了一個引數self
。
這個引數發生了什麼事? 當不帶引數地呼叫一個需要引數的函式時 Python 肯定會引發異常即使引數實際未被使用。
方法的特殊之處就在於例項物件會作為函式的第一個引數被傳入,也就是self
,這就是方法和函式的區別?應該是了。
在我們的示例中,呼叫x.f()
其實就相當於MyClass.f(x)
。
總之,呼叫一個具有n
個引數的方法就相當於呼叫再多一個引數的對應函式,這個引數值為方法所屬例項物件,位置在其他引數之前。
當然方法物件簡單直接很多,關係需要理解函式的self引數代表自身即可。
如果你仍然無法理解方法的運作原理,那麼檢視實現細節可能會澄清問題。 當一個例項的非資料屬性被引用時,將搜尋例項所屬的類。
如果名稱表示一個屬於函式物件的有效類屬性,會通過合併打包(指向)例項物件和函式物件到一個抽象物件中的方式來建立一個方法物件:這個抽象物件就是方法物件。
當附帶引數列表呼叫方法物件時,將基於例項物件和引數列表構建一個新的引數列表,並使用這個新引數列表呼叫相應的函式物件。
覺得有點繞,反正記住類例項.方法少傳一個引數就可。
5) 類變數和例項變數
例項變數用於每個例項的唯一資料,而類變數用於類的所有例項共享的屬性和方法,比如下面的狗類定義和測試:
class Dog:
kind = 'canine' #類變數,所有例項共享
def __init__(self, name):
self.name = name # 歸屬例項的唯一變數,或者說每個例項不一樣
>>> d = Dog('Fido')
>>> e = Dog('Buddy')
>>> d.kind # shared by all dogs
'canine'
>>> e.kind # shared by all dogs
'canine'
>>> d.name # unique to d
'Fido'
>>> e.name # unique to e
'Buddy'
然後建立兩個狗例項d和e,他們是相同的品種,但是有不同的名字。
前面說過,共享資料可能在涉及可變(mutable) 物件例如列表和字典的時候導致令人驚訝的結果。
如以下程式碼中的tricks
列表不應該被用作類變數,因為所有的 Dog 例項將只共享一個單獨的列表:
class Dog:
tricks = [] # mistaken use of a class variable
def __init__(self, name):
self.name = name
def add_trick(self, trick):
self.tricks.append(trick)
>>> d = Dog('Fido')
>>> e = Dog('Buddy')
>>> d.add_trick('roll over')
>>> e.add_trick('play dead')
>>> d.tricks # unexpectedly shared by all dogs
['roll over', 'play dead']
由於錯誤使用了類變數,導致例項e修改tricks
,另一個例項d也被修改,這是不符合預期的。
正確的類設計應該使用例項變數:
class Dog:
def __init__(self, name):
self.name = name
self.tricks = [] # creates a new empty list for each dog
def add_trick(self, trick):
self.tricks.append(trick)
>>> d = Dog('Fido')
>>> e = Dog('Buddy')
>>> d.add_trick('roll over')
>>> e.add_trick('play dead')
>>> d.tricks
['roll over']
>>> e.tricks
['play dead']
補充說明
資料屬性會覆蓋掉具有相同名稱的方法屬性。
為了避免會在大型程式中導致難以發現的錯誤的意外名稱衝突,明智的做法是使用某種約定來最小化衝突的發生機率。其實也算編碼規範的一種。
可能的約定包括方法名稱使用大寫字母,屬性名稱加上獨特的短字串字首(或許只加一個下劃線),或者是用動詞來命名方法,而用名詞來命名資料屬性。
資料屬性可以被方法以及一個物件的普通使用者(“客戶端”)所引用。
換句話說,類不能用於實現純抽象資料型別。
實際上,在 Python 中沒有任何東西能強制隱藏資料--- 它是完全基於約定的。
客戶端應當謹慎地使用資料屬性 --- 客戶端可能通過直接操作資料屬性的方式破壞由方法所維護的固定變數。
請注意客戶端可以向一個例項物件新增他們自己的資料屬性而不會影響方法的可用性,只要保證避免名稱衝突。
再次提醒,在此使用命名約定可以省去許多令人頭痛的麻煩。
在方法內部引用資料屬性(或其他方法!)並沒有簡便方式(就是需要用self),實際上提升了方法的可讀性:當瀏覽一個方法程式碼時,不會存在混淆區域性變數和例項變數的機會。
方法的第一個引數常常被命名為self
,這也不過就是一個約定: self 這一名稱在Python 中絕對沒有特殊含義。 但是要注意,不遵循此約定會使得你的程式碼對其他 Python 程式設計師來說缺乏可讀性。
任何一個作為類屬性的函式都為該類的例項定義了一個相應方法。
我理解就算只要為了class縮排關鍵字內的函式,類例項都可以作為方法屬性呼叫,不存在什麼私有函式啥的。
函式定義的文字並非必須包含於類定義之內:將一個函式物件賦值給一個區域性變數也是可以的,如:
# Function defined outside the class
def f1(self, x, y):
return min(x, x+y)
class C:
f = f1
def g(self):
return 'hello world'
h = g
現在f
,g
和h
都是C
類的引用函式物件的屬性,因而它們就都是C
的例項的方法 --- 其中h
完全等同於g
。
這只是示例,實際不建議使用,老老實實的按照標準語法吧。
方法(就是def語句那個,就是函式,只是有self引數,在class內部,故稱之為方法)可以通過使用self
引數的方法屬性呼叫其他方法:
class Bag:
def __init__(self):
self.data = []
def add(self, x):
self.data.append(x)
def addtwice(self, x):
self.add(x)
self.add(x)
方法可以通過與普通函式相同的方式引用全域性名稱。 與方法相關聯的全域性作用域就是包含其定義的模組。 (類永遠不會被作為全域性作用域。)
雖然我們很少會有充分的理由在方法中使用全域性作用域,但全域性作用域存在許多合法的使用場景:舉個例子,匯入到全域性作用域的函式和模組可以被方法所使用,在其中定義的函式和類也一樣。
通常,包含該方法的類本身是在全域性作用域中定義的,而在下一節中我們將會發現為何方法需要引用其所屬類的很好的理由。
每個值都是一個物件,因此具有類(也稱為 型別),並存儲為object.__class__
。
__class__屬性測試
難道1不是作為內建的int
型別,有啥特別的?
繼承
如果不支援繼承,語言特性就不值得稱為“類”。派生類(繼承)定義的語法如下所示:
class DerivedClassName(BaseClassName):
<statement-1>
……
<statement-N>
和之前不一樣的地方,類定義()中多了一個父類名稱,名稱BaseClassName
必須定義於包含派生類定義的作用域中,也允許用其他任意表達式代替基類名稱所在的位置,如其他模組中的基類:class DerivedClassName(modname.BaseClassName):
派生類定義的執行過程與基類相同。 當構造類物件時,基類會被記住。 此資訊將被用來解析屬性引用:如果請求的屬性在類中找不到,搜尋將轉往基類中進行查詢。 如果基類本身也派生自其他某個類,則此規則將被遞迴地應用。
也就是先找自身,再找父類,這就是覆蓋的基礎。
派生類的例項化沒有任何特殊之處:DerivedClassName()
會建立該類的一個新例項。
方法引用將按以下方式解析:搜尋相應的類屬性,如有必要將按基類繼承鏈逐步向下查詢,如果產生了一個函式物件則方法引用就生效。
其實就是上面說的。
派生類可能會過載其基類的方法。 因為方法在呼叫同一物件的其他方法時沒有特殊許可權,呼叫同一基類中定義的另一方法的基類方法最終可能會呼叫覆蓋它的派生類的方法。
這句話暫時理解不深,也許以後遇到問題才能知道,猜測派生類的同名方法使用要小心。
在派生類中的過載方法實際上可能想要擴充套件而非簡單地替換同名的基類方法。
有一種方式可以簡單地直接呼叫基類方法:即呼叫BaseClassName.methodname(self, arguments)
。 有時這對客戶端來說也是有用的。 (請注意僅當此基類可在全域性作用域中以 BaseClassName 的名稱被訪問時方可使用此方式。)
Python有兩個內建函式可被用於繼承機制:
-
使用
isinstance()
來檢查一個例項的型別:isinstance(obj, int)
僅會在obj.__class__
為int
或某個派生自int
的類時為True
。 -
使用
issubclass()
來檢查類的繼承關係:issubclass(bool, int)
為True
,因為bool
是int
的子類。 但是,issubclass(float, int)
為False
,因為float
不是int
的子類。
多重繼承
Python支援多重繼承,如:
class DerivedClassName(Base1, Base2, Base3):
<statement-1>
……
<statement-N>
對於多數應用來說,在最簡單的情況下,你可以認為搜尋從父類所繼承屬性的操作是深度優先、從左至右的,當層次結構中存在重疊時不會在同一個類中搜索兩次。 因此,如果某一屬性在DerivedClassName
中未找到,則會到Base1
中搜索它,然後(遞迴地)到Base1
的基類中搜索,如果在那裡未找到,再到Base2
中搜索,依此類推。
真實情況比這個更復雜一些;方法解析順序會動態改變以支援對super()
的協同呼叫。
動態改變順序是有必要的,因為所有多重繼承的情況都會顯示出一個或更多的菱形關聯(即至少有一個父類可通過多條路徑被最底層類所訪問)。
例如,所有類都是繼承自object
,因此任何多重繼承的情況都提供了一條以上的路徑可以通向object
。
為了確保基類不會被訪問一次以上,動態演算法會用一種特殊方式將搜尋順序線性化, 保留每個類所指定的從左至右的順序,只調用每個父類一次,並且保持單調(即一個類可以被子類化而不影響其父類的優先順序)。 總而言之,這些特性使得設計具有多重繼承的可靠且可擴充套件的類成為可能。 要了解更多細節,請參閱https://www.python.org/download/releases/2.3/mro/。(留待以後再看)
私有變數
那種僅限從一個物件內部訪問的“私有”例項變數在 Python 中並不存在。
但是,大多數 Python 程式碼都遵循這樣一個約定:帶有一個下劃線的名稱(例如 _spam) 應該被當作是API 的非公有部分(無論它是函式、方法或是資料成員)。
這應當被視為一個實現細節,可能不經通知即加以改變。
由於存在對於類私有成員的有效使用場景(例如避免名稱與子類所定義的名稱相沖突),因此存在對此種機制的有限支援,稱為名稱改寫。
任何形式為__spam
的識別符號(至少帶有兩個字首下劃線,至多一個字尾下劃線)的文字將被替換為_classname__spam
,其中classname
是去除了字首下劃線的當前類名稱。
這種改寫不考慮識別符號的句法位置,只要它出現在類定義內部就會進行。
名稱改寫有助於讓子類過載方法而不破壞類內方法呼叫。例如:
class Mapping:
def __init__(self, iterable):
self.items_list = []
self.__update(iterable)
def update(self, iterable):
for item in iterable:
self.items_list.append(item)
__update = update # private copy of original update() method
class MappingSubclass(Mapping):
def update(self, keys, values):
# provides new signature for update()
# but does not break __init__()
for item in zip(keys, values):
self.items_list.append(item)
上面的示例即使在MappingSubclass
引入了一個__update
識別符號的情況下也不會出錯,因為它會在Mapping
類中被替換為_Mapping__update
而在MappingSubclass
類中被替換為_MappingSubclass__update
。
請注意,改寫規則的設計主要是為了避免意外衝突;訪問或修改被視為私有的變數仍然是可能的。
這在特殊情況下甚至會很有用,例如在偵錯程式中。
請注意傳遞給exec()
或eval()
的程式碼不會將發起呼叫類的類名視作當前類;這類似於global
語句的效果,因此這種效果僅限於同時經過位元組碼編譯的程式碼。 同樣的限制也適用於getattr()
,setattr()
和delattr()
,以及對於__dict__
的直接引用。
說實話,以前沒怎麼實現過類,對上面很多概念理解不深,只是記錄再記錄,希望以後遇到問題能夠快速解決。
雜項說明
隱約記得C語言中有stuct
這樣的資料型別,將一些命名資料項捆綁在一起,適合定義一個空類:
class Employee:
pass
john = Employee() # Create an empty employee record
# Fill the fields of the record
john.name = 'John Doe'
john.dept = 'computer lab'
john.salary = 1000
感覺好方便,想要什麼屬性隨便加。
一段需要特定抽象資料型別的 Python 程式碼往往可以被傳入一個模擬了該資料型別的方法的類作為替代。
例如,如果你有一個基於檔案物件來格式化某些資料的函式,你可以定義一個帶有read()
和readline()
方法從字串快取獲取資料的類,並將其作為引數傳入。
例項方法物件也具有屬性:m.__self__
就是帶有m()
方法的例項物件,而m.__func__
則是該方法所對應的函式物件。
說實話,還是沒用過,不知道幹嘛的。
說實話雖然Python程式設計兩年,但是我居然沒怎麼用過類,正好這次從0開始看一次,後續有機會把部分程式碼封裝成Class。
迭代器
還記得for
語句嗎,配合in
關鍵字非常方便,很多Python容器(可以保持一組物件的空間,如list等)都支援for
語句。
for element in [1, 2, 3]:
print(element)
for element in (1, 2, 3):
print(element)
for key in {'one':1, 'two':2}:
print(key)
for char in "123":
print(char)
for line in open("myfile.txt"):
print(line, end='')
這種訪問風格清晰、簡潔又方便。
迭代器的使用非常普遍並使得 Python 成為一個統一的整體。
在幕後,for
語句會在容器物件上呼叫iter()
。 該函式返回一個定義了__next__()
方法的迭代器物件,此方法將逐一訪問容器中的元素。
當元素用盡時,__next__()
將引發StopIteration
異常來通知終止for
迴圈。 你可以使用next()
內建函式來呼叫__next__()
方法;這個例子顯示了它的運作方式:
>>> s = 'abc'
>>> it = iter(s)
>>> it
<iterator object at 0x00A1DB50>
>>> next(it)
'a'
>>> next(it)
'b'
>>> next(it)
'c'
>>> next(it)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
next(it)
StopIteration
看過迭代器協議的幕後機制,給你的類新增迭代器行為就很容易了。 定義一個__iter__()
方法來返回一個帶有__next__()
方法的物件。 如果類已定義了__next__()
,則__iter__()
可以簡單地返回self
:
class Reverse:
"""Iterator for looping over a sequence backwards."""
def __init__(self, data):
self.data = data
self.index = len(data)
def __iter__(self):
return self
def __next__(self):
if self.index == 0:
raise StopIteration
self.index = self.index - 1
return self.data[self.index]
實現了一個反轉類,如果可以嘗試傳入一個字串或者列表:
>>> rev = Reverse('spam')
>>> iter(rev)
<__main__.Reverse object at 0x00A1DB50>
>>> for char in rev:
... print(char)
...
m
a
p
s
生成器
Generator
是一個用於建立迭代器的簡單而強大的工具。 它們的寫法類似標準的函式,但當它們要返回資料時會使用yield
語句。 每次對生成器呼叫next()
時,它會從上次離開位置恢復執行(它會記住上次執行語句時的所有資料值)。 顯示如何非常容易地建立生成器的示例如下:
def reverse(data):
for index in range(len(data)-1, -1, -1):
yield data[index]
>>> for char in reverse('golf'):
... print(char)
...
f
l
o
g
可以用生成器來完成的操作同樣可以用前一節所描述的基於類的迭代器來完成。 但生成器的寫法更為緊湊,因為它會自動建立__iter__()
和__next__()
方法。
另一個關鍵特性在於區域性變數和執行狀態會在每次呼叫之間自動儲存。 這使得該函式相比使用self.index
和self.data
這種例項變數的方式更易編寫且更為清晰。
除了會自動建立方法和儲存程式狀態,當生成器終結時,它們還會自動引發StopIteration
。
這些特性結合在一起,使得建立迭代器能與編寫常規函式一樣容易。
生成器表示式
某些簡單的生成器可以寫成簡潔的表示式程式碼,所用語法類似列表推導式,將外層為圓括號而非方括號。
這種表示式被設計用於生成器將立即被外層函式所使用的情況。
生成器表示式相比完整的生成器更緊湊但較不靈活,相比等效的列表推導式則更為節省記憶體。示例:
>>> sum(i*i for i in range(10)) # 平方和
285
>>> xvec = [10, 20, 30]
>>> yvec = [7, 5, 3]
>>> sum(x*y for x,y in zip(xvec, yvec)) # 向量點積
260
>>> from math import pi, sin
>>> sine_table = {x: sin(x*pi/180) for x in range(0, 91)}
>>> unique_words = set(word for line in page for word in line.split())
>>> valedictorian = max((student.gpa, student.name) for student in graduates)
>>> data = 'golf'
>>> list(data[i] for i in range(len(data)-1, -1, -1))
['f', 'l', 'o', 'g']
03 為什麼
為什麼要這麼做?
首先任何東西的官方文件都是最全面最權威的教程。
以前只是受限於英語水平,
對官方網站敬而遠之,
遇到問題都百度,
很多答案講的都不到位,
沒有說明為什麼?
越到後面,收穫越大。
類、面向物件很重要,當然今天重點在類的關鍵知識,類物件、例項物件、方法物件、類變數和例項變數,補充說明的屬性覆蓋、方法self
引數,繼承的相關知識與私有變數,迭代器和生成器,只要實現__iter__()
和__next__()
方法即可。
收穫滿滿。
04 更好的選擇
有沒有更好的選擇
還是那句話,多敲程式碼,結合案例,偶爾看看官方原始碼,加深理解。
好像我們接觸的python物件,如int
、str
、list
內部都是類實現的,有能力可以看看原始碼,非常鍛鍊人。
我之前資料分析物件告警資訊,有屬性(欄位,如告警時間等),還有告警的一些操作,如標準化,其實可以改成類寫法,定義一個告警類Class Alarm
,然後設定一些屬性和方法。
一句話
面向物件是語言的靈魂,python萬物皆物件。類就是使用者自定義的一種物件,實現資料和功能的封裝,Python類物件、例項物件、方法物件需要了解,類變數和例項變數以及某種意義上的私有變數,繼承的方法,如所有的類都派生自object
類。迭代器和生成器語法可以讓類在操作上更為便利。