[python] [VI coding] 第六章 有效的函數設計 - 教學區

[python] [VI coding] 第六章 有效的函數設計

特種兵

特種兵圖像(預設)

2020-10-25 21:21:58

From:1.161.140.205

第六章 有效的函數設計

6-1 函數的參數

之前大家已經學過了基本函數的定義,參數的傳遞,函數的呼叫等,

接下來補充一些靈活使用參數的內容讓各位參考。先來個簡單的範例:

def hello(user):
	print('hello ' + user)

執行上面的程式在畫面上不會發生任何事,因為我們並沒有呼叫這個函數。

那些縮進的部分都是函數的定義,在還沒被呼叫前都不會被執行。

那我們來呼叫他看看:

>>> hello()
TypeError: hello() missing 1 required positional argument: 'user'

發生錯誤了,因為在這個 hello 函數的定義中有一個參數,但我們在呼叫時並沒有給他,那就乖乖給吧:

>>> hello('小明')
hello 小明

當然,你想先存成變數再帶進去也沒問題:

>>> name = '小明'
>>> hello(name)
hello 小明

其實我們可以讓函數的參數有點彈性,例如定義函數時給參數預設值,這樣在呼叫函數時沒有給定參數時就不會發生錯誤了:

def hello(user = '魔神仔'):
	print('hello ' + user)

hello()
# 顯示 hello 魔神仔
name = '小明'
hello(name)
# 顯示 hello 小明

那如果有多個參數,有些有預設值但有些沒有,記得要把有預設值的放在靠右邊的位置,

當函數呼叫時,程式會判斷,先把有給值的從左邊開始帶入,沒有給的就看參數有沒有預設值,像這樣:

def hello(n, h, w = 50, y = 20):
	print('我是 ' + n)
	print('我的身高是', h, '體重是', w, '今年', y, '歲')

hello('小明', 170, 68, 30)
# 執行結果
# 我是 小明
# 我的身高是 170 體重是 68 今年 30 歲
hello('小明', 170)
# 執行結果
# 我是 小明
# 我的身高是 170 體重是 50 今年 20 歲

又如果我們想自己指定帶入參數的順序呢,也是可以的,像這樣:

def hello(n, h, w = 50, y = 20):
	print('我是 ' + n)
	print('我的身高是', h, '體重是', w, '今年', y, '歲')

high = 180
hello(h = high, n = '小明', y = 100)
# 執行結果
# 我是 小明
# 我的身高是 180 體重是 50 今年 100 歲

所以函數的參數其實可以很靈活的,不一定只能完全按照既定的順序來呼叫。

6-2 返回值

我們之前使用過一些內建的函數,像是數學函數,它們會返回一個結果,通常會產生一個值。

這個返回值會被用來當成變數的值或當作一個運算式來使用。例如 exp 函數返回該參數的指數。

>>> import math
>>> e = math.exp(1.0)
>>> e
2.718281828459045

到目前為止,我們寫的函數都是沒有返回值的函數,雖然它們有做一些事,但都沒有直接回傳值。

嚴格說來,沒有返回值的函數其實是返回 None 才對。在本章中我們會寫一些有返回值的函數。

第一個例子是一個名為 area 的函數,給定半徑作為參數傳遞給該函數,而該函數返回圓面積。

def area(radius):
	a = math.pi * radius ** 2
	return a

我們之前已經看過,使用 return 這個關鍵字來返回值並結束函數的調用。

return 通常接一個運算式,所以我們將上面的例子改得更精簡一些:

def area(radius):
	return math.pi * radius ** 2

臨時的變數像 a 這樣,有時會讓我們除錯更容易些。有時多個 return 敘述是很好用的,在不同的條件下,可能會回傳不同的值。

def absoluteValue(x):
	if x < 0:
		return -x
	else:
		return x

雖然在這個函數中有兩個 return 敘述,但在二擇一的判斷式中,

一次只可能執行一種,這就是之前我們提過的 替代條件

位在 return 之後的程式碼將永遠被跳過,因為執行完 return 敘述後函數結束。

永遠不會執行到的程式碼又稱為 死程式碼

在一個有效的函數中,確保每個路徑最後都能有一個 return 返回值,下面有一個不好的例子:

def absoluteValue(x):
	if x < 0:
		return -x
	if x > 0:
		return x

這個函數設計得不好,因為當參數為 0 時將不會執行任何敘述,此時該函數的返回值是 None

>>> print(absoluteValue(0))
None

順帶一提,python 有個內建函數 abs 用來計算傳入參數的絕對值並回傳,所以我們上面只是舉例。

需要回傳絕對值的話可以直接使用內建的函數就好。

6-3 增量開發

如果你一次寫了一個很大的函數,你會花很多時間在除錯上。

你需要用一個比較好的方式來管理日益龐大的函數,那就是增量開發。

每次新增一段代碼就測試它以確保功能正常,當函數愈來愈大時也不擔心除錯的困難。

假設我們想撰寫一個求出兩地距離的函數 distance, 首先思考它應該看起來像什麼?

以程式的角度來說,就是它的輸入(參數)與輸出(返回值)是什麼?

就這個例子而言,我們可以用 x, y 座標來表示一個地方的位置。

而求出兩點的距離公式為: (x2 - x1) 的平方 + (y2 - y1) 的平方,然後開根號。

  1. 輸入:兩個地方就是輸入四個點,它們會是整數。
  2. 輸出:返回值就是求兩點之間距離的公式,它是浮點數。

我們現在可以寫出函數的大綱:

def distance(x1, y1, x2, y2):
	return 0.0

它不會實際的算出答案,只會回傳 0 這個值,但語法正確。

帶入的參數盡量簡單,簡單到你可以預先知道答案,

這樣我們在測試函數時可以很快的確認計算出來的結果是否正確。例如:

>>> distance(1, 2, 4, 6)

從這組數字套進公式中,我們很快的得到答案是 5 這個數,這樣經過設計的參數有利於除錯。

接下來我們要一點一滴的加入一段段的程式碼,直到我們確定它們完全正確才會再繼續添加後續程式碼。

我們先計算出 x 與 y 兩點的差,並確認它們的值是否正確:

def distance(x1, y1, x2, y2):
	dx = x2 - x1
	dy = y2 - y1
	print('dx 是', dx)
	print('dy 是', dy)
	return 0.0

如果一切順利,我們會得到 dx 是 3, dy 是 4 的結果。

如果出錯,我們也只要檢查這幾行程式碼,因此我們可以很快的發現錯誤。

接下來,我們計算 dx 與 dy 的平方和:

def distance(x1, y1, x2, y2):
	dx = x2 - x1
	dy = y2 - y1
	dsquared = dx ** 2 + dy ** 2
	print('dsquared 是: ', dsquared)
	return 0.0

我們已經確認了 dx 與 dy 的值是正確的,因此在上例我們先把那兩個 print 函數刪掉。並加入計算平方和的程式碼。

再次執行程式,確認有沒有印出 25 這個值。最後,你可以使用 math.sqrt 函數來將傳入的參數開根號。

def distance(x1, y1, x2, y2):
	dx = x2 - x1
	dy = y2 - y1
	dsquared = dx ** 2 + dy ** 2
	result = math.sqrt(dsquared)
	return result

如果能夠正確的回傳 5 就表示我們使用增量開發完成了函數的撰寫。

若結果出錯,那在回傳前,我們試著印出 result 並檢查新添加的程式碼哪裡有錯?

最後這個版本的函數,它不會顯示任何資訊,而是回傳最終的結果。

我們使用 print 函數來一一確認值是否正確,但這只是一個過程,

當函數開發完成後,我們將 print 或測試的程式碼移除,它不是我們最終產品實際需要的部分。

一次增加一兩行的開發方式在你對撰寫程式還不熟練時是個好主意。

當你更熟悉也更有把握時,你可能一次會維護更大的程式區塊。

整理增量開發的要點如下:

  1. 一開始從事小範圍的新增與修改,當程式出錯時你可以很容易的發現錯誤點在哪。
  2. 使用變數來保存過程中的值,將它印出來並確認是否正確。
  3. 當完成開發後,你會把多餘的測試程式碼移除(認為有需要先保留則可以註解掉),但以不影響程式的理解為考量。

當然,上面的函數可以更精減,事實上我們不需要那麼多變數,這部分留給大家練習吧。

6-4 組合

我們已經知道一個函數中可以調用其他的函數,現在我們來封裝他們,並把函數改得更簡潔。

現在假設我們有兩個點,他們分別落在一個圓的圓心跟圓周上。

利用剛剛撰寫的 distance 函數求出兩點的距離就是這個圓的半徑。

利用本章一開始的函數 area 又可經由半徑計算出圓的面積。

所以我們現在要寫一個函數名為 circleArea, 給兩個點的座標求出圓面積,並且封裝那兩個函數進去。

def circleArea(xc, yc, xp, yp):
	radius = distance(xc, yc, xp, yp)
	result = area(radius)
	return result

臨時的變數有助於除錯,組合整理一下,我們可以把函數改得更簡潔:

def circleArea(xc, yc, xp, yp):
	return area(distance(xc, yc, xp, yp))

6-5 布林函數

函數可以僅回傳布林值,這有助於隱藏函數中複雜的計算過程,

在調用函數後,如果只需要得到一個是或否、有或沒有的情況非常有用。例如:

def isDivisible(x, y):
	if x % y == 0:
		return True
	else:
		return False

上面的函數可以很明確的知道,如果調用該函數並回傳 True 就表示 x 可以被 y 整除,

呼叫這個函數的例子如下:

>>> isDivisible(6, 4)
False
>>> isDivisible(6, 3)
True

結果恰如其份的與函數名稱相同,非常直覺。

讓我們再次優化這個函數:

def isDivisible(x, y):
	return x % y == 0

布林函數通常會用在條件判斷式中,如下:

if isDivisible(x, y):
	print('x 可以被 y 整除')

你可能會想這樣寫:

if isDivisible(x, y) == True:
	print('x 可以被 y 整除')

但額外或多餘的比較是不必要的。

6-6 又是遞迴

我們雖然只介紹了一些小主題,但經過這樣的方式,你會學到該怎麼用 python 的方式去思考問題或架構函數與程式。

事實上每種程式語言都可以用這樣的方式去開發或改寫,因為程式語言的思考或架構都是類似的,只是語法與特性不同而已。

為了檢視目前所學,我們來看一下使用遞迴方式開發的數學函數。

遞迴的定義類似循環定義,我們來看一下計算階乘的函數 factorial 吧。

首先,幫大家複習一下什麼是階乘,階乘使用 ! (驚嘆號) 來表示,像這樣:

0! = 1 
n! = n(n−1)!

簡單的說,以 3 階乘為例,3 階就是 3 乘以 2 階,2 階就是 2 乘以 1 階,1 階就是 1 乘以 0 階。因為 0 階永遠都是 1 而任何數乘 1 都是任何數。

完整的展開就是,3 階乘等於 3 乘 2 乘 1 乘 1 也就是 6。

我們現在要來寫一個用遞迴方式計算階乘的函數 factorial,一開始,我們很清楚的知道應該回傳階乘的答案且它會是一個整數型態,

接著它需要一個參數作為階乘傳進來供函數計算。所以,像這樣:

def factorial(n):

先來處理 0 階乘,這也是我們停止遞迴函數的條件,不管多少階乘,最後都會一直遞減算到 0 階乘,並終止函數的呼叫返回 1 這個值,像這樣:

def factorial(n):
	if n == 0:
		return 1

然後我們要實現如果參數不是 0 就要計算 n - 1 階乘再乘以 n 就算完成了,像這樣:

def factorial(n):
	if n == 0:
		return 1
	else:
		recurse = factorial(n - 1)
		result = n * recurse
		return result

執行的過程就像之前的第五章提過的那樣:

  1. 帶入 3 因為不是 0 就走 else 計算 factorial(3 - 1)
  2. 帶入 2 因為不是 0 就走 else 計算 factorial(2 - 1)
  3. 帶入 1 因為不是 0 就走 else 計算 factorial(1 - 1)
  4. 帶入 0 因為是 0 就走 if 回傳 1 且停止遞迴函數的呼叫。

堆疊具有 後進先出 的特徵,當 factorial(1 - 1) 時 return 1 結束程式,那時不會有 recurse 跟 result,因為 else 沒有被執行。

所以 recurse = 1 然後被 n 乘。此時 n = 1 所以是 1 * 1 = 1 以此類推,n = 2, n = 3 最後得出 6 的結果。

我覺得在撰寫遞迴函數最困難的是歸納需求成為公式,如果已經有公式,那麼在撰寫上就容易很多了,定好終止條件就差不多完成了。

6-7 信仰的飛越

根據程式撰寫的順序閱讀程式,這樣的方式顯然不一定行得通。當你呼叫一個函數,

如果這一切都運作正常。事實上,在這個過程中,你已經練習了 信仰飛越 的閱讀方式。

當我們呼叫一個內建函數如 math.sqrt 來開根號,我們在當下無法把這個函數的內容展開,

我們有理由相信這個函數可以正常工作,因為他們都是高手寫出來且經過無數次的試驗。在程式中讓我們獲取想要的計算結果。

當呼叫自己撰寫的函數也是一樣的道理,例如我們之前寫了一個 isDivisible 來判斷 a 是否可以被 b 整除的函數,你也必須相信這個函數能夠正常運作。

我們總不希望每次呼叫這個函數時都必須再次進入這個函數的內部去檢查是否正常,而是不進入他的內容但可以順利地完成調用。

擁有遞迴函數的程式也是一樣,我們相信該函數可以順利返回正確的值,而不是一直進行遞迴函數的展開與演練其計算過程。

當然,你在剛開始撰寫函數時與了解遞迴概念時是例外情況,為了將來的呼叫無誤,你必須仔細確認函數的精確性。

6-8 fibonacci 數列

看完 factorial 計算階乘函數後,接下來看一下 fibonacci 數列,你可以查一下它的定義。

fibonacci(0) = 0 
fibonacci(1) = 1 
fibonacci(n) = fibonacci(n − 1) + fibonacci(n − 2)

試著轉換成 python 的話,函數應該像這樣:

def fibonacci(n):
	if n == 0:
		return 0
	elif  n == 1:
		return 1
	else:
		return fibonacci(n - 1) + fibonacci(n - 2)

如果你打算用傳統的方式一一展開遞迴函數的話,就算 n 的值很小,你的腦子也會爆炸,因為同時有兩個遞迴函數,

而遞迴中又有遞迴,這太複雜了,所以你只要帶對公式,利用思想的飛越理解程式就好,

而計算的事情就交給電腦吧。

6-9 確認型態

如果我們給 factorial 函數 1.5 當作參數會發生什麼事?

>>> factorial(1.5)
RuntimeError: Maximum recursion depth exceeded

這個錯誤說我們的函數超過了遞迴的深度,為什麼會這樣?因為我們終止函數遞迴的條件是 n = 0,

但我們給了一個 1.5 的值作為參數,每次呼叫遞迴時都會減一,而 1.5 一直減一也永遠不會是 0 這個值,

所以我們的遞迴函數永遠無法停止。想要解決這個問題,你有兩種選擇:

  1. 試著讓我們原本的 factorial 可以接受浮點數的參數。
  2. 讓我們的 factorial 在執行前可以先檢查傳進來的參數資料型態。

第一個選擇目前超出我們的學習範圍,所以我們先選擇第二個方式。

我們可以利用內建的函數 isinstance 來檢查變數的資料型態,順便處理參數是負數的狀況:

def factorial(n):
	if not isinstance(n, int):
		print('Factorial 只能使用整數')
		return None
	elif n < 0:
		print('Factorial 不能是負數')
		return None
	elif n == 0:
		return 1
	else:
		return n * factorial(n - 1)

現在加入了兩個判斷敘述,當傳入的參數不是整數型態或小於零時都會印出錯誤訊息且返回 None.

>>> print(factorial('fred'))
Factorial 只能使用整數
None
>>> print(factorial(-2))
Factorial 不能是負數
None

在後續的章節中我們還可以有其他處理錯誤的方式,那就是 拋出異常

除錯

將大程式化成小函數有助於我們查找錯誤的根源。當函數無法正常工作時,請檢查是否發生以下狀況:

  1. 函數接收的參數有誤,違反了先決條件。參數型態不正確、參數個數不對都是典型的例子。
  2. 函數功能異常,違反了後置條件。
  3. 返回值設計有誤或使用不正常的方式呼叫函數。

在函數一開始印出帶入的參數可以確認其值與型態是否正確,或者寫一些檢查參數的程式碼。

在返回值敘述之前印出預傳回的結果也可以確保傳回值的正確性。

下面是一個帶有 print 函數檢查的 factorial 版本:

def factorial(n):
	space = ' ' * (4 * n)
	print(space, 'factorial', n)
	if n == 0:
		print(space, 'returning 1')
		return 1
	else:
		recurse = factorial(n-1)
		result = n * recurse
		print(space, 'returning', result)
		return result

space 是用來控制印出的資訊,主要是用來縮排對齊而已。

我們使用 4 當作參數帶入 factorial 的結果如下:

					factorial 4
				factorial 3
			factorial 2
		factorial 1
	factorial 0
	returning 1
		returning 1
			returning 2
				returning 6
					returning 24

觀察上例的輸出,對於我們了解 factorial 遞迴函數的運作也有很大的幫助。

動動腦

  1. 使用增量開發的方式,寫一個名為 hypotenuse 的函數,接受兩個參數做為直角三角形的兩股,回傳該三角形的斜邊長,並寫一個程式來驗證結果,紀錄這個開發過程。
  2. 寫一個像這樣的函數, isBetween(x, y, z),當 x 小於等於 y 小於等於 z 就回傳 True,否則回傳 False。

練習

第1題 檔名 6-1.py

寫一個名為 compareFunc 函數,接受兩個參數 a 與 b,功能如下:

  1. 當 a 大於 b 時回傳 1
  2. 當 a 等於 b 時回傳 0
  3. 當 a 小於 b 時回傳 -1

寫一個程式測試其結果。

第2題

追蹤以下程式,看看是否能夠自己推測出最後會印出什麼結果,實際執行結果以確認和你預想的是否一致。

def b(z):
	prod = a(z, z)
	print(z, prod)
	return prod

def a(x, y):
	x = x + 1
	return x * y

def c(x, y, z):
	total = x + y + z
	square = b(total)**2
	return square

x = 1
y = x + 1
print(c(x, y+3, x+y))

寫下你在這個過程中有什麼心得或想法。

第3題 檔名 6-2.py

當 b 除以 a 的商是 a 且能夠整除時,我們說 b 是 a 的完全平方。

寫一個函數名為 isPower 帶入兩個參數,當 b 是 a 的完全平方時回傳 True 否則回傳 False,

驗證 36 是 6 的完全平方,361 為 19 的完全平方, 179 不是 13 的完全平方。

第4題 檔名 6-3.py

寫一個遞迴函數 gcd 求兩數的最大公因數。

根據觀察,兩個數 a 與 b 的最大公因數,gcd(a, b) = gcd(b, r), r 是餘數,直到最後的那個數就是它們的最大公因數。

透過測試與觀察,我們必須思考一下終止遞迴的條件怎麼寫。

寫一個程式驗證: 24 與 84 的最大公因數是 12, 12 和 20 的最大公因數是 4

參考資料

商高定理

求三角形斜邊教學

完全平方數

最大公因數

最後更新:2020-11-04 09:22:36

From: 211.23.21.202

By: 特種兵