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

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

文章瀏覽次數 2979

特種兵

特種兵圖像(預設)

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 小明

在主程式,也就是自定函數定義之外,name 這個變數就代表小明。

當以 name 當作參數帶進函數呼叫時,在函數定義內,user 就是 name, 也就是小明。

這個有點抽象化的過程是剛學程式的伙伴必須經歷的過程,使用函數的其中關鍵就在此。

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

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 歲
# 後面雖然少了兩個參數,但因為定義函數時有預設值,所以不會報錯,程式會直接使用預設值

如果只帶一個參數進入函數呢,原本函數定義有四個參數,因為只有兩個參數有預設值,所以另外兩個沒預設值的參數一定要有值。

hello(170)
TypeError: hello() missing 1 required positional argument: 'h'

果然報錯了,仔細看錯誤訊息的最後那個 'h' 的部分。訊息表示 h 參數的值沒有給定。

在 hello 函數定義中,四個參數分別為 n, h, w, y, 所以呼叫時會從最左邊開始分配參數。

所以 170 會先分配給 n, 雖然用人的眼光看起來,170 比較像是身高,但這就是程式邏輯。

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

# 原本的函數定義完全不變
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 是 python 關鍵字,用來回傳右邊的值並終止函數,通常接一個運算式或一個值,所以我們將上面的例子改得更精簡一些:

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

少了一個變數也少一行,主要是 a 變數儲存完運算結果,就隨即回傳並結束函數,

那不如直接回傳計算完的結果就好了。

不過臨時的變數像 a 這樣,有時會讓我們除錯更容易些,例如可以在回傳前先印出來看看有沒有計算錯誤。

又或者 a 得到計算結果後,還要做一些處理而不是直接回傳的話,就有必要先把值存下來了。

有時會看到數個 return 敘述,在不同的條件下,可能會回傳不同的值,回傳之後就結束程式,如此需求就可以使用多個 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

顯然我們漏掉了某些情況沒有判斷到,在規劃程式及測試時應小心避免。

當參數是 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 這個數,這樣經過設計的參數有利於除錯。

(4-1)**2 + (6-2)**2 = 25, 根號 25 為 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 的值是正確的,因此上例中,先把那兩個印出 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. 完成開發後,你會把多餘的測試程式碼移除(認為有需要先保留則可以註解掉),但以不影響程式的理解為考量。

當然,上面的函數可以更精簡,事實上我們不需要那麼多變數,整理如下:

import math

def distance(x1, y1, x2, y2):
	return math.sqrt((x2 - x1)**2 + (y2 - y1)**2)

呼叫執行看看:

>>> print(distance(1, 2, 4, 6))
5.0
>>> print(distance(10, 20, 40, 60))
50.0

沒問題,任務完成。

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

return 右邊的算式,比較運算子 == 的左右兩邊,相同回傳 True 否則回傳 False.

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

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

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

這邊也特別注意一下,print 其實是印出 factorial 函數執行完的回傳值,

至於錯誤訊息則是執行 factorial 所產生的結果。

除錯

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

  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 的函數,接受兩個參數作為直角三角形的兩股,回傳該三角形的斜邊長,並寫一個程式來驗證結果,記錄這個開發過程。
import math

def hypotenuse(a, b):
	return math.sqrt((a**2 + b**2))

print(int(hypotenuse(3, 4)))
  1. 寫一個像這樣的函數, isBetween(x, y, z),當 x 小於等於 y 小於等於 z 就回傳 True,否則回傳 False。
def isBetween(x, y, z):
	return x <= y <= z

print(isBetween(1, 3, 1))
print(isBetween(1, 3, 5))

練習

第1題 檔名 6-1.py

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

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

寫一個程式測試其結果。

第2題 檔名 6-2.py

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

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-3.py

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

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

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

第4題 檔名 6-4.py

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

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

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

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

第5題 檔名 6-5.py

要判斷一個數能不能被 11 整除的條件是:

  1. 該數必須大於等於 11
  2. 該數的所有奇數位數相加後,減掉偶數位數相加後等於零

請依上述條件寫一個函數,帶入一個數字判斷是否可以被 11 整除,是則回傳 True, 否則回傳 False

參考資料

商高定理

求三角形斜邊教學

完全平方數

最大公因數

影片

第六章 有效的函數設計 part one

第六章 有效的函數設計 part two

最後更新:2021-10-08 21:52:24

From: 111.249.165.250

By: 特種兵