[Python] 自訂類別的參考準則

因為工作的關係, 目前我手上能用的語言只剩下 JavaScript 和 Python。JavaScript 還好, 因為反正已經寫很久了; Python 就比較頭痛。因為以前並沒有認真地學習 Python, 真的要用時才發現腦筋打結。最主要的困難點在於不時受到 C# 和 JavaScript 的干擾。只好心一橫, 暫時都不去碰它們, 否則怎樣都寫不好。

話說在前面, 我並沒有打算寫什麼 Python 的入門教學, 所以請讀者別抱怨說有什麼基本觀念我在這裡沒有提到。如果你是要來學「基本觀念」的, 可能要請你轉台到 Google 去囉!

Python 是直譯式語言 (Interpreted), 基本上你要 print 什麼它就會 print 什麼, 相當直觀。所以我們可以很精準地預期程式的輸出, 也很好除邏輯上的錯。但是它的執行效率就沒有 C# 來得好; 就我自己的感覺, 似乎和 JavaScript 差不多, 或者差一點點, 但是沒有差很多。

Python 最大的問題, 在於討論的熱度似乎比不上 Java、C# 或者 JavaScript 之類的主流語言 (尤其是進階的主題)。中文的資訊既少又粗糙 (相較於上述三種語言), 英文的也不夠豐富, 連 StackOverlow 都嫌不夠豐富。所以我覺得 Python 寫起來比較辛苦, 很多東西都只能自己邊試邊體會。其次, 目前的 Python 有 2 和 3 兩種版本, 網路上的資源很多都沒有清楚標示。基本上在 2015 年以後的資訊才有較多適用於 Phtyon 3。以下 (和以後) 我所提到的 Python 都指 Python 3。

鴨子型別

Python 使用動態型別, 而非強型別。在許多描述 Python 型別系別的文章裡都會說這個語言使用的是所謂的 Duck Typing。Duck Typing 的意思是:

If it talks and walks like a duck, then it is a duck. (如果它叫起來像鴨子、走路也像個鴨子, 那麼牠就是一隻鴨子)

換句話說, Python 和 C# 有個相當大的不同, 就是它有一些物件導向的功能, 但是並不需要事先宣告或明確地定義, 只要看起來像回事就可以了。在下面章節中會有更詳細的說明。

Python 和 C# 倒是有一相似之處; 它的型別就是類別。所以自訂型別 (Custom Type) 和自訂類別 (Custom Class) 指的是同一件事。不過, 它畢竟不像 C# 有極度嚴謹的型別系統。例如, Python 內建有 Iterable 這種所謂的「抽象型別」, 但是你並不用去繼承它 (collections.Iterable), 也可以實作 Iterable。這和 C# 相當地不同。

事實上, 我們只要在自訂類別中加入一個 __iter__() 方法, 就表示「實作」了 Iterable 介面了 (注意這裡的 I 並不是指 Interface)。你說, 這樣是不是充分發揮「如果它叫起來像鴨子、走路也像個鴨子, 那麼牠就是一隻鴨子」這句話的精神?

喔對了, 到底這個 Iterable 算是「抽象型別」、「抽象類別」還是「介面」呢? 在 Python 裡面似乎並沒有正式賦予這種定義或區隔, 都只是習慣性的稱呼。所以, 再一次, 領受一下「鴨子型別」的威力吧! 感嘆鴨子, 讚嘆鴨子! 

其次, 雖然說 Python 和 JavaScript 都是動態型別的語言, Python 解譯器在轉換型別的能力似乎比較弱 (這並不表示比較不好)。例如 1 + 'a' 這種語法在 Python 是行不通的。所以在寫程式時還是要盡量避免這種小陷阱。

自訂類別

Python 既然自稱為物件導向語言, 當然有這方面的特色。和 C# 一樣, 除了既有的型別, 我們也可以自己定義型別。如果你只想寫一些自動化的小程式, 那麼你可能沒有這方面的需要; 但是如果你要寫一些較複雜的商用程式, 應該就會用到自訂型別了。

基本上 Python 的類別和 C# 有點像; 定義之後, 可以使用 Class.Attribute 和 Class.Method() 的形式來存取或執行其屬性和方法。最基本的宣告方式如下:

class CustomClass():
    pass

定義完畢之後, 可以產生以 CustomClass 為型別的 instance:

cust = CustomClass()

如果我們使用 type(cust) 去檢驗其型別, 會得到 <class '__main__.CustomClass'>。

基本範例

以下我要建立一個比較接近現實的類別來作本文的範例。

假設我要做一個經銷商管理系統, 那麼我可能會建立一個叫做 Retailer 的型別, 而且我會使用這個 Retailer 型別把經銷商資料建立起來以方便管理。

經銷商會有什麼屬性呢? 例如證號 (Id)、區域 (Area)、商號 (Company)、負責人 (Owner)、地址 (Address) 和電話 (Phone) 等等, 現實生活中恐怕會有幾十個欄位以上。為避免失焦, 在此我們取幾個代表性的欄位就行了。

Python 有一個令人困擾的地方, 就是 Class 和 Module 並沒有很明確的區隔, 有時又被稱做 Namespace (可能是因為「看來都很像一隻鴨子」)。所以其實類別的屬性是可以隨時、隨便加上去的 (下面會有說明)。這對使用其它物件導向語言的人是不可思議的。我個人非常不推薦這種做法, 否則可能會產生除不完的錯。在以下(和以後)我在我的範例程式都不會採用那種做法。所有屬性和方法都只能在 Class 裡面清楚地定義, 沒有例外。當然你可以那麼做, 但不表示你一定要那麼做。

當我們建立好類別之後, 我們可以在建立 instance 時同時把屬性都建立起來。如此一來, 例如當我們從資料庫一次讀入經銷商資料時, 就可以一筆一筆把資料倒進來。要怎麼做呢?

請建立一個新檔, 取名為 Retailer.py。我們可以在這個類別中建立一個 __init__() 方法來做這件事, 例如:

# 程式 1
class Retailer():
        def __init__(self, Id=None, Area=None, Company=None):
                self.Id = Id
                self.Area = Area
                self.Company = Company

這個 __init__() 通常稱之為「建構式」(constructor)。

這麼一來, 我們就可以把這個 Retailer 當作型別來用並建立 instance 了, 例如:

r = Retailer(1,'台北市', '阿明號')

或者

r = Retailer(Id=1, Area='台北市', Company='阿明號')

不過, 感謝 Python 強大的可省略參數設定功能, 我們可以把這個類別再改良一下, 讓它直接接受單行文字作為初始值:

# 程式 2
class Retailer():
	def __init__(self, Id=None, Area=None, Company=None, LineIn=None, separator=','):
		if LineIn:
			arr = LineIn.split(separator)
			if len(arr) == 3:
				self.Id=arr[0]
				self.Area=arr[1]
				self.Company=arr[2]
		else:
			self.Id = Id
			self.Area = Area
			self.Company = Company;

如此一來, 我們也可以接受從文字檔案或其它來源讀取進來的單行文字來建立 instance:

r = Retailer(LineIn='1,台北市,阿明號')

如果你的資料列區隔字元不是逗號而是 TAB 字元, 就把程式改成:

r = Retailer(LineIn='1,台北市,阿明號', Separator='\t')

請注意, 我在以上範例裡並沒有做防呆檢查。在我真正的程式裡, 我會對每個欄位做基本的檢查, 例如 Id 是不是 None、是否符合經銷商編碼格式、參數順序有沒有可能出錯等等; 如果有任何問題, 會發出 exception 或者寫進 log 裡。如果資料量並不龐大的話, 至少會把出現錯誤的地方 print 出來。

下面我們會再回頭來談談防呆的問題。

簡單介面實作範例

Iterable 和 Iterator 是 Python 裡最普遍的兩個介面。但是對於本文中的 Retailer 類別而言, Iterable 與 Iterator 其實是沒有必要實作的 (因為它並不是集合型別); 但是因為它們很適合用來說明, 所以我純粹只是拿它們來當作例子。

在 Python 中如果要實作一個介面, 就像前面一再提到的, 只要看起來像就可以了。以 Iterable 介面來講, 只要在類別中有實作 __iter__() 方法即可; Iterator 則是實作 __next()__ 方法。但是有個前題, 就是必須在類別中加入兩個額外的屬性 (在這裡取名為 start 與 end; 但可以任取):

# 程式 3
class Retailer():
	def __init__(self, Id=None, Area=None, Company=None, LineIn=None, separator=',', start = 0, end = 0):
		if LineIn:
			arr = LineIn.split(separator)
			if len(arr) == 3:
				self.Id=arr[0]
				self.Area=arr[1]
				self.Company=arr[2]
		else:
			self.Id = Id
			self.Area = Area
			self.Company = Company;
        	# For iterator interface
        	self.current = start
        	self.end = end

    # For iterable interface
    def __iter__(self):
        return self;

    # For iterator interface
    def __next__(self):
        if self.current > self.end:
            raise StopIteration
        else:
            self.current += 1
            return self.current - 1;

如上所述, 我們這樣寫就算實作了 Iterable 和 Iterator 介面了, 不必在其它任何地方宣告什麼。

不過由於在非集合型別中實作這兩個介面實際上是沒有意義的, 所以程式3在後面不會再用到。

常用介面與常用功能

接著, 我們可以在類別中加入 __str__() 方法:

def __str__(self):
    return self.Id

如此一來, 我們就可以使用 str(r) 來將 r 這個 Retailer 物件轉換成字串。在這裡我只簡單地傳回 Id 屬性; 你可以使用更複雜的顯示方式。我個人在工作中則習慣傳回一道關於該經銷商的摘要資訊。

我們也可以實作 __repr__() 方法, 這樣我們就可以使用 repr(r) 來傳回物件的資訊。由於方法和 __str__() 一模一樣, 我就不列程式了。在範例中是傳回和 __str__() 相同的字串; 你可以自己選擇是否使用不同的字串。

有個很好用的 __contains__() 方法是可以考慮實作的:

# Used for .. in .. statement.
def __contains__(self, keyword):
    return  str(keyword).strip() in str(self.Id)

實作這個方法之後, 我們就可以使用像 '2019' in r 這樣的指令來檢核經銷商證號中是否包含 '2019' 這個文字片段。這是因為我們的經銷商證號有固定規則可循, 用這個方法就可以用來方便地找出來具有相同特色的經銷商。當然你不需要完全依照我的做法或邏輯, 你也可以檢核其它欄位。不過話說回來, 這個功能對集合型別的用處比較大。

此外, 我建議你另外實作 keys() 與 values() 這兩種 Dictionary 物件擁有的方法:

def keys(self):
	return [
		'Id',
		'Area',
		'Company']

def values(self):
	return [
		self.Id,
		self.Area,
		self.Company]

如此一來, 我們就可以輕鬆地提供型別物件轉換為 Dicttionary 物件的功能:

def ToDict(self):
	return dict(zip(self.keys(), self.values()));

雖然我們也可以使用既有的 __dict__ 屬性, 但在許多情況下那恐怕並不是你想要的。

既然寫了 ToDict() 方法, 我們自然也會想要順便來寫一個 FromDict() 方法:

def FromDict(d):
    if not isinstance(d, dict): # 防呆
        return None;
    dk = list(d.keys())
    dv = list(d.values())
    r = Retailer()
    for i in range(len(dk)):
        if dk[i] == 'Id':
            r.RetailerId = int(dv[i])
        if dk[i] == 'Area':
            r.Area = dv[i]
        if dk[i] == 'Company':
            r.Company = dv[i]
    return r

這樣一來, 我們就可以從一個 dict 物件產生出一個 Retailer 物件了:

import Retailer
d = {'Id': 10, 'Area': '台北', 'Company': '發大財'}
r = Retailer.FromDict(d)

既然寫了 ToDict() 和 FromDict(), 會不會想再寫 ToList() 和 FromList() 呢? 把範例程式稍為改一下就行了。但到底要不要實作, 你自己看著辦吧! 我就不舉例了。

提升效能之考慮

前面提到 Python 類別可以任意加入屬性, 這使得類別物件的空間配置方式(採用字典結構)是鬆散的。這種配置方式當然會造成記憶體的浪費; 一旦物件太多時, 系統的效能就會大打折扣。

幸好 Python 有考慮到這種問題, 所以提供了特殊屬性 __slots__ (其結構是 list)。你只需要在類別中加入這個值, 並且列明類別會使用到的所有屬性, 就完成了。

這麼做會讓你只能使用列表中的屬性。但是由於它侷限了類別的屬性數目, 系統在規畫記憶體時能夠更有效率, 結果就是程式的效能提高了, 佔用的記憶體變小了。而缺點之一, 就是你不能隨心所欲地增加類別實體的屬性。

但是如同我在前面提到的, 我們本來就不應該任意使用類別沒有定義的屬性! 所以對我來講, 那個「缺點」根本就不能說是「缺點」。應該是「優點」才對!

在加入 __slots__ 這個值以前, 我們可以隨便加入屬性:

>>> r=Retailer(Id=10, Area='屏東市', Company='豪大')
>>> r.Id
10
>>> r.Number=5
>>> 

現在請在程式中加入該值:

__slots__ = ['Id', 'Area', 'Company']

這樣使用者就不能亂訂屬性了:

>>> r=Retailer(Id=10, Area='屏東市', Company='豪大')
>>> r.Id
10
>>> r.Number=5
Traceback (most recent call last):
  File "<pyshell#108>", line 1, in <module>
    r.Number=5
AttributeError: 'Retailer' object has no attribute 'Number'

只需要簡單地加入一行指令, 程式佔用的記憶體最多可以減少到一半以上。如果你的程式動輒產生幾百萬個 instance, 差異就會非常地明顯。

不過, 不要高興得太早, 因為這個功能有另外一個缺點。一旦你定義了 __slots__ 值, 你的類別就失去了一些物件導向設計的支援能力, 例如多重繼承 (因為它是利用 Python 原有的動態配置能力來實作的)。所以, 魚與熊掌不可兼得, 看需要取捨吧!

本文到此為止完整的程式如下:

# 程式 4
class Retailer():
    __slots__ = ['Id', 'Area', 'Company']
    def __init__(self, Id=None, Area=None, Company=None, LineIn=None, separator=','):
        if LineIn:
            arr = LineIn.split(separator)
            if len(arr) == 3:
                self.Id=arr[0]
                self.Area=arr[1]
                self.Company=arr[2]
        else:
            self.Id = Id
            self.Area = Area
            self.Company = Company;

    # Used for str() function
    def __str__(self):
        return str(self.Id)
        
    # Used for repr() function
    def __repr__(self):
        return str(self.Id)

    # Used for .. in .. statement.
    def __contains__(self, keyword):
        return  str(keyword).strip() in str(self.Id)

    def keys(self):
        return [
            'Id',
            'Area',
            'Company']

    def values(self):
        return [
          self.Id,
          self.Area,
          self.Company
        ]

    def ToDict(self):
        return dict(zip(self.keys(), self.values()));
    
    def FromDict(d):
        if not isinstance(d, dict): # 防呆
            return None;
        dk = list(d.keys())
        dv = list(d.values())
        r = Retailer()
        for i in range(len(dk)):
            if dk[i] == 'Id':
                r.Id = int(dv[i])
            if dk[i] == 'Area':
                r.Area = dv[i]
            if dk[i] == 'Company':
                r.Company = dv[i]
        return r

不過在以下的範例中我們都不使用 slots。

Getter 與 Setter

我原本一直以為 Python 的類別是沒有 Getter 與 Setter 的, 後來才發現它真的有! 只不過, Python 的類別不管有沒有定義 Getter 與 Setter, 都可以正常運作; 這真是蠻神奇的一件事。

以 Id 屬性為例, 基本的 Getter/Setter 寫法如下:

# Getter/Setter for Id
@property
def Id(self):
    return self._Id

@Id.setter
def Id(self, value):
    self._Id = value

在 Python 中, 和 JavaScript 一樣, 「私有」(或稱 local) 變數通常都以加上一個前導底線來表示 (雖然這只是一種「慣例」; 它不是真的「私有」, 因為從外面還是可以存取得到)。在這裡 _Id 就代表 Id 屬性的一個「分身」。寫過 .Net 3.0 版本以前的程式的朋友應該也很熟悉這種寫法, 只是表達方式略有不同而已。

很可惜地, Getter/Setter 也和 __slots__ 衝突。兩種功能只能擇一使用。

為什麼要使用 Getter/Setter 呢? 最大的作用, 當然就是為了增強封裝的有效性。有了它們, 我們就能夠在類別內部做好輸出入值的把關動作。要把什麼關呢? 下面會再談到。

重點在於 @property 這個描述子。加上這個描述子, 等於在類別中正式定義了一個「屬性」(Property)。這個 @property 和後面的 @..setter 是一起作用的。

在本例中, Id、Area 和 Company 可以稱之為資料屬性。但我們也可以運用相同的方法來定義邏輯屬性, 其值是從資料屬性計算而來。例如, 我們可以另外建立一個叫做 BriefName 的屬性, 它是用程式把 Company 名稱裡所有「公司」或「商號」等文字濾掉而得來的「簡短名稱」。這種做法是和 C#/Java 一樣的。

除了 Getter 和 Setter 之外, 事實上 Python 還同時提供了一個 Deleter:

@Id.deleter
def Id(self):
    del self._Id

在這裡, 由於 Id 對 Retailer 而言是鍵值, 把鍵值刪除就等於造成更大的問題。所以這種做法在真實狀況中是否恰當, 恐怕值得商確。一般來講, 把屬性欄位刪除是個不建議的做法, 所以我們也可以攔截這個動作, 以免使用者誤下了指令:

@Id.deleter
def Id(self):
    raise AttributeError('類別欄位不能刪除!')

問題是, 就算你沒有實作這個 deleter, Python 本來就不允許刪除類別欄位:

>>> r = Retailer(LineIn='1, 新北市, 金大成')
>>> del r.Id
Traceback (most recent call last):
  File "<pyshell#67>", line 1, in <module>
    del r.Id
AttributeError: can't delete attribute

換句話說, 如果你原本想做的 deleter 只是讓它不能被刪除的話, 那麼其實我們根本什麼都不用做的, 也用不著去實作那個 Id.deleter 了。你反而是必須有實作這個 deleter, 才能刪除這個欄位。我在下一篇系列文章裡會再說明。

或許我們會對 Python 提供這個 deleter 感到奇怪。但如果認真思考, 事實上, 一個類別並不一定非得要對應到資料庫中的欄位; 類別中的物件屬性, 原本就是以字典型式儲存在記憶體中的, 把一個 key/value pair 從字典裡刪除, 其實並不是什麼奇怪的事。但是在本文情境中事實上是不應該實作 deleter 的。

進階思考

上面已經示範了一個基本的 Python 自訂型別的寫法。但是如果只是寫成這樣, 只能算是一個架構而已。在實際應用上, 恐怕是難以應付各種情況的。以下我把一些必須思考的地方條列出來。

若照程式4 的寫法, 假設有一個 Retailer 物件 r, 如果它的 Id 是 1, 那麼當你把它印出來時, 它也只顯示為 1:

>>> r
1

如果一個不小心, 使用者可能會誤以為 r 是一個數字物件或者字串物件。

事實上, Python 中許多型別在實作 __repr__() 方法時, 會傳回較複雜的表示法。或許我們可以模仿它們的寫法, 把程式4 稍為改一下:

def __repr__(self):
    return '<Retailer Id=%r Area=%r Company=%r>' % (self.Id, self.Area, self.Company)

這樣一來, 它就會變得更容易辨識了:

>>> r
<Retailer Id=1 Area='台北市' Company='高進賭城'>

不管怎樣, 如果你打算開發複雜的商業程式, 最好是訂定一套通用的 Style Guide 或者 Coding Standards, 讓所有開發人員一致遵循。

其次, 我們應該思考的是關於防呆的做法。

如同我在「程式內的防呆之道」一文中提到的, 程式設計師應該在設計程式時思考到防呆的必要性, 而不是不管傳入什麼值都接。在我的實際程式中, 加入了範例4 中沒有的許多防呆程式。

要防哪些呆? 在哪裡防? 以 Retailer 類別為例, 你有想到一些莫名其妙、天馬行空的傳入值嗎? 例如:

  • 負值的 Id
  • 天文數字的 Id
  • 加入標點符號的 Id
  • 全形數字的 Id
  • 不存在的 Area
  • 已經撤銷不用的 Area
  • 位於國外的 Area
  • 以經緯度標示的 Area
  • 用 Google Map 網址標示的 Area
  • 簡體字的 Company
  • 英文拼字的 Company
  • 事實上是地址的 Company
  • 事實上是手機號碼的 Company
  • 內含 SQL injection 片段的 Company

呆防得愈多, 程式就愈複雜, 效率就愈不好。所以, 我們可以考慮再加入一個屬性, 控制被呼叫端(也就是類別本身)的防呆需不需要做。假設把它叫做 InputCheck 好了。如果把這個屬性設定為 False, 那麼防呆檢查就通通不必做, 反之就通通要做。如果設定 InputCheck = False, 表示格式檢查是由呼叫者自己處理。

不過, 簡單的檢查和修正還是要做的。例如傳入的值是不是 None、該是文字的欄位應該套上 str() 函式、幫它補個 strip() 以過濾掉頭尾的空白等等。這些最基本的防呆仍然要做, 不管 InputCheck 設為什麼。

以 Id 這個欄位為例。我們可以在欄位的 Setter 中進行把關的動作。像這種欄位, 通常都被拿來當作資料庫裡面的鍵值 (key), 所以是在指定後就不能再改的。可惜的是, Python 的 Setter 似乎並沒有 ReadOnly 這樣的描述子可用。沒關係, 我們可以自己寫 (見本系列下一篇)。

其次, 我們是不是應該想辦法預防上面提到的那些奇怪的輸入值? 例如, 明明我們在前端已經限制使用者輸入六位數以下的數字, 但是從文字檔匯入時, 作業人員卻不小心把款項欄位擺進 Id 欄位了; 難道你不想即時發現嗎?

像這種情境下, 使用 Regular Expression 來當作檢核工具是最適合不過的了。況且, Regex 的 pattern 可以存在程式之外 (例如組態檔), 可以視情況更改, 增加程式的機動性。因此, 我們可以把 Id 欄位的 Getter/Setter 再改得更周嚴一點:

import re

_pattId = r'^[0-9]{1,6}$'
_Id = None

# Getter/Setter for Id
@property
def Id(self):
    return self._Id

@Id.setter
def Id(self, value):
    if re.match(_pattId, str(value).strip()) == None:
        raise ValueError('Id 不符合格式!')
    self._Id = int(value)

其它欄位也可比照辦理。

當然, 我們有時會遇到一些想像不到的例外狀況。例如, 假設你的資料是從 Excel 檔案倒進來的話, 你會發現資料 (特別是有計算式的欄位) 裡經常會有像 "nan" 或 "NaT" 這樣的奇怪的值, 但是將檔案打開的話, 該欄位卻是空白的。或者, 明明看到的是整數值 (例如 "120"), 但讀進來時卻有小數 (例如 "120.0")。這時資料必須進行修正, 程式才有辦法繼續。那些欄位可能不是字串; 所以這就不是 Regex 能夠幫忙的了。如果你使用 Pandas 套件的話, 可以藉助 pandas.isna() 函式來判斷那些空值。

不過話說回來, 如果你在類別裡不做(或不必做)欄位輸出入值的防呆檢查或其它處理, 那麼你其實也不必花功夫去實作 Getter/Setter 了。在 C#/Java 裡, Getter/Setter 是必要的, Python 並不是。不要因為習慣而去做沒有意義的事, 以免疊床架屋。如果你寫的只是簡單的小程式, 或者是極度要求效率的即時系統, 而且用不上物件封裝和物件導向, 本文中介紹的大部份寫法其實都不是必要的。

但是, 如果你要寫的是商業用途的較大系統, 那麼程式的規劃方向就必須相當謹慎周嚴了 -- 你必須知道自己在做什麼。如何取捨, 要好好拿捏。

多重建構式

在 C# 中很容易加入多重建構式; 只要參數型式不同就行了。但在 Python 中完全不是這樣。

不過這倒不是說在 Python 中很難辦到。事實上只要遵照以下三點原則就行了:

  1. 第二個之後的建構式不能取名為 __init__()
  2. 要加上 @classmethod 描述子
  3. 要傳回類別 instance

如下例:

class Retailer():
    def __init__(self, Id=None, Area=None, Company=None, LineIn=None, separator=','):
        ... (略)

    @classmethod
    def Home(cls):
        return cls(0, '台北市', '總公司')

程式中 Home() 指的就是第二個建構式。這裡我採用的情境是這樣的:我們旗下有許多經銷商, 但公司本身也銷售產品, 所以它也是一家經銷商。透過第二個建構式, 我只需要寫一行程式:

home = Retailer.Home()

那麼它就可以很方便地建立出「總公司」這個經銷商, 而無需每次輸入那些根本不會改變的初始值。

不過, 說到這裡, 有個弔詭的事情發生了。還記不記得在程式四中我們寫了一個 FromDict() 這個方法? 同樣是傳回一個類別實體, 和這種所謂的「建構式」難道有什麼不一樣嗎?

說到底, 我也說不上來有什麼不一樣。差只差在套用上 @classmethod 這個描述子之後, 它會把 self 帶進去, 但是也如此而已。如果你要說這叫做還是不叫做「多重建構式」, 我個人也只能不置可否了。這就是 Python, 對吧?

不過既然 Python 有這樣的慣例, 那麼我們在程式四裡就應該把 FromDict() 方法改回上面提到的樣子, 讓它也變成所謂的建構式。照我原來的寫法, 其實只是把 Retailer 當作 namespace 來用而已, 這似乎並不是個好的做法。

實作 Comparer 介面

雖然用在 Retailer 這樣的類別有點不太需要, 但是如果能夠在兩個 Retailer 實體之間比大小, 在某些場合下也許能派上用場。例如, 假設 r1 這個 instance 的 Id 是 1, r2 是2, 那麼如果能夠以 r1 < r2 來做比較, 至少是件有趣的事。要怎麼做呢?

很簡單, 只要在類別裡實作兩個函式就行了:

# For comparer
def __eq__(self, other):
    return self.Id == other.Id

def __lt__(self, other):
    return self.Id < other.Id

如此一來, 我們就能夠在兩個實體間比較大小了:

>>> r1 = Retailer(LineIn='1, 新北市, 金大成')
>>> r2 = Retailer(LineIn='2, 台中市, 金小成')
>>> r1
<Retailer Id=1 Area='新北市' Company='金大成'>
>>> r2
<Retailer Id=2 Area='台中市' Company='金小成'>
>>> r1==r2
False
>>> r1<r2
True
>>> r1>r2
False
>>> r2>r1
True

eq 指的是 equal, lt 指的是 less than; 照理說我們應該還要實作 gt, ge, le 等等, 照樣補上去就行了。

實作了 comparer 介面之後, 我們就可以為 Retailer 集合物件進行預設排序動作。現在假設我們沒有實作 comparer 介面, 那麼在一個只有 Retailer 物件的  list 裡, 我們沒辦法幫它做預設排序動作:

>>> r1 = Retailer(LineIn='1, 新北市, 金大成')
>>> r2 = Retailer(LineIn='2, 台中市, 金小成')
>>> l=[r2,r1]
>>> l.sort()
Traceback (most recent call last):
  File "<pyshell#20>", line 1, in <module>
    l.sort()
TypeError: '<' not supported between instances of 'Retailer' and 'Retailer'

但是如果我們把 comparer 介面實作之後, 就可以了:

>>> r1 = Retailer(LineIn='1, 新北市, 金大成')
>>> r2 = Retailer(LineIn='2, 台中市, 金小成')
>>> l=[r1,r2]
>>> l.sort()
>>> l
[<Retailer Id=1 Area='新北市' Company='金大成'>, <Retailer Id=2 Area='台中市' Company='金小成'>]

當然, 我們並不一定要實作 comparer 介面才能做到如上的排序動作; Python 和 C# 一樣, 可以動態指定 comparer:

l.sort(key=lambda r: r.Id)

或者也可以動態指定其它欄位來做排序:

l.sort(key=lambda r: r.Area, reverse=True)

依此類推。

現在有一個問題。由於 Python 的 list 並沒有限定它只能放置單一型別的物件, 那麼假設在上述範例中的 list 裡你放進了一個 int, 結果如何?

>>> r1 = Retailer(LineIn='1, 新北市, 金大成')
>>> r2 = Retailer(LineIn='2, 台中市, 金小成')
>>> l=[r1,r2, 5]
>>> l.sort()
Traceback (most recent call last):
  File "<pyshell#55>", line 1, in <module>
    l.sort()
TypeError: '<' not supported between instances of 'int' and 'Retailer'

很顯然是不行的。但是如果你還是希望它能夠排序, 你應該怎麼做?

一樣, 你只要修改上述幾個方法就行了。你可以讓 self.Id 和另一個 int 物件做比較, 那麼排序還是可以進行。

但是這樣你不能只實作 __lt__() 了。你要連 __gt__() 也寫上去:

# For comparer
def __eq__(self, other):
    if isinstance(other, int):
        return self.Id == other
    return self.Id == other.Id

def __lt__(self, other):
    if isinstance(other, int):
        return self.Id < other
    return self.Id < other.Id

def __gt__(self, other):
    if isinstance(other, int):
        return self.Id > other
    return self.Id > other.Id

這樣就可以正常排序了:

>>> r1 = Retailer(LineIn='1, 新北市, 金大成')
>>> r2 = Retailer(LineIn='2, 台中市, 金小成')
>>> l=[r1,r2,5]
>>> l.sort()
>>> l
[<Retailer Id=1 Area='新北市' Company='金大成'>, <Retailer Id=2 Area='台中市' Company='金小成'>, 5]
>>> l.sort(reverse=True)
>>> l
[5, <Retailer Id=2 Area='台中市' Company='金小成'>, <Retailer Id=1 Area='新北市' Company='金大成'>]

至於這樣做到底是不是有意義, 就見仁見智了。


Dev 2Share @ 點部落