- 深度學習程序設計實戰
- 方林 陳海波編著
- 2781字
- 2021-08-12 17:34:25
1.3 面向對象的程序設計
面向對象方法的核心有兩個,一個是封裝,另一個是繼承。簡單地說,封裝就是把程序和數據綁定在一起,所以調用一個函數(或者說方法,以下同)的時候,一般要指明調用的是哪個對象的函數。繼承就是讓子類可以做兩件事:第一,定義新的函數;第二,重定義父類中的函數。下面我們以實例來說明面向對象方法的妙用。
1.3.1 方法重定義和分數
一般計算機語言中沒有分數類型,Python也不例外。不過,我們可以創建一個分數類Fraction,然后實現分數的加減乘除。這個類的框架如下:

在主程序中,我們首先調用Fraction(3,4),以便創建一個分數對象3/4。這時,Python約定會調用Fraction類中定義的構造函數[1]__init__(),并傳入相應的參數3和4。在Python中構造函數是__init__(),而在Java和C++中構造函數約定就是類的名字。例如,如果用Java或者C++也編寫一個類Fraction,那么它的構造函數就是Fraction(),而不是所謂的__init__()。另外,Python中的任何函數(包括普通函數、內部函數、類的成員函數、類的靜態函數和構造函數)不能重復定義。如果重復定義了,Python運行時也不報錯,而是用最后一個同名函數替換。也就是說,Python不支持面向對象方法中所謂的重載[2](Overload),但是Java和C++等都支持。順便提一句,它們仨都支持重定義[3](Overriden)。關于Python的重定義我們馬上就會講到。
定義好分數f1之后,我們緊接著就打印它。print()是Python的內部函數。這樣的內部函數還有很多,例如len()是求一個字符串或者列表的長度等。print()在打印一個對象時,約定調用的是這個對象的內部成員函數__repr__(),就像Java在打印一個對象時會調用它的toString()方法一樣。所以我們在類Fraction的內部成員函數__repr__()中使用了字符串,并用%操作把分數對象的分子和分母轉成形如“(分子/分母)”的字符串。其中的關鍵字self是對當前對象的引用,意義與Java和C++中的this相同。不同的是,Python的成員函數的第一個形式參數必須是self。
大家可以運行一下代碼1-17,會得到分數“(3/4)”。
下面,我們實現分數的加法運算。值得一提的是,和C++一樣,Python也可以對運算符(例如+)進行重定義。我們只需在Fraction類中定義函數__add__()即可。代碼如下:

運行以后得到結果(17/12)。根據同樣的方法,我們可以增加減、乘、除等方法。注意,除法對應的函數名是__truediv__。代碼如下:


有意思的是,我們甚至可以直接進行組合運算,或者使用括號改變運算次序。例如,如果打印“f1?(f1+f2)”,結果就是(51/48)。
除了加減乘除之外,還可以重定義其他運算符,見表1-2。
表1-2 Python運算符/操作與內部成員函數對照表

(續)

(續)

運算符之間的優先級遵循Python的約定。
表1-2展示了哪些運算符和內部函數可以被重定義。事實上,對分數來說,大部分運算是不必重定義的。例如,位運算符(&和|)對分數來說就沒有什么實際意義。
表1-2中沒有邏輯運算符(not、and、or),這說明邏輯運算是不可以重定義的。另外,除了求相反數(-)和按位取反(~)兩個一元運算之外,所有算術運算符和位運算符都是二元的,并且都分別對應兩個函數。例如加法(+)對應__add__()和__radd__()。其中__add__()表示當前對象對應的是兩個運算元中左邊那個,如f1+f2就會調用f1.__add__()函數,而不會調用f2.__add__()函數。而__radd__()表示當前對象對應的是兩個運算元中右邊[4]那個,如3+f2就會調用f2.__radd__()函數,并把3作為參數傳入。
考慮到整數與分數的加減乘除的確存在,我們把加減乘除函數改動一下,以適應參數是一個整數的情況,再把右加、右減、右乘和右除重定義,得到如下代碼:



輸出結果略。
1.3.2 二十四點問題
下面我們結合上節提到的表達式、加減乘除、運算的概念,解決著名的二十四點問題。
二十四點問題的規則是,給出4張撲克牌,利用它們的點數,結合任意的加減乘除運算,以使最終的結果等于24。例如,10、2、3、7可以湊成表達式10×2+(7-3),其結果為24。
這個問題的主程序比較簡單,就是利用numpy生成4個1~13之間的隨機整數,然后調用子程序以獲取用這4個數生成24的所有運算表達式,最后再打印出來。代碼片段如下:

接下來我們就要考慮函數make24()的實現了。我們可以用1、2節學習過的遞歸方法進行思考。顯然這個問題的輸入參數是列表numbers,輸出是這些數所能湊成的表達式及其相應的值。例如[3,5,2]就能湊成“3+5+2”“3+5-2”“3 ×(5+2)”等各種各樣的表達式,對應的值分別是10、6、21等。
明確了輸入和輸出之后,我們來確定遞歸邊界。numbers處于什么狀態時問題最簡單,我們可以直接給出輸出?顯然當numbers中僅含有一個數時,輸出就是這個數本身。
遞歸假設比較簡單,我們直接看遞歸推導。我們可以把numbers中的數據任意分成左右兩個列表,每個列表中至少含有一個數。例如[3,5,2]就可以分成[3]和[5,2]、[5]和[2,3]、[2]和[3,5]等。根據遞歸假設,每個列表都可以通過make24()函數計算出一組值及其對應的表達式。我們只需從左右兩組值中任意各取一個值,按照加減乘除4種運算湊成表達式即可。代碼如下:

注意,在做減法和除法時,要把運算符左右兩邊交換的情況也考慮在內。
接著,我們要考慮的是make24()中調用的子程序split(numbers),它用來把numbers列表中的數據分成任意兩個部分,每一部分至少含有一個數,并分別稱為左部和右部。我們用變量lefts表示左部列表中數的個數,lefts從1到len(numbers)//2循環。循環體內則調用子程序get_indices_list(indices,lefts),以獲取下標列表indices中任意lefts個下標構成的組合的集合,其中indices={0,1,2,…,len(numbers)-1},是numbers的所有可能的下標的集合。例如get_indices_list([0,1,2],2)的返回結果是{{0,1},{0,2},{1,2}}。最后用indices減去每個下標組合就可以得到相應的右部下標組合的集合,例如在上例中,右部下標組合分別是{2}、{1}、{0}。搞清楚了split()的來龍去脈,再來看它的代碼就不難了:

其中包itertools中的函數combinations(numbers,num)用來獲取由序列numbers中任意num個元素構成的組合的集合。這正是get_indices_list()要達到的目的。如果你對Python并不是太熟悉,不知道有這個包以及這個函數,或者你使用的是C++或Java之類沒有這類庫函數的語言,那么你也可以用遞歸方法直接實現這個函數。我們在代碼1-15解決組合問題的遞歸程序中已經有了解決方案,這里不再贅述。
上述所有子程序實現之后,運行程序就可以隨機產生4個1~13之間的整數,然后回答用它們能否湊出24。如果能,輸出所有可能的表達式。下面是一個示例:
[8 6 12 11]
1.(6 ?(12 /(11-8)))
2.(6 /((11-8)/ 12))
3.(6 ?(12 /(11-8)))
4.(6 /((11-8)/ 12))
5.(12 ?(6 /(11-8)))
6.(12 /((11-8)/ 6))
7.(12 ?(6 /(11-8)))
8.(12 /((11-8)/ 6))
9.((6 ?12)/(11-8))
10.((12 ?6)/(11-8))
11.((6 ?12)/(11-8))
12.((12 ?6)/(11-8))
13.((6 ?12)/(11-8))
14.((6 ?12)/(11-8))
15.((12 ?6)/(11-8))
16.((12 ?6)/(11-8))
當然,這其中存在著不少重復的表達式。如何消除其中重復的表達式,留待讀者思考和練習。