banner
Zoney

Zoney

一个励志成为Web安全工程师的女青年!

python 學習 2

三 函數#

調用函數

Python 內置了很多有用的函數,我們可以直接調用。要調用一個函數,需要知道函數的名稱和參數,比如求絕對值的函數 abs,只有一個參數。
可以直接從 Python 的官方網站查看文檔:
http://docs.python.org/3/library/functions.html#abs
也可以在交互式命令行通過 help (abs) 查看 abs 函數的幫助信息。

調用函數的時候,如果傳入的參數數量不對,會報TypeError的錯誤,並且 Python 會明確地告訴你:abs () 有且僅有 1 個參數,但給出了兩個:

>>> abs(1, 2)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: abs() takes exactly one argument (2 given)

如果傳入的參數數量是對的,但參數類型不能被函數所接受,也會報 TypeError 的錯誤,並且給出錯誤信息:str 是錯誤的參數類型:

>>> abs('a')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: bad operand type for abs(): 'str'

而 max 函數 max () 可以接收任意多個參數,並返回最大的那個:

>>> max(1, 2)
2
>>> max(2, 3, 1, -5)
3

數據類型轉換
Python 內置的常用函數還包括數據類型轉換函數,比如 int () 函數可以把其他數據類型轉換為整數:

>>> int('123')
123
>>> int(12.34)
12
>>> float('12.34')
12.34
>>> str(1.23)
'1.23'
>>> str(100)
'100'
>>> bool(1)
True
>>> bool('')
False

函數名其實就是指向一個函數對象的引用,完全可以把函數名賦給一個變量,相當於給這個函數起了個 “別名”:

>>> a = abs # 變量a指向abs函數
>>> a(-1) # 所以也可以通過a調用abs函數
1

定義函數
在 Python 中,定義一個函數要使用 def 語句,依次寫出函數名、括號、括號中的參數和冒號:,然後,在縮進塊中編寫函數體,函數的返回值用 return 語句返回。

以自定義一個求絕對值的 my_abs 函數為例:

def my_abs(x):
    if x >= 0:
        return x
    else:
        return -x

請注意,函數體內部的語句在執行時,一旦執行到 return 時,函數就執行完畢,並將結果返回。因此,函數內部通過條件判斷和循環可以實現非常複雜的邏輯。

如果沒有 return 語句,函數執行完畢後也會返回結果,只是結果為None。return None 可以簡寫為 return。

在 Python 交互環境中定義函數時,注意 Python 會出現... 的提示。函數定義結束後需要按兩次回車重新回到 >>> 提示符下

>>>def my_abs(x):
...   if x >= 0:
...       return x
...    else:
...        return -x
... 
>>> my_abs(-9)
9
>>>

如果你已經把 my_abs () 的函數定義保存為 abstest.py 文件了,那麼,可以在該文件的當前目錄下啟動 Python 解釋器,用 from abstest import my_abs 來導入 my_abs () 函數,注意 abstest 是文件名(不含.py 擴展名)

>>> from abstest import my_abs 
>>> my_abs(-9) 
9  

空函數
如果想定義一個什麼事也不做的空函數,可以用 pass 語句:

def nop():
    pass

pass 語句什麼都不做,那有什麼用?實際上 pass 可以用來作為佔位符,比如現在還沒想好怎麼寫函數的代碼,就可以先放一個 pass,讓代碼能運行起來。

pass 還可以用在其他語句裡,比如:

if age >= 18:
    pass

缺少了 pass,代碼運行就會有語法錯誤。

參數檢查
調用函數時,如果參數個數不對,Python 解釋器會自動檢查出來,並拋出 TypeError:

>>> my_abs(1, 2)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: my_abs() takes 1 positional argument but 2 were given

但是如果參數類型不對,Python 解釋器就無法幫我們檢查。試試 my_abs 和內置函數 abs 的差別:

>>> my_abs('A')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in my_abs
TypeError: unorderable types: str() >= int()
>>> abs('A')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: bad operand type for abs(): 'str'

當傳入了不恰當的參數時,內置函數 abs 會檢查出參數錯誤,而我們定義的 my_abs 沒有參數檢查,會導致 if 語句出錯,出錯信息和 abs 不一樣。所以,這個函數定義不夠完善。

讓我們修改一下 my_abs 的定義,對參數類型做檢查,只允許整數和浮點數類型的參數。數據類型檢查可以用內置函數isinstance() 實現:

def my_abs(x):
    if not isinstance(x, (int, float)):
        raise TypeError('bad operand type')
    if x >= 0:
        return x
    else:
        return -x

添加了參數檢查後,如果傳入錯誤的參數類型,函數就可以拋出一個錯誤:

>>> my_abs('A')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in my_abs
TypeError: bad operand type

返回多個值
函數可以返回多個值

比如在遊戲中經常需要從一個點移動到另一個點,給出坐標、位移和角度,就可以計算出新的坐標:

import math

def move(x, y, step, angle=0):
    nx = x + step * math.cos(angle)
    ny = y - step * math.sin(angle)
    return nx, ny

import math 語句表示導入 math 包,並允許後續代碼引用 math 包裡的 sin、cos 等函數。
然後,我們就可以同時獲得返回值:

>>> x, y = move(100, 100, 60, math.pi / 6)
>>> print(x, y)
151.96152422706632 70.0

但其實這只是一種假象,Python 函數返回的仍然是單一值:

>>> r = move(100, 100, 60, math.pi / 6)
>>> print(r)
(151.96152422706632, 70.0)

原來返回值是一個tuple!但是,在語法上,返回一個 tuple 可以省略括號,而多個變量可以同時接收一個 tuple,按位置賦給對應的值,所以,Python 的函數返回多值其實就是返回一個 tuple,但寫起來更方便。
函數的參數
對於函數的調用者來說,只需要知道如何傳遞正確的參數,以及函數將返回什麼樣的值就夠了,函數內部的複雜邏輯被封裝起來,調用者無需了解。

Python 的函數定義非常簡單,但靈活度卻非常大。除了正常定義的必選參數外,還可以使用默認參數可變參數關鍵字參數,使得函數定義出來的接口,不但能處理複雜的參數,還可以簡化調用者的代碼。

位置參數
我們先寫一個計算 x2 的函數:

def power(x):
    return x * x

對於 power (x) 函數,參數 x 就是一個位置參數。

當我們調用 power 函數時,必須傳入有且僅有的一个參數 x:

>>> power(5)
25

現在,如果我們要計算 x3 怎麼辦?可以再定義一個 power3 函數,但是如果要計算 x4、x5…… 怎麼辦?我們不可能定義無限多個函數。

你也許想到了,可以把 power (x) 修改為 power (x, n),用來計算 xn

def power(x, n):
    s = 1
    while n > 0:
        n = n - 1
        s = s * x
    return s

對於這個修改後的 power (x, n) 函數,可以計算任意 n 次方:

>>> power(5, 2)
25

修改後的 power (x, n) 函數有兩個參數:x 和 n,這兩個參數都是位置參數,調用函數時,傳入的兩個值按照位置順序依次賦給參數 x 和 n。

位置參數
我們先寫一個計算 x2 的函數:

def power(x):
return x * x
對於 power (x) 函數,參數 x 就是一個位置參數。

當我們調用 power 函數時,必須傳入有且僅有的一个參數 x:

power(5)
25
power(15)
225
現在,如果我們要計算 x3 怎麼辦?可以再定義一個 power3 函數,但是如果要計算 x4、x5…… 怎麼辦?我們不可能定義無限多個函數。

你也許想到了,可以把 power (x) 修改為 power (x, n),用來計算 xn,說幹就幹:

def power(x, n):
s = 1
while n > 0:
n = n - 1
s = s * x
return s
對於這個修改後的 power (x, n) 函數,可以計算任意 n 次方:

>>> power(5, 2)
25
>>> power(5, 3)
125

修改後的 power (x, n) 函數有兩個參數:x 和 n,這兩個參數都是位置參數,調用函數時,傳入的兩個值按照位置順序依次賦給參數 x 和 n。

默認參數
新的 power (x, n) 函數定義沒有問題,但是,舊的調用代碼失敗了,原因是我們增加了一個參數,導致舊的代碼因為缺少一個參數而無法正常調用:

>>> power(5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: power() missing 1 required positional argument: 'n'

Python 的錯誤信息很明確:調用函數 power () 缺少了一个位置參數 n。

這個時候,默認參數就排上用場了。由於我們經常計算 x2,所以,完全可以把第二個參數 n 的默認值設定為 2:

def power(x, n=2):
    s = 1
    while n > 0:
        n = n - 1
        s = s * x
    return s

這樣,當我們調用 power (5) 時,相當於調用 power (5, 2):

>>> power(5)
25
>>> power(5, 2)
25

而對於 n > 2 的

其他情況,就必須明確地傳入 n,比如 power (5, 3)。

從上面的例子可以看出,默認參數可以簡化函數的調用。設置默認參數時,有幾點要注意:

一是必選參數在前默認參數在後,否則 Python 的解釋器會報錯

二是如何設置默認參數。
當函數有多個參數時,把變化大的參數放前面,變化小的參數放後面。變化小的參數就可以作為默認參數。

使用默認參數有什麼好處?最大的好處是能降低調用函數的難度。

舉個例子,我們寫個一年級小學生註冊的函數,需要傳入 name 和 gender 兩個參數:

def enroll(name, gender):
    print('name:', name)
    print('gender:', gender)

這樣,調用 enroll () 函數只需要傳入兩個參數:

>>> enroll('Sarah', 'F')
name: Sarah
gender: F

如果要繼續傳入年齡、城市等信息怎麼辦?這樣會使得調用函數的複雜度大大增加。

我們可以把年齡和城市設為默認參數:

def enroll(name, gender, age=6, city='Beijing'):
    print('name:', name)
    print('gender:', gender)
    print('age:', age)
    print('city:', city)

這樣,大多數學生註冊時不需要提供年齡和城市,只提供必須的兩個參數:

>>> enroll('Sarah', 'F')
name: Sarah
gender: F
age: 6
city: Beijing

只有與默認參數不符的學生才需要提供額外的信息:

enroll('Bob', 'M', 7)
enroll('Adam', 'M', city='Tianjin')
可見,默認參數降低了函數調用的難度,而一旦需要更複雜的調用時,又可以傳遞更多的參數來實現。無論是簡單調用還是複雜調用,函數只需要定義一個。

有多個默認參數時,調用的時候,既可以按順序提供默認參數,比如調用 enroll ('Bob', 'M', 7),意思是,除了 name,gender 這兩個參數外,最後 1 個參數應用在參數 age 上,city 參數由於沒有提供,仍然使用默認值。

也可以不按順序提供部分默認參數。當不按順序提供部分默認參數時,需要把參數名寫上。比如調用 enroll ('Adam', 'M', city='Tianjin'),意思是,city 參數用傳進去的值,其他默認參數繼續使用默認值。

默認參數很有用,但使用不當,也會掉坑裡。默認參數有個最大的坑,演示如下:

先定義一個函數,傳入一個 list,添加一個 END 再返回:

def add_end(L=[]):
    L.append('END')
    return L

當你正常調用時,結果似乎不錯:

>>> add_end([1, 2, 3])
[1, 2, 3, 'END']
>>> add_end(['x', 'y', 'z'])
['x', 'y', 'z', 'END']

當你使用默認參數調用時,一開始結果也是對的:

>>> add_end()
['END']

但是,再次調用 add_end () 時,結果就不對了:

>>> add_end()
['END', 'END']
>>> add_end()
['END', 'END', 'END']

很多初學者很疑惑,默認參數是 [],但是函數似乎每次都 “記住了” 上次添加了 'END' 後的 list。

原因解釋如下:
Python 函數在定義的時候,默認參數 L 的值就被計算出來了,即 [],因為默認參數 L 也是一個變量,它指向對象 [],每次調用該函數,如果改變了 L 的內容,則下次調用時,默認參數的內容就變了,不再是函數定義時的 [] 了。

定義默認參數要牢記一點:默認參數必須指向不變對象!

要修改上面的例子,我們可以用 None 這個不變對象來實現:

def add_end(L=None):
    if L is None:
        L = []
    L.append('END')
    return L

現在,無論調用多少次,都不會有問題:

>>> add_end()
['END']
>>> add_end()
['END']

為什麼要設計 str、None 這樣的不變對象呢?因為不變對象一旦創建,對象內部的數據就不能修改,這樣就減少了由於修改數據導致的錯誤。此外,由於對象不變,多任務環境下同時讀取對象不需要加鎖,同時讀一點問題都沒有。我們在編寫程序時,如果可以設計一個不變對象,那就儘量設計成不變對象。

可變參數
在 Python 函數中,還可以定義可變參數。顧名思義,可變參數就是傳入的參數個數是可變的,可以是 1 個、2 個到任意個,還可以是 0 個。

我們以數學題為例子,給定一組數字 a,b,c……,請計算 a2 + b2 + c2 + ……。

要定義出這個函數,我們必須確定輸入的參數。由於參數個數不確定,我們首先想到可以把 a,b,c…… 作為一個 list 或 tuple 傳進來,這樣,函數可以定義如下:

def calc(numbers):
    sum = 0
    for n in numbers:
        sum = sum + n * n
    return sum

但是調用的時候,需要先組裝出一個 list 或 tuple:

>>> calc([1, 2, 3])
14
>>> calc((1, 3, 5, 7))
84

如果利用可變參數,調用函數的方式可以簡化成這樣:

>>> calc(1, 2, 3)
14
>>> calc(1, 3, 5, 7)
84

所以,我們把函數的參數改為可變參數:

def calc(*numbers):
    sum = 0
    for n in numbers:
        sum = sum + n * n
    return sum

定義可變參數和定義一個 list 或 tuple 參數相比,僅僅在參數前面加了一個 * 號。在函數內部,參數 numbers 接收到的是一個tuple,因此,函數代碼完全不變。但是,調用該函數時,可以傳入任意個參數,包括 0 個參數:

>>> calc(1, 2)
5
>>> calc()
0

如果已經有一個 list 或者 tuple,要調用一個可變參數怎麼辦?可以這樣做:

>>> nums = [1, 2, 3]
>>> calc(nums[0], nums[1], nums[2])
14

這種寫法當然是可行的,問題是太繁瑣,所以 Python 允許你在 list 或 tuple 前面加一個 * 號,把 list 或 tuple 的元素變成可變參數傳進去:

>>> nums = [1, 2, 3]
>>> calc(*nums)
14

*nums 表示把 nums 這個 list 的所有元素作為可變參數傳進去。這種寫法相當有用,而且很常見。

關鍵字參數
可變參數允許你傳入 0 個或任意個參數,這些可變參數在函數調用時自動組裝為一個tuple。而關鍵字參數允許你傳入 0 個或任意個含參數名的參數,這些關鍵字參數在函數內部自動組裝為一個dict

def person(name, age, **kw):
    print('name:', name, 'age:', age, 'other:', kw)

函數 person 除了必選參數 name 和 age 外,還接受關鍵字參數 kw。在調用該函數時,可以只傳入必選參數:

>>> person('Michael', 30)
name: Michael age: 30 other: {}

也可以傳入任意個數的關鍵字參數:

>>> person('Bob', 35, city='Beijing')
name: Bob age: 35 other: {'city': 'Beijing'}
>>> person('Adam', 45, gender='M', job='Engineer')
name: Adam age: 45 other: {'gender': 'M', 'job': 'Engineer'}

關鍵字參數有什麼用?它可以擴展函數的功能。比如,在 person 函數裡,我們保證能接收到 name 和 age 這兩個參數,但是,如果調用者願意提供更多的參數,我們也能收到。試想你正在做一個用戶註冊的功能,除了用戶名和年齡是必填項外,其他都是可選項,利用關鍵字參數來定義這個函數就能滿足註冊的需求。

和可變參數類似,也可以先組裝出一個 dict,然後,把該 dict 轉換為關鍵字參數傳進去:

>>> extra = {'city': 'Beijing', 'job': 'Engineer'}
>>> person('Jack', 24, city=extra['city'], job=extra['job'])
name: Jack age: 24 other: {'city': 'Beijing', 'job': 'Engineer'}

當然,上面複雜的調用可以用簡化的寫法:

>>> extra = {'city': 'Beijing', 'job': 'Engineer'}
>>> person('Jack', 24, **extra)
name: Jack age: 24 other: {'city': 'Beijing', 'job': 'Engineer'}

extra 表示把 extra 這個 dict 的所有 key-value 用關鍵字參數傳入到函數的kw 參數,kw 將獲得一個 dict,注意 kw 獲得的 dict 是 extra 的一份拷貝,對 kw 的改動不會影響到函數外的 extra。

命名關鍵字參數
對於關鍵字參數,函數的調用者可以傳入任意不受限制的關鍵字參數。至於到底傳入了哪些,就需要在函數內部通過 kw 檢查。

仍以 person () 函數為例,我們希望檢查是否有 city 和 job 參數:

def person(name, age, **kw):
    if 'city' in kw:
        # 有city參數
        pass
    if 'job' in kw:
        # 有job參數
        pass
    print('name:', name, 'age:', age, 'other:', kw)

但是調用者仍可以傳入不受限制的關鍵字參數:

>>> person('Jack', 24, city='Beijing', addr='Chaoyang', zipcode=123456)

如果要限制關鍵字參數的名字,就可以用命名關鍵字參數,例如,只接收 city 和 job 作為關鍵字參數。這種方式定義的函數如下:

def person(name, age, *, city, job):
    print(name, age, city, job)

和關鍵字參數 **kw 不同,命名關鍵字參數需要一個特殊分隔符 *,* 後面的參數被視為命名關鍵字參數。

調用方式如下:

>>> person('Jack', 24, city='Beijing', job='Engineer')
Jack 24 Beijing Engineer

如果函數定義中已經有了一個可變參數,後面跟著的命名關鍵字參數就不再需要一個特殊分隔符 * 了:

def person(name, age, *args, city, job):
    print(name, age, args, city, job)

命名關鍵字參數必須傳入參數名,這和位置參數不同。如果沒有傳入參數名,調用將報錯:

>>> person('Jack', 24, 'Beijing', 'Engineer')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: person() missing 2 required keyword-only arguments: 'city' and 'job'

由於調用時缺少參數名 city 和 job,Python 解釋器把前兩個參數視為位置參數,後兩個參數傳給 * args,但缺少命名關鍵字參數導致報錯。

命名關鍵字參數可以有缺省值,從而簡化調用:

def person(name, age, *, city='Beijing', job):
    print(name, age, city, job)

由於命名關鍵字參數 city 具有默認值,調用時,可不傳入 city 參數:

>>> person('Jack', 24, job='Engineer')
Jack 24 Beijing Engineer

使用命名關鍵字參數時,要特別注意,如果沒有可變參數,就必須加一個作為特殊分隔符。如果缺少,Python 解釋器將無法識別位置參數和命名關鍵字參數

def person(name, age, city, job):
    # 缺少 *,city和job被視為位置參數
    pass

參數組合
在 Python 中定義函數,可以用必選參數、默認參數、可變參數、命名關鍵字參數和關鍵字參數這 5 種參數都可以組合使用。但是請注意,參數定義的順序必須是:必選參數、默認參數、可變參數、命名關鍵字參數和關鍵字參數

比如定義一個函數,包含上述若干種參數:

def f1(a, b, c=0, *args, **kw):
    print('a =', a, 'b =', b, 'c =', c, 'args =', args, 'kw =', kw)

def f2(a, b, c=0, *, d, **kw):
    print('a =', a, 'b =', b, 'c =', c, 'd =', d, 'kw =', kw)

在函數調用的時候,Python 解釋器自動按照參數位置和參數名把對應的參數傳進去。

>>> f1(1, 2)
a = 1 b = 2 c = 0 args = () kw = {}
>>> f1(1, 2, c=3)
a = 1 b = 2 c = 3 args = () kw = {}
>>> f1(1, 2, 3, 'a', 'b')
a = 1 b = 2 c = 3 args = ('a', 'b') kw = {}
>>> f1(1, 2, 3, 'a', 'b', x=99)
a = 1 b = 2 c = 3 args = ('a', 'b') kw = {'x': 99}
>>> f2(1, 2, d=99, ext=None)
a = 1 b = 2 c = 0 d = 99 kw = {'ext': None}

最神奇的是通過一個 tuple 和 dict,你也可以調用上述函數:

>>> args = (1, 2, 3, 4)
>>> kw = {'d': 99, 'x': '#'}
>>> f1(*args, **kw)
a = 1 b = 2 c = 3 args = (4,) kw = {'d': 99, 'x': '#'}
>>> args = (1, 2, 3)
>>> kw = {'d': 88, 'x': '#'}
>>> f2(*args, **kw)
a = 1 b = 2 c = 3 d = 88 kw = {'x': '#'}

所以,對於任意函數,都可以通過類似 func (*args, **kw) 的形式調用它,無論它的參數是如何定義的。

雖然可以組合多達 5 種參數,但不要同時使用太多的組合,否則函數接口的可理解性很差。

小結
1 Python 的函數具有非常靈活的參數形態,既可以實現簡單的調用,又可以傳入非常複雜的參數。

2 默認參數一定要用不可變對象,如果是可變對象,程序運行時會有邏輯錯誤!

3 要注意定義可變參數和關鍵字參數的語法:

*args 是可變參數,args 接收的是一個 tuple;

**kw 是關鍵字參數,kw 接收的是一個 dict。

4 以及調用函數時如何傳入可變參數和關鍵字參數的語法:

可變參數既可以直接傳入:func (1, 2, 3),又可以先組裝 list 或 tuple,再通過args 傳入:func ((1, 2, 3));

關鍵字參數既可以直接傳入:func (a=1, b=2),又可以先組裝 dict,再通過kw 傳入:func ({'a': 1, 'b': 2})。

使用 * args 和 **kw 是 Python 的習慣寫法,當然也可以用其他參數名,但最好使用習慣用法。

5 命名的關鍵字參數是為了限制調用者可以傳入的參數名,同時可以提供默認值。

6 定義命名的關鍵字參數在沒有可變參數的情況下不要忘了寫分隔符 *,否則定義的將是位置參數。

遞歸函數
在函數內部,可以調用其他函數。如果一個函數在內部調用自身本身,這個函數就是遞歸函數。

舉個例子,我們來計算階乘 n! = 1 x 2 x 3 x ... x n,用函數 fact (n) 表示,可以看出:

fact(n)=n!=1×2×3×⋅⋅⋅×(n−1)×n=(n−1)!×n=fact(n−1)×n

所以,fact (n) 可以表示為 n x fact (n-1),只有 n=1 時需要特殊處理。

於是,fact (n) 用遞歸的方式寫出來就是:

def fact(n):
    if n==1:
        return 1
    return n * fact(n - 1)

上面就是一個遞歸函數。可以試試:

>>> fact(1)
1
>>> fact(5)
120

如果我們計算 fact (5),可以根據函數定義看到計算過程如下:

===> fact(5)
===> 5 * fact(4)
===> 5 * (4 * fact(3))
===> 5 * (4 * (3 * fact(2)))
===> 5 * (4 * (3 * (2 * fact(1))))
===> 5 * (4 * (3 * (2 * 1)))
===> 5 * (4 * (3 * 2))
===> 5 * (4 * 6)
===> 5 * 24
===> 120

遞歸函數的優點是定義簡單,邏輯清晰。理論上,所有的遞歸函數都可以寫成循環的方式,但循環的邏輯不如遞歸清晰。

使用遞歸函數需要注意防止棧溢出。在計算機中,函數調用是通過(stack)這種數據結構實現的,每當進入一個函數調用,棧就會加一層棧幀,每當函數返回,棧就會減一層棧幀。由於棧的大小不是無限的,所以,遞歸調用的次數過多,會導致棧溢出。可以試試 fact (1000):

>>> fact(1000)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 4, in fact
  ...
  File "<stdin>", line 4, in fact
RuntimeError: maximum recursion depth exceeded in comparison

解決遞歸調用棧溢出的方法是通過尾遞歸優化,事實上尾遞歸和循環的效果是相同的,所以,把循環看成是一種特殊的尾遞歸函數也是可以的。

尾遞歸: 是指在函數返回的時候,調用自身本身,並且,return 語句不能包含表達式
這樣,編譯器或者解釋器就可以把尾遞歸做優化,使遞歸本身無論調用多少次,都只佔用一個棧幀,不會出現棧溢出的情況。

上面的 fact (n) 函數由於 return n * fact (n - 1) 引入了乘法表達式,所以就不是尾遞歸了。要改成尾遞歸方式,需要多一點代碼,主要是要把每一步的乘積傳入到遞歸函數中:

def fact(n):
    return fact_iter(n, 1)

def fact_iter(num, product):
    if num == 1:
        return product
    return fact_iter(num - 1, num * product)

可以看到,return fact_iter (num - 1, num * product) 僅返回遞歸函數本身,num - 1 和 num * product 在函數調用前就會被計算,不影響函數調用。

fact (5) 對應的 fact_iter (5, 1) 的調用如下:

===> fact_iter(5, 1)
===> fact_iter(4, 5)
===> fact_iter(3, 20)
===> fact_iter(2, 60)
===> fact_iter(1, 120)
===> 120

尾遞歸調用時,如果做了優化,棧不會增長,因此,無論多少次調用也不會導致棧溢出。

遺憾的是,大多數編程語言沒有針對尾遞歸做優化,Python 解釋器也沒有做優化,所以,即使把上面的 fact (n) 函數改成尾遞歸方式,也會導致棧溢出。

小結
1 使用遞歸函數的優點是邏輯簡單清晰,缺點是過深的調用會導致棧溢出。

2 針對尾遞歸優化的語言可以通過尾遞歸防止棧溢出。尾遞歸事實上和循環是等價的,沒有循環語句的編程語言只能通過尾遞歸實現循環。

3 Python 標準的解釋器沒有針對尾遞歸做優化,任何遞歸函數都存在棧溢出的问题。

四 高級特性#

代碼越少,開發效率越高
切片
取一個 list 或 tuple 的部分元素是非常常見的操作。比如,一個 list 如下:

>>> L = ['Michael', 'Sarah', 'Tracy', 'Bob', 'Jack']

取前 3 個元素,應該怎麼做?

笨辦法:

>>> [L[0], L[1], L[2]]
['Michael', 'Sarah', 'Tracy']

之所以是笨辦法是因為擴展一下,取前 N 個元素就沒轍了。

取前 N 個元素,也就是索引為 0-(N-1) 的元素,可以用循環:

>>> r = []
>>> n = 3
>>> for i in range(n):
...     r.append(L[i])
... 
>>> r
['Michael', 'Sarah', 'Tracy']

對這種經常取指定索引範圍的操作,用循環十分繁瑣,因此,Python 提供了切片(Slice)操作符,能大大簡化這種操作。

對應上面的问题,取前 3 個元素,用一行代碼就可以完成切片:

>>> L[0:3]
['Michael', 'Sarah', 'Tracy']

L [0:3] 表示,從索引 0 開始取,直到索引 3 為止,但不包括索引 3。即索引 0,1,2,正好是 3 個元素。

如果第一個索引是 0,還可以省略:

>>> L[:3]
['Michael', 'Sarah', 'Tracy']

也可以從索引 1 開始,取出 2 個元素出來:

>>> L[1:3]
['Sarah', 'Tracy']

類似的,既然 Python 支持 L [-1] 取倒數第一個元素,那麼它同樣支持倒數切片

>>> L[-2:]
['Bob', 'Jack']
>>> L[-2:-1]
['Bob']

記住倒數第一個元素的索引是 -1

切片操作十分有用。我們先創建一個 0-99 的

>>> L = list(range(100))
>>> L
[0, 1, 2, 3, ..., 99]

可以通過切片輕鬆取出某一段數列。比如前 10 個數:

>>> L[:10]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

後 10 個數:

>>> L[-10:]
[90, 91, 92, 93, 94, 95, 96, 97, 98, 99]

前 11-20 個數:

>>> L[10:20]
[10, 11, 12, 13, 14, 15, 16, 17, 18, 19]

前 10 個數,每兩個取一個:

>>> L[:10:2]
[0, 2, 4, 6, 8]

所有數,每 5 個取一個:

>>> L[::5]
[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95]

甚至什麼都不寫,只寫 [:] 就可以原樣複製一個 list:

>>> L[:]
[0, 1, 2, 3, ..., 99]

tuple 也是一種 list,唯一区别是 tuple 不可變。因此,tuple 也可以用切片操作,只是操作的結果仍是 tuple:

>>> (0, 1, 2, 3, 4, 5)[:3]
(0, 1, 2)

字符串 'xxx' 也可以看成是一種 list,每個元素就是一個字符。因此,字符串也可以用切片操作,只是操作結果仍是字符串:

>>> 'ABCDEFG'[:3]
'ABC'
>>> 'ABCDEFG'[::2]
'ACEG'

在很多編程語言中,針對字符串提供了很多各種截取函數(例如,substring),其實目的就是對字符串切片。Python 沒有針對字符串的截取函數,只需要切片一個操作就可以完成,非常簡單。

迭代
如果給定一個 list 或 tuple,我們可以通過 for 循環來遍歷這個 list 或 tuple,這種遍歷我們稱為迭代(Iteration)。

在 Python 中,迭代是通過for ... in來完成的
而很多語言比如 C 語言,迭代 list 是通過下標完成的,比如 C 代碼:

for (i=0; i<length; i++) {
    n = list[i];
}

可以看出,Python 的 for 循環抽象程度要高於 C 的 for 循環,因為 Python 的 for 循環不僅可以用在 list 或 tuple 上,還可以作用在其他可迭代對象上。

list 這種數據類型雖然有下標,但很多其他數據類型是沒有下標的,但是,只要是可迭代對象,無論有無下標,都可以迭代,比如 dict 就可以迭代:

>>> d = {'a': 1, 'b': 2, 'c': 3}
>>> for key in d:
...     print(key)
...
a
c
b

因為 dict 的存儲不是按照 list 的方式順序排列,所以,迭代出的結果順序很可能不一樣。

默認情況下,dict 迭代的是 key。如果要迭代 value,可以用for value in d.values(),如果要同時迭代 key 和 value,可以用for k, v in d.items()

由於字符串也是可迭代對象,因此,也可以作用於 for 循環:

>>> for ch in 'ABC':
...     print(ch)
...
A
B
C

所以,當我們使用 for 循環時,只要作用於一個可迭代對象,for 循環就可以正常運行,而我們不太關心該對象究竟是 list 還是其他數據類型。

那麼,如何判斷一個對象是可迭代對象呢?方法是通過collections.abc 模塊的 Iterable類型判斷:

>>> from collections.abc import Iterable
>>> isinstance('abc', Iterable) # str是否可迭代
True
>>> isinstance([1,2,3], Iterable) # list是否可迭代
True
>>> isinstance(123, Iterable) # 整數是否可迭代
False

最後一個問題,如果要對 list 實現類似 Java 那樣的下標循環怎麼辦?Python 內置的enumerate 函數可以把一個 list 變成索引 - 元素對,這樣就可以在 for 循環中同時迭代索引和元素本身:

>>> for i, value in enumerate(['A', 'B', 'C']):
...     print(i, value)
...
0 A
1 B
2 C

上面的 for 循環裡,同時引用了兩個變量,在 Python 裡是很常見的,比如下面的代碼:

>>> for x, y in [(1, 1), (2, 4), (3, 9)]:
...     print(x, y)
...
1 1
2 4
3 9

任何可迭代對象都可以作用於 for 循環,包括我們自定義的數據類型,只要符合迭代條件,就可以使用 for 循環。

列表生成式
列表生成式即 List Comprehensions,是 Python 內置的非常簡單卻強大的可以用來創建 list 的生成式。

舉個例子,要生成 list [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 可以用 list (range (1, 11))

但如果要生成 [1x1, 2x2, 3x3, ..., 10x10] 怎麼做?方法一是循環:

>>> L = []
>>> for x in range(1, 11):
...    L.append(x * x)
...
>>> L
[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

但是循環太繁瑣,而列表生成式則可以用一行語句代替循環生成上面的 list:

>>> [x * x for x in range(1, 11)]
[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

寫列表生成式時,把要生成的元素 x * x 放到前面,後面跟 for 循環,就可以把 list 創建出來

for 循環後面還可以加上 if 判斷,這樣我們就可以篩選出僅偶數的平方:

>>> [x * x for x in range(1, 11) if x % 2 == 0]
[4, 16, 36, 64, 100]

還可以使用兩層循環,可以生成全排列:

>>> [m + n for m in 'ABC' for n in 'XYZ']
['AX', 'AY', 'AZ', 'BX', 'BY', 'BZ', 'CX', 'CY', 'CZ']

三層和三層以上的循環就很少用了。

運用列表生成式,可以寫出非常簡潔的代碼。例如,列出當前目錄下的所有文件和目錄名,可以通過一行代碼實現:

>>> import os # 導入os模塊,模塊的概念後面講到
>>> [d for d in os.listdir('.')] # os.listdir可以列出文件和目錄
['.emacs.d', '.ssh', '.Trash', 'Adlm', 'Applications', 'Desktop', 'Documents', 'Downloads', 'Library', 'Movies', 'Music', 'Pictures', 'Public', 'VirtualBox VMs', 'Workspace', 'XCode']

for 循環其實可以同時使用兩個甚至多個變量,比如 dict 的 items () 可以同時迭代 key 和 value:

>>> d = {'x': 'A', 'y': 'B', 'z': 'C' }
>>> for k, v in d.items():
...     print(k, '=', v)
...
y = B
x = A
z = C

因此,列表生成式也可以使用兩個變量來生成 list:

>>> d = {'x': 'A', 'y': 'B', 'z': 'C' }
>>> [k + '=' + v for k, v in d.items()]
['y=B', 'x=A', 'z=C']

最後把一個 list 中所有的字符串變成小寫:

>>> L = ['Hello', 'World', 'IBM', 'Apple']
>>> [s.lower() for s in L]
['hello', 'world', 'ibm', 'apple']

if ... else
使用列表生成式的時候,有些童鞋經常搞不清楚 if...else 的用法。

例如,以下代碼正常輸出偶數:

>>> [x for x in range(1, 11) if x % 2 == 0]
[2, 4, 6, 8, 10]

但是,我們不能在最後的 if 加上 else:

>>> [x for x in range(1, 11) if x % 2 == 0 else 0]
  File "<stdin>", line 1
    [x for x in range(1, 11) if x % 2 == 0 else 0]
                                              ^
SyntaxError: invalid syntax

這是因為跟在 for 後面的 if 是一個篩選條件,不能帶 else,否則如何篩選?

把 if 寫在 for 前面必須加 else,否則報錯:

>>> [x if x % 2 == 0 for x in range(1, 11)]
  File "<stdin>", line 1
    [x if x % 2 == 0 for x in range(1, 11)]
                       ^
SyntaxError: invalid syntax

這是因為 for 前面的部分是一個表達式,它必須根據 x 計算出一個結果。因此,考察表達式:x if x % 2 == 0,它無法根據 x 計算出結果,因為缺少 else,必須加上 else:

>>> [x if x % 2 == 0 else -x for x in range(1, 11)]
[-1, 2, -3, 4, -5, 6, -7, 8, -9, 10]

上述 for 前面的表達式 x if x % 2 == 0 else -x 才能根據 x 計算出確定的結果。

可見,在一個列表生成式中,for 前面的 if ... else 是表達式,而 for 後面的 if 是過濾條件,不能帶 else。

生成器
通過列表生成式,我們可以直接創建一個列表。但是,受到內存限制,列表容量肯定是有限的。而且,創建一個包含 100 萬個元素的列表,不僅佔用很大的存儲空間,如果我們僅僅需要訪問前面幾個元素,那後面絕大多數元素佔用的空間都白白浪費了。

所以,如果列表元素可以按照某種算法推算出來,那我們是否可以在循環的過程中不斷推算出後續的元素呢?這樣就不必創建完整的 list,從而節省大量的空間。在 Python 中,這種一邊循環一邊計算的機制,稱為生成器:generator

要創建一個 generator,有很多種方法。第一種方法很簡單,只要把一個列表生成式的 [] 改成 (),就創建了一個 generator:

>>> L = [x * x for x in range(10)]
>>> L
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
>>> g = (x * x for x in range(10))
>>> g
<generator object <genexpr> at 0x1022ef630>

創建 L 和 g 的區別僅在於最外層的 [] 和 (),L 是一個 list,而 g 是一個 generator。

我們可以直接打印出 list 的每一個元素,但我們怎麼打印出 generator 的每一個元素呢?

如果要一個一個打印出來,可以通過next () 函數獲得 generator 的下一個返回值:

>>> next(g)
0
>>> next(g)
1
>>> next(g)
4
>>> next(g)
9
>>> next(g)
16
>>> next(g)
25
>>> next(g)
36
>>> next(g)
49
>>> next(g)
64
>>> next(g)
81
>>> next(g)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

generator 保存的是算法,每次調用 next (g),就計算出 g 的下一個元素的值,直到計算到最後一個元素,沒有更多的元素時,拋出 StopIteration 的錯誤。

當然,上面這種不斷調用 next (g) 實在是太变态了,正確的方法是使用 for 循環,因為generator 也是可迭代對象

>>> g = (x * x for x in range(10))
>>> for n in g:
...     print(n)
... 
0
1
4
9
16
25
36
49
64
81

所以,我們創建了一個 generator 後,基本上永遠不會調用 next (),而是通過for 循環來迭代它,並且不需要關心 StopIteration 的錯誤。

generator 非常強大。如果推算的算法比較複雜,用類似列表生成式的 for 循環無法實現的時候,還可以用函數來實現。

比如,著名的斐波拉契數列(Fibonacci),除第一個和第二個數外,任意一個數都可由前兩個數相加得到:
1, 1, 2, 3, 5, 8, 13, 21, 34, ...
斐波拉契數列用列表生成式寫不出來,但是,用函數把它打印出來卻很容易:

def fib(max):
    n, a, b = 0, 0, 1
    while n < max:
        print(b)
        a, b = b, a + b
        n = n + 1
    return 'done'

注意,賦值語句:

a, b = b, a + b

相當於:

t = (b, a + b) # t是一個tuple
a = t[0]
b = t[1]

但不必顯式寫出臨時變量 t 就可以賦值。

上面的函數可以輸出斐波那契數列的前 N 個數:

>>> fib(6)
1
1
2
3
5
8
'done'

仔細觀察,可以看出,fib 函數實際上是定義了斐波拉契數列的推算規則,可以從第一個元素開始,推算出後續任意的元素,這種邏輯其實非常類似 generator。

也就是說,上面的函數和 generator 僅一步之遙。要把 fib 函數變成 generator 函數,只需要把 print (b) 改為 yield b 就可以了:

def fib(max):
    n, a, b = 0, 0, 1
    while n < max:
        yield b
        a, b = b, a + b
        n = n + 1
    return 'done'

這就是定義 generator 的另一種方法。如果一個函數定義中包含yield關鍵字,那麼這個函數就不再是一個普通函數,而是一個generator 函數,調用一個 generator 函數將返回一個 generator:

>>> f = fib(6)
>>> f
<generator object fib at 0x104feaaa0>

這裡,最難理解的就是 generator 函數和普通函數的執行流程不一樣。普通函數是順序執行,遇到 return 語句或者最後一行函數語句就返回。而變成 generator 的函數,在每次調用 next () 的時候執行,遇到 yield 語句返回,再次執行時從上次返回的 yield 語句處繼續執行

舉個簡單的例子,定義一個 generator 函數,依次返回數字 1,3,5:

def odd():
    print('step 1')
    yield 1
    print('step 2')
    yield(3)
    print('step 3')
    yield(5)

調用該 generator 函數時,首先要生成一個 generator 對象,然後用 next () 函數不斷獲得下一个返回值:

>>> o = odd()
>>> next(o)
step 1
1
>>> next(o)
step 2
3
>>> next(o)
step 3
5

可以看到,odd 不是普通函數,而是 generator 函數,在執行過程中,遇到 yield 就中斷,下次又繼續執行。執行 3 次 yield 後,已經沒有 yield 可以執行了,所以,第 4 次調用 next (o) 就報錯。

請務必注意:調用 generator 函數會創建一個 generator 對象,多次調用 generator 函數會創建多個相互獨立的 generator。
有的發現這樣調用 next () 每次都返回 1:

>>> next(odd())
step 1
1
>>> next(odd())
step 1
1
>>> next(odd())
step 1
1

原因在於 odd () 會創建一個新的 generator 對象,上述代碼實際上創建了 3 個完全獨立的 generator,對 3 個 generator 分別調用 next () 當然每個都會返回第一個值。

正確的寫法是創建一個 generator 對象,然後不斷對這一個 generator 對象調用 next ():

>>> g = odd()
>>> next(g)
step 1
1
>>> next(g)
step 2
3
>>> next(g)
step 3
5

回到 fib 的例子,我們在循環過程中不斷調用 yield,就會不斷中斷。當然要給循環設置一個條件來退出循環,不然就會產生一個無限數列出來。

同樣的,把函數改成 generator 函數後,我們基本上從來不會用 next () 來獲取下一個返回值,而是直接使用 for 循環來迭代:

>>> for n in fib(6):
...     print(n)
...
1
1
2
3
5
8

但是用 for 循環調用 generator 時,發現拿不到 generator 的 return 語句的返回值。如果想要拿到返回值,必須捕獲 StopIteration 錯誤,返回值包含在 StopIteration 的 value 中:

>>> g = fib(6)
>>> while True:
...     try:
...         x = next(g)
...         print('g:', x)
...     except StopIteration as e:
...         print('Generator return value:', e.value)
...         break
...
g: 1
g: 1
g: 2
g: 3
g: 5
g: 8
Generator return value: done

小結
1 generator 是非常強大的工具,在 Python 中,可以簡單地把列表生成式改成 generator,也可以通過函數實現複雜邏輯的 generator

要理解 generator 的工作原理,它是在 for 循環的過程中不斷計算出下一個元素,並在適當的條件結束 for 循環。對於函數改成的 generator 來說,遇到 return 語句或者執行到函數體最後一行語句,就是結束 generator 的指令,for 循環隨之結束。

請注意區分普通函數和 generator 函數,普通函數調用直接返回結果:

>>> r = abs(6)
>>> r
6

generator 函數的調用實際返回一個 generator 對象

>>> g = fib(6)
>>> g
<generator object fib at 0x1022ef948>

迭代器
已經知道,可以直接作用於 for 循環的數據類型有以下幾種:

一類是集合數據類型,如 list、tuple、dict、set、str 等;

一類是generator,包括生成器和帶 yield 的 generator function。

這些可以直接作用於 for 循環的對象統稱為可迭代對象:Iterable。

可以使用isinstance() 判斷一個對象是否是 Iterable 對象:

>>> from collections.abc import Iterable
>>> isinstance([], Iterable)
True
>>> isinstance({}, Iterable)
True
>>> isinstance('abc', Iterable)
True
>>> isinstance((x for x in range(10)), Iterable)
True
>>> isinstance(100, Iterable)
False
 

而生成器不但可以作用於 for 循環,還可以被 next () 函數不斷調用並返回下一個值,直到最後拋出 StopIteration 錯誤表示無法繼續返回下一個值了。

可以被 next () 函數調用並不斷返回下一個值的對象稱為迭代器:Iterator。

可以使用isinstance() 判斷一個對象是否是 Iterator 對象:

>>> from collections.abc import Iterator
>>> isinstance((x for x in range(10)), Iterator)
True
>>> isinstance([], Iterator)
False
>>> isinstance({}, Iterator)
False
>>> isinstance('abc', Iterator)
False

生成器都是 Iterator 對象,但 list、dict、str 雖然是 Iterable 卻不是 Iterator。

把 list、dict、str 等 Iterable 變成 Iterator 可以使用iter() 函數:

>>> isinstance(iter([]), Iterator)
True
>>> isinstance(iter('abc'), Iterator)
True

你可能會問,為什麼 list、dict、str 等數據類型不是 Iterator?

這是因為 Python 的 Iterator 對象表示的是一個數據流,Iterator 對象可以被 next () 函數調用並不斷返回下一個數據,直到沒有數據時拋出 StopIteration 錯誤。可以把這個數據流看做是一個有序序列,但我們卻不能提前知道序列的長度,只能不斷通過 next () 函數實現按需計算下一個數據,所以 Iterator 的計算是惰性的,只有在需要返回下一個數據時它才會計算。

Iterator 甚至可以表示一個無限大的數據流,例如全體自然數。而使用 list 是永遠不可能存儲全體自然數的。

小結
1 凡是可作用於 for 循環的對象都是 Iterable 類型;

2 凡是可作用於 next () 函數的對象都是 Iterator 類型,它們表示一個惰性計算的序列;

3 集合數據類型如 list、dict、str 等是 Iterable 但不是 Iterator,不過可以通過 iter () 函數獲得一個 Iterator 對象。

Python 的 for 循環本質上就是通過不斷調用 next () 函數實現的,例如:

for x in [1, 2, 3, 4, 5]:
    pass

實際上完全等價於:

# 首先獲得Iterator對象:
it = iter([1, 2, 3, 4, 5])
# 循環:
while True:
    try:
        # 獲得下一個值:
        x = next(it)
    except StopIteration:
        # 遇到StopIteration就退出循環
        break
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。