Python 類屬性:過於詳盡的指南

已發表: 2022-03-11

我最近進行了一次編程面試,在電話屏幕上我們使用了協作文本編輯器。

我被要求實現某個 API,並選擇在 Python 中實現。 抽像出問題陳述,假設我需要一個類,其實例存儲一些data和一些other_data

我深吸一口氣,開始打字。 幾行之後,我有這樣的事情:

 class Service(object): data = [] def __init__(self, other_data): self.other_data = other_data ...

我的面試官阻止了我:

  • 採訪者:“那一行: data = [] 。 我認為那不是有效的 Python?”
  • 我:“我很確定它是。 它只是為實例屬性設置一個默認值。”
  • 採訪者:“那段代碼什麼時候被執行?”
  • 我:“我不確定。 我會修復它以避免混淆。”

作為參考,並讓您了解我的目標,以下是我修改代碼的方式:

 class Service(object): def __init__(self, other_data): self.data = [] self.other_data = other_data ...

事實證明,我們都錯了。 真正的答案在於理解 Python 類屬性和 Python 實例屬性之間的區別。

Python 類屬性與 Python 實例屬性

注意:如果您對類屬性有專家處理,您可以直接跳到用例。

Python 類屬性

我的面試官錯了,因為上面的代碼語法上是有效的。

我也錯了,因為它沒有為實例屬性設置“默認值”。 相反,它將data定義為具有值[]屬性。

根據我的經驗,Python 類屬性是一個很多人都知道的話題,但很少有人能完全理解。

Python 類變量與實例變量:有什麼區別?

Python 類屬性是類的屬性(我知道是圓形的),而不是類實例的屬性。

讓我們使用一個 Python 類示例來說明差異。 這裡, class_var是類屬性, i_var是實例屬性:

 class MyClass(object): class_var = 1 def __init__(self, i_var): self.i_var = i_var

請注意,該類的所有實例都可以訪問class_var ,並且它也可以作為類本身的屬性進行訪問:

 foo = MyClass(2) bar = MyClass(3) foo.class_var, foo.i_var ## 1, 2 bar.class_var, bar.i_var ## 1, 3 MyClass.class_var ## <— This is key ## 1

對於 Java 或 C++ 程序員,類屬性與靜態成員相似但不完全相同。 我們稍後會看到它們有何不同。

類與實例命名空間

為了理解這裡發生了什麼,讓我們簡單談談Python 命名空間

命名空間是從名稱到對象的映射,其特性是不同命名空間中的名稱之間的關係為零。 它們通常被實現為 Python 字典,儘管這是抽像出來的。

根據上下文,您可能需要使用點語法(例如, object.name_from_objects_namespace )或作為局部變量(例如, object_from_namespace )來訪問命名空間。 作為一個具體的例子:

 class MyClass(object): ## No need for dot syntax class_var = 1 def __init__(self, i_var): self.i_var = i_var ## Need dot syntax as we've left scope of class namespace MyClass.class_var ## 1

Python 類類的實例都有各自不同的命名空間,分別由預定義屬性MyClass.__dict__instance_of_MyClass.__dict__表示。

當您嘗試從類的實例訪問屬性時,它首先查看其實例名稱空間。 如果找到該屬性,則返回關聯的值。 如果沒有,它命名空間中查找並返回屬性(如果存在,則拋出錯誤)。 例如:

 foo = MyClass(2) ## Finds i_var in foo's instance namespace foo.i_var ## 2 ## Doesn't find class_var in instance namespace… ## So look's in class namespace (MyClass.__dict__) foo.class_var ## 1

實例命名空間優先於類命名空間:如果兩者中存在同名的屬性,則將首先檢查實例命名空間並返回其值。 這是用於屬性查找的代碼(源代碼)的簡化版本:

 def instlookup(inst, name): ## simplified algorithm... if inst.__dict__.has_key(name): return inst.__dict__[name] else: return inst.__class__.__dict__[name]

並且,以視覺形式:

視覺形式的屬性查找

類屬性如何處理分配

考慮到這一點,我們可以理解 Python 類屬性如何處理賦值:

  • 如果通過訪問類來設置類屬性,它將覆蓋所有實例的值。 例如:

     foo = MyClass(2) foo.class_var ## 1 MyClass.class_var = 2 foo.class_var ## 2

    在命名空間級別……我們設置MyClass.__dict__['class_var'] = 2 。 (注意:這不是確切的代碼(這將是setattr(MyClass, 'class_var', 2) ),因為__dict__返回一個 dictproxy,一個防止直接分配的不可變包裝器,但它有助於演示)。 然後,當我們訪問foo.class_var時, class_var在類命名空間中有一個新值,因此返回 2。

  • 如果通過訪問實例設置 Paython 類變量,它將僅覆蓋該實例​​的值。 這實質上覆蓋了類變量並將其轉換為一個實例變量,直觀地,僅適用於該實例。 例如:

     foo = MyClass(2) foo.class_var ## 1 foo.class_var = 2 foo.class_var ## 2 MyClass.class_var ## 1

    在命名空間級別……我們將class_var屬性添加到foo.__dict__ ,因此當我們查找foo.class_var時,我們返回 2。同時, MyClass的其他實例在它們的實例命名空間中不會class_var ,因此它們會繼續查找class_varMyClass.__dict__ ,因此返回 1。

可變性

測驗問題:如果您的類屬性具有可變類型怎麼辦? 您可以通過特定實例訪問類屬性來操縱(破壞?)類屬性,進而最終操縱所有實例正在訪問的引用對象(正如 Timothy Wiseman 所指出的那樣)。

這可以通過例子得到最好的證明。 讓我們回到我之前定義的Service ,看看我對類變量的使用是如何導致問題的。

 class Service(object): data = [] def __init__(self, other_data): self.other_data = other_data ...

我的目標是讓空列表 ( [] ) 作為data的默認值,並且每個Service實例都有自己的數據,這些數據會隨著時間的推移逐個實例地更改。 但是在這種情況下,我們會得到以下行為(回想一下Service接受一些參數other_data ,在這個例子中是任意的):

 s1 = Service(['a', 'b']) s2 = Service(['c', 'd']) s1.data.append(1) s1.data ## [1] s2.data ## [1] s2.data.append(2) s1.data ## [1, 2] s2.data ## [1, 2]

這不好——通過一個實例更改類變量會更改所有其他實例!

在命名空間級別……所有Service實例都在訪問和修改Service.__dict__中的同一個列表,而沒有在它們的實例命名空間中創建自己的data屬性。

我們可以使用賦值來解決這個問題; 也就是說,我們可以分配我們的Service對象擁有自己的列表,而不是利用列表的可變性,如下所示:

 s1 = Service(['a', 'b']) s2 = Service(['c', 'd']) s1.data = [1] s2.data = [2] s1.data ## [1] s2.data ## [2]

在這種情況下,我們添加了s1.__dict__['data'] = [1] ,所以原來的Service.__dict__['data']保持不變。

不幸的是,這需要Service用戶對其變量有深入的了解,並且肯定容易出錯。 從某種意義上說,我們將解決症狀而不是原因。 我們更喜歡構造正確的東西。

我個人的解決方案:如果您只是使用類變量為可能的 Python 實例變量分配默認值,請不要使用 mutable values 。 在這種情況下,每個Service實例最終都會用自己的實例屬性覆蓋Service.data ,因此使用空列表作為默認值會導致一個容易被忽略的小錯誤。 除了上述內容,我們還可以:

  1. 完全堅持實例屬性,如介紹中所示。
  2. 避免使用空列表(可變值)作為我們的“默認值”:

     class Service(object): data = None def __init__(self, other_data): self.other_data = other_data ...

    當然,我們必須適當地處理None情況,但這是一個很小的代價。

那麼什麼時候應該使用 Python 類屬性呢?

類屬性很棘手,但讓我們看一下它們會派上用場的幾種情況:

  1. 存儲常量。 由於類屬性可以作為類本身的屬性來訪問,因此使用它們來存儲類範圍的、特定於類的常量通常很好。 例如:

     class Circle(object): pi = 3.14159 def __init__(self, radius): self.radius = radius def area(self): return Circle.pi * self.radius * self.radius Circle.pi ## 3.14159 c = Circle(10) c.pi ## 3.14159 c.area() ## 314.159
  2. 定義默認值。 作為一個簡單的例子,我們可以創建一個有界列表(即,一個只能包含一定數量或更少元素的列表)並選擇默認上限為 10 個項目:

     class MyClass(object): limit = 10 def __init__(self): self.data = [] def item(self, i): return self.data[i] def add(self, e): if len(self.data) >= self.limit: raise Exception("Too many elements") self.data.append(e) MyClass.limit ## 10

    然後,我們也可以通過分配給實例的limit屬性來創建具有自己特定限制的實例。

     foo = MyClass() foo.limit = 50 ## foo can now hold 50 elements—other instances can hold 10

    僅當您希望MyClass的典型實例僅包含 10 個或更少元素時,這才有意義——如果您為所有實例提供不同的限制,那麼limit應該是一個實例變量。 (但請記住:使用可變值作為默認值時要小心。)

  3. 跟踪給定類的所有實例的所有數據。 這有點具體,但我可以看到一個場景,您可能希望訪問與給定類的每個現有實例相關的一段數據。

    為了使場景更具體,假設我們有一個Person類,每個人都有一個name 。 我們希望跟踪所有已使用的名稱。 一種方法可能是遍歷垃圾收集器的對象列表,但使用類變量更簡單。

    請注意,在這種情況下, names將僅作為類變量訪問,因此可變默認值是可以接受的。

     class Person(object): all_names = [] def __init__(self, name): self.name = name Person.all_names.append(name) joe = Person('Joe') bob = Person('Bob') print Person.all_names ## ['Joe', 'Bob']

    我們甚至可以使用這種設計模式來跟踪給定類的所有現有實例,而不僅僅是一些相關的數據。

     class Person(object): all_people = [] def __init__(self, name): self.name = name Person.all_people.append(self) joe = Person('Joe') bob = Person('Bob') print Person.all_people ## [<__main__.Person object at 0x10e428c50>, <__main__.Person object at 0x10e428c90>]
  4. 性能(有點……見下文)。

相關: Toptal 開發人員的 Python 最佳實踐和技巧

引擎蓋下

注意:如果你擔心這個級別的性能,你可能不想一開始就使用 Python,因為差異將在十分之一毫秒的數量級上——但稍微探索一下仍然很有趣,並有助於說明。

回想一下,類的命名空間是在定義類時創建和填充的。 這意味著我們只對給定的類變量進行一次賦值,而每次創建新實例時都必須為實例變量賦值。 讓我們舉個例子。

 def called_class(): print "Class assignment" return 2 class Bar(object): y = called_class() def __init__(self, x): self.x = x ## "Class assignment" def called_instance(): print "Instance assignment" return 2 class Foo(object): def __init__(self, x): self.y = called_instance() self.x = x Bar(1) Bar(2) Foo(1) ## "Instance assignment" Foo(2) ## "Instance assignment"

我們只分配給Bar.y一次,但在每次調用__init__時都分配給instance_of_Foo.y

作為進一步的證據,讓我們使用 Python 反彙編程序:

 import dis class Bar(object): y = 2 def __init__(self, x): self.x = x class Foo(object): def __init__(self, x): self.y = 2 self.x = x dis.dis(Bar) ## Disassembly of __init__: ## 7 0 LOAD_FAST 1 (x) ## 3 LOAD_FAST 0 (self) ## 6 STORE_ATTR 0 (x) ## 9 LOAD_CONST 0 (None) ## 12 RETURN_VALUE dis.dis(Foo) ## Disassembly of __init__: ## 11 0 LOAD_CONST 1 (2) ## 3 LOAD_FAST 0 (self) ## 6 STORE_ATTR 0 (y) ## 12 9 LOAD_FAST 1 (x) ## 12 LOAD_FAST 0 (self) ## 15 STORE_ATTR 1 (x) ## 18 LOAD_CONST 0 (None) ## 21 RETURN_VALUE

當我們查看字節碼時,很明顯Foo.__init__必須做兩次賦值,而Bar.__init__只做一次。

在實踐中,這種增益究竟是什麼樣的? 我將首先承認時序測試高度依賴於通常不可控的因素,並且它們之間的差異通常很難準確解釋。

但是,我認為這些小片段(與 Python timeit 模塊一起運行)有助於說明類變量和實例變量之間的差異,因此無論如何我都將它們包括在內。

注意:我使用的是裝有 OS X 10.8.5 和 Python 2.7.2 的 MacBook Pro。

初始化

10000000 calls to `Bar(2)`: 4.940s 10000000 calls to `Foo(2)`: 6.043s

Bar的初始化速度快了一秒多,所以這裡的差異似乎在統計上是顯著的。

那麼為什麼會這樣呢? 一種推測性的解釋:我們在Foo.__init__中做了兩個賦值,但在Bar.__init__中只做了一個。

任務

10000000 calls to `Bar(2).y = 15`: 6.232s 10000000 calls to `Foo(2).y = 15`: 6.855s 10000000 `Bar` assignments: 6.232s - 4.940s = 1.292s 10000000 `Foo` assignments: 6.855s - 6.043s = 0.812s

注意:沒有辦法在每次試驗中使用 timeit 重新運行您的設置代碼,因此我們必須在試驗中重新初始化我們的變量。 第二行時間代表上述時間減去之前計算的初始化時間。

從上面看, Foo處理分配的時間似乎只需要Bar的 60% 左右。

為什麼會這樣? 一種推測性的解釋:當我們分配給Bar(2).y時,我們首先查看實例命名空間( Bar(2).__dict__[y] ),找不到y ,然後查看類命名空間( Bar.__dict__[y] ),然後進行正確的分配。 當我們分配給Foo(2).y時,我們執行的查找次數是立即分配給實例命名空間( Foo(2).__dict__[y] )的一半。

總而言之,儘管這些性能提昇在現實中並不重要,但這些測試在概念層面上很有趣。 如果有的話,我希望這些差異有助於說明類和實例變量之間的機械區別。

綜上所述

類屬性在 Python 中似乎沒有得到充分利用; 許多程序員對他們的工作方式以及為什麼他們可能會有所幫助有不同的印象。

我的看法:Python 類變量在優秀代碼學院中佔有一席之地。 小心使用時,它們可以簡化事情並提高可讀性。 但是,當不小心被扔進給定的班級時,他們肯定會絆倒你。

附錄:私有實例變量

我想包括但沒有自然入口點的一件事……

Python 沒有私有變量,可以這麼說,但是類和實例命名之間的另一個有趣的關係是名稱修飾。

在 Python 風格指南中,據說偽私有變量應該以雙下劃線為前綴:'__'。 這不僅向其他人表明您的變量應該被私下處理,而且也是一種防止訪問它的方法。 這就是我的意思:

 class Bar(object): def __init__(self): self.__zap = 1 a = Bar() a.__zap ## Traceback (most recent call last): ## File "<stdin>", line 1, in <module> ## AttributeError: 'Bar' object has no attribute '__baz' ## Hmm. So what's in the namespace? a.__dict__ {'_Bar__zap': 1} a._Bar__zap ## 1

看一下:實例屬性__zap自動以類名作為前綴以產生_Bar__zap

雖然仍然可以使用a._Bar__zap和獲取,但此名稱修飾是一種創建“私有”變量的方法,因為它可以防止您其他人意外或無知訪問它。

編輯:正如 Pedro Werneck 親切地指出的那樣,這種行為主要是為了幫助子類化。 在 PEP 8 風格指南中,他們認為它有兩個目的:(1) 防止子類訪問某些屬性,以及 (2) 防止這些子類中的命名空間衝突。 雖然有用,但變量修飾不應被視為邀請編寫具有假定公私區別的代碼,例如 Java 中存在的。

相關:變得更高級:避免 Python 程序員犯的 10 個最常見錯誤