中文亚洲精品无码_熟女乱子伦免费_人人超碰人人爱国产_亚洲熟妇女综合网

當前位置: 首頁 > news >正文

網站banner圖的作用公司網站與推廣

網站banner圖的作用,公司網站與推廣,學校部門網站建設總結,android手機版Python簡介 Python是著名的“龜叔”Guido van Rossum在1989年圣誕節(jié)期間,為了打發(fā)無聊的圣誕節(jié)而編寫的一個編程語言。 現(xiàn)在,全世界差不多有600多種編程語言,但流行的編程語言也就那么20來種。如果你聽說過TIOBE排行榜,你就能知…

Python簡介

Python是著名的“龜叔”Guido van Rossum在1989年圣誕節(jié)期間,為了打發(fā)無聊的圣誕節(jié)而編寫的一個編程語言。

現(xiàn)在,全世界差不多有600多種編程語言,但流行的編程語言也就那么20來種。如果你聽說過TIOBE排行榜,你就能知道編程語言的大致流行程度。這是最近10年最常用的10種編程語言的變化圖:

tpci_trends

總的來說,這幾種編程語言各有千秋。C語言是可以用來編寫操作系統(tǒng)的貼近硬件的語言,所以,C語言適合開發(fā)那些追求運行速度、充分發(fā)揮硬件性能的程序。而Python是用來編寫應用程序的高級編程語言。

當你用一種語言開始作真正的軟件開發(fā)時,你除了編寫代碼外,還需要很多基本的已經寫好的現(xiàn)成的東西,來幫助你加快開發(fā)進度。比如說,要編寫一個電子郵件客戶端,如果先從最底層開始編寫網絡協(xié)議相關的代碼,那估計一年半載也開發(fā)不出來。高級編程語言通常都會提供一個比較完善的基礎代碼庫,讓你能直接調用,比如,針對電子郵件協(xié)議的SMTP庫,針對桌面環(huán)境的GUI庫,在這些已有的代碼庫的基礎上開發(fā),一個電子郵件客戶端幾天就能開發(fā)出來。

Python就為我們提供了非常完善的基礎代碼庫,覆蓋了網絡、文件、GUI、數(shù)據(jù)庫、文本等大量內容,被形象地稱作“內置電池(batteries included)”。用Python開發(fā),許多功能不必從零編寫,直接使用現(xiàn)成的即可。

除了內置的庫外,Python還有大量的第三方庫,也就是別人開發(fā)的,供你直接使用的東西。當然,如果你開發(fā)的代碼通過很好的封裝,也可以作為第三方庫給別人使用。

許多大型網站就是用Python開發(fā)的,例如YouTube、Instagram,還有國內的豆瓣。很多大公司,包括Google、Yahoo等,甚至NASA(美國航空航天局)都大量地使用Python。

龜叔給Python的定位是“優(yōu)雅”、“明確”、“簡單”,所以Python程序看上去總是簡單易懂,初學者學Python,不但入門容易,而且將來深入下去,可以編寫那些非常非常復雜的程序。

總的來說,Python的哲學就是簡單優(yōu)雅,盡量寫容易看明白的代碼,盡量寫少的代碼。如果一個資深程序員向你炫耀他寫的晦澀難懂、動不動就幾萬行的代碼,你可以盡情地嘲笑他。

那Python適合開發(fā)哪些類型的應用呢?

首選是網絡應用,包括網站、后臺服務等等;

其次是許多日常需要的小工具,包括系統(tǒng)管理員需要的腳本任務等等;

另外就是把其他語言開發(fā)的程序再包裝起來,方便使用。

最后說說Python的缺點。

任何編程語言都有缺點,Python也不例外。優(yōu)點說過了,那Python有哪些缺點呢?

第一個缺點就是運行速度慢,和C程序相比非常慢,因為Python是解釋型語言,你的代碼在執(zhí)行時會一行一行地翻譯成CPU能理解的機器碼,這個翻譯過程非常耗時,所以很慢。而C程序是運行前直接編譯成CPU能執(zhí)行的機器碼,所以非???。

但是大量的應用程序不需要這么快的運行速度,因為用戶根本感覺不出來。例如開發(fā)一個下載MP3的網絡應用程序,C程序的運行時間需要0.001秒,而Python程序的運行時間需要0.1秒,慢了100倍,但由于網絡更慢,需要等待1秒,你想,用戶能感覺到1.001秒和1.1秒的區(qū)別嗎?這就好比F1賽車和普通的出租車在北京三環(huán)路上行駛的道理一樣,雖然F1賽車理論時速高達400公里,但由于三環(huán)路堵車的時速只有20公里,因此,作為乘客,你感覺的時速永遠是20公里。

不要在意程序運行速度

第二個缺點就是代碼不能加密。如果要發(fā)布你的Python程序,實際上就是發(fā)布源代碼,這一點跟C語言不同,C語言不用發(fā)布源代碼,只需要把編譯后的機器碼(也就是你在Windows上常見的xxx.exe文件)發(fā)布出去。要從機器碼反推出C代碼是不可能的,所以,凡是編譯型的語言,都沒有這個問題,而解釋型的語言,則必須把源碼發(fā)布出去。

這個缺點僅限于你要編寫的軟件需要賣給別人掙錢的時候。好消息是目前的互聯(lián)網時代,靠賣軟件授權的商業(yè)模式越來越少了,靠網站和移動應用賣服務的模式越來越多了,后一種模式不需要把源碼給別人。

再說了,現(xiàn)在如火如荼的開源運動和互聯(lián)網自由開放的精神是一致的,互聯(lián)網上有無數(shù)非常優(yōu)秀的像Linux一樣的開源代碼,我們千萬不要高估自己寫的代碼真的有非常大的“商業(yè)價值”。那些大公司的代碼不愿意開放的更重要的原因是代碼寫得太爛了,一旦開源,就沒人敢用他們的產品了。

哪有功夫破解你的爛代碼

當然,Python還有其他若干小缺點,請自行忽略,就不一一列舉了。

安裝Python

因為Python是跨平臺的,它可以運行在Windows、Mac和各種Linux/Unix系統(tǒng)上。在Windows上寫Python程序,放到Linux上也是能夠運行的。

要開始學習Python編程,首先就得把Python安裝到你的電腦里。安裝后,你會得到Python解釋器(就是負責運行Python程序的),一個命令行交互環(huán)境,還有一個簡單的集成開發(fā)環(huán)境。

安裝Python 3.5

目前,Python有兩個版本,一個是2.x版,一個是3.x版,這兩個版本是不兼容的。由于3.x版越來越普及,我們的教程將以最新的Python 3.5版本為基礎。請確保你的電腦上安裝的Python版本是最新的3.5.x,這樣,你才能無痛學習這個教程。

在Mac上安裝Python

如果你正在使用Mac,系統(tǒng)是OS X 10.8~10.10,那么系統(tǒng)自帶的Python版本是2.7。要安裝最新的Python 3.5,有兩個方法:

方法一:從Python官網下載Python 3.5的安裝程序(網速慢的同學請移步國內鏡像),雙擊運行并安裝;

方法二:如果安裝了Homebrew,直接通過命令brew install python3安裝即可。

在Linux上安裝Python

如果你正在使用Linux,那我可以假定你有Linux系統(tǒng)管理經驗,自行安裝Python 3應該沒有問題,否則,請換回Windows系統(tǒng)。

對于大量的目前仍在使用Windows的同學,如果短期內沒有打算換Mac,就可以繼續(xù)閱讀以下內容。

在Windows上安裝Python

首先,根據(jù)你的Windows版本(64位還是32位)從Python的官方網站下載Python 3.5對應的64位安裝程序或32位安裝程序(網速慢的同學請移步國內鏡像),然后,運行下載的EXE安裝包:

install-py35

特別要注意勾上Add Python 3.5 to PATH,然后點“Install Now”即可完成安裝。

默認會安裝到C:\Python35目錄下,然后打開命令提示符窗口,敲入python后,會出現(xiàn)兩種情況:

情況一:

run-py3-win

看到上面的畫面,就說明Python安裝成功!

你看到提示符>>>就表示我們已經在Python交互式環(huán)境中了,可以輸入任何Python代碼,回車后會立刻得到執(zhí)行結果。現(xiàn)在,輸入exit()并回車,就可以退出Python交互式環(huán)境(直接關掉命令行窗口也可以)。

情況二:得到一個錯誤:

‘python’ 不是內部或外部命令,也不是可運行的程序或批處理文件。

python-not-found

這是因為Windows會根據(jù)一個Path的環(huán)境變量設定的路徑去查找python.exe,如果沒找到,就會報錯。如果在安裝時漏掉了勾選Add Python 3.5 to PATH,那就要手動把python.exe所在的路徑添加到Path中。

如果你不知道怎么修改環(huán)境變量,建議把Python安裝程序重新運行一遍,務必記得勾上Add Python 3.5 to PATH

小結

學會如何把Python安裝到計算機中,并且熟練打開和退出Python交互式環(huán)境。

在Windows上運行Python時,請先啟動命令行,然后運行python。

在Mac和Linux上運行Python時,請打開終端,然后運行python3。

Python解釋器

當我們編寫Python代碼時,我們得到的是一個包含Python代碼的以.py為擴展名的文本文件。要運行代碼,就需要Python解釋器去執(zhí)行.py文件。

由于整個Python語言從規(guī)范到解釋器都是開源的,所以理論上,只要水平夠高,任何人都可以編寫Python解釋器來執(zhí)行Python代碼(當然難度很大)。事實上,確實存在多種Python解釋器。

CPython

當我們從Python官方網站下載并安裝好Python 3.5后,我們就直接獲得了一個官方版本的解釋器:CPython。這個解釋器是用C語言開發(fā)的,所以叫CPython。在命令行下運行python就是啟動CPython解釋器。

CPython是使用最廣的Python解釋器。教程的所有代碼也都在CPython下執(zhí)行。

IPython

IPython是基于CPython之上的一個交互式解釋器,也就是說,IPython只是在交互方式上有所增強,但是執(zhí)行Python代碼的功能和CPython是完全一樣的。好比很多國產瀏覽器雖然外觀不同,但內核其實都是調用了IE。

CPython用>>>作為提示符,而IPython用In [序號]:作為提示符。

PyPy

PyPy是另一個Python解釋器,它的目標是執(zhí)行速度。PyPy采用JIT技術,對Python代碼進行動態(tài)編譯(注意不是解釋),所以可以顯著提高Python代碼的執(zhí)行速度。

絕大部分Python代碼都可以在PyPy下運行,但是PyPy和CPython有一些是不同的,這就導致相同的Python代碼在兩種解釋器下執(zhí)行可能會有不同的結果。如果你的代碼要放到PyPy下執(zhí)行,就需要了解PyPy和CPython的不同點。

Jython

Jython是運行在Java平臺上的Python解釋器,可以直接把Python代碼編譯成Java字節(jié)碼執(zhí)行。

IronPython

IronPython和Jython類似,只不過IronPython是運行在微軟.Net平臺上的Python解釋器,可以直接把Python代碼編譯成.Net的字節(jié)碼。

小結

Python的解釋器很多,但使用最廣泛的還是CPython。如果要和Java或.Net平臺交互,最好的辦法不是用Jython或IronPython,而是通過網絡調用來交互,確保各程序之間的獨立性。

本教程的所有代碼只確保在CPython 3.5版本下運行。請務必在本地安裝CPython(也就是從Python官方網站下載的安裝程序)。

第一個Python程序

現(xiàn)在,了解了如何啟動和退出Python的交互式環(huán)境,我們就可以正式開始編寫Python代碼了。

在寫代碼之前,請千萬不要用“復制”-“粘貼”把代碼從頁面粘貼到你自己的電腦上。寫程序也講究一個感覺,你需要一個字母一個字母地把代碼自己敲進去,在敲代碼的過程中,初學者經常會敲錯代碼,所以,你需要仔細地檢查、對照,才能以最快的速度掌握如何寫程序。

simpson-learn-py3

在交互式環(huán)境的提示符>>>下,直接輸入代碼,按回車,就可以立刻得到代碼執(zhí)行結果。現(xiàn)在,試試輸入100+200,看看計算結果是不是300:

>>> 100+200
300

很簡單吧,任何有效的數(shù)學計算都可以算出來。

如果要讓Python打印出指定的文字,可以用print()函數(shù),然后把希望打印的文字用單引號或者雙引號括起來,但不能混用單引號和雙引號:

>>> print('hello, world')
hello, world

這種用單引號或者雙引號括起來的文本在程序中叫字符串,今后我們還會經常遇到。

最后,用exit()退出Python,我們的第一個Python程序完成!唯一的缺憾是沒有保存下來,下次運行時還要再輸入一遍代碼。

小結

在Python交互式命令行下,可以直接輸入代碼,然后執(zhí)行,并立刻得到結果。

使用文本編輯器

在Python的交互式命令行寫程序,好處是一下就能得到結果,壞處是沒法保存,下次還想運行的時候,還得再敲一遍。

所以,實際開發(fā)的時候,我們總是使用一個文本編輯器來寫代碼,寫完了,保存為一個文件,這樣,程序就可以反復運行了。

現(xiàn)在,我們就把上次的'hello, world'程序用文本編輯器寫出來,保存下來。

那么問題來了:文本編輯器到底哪家強?

推薦兩款文本編輯器:

一個是Sublime Text,免費使用,但是不付費會彈出提示框:

sublime

一個是Notepad++,免費使用,有中文界面:

notepad++

請注意,用哪個都行,但是絕對不能用Word和Windows自帶的記事本。Word保存的不是純文本文件,而記事本會自作聰明地在文件開始的地方加上幾個特殊字符(UTF-8 BOM),結果會導致程序運行出現(xiàn)莫名其妙的錯誤。

安裝好文本編輯器后,輸入以下代碼:

print('hello, world')

注意print前面不要有任何空格。然后,選擇一個目錄,例如C:\work,把文件保存為hello.py,就可以打開命令行窗口,把當前目錄切換到hello.py所在目錄,就可以運行這個程序了:

C:\work>python hello.py
hello, world

也可以保存為別的名字,比如first.py,但是必須要以.py結尾,其他的都不行。此外,文件名只能是英文字母、數(shù)字和下劃線的組合。

如果當前目錄下沒有hello.py這個文件,運行python hello.py就會報錯:

C:\Users\IEUser>python hello.py
python: can't open file 'hello.py': [Errno 2] No such file or directory

報錯的意思就是,無法打開hello.py這個文件,因為文件不存在。這個時候,就要檢查一下當前目錄下是否有這個文件了。如果hello.py存放在另外一個目錄下,要首先用cd命令切換當前目錄:

命令行模式和Python交互模式

請注意區(qū)分命令行模式和Python交互模式。

看到類似C:\>是在Windows提供的命令行模式:

mode-cmd

在命令行模式下,可以執(zhí)行python進入Python交互式環(huán)境,也可以執(zhí)行python hello.py運行一個.py文件。

看到>>>是在Python交互式環(huán)境下:

run-py3-win

在Python交互式環(huán)境下,只能輸入Python代碼并立刻執(zhí)行。

此外,在命令行模式運行.py文件和在Python交互式環(huán)境下直接運行Python代碼有所不同。Python交互式環(huán)境會把每一行Python代碼的結果自動打印出來,但是,直接運行Python代碼卻不會。

例如,在Python交互式環(huán)境下,輸入:

>>> 100 + 200 + 300
600

直接可以看到結果600

但是,寫一個calc.py的文件,內容如下:

100 + 200 + 300

然后在命令行模式下執(zhí)行:

C:\work>python calc.py

發(fā)現(xiàn)什么輸出都沒有。

這是正常的。想要輸出結果,必須自己用print()打印出來。把calc.py改造一下:

print(100 + 200 + 300)

再執(zhí)行,就可以看到結果:

C:\work>python calc.py
600
直接運行py文件

還有同學問,能不能像.exe文件那樣直接運行.py文件呢?在Windows上是不行的,但是,在Mac和Linux上是可以的,方法是在.py文件的第一行加上一個特殊的注釋:

#!/usr/bin/env python3print('hello, world')

然后,通過命令給hello.py以執(zhí)行權限:

$ chmod a+x hello.py

就可以直接運行hello.py了,比如在Mac下運行:

run-python-in-shell

小結

用文本編輯器寫Python程序,然后保存為后綴為.py的文件,就可以用Python直接運行這個程序了。

Python的交互模式和直接運行.py文件有什么區(qū)別呢?

直接輸入python進入交互模式,相當于啟動了Python解釋器,但是等待你一行一行地輸入源代碼,每輸入一行就執(zhí)行一行。

直接運行.py文件相當于啟動了Python解釋器,然后一次性把.py文件的源代碼給執(zhí)行了,你是沒有機會以交互的方式輸入源代碼的。

用Python開發(fā)程序,完全可以一邊在文本編輯器里寫代碼,一邊開一個交互式命令窗口,在寫代碼的過程中,把部分代碼粘到命令行去驗證,事半功倍!前提是得有個27'的超大顯示器!

參考源碼

hello.py

Python代碼運行助手

Python代碼運行助手可以讓你在線輸入Python代碼,然后通過本機運行的一個Python腳本來執(zhí)行代碼。原理如下:

  • 在網頁輸入代碼:

code-input

  • 點擊Run按鈕,代碼被發(fā)送到本機正在運行的Python代碼運行助手;

  • Python代碼運行助手將代碼保存為臨時文件,然后調用Python解釋器執(zhí)行代碼;

  • 網頁顯示代碼執(zhí)行結果:

code-result

下載

點擊右鍵,目標另存為:learning.py

備用下載地址:learning.py

運行

在存放learning.py的目錄下運行命令:

C:\Users\michael\Downloads> python learning.py

如果看到Ready for Python code on port 39093...表示運行成功,不要關閉命令行窗口,最小化放到后臺運行即可:

run-learning.py

試試效果

需要支持HTML5的瀏覽器:

  • IE >= 9
  • Firefox
  • Chrome
  • Sarafi
# 測試代碼:
----
print('Hello, world')

輸入和輸出

輸出

print()在括號中加上字符串,就可以向屏幕上輸出指定的文字。比如輸出'hello, world',用代碼實現(xiàn)如下:

>>> print('hello, world')

print()函數(shù)也可以接受多個字符串,用逗號“,”隔開,就可以連成一串輸出:

>>> print('The quick brown fox', 'jumps over', 'the lazy dog')
The quick brown fox jumps over the lazy dog

print()會依次打印每個字符串,遇到逗號“,”會輸出一個空格,因此,輸出的字符串是這樣拼起來的:

print-explain

print()也可以打印整數(shù),或者計算結果:

>>> print(300)
300
>>> print(100 + 200)
300

因此,我們可以把計算100 + 200的結果打印得更漂亮一點:

>>> print('100 + 200 =', 100 + 200)
100 + 200 = 300

注意,對于100 + 200,Python解釋器自動計算出結果300,但是,'100 + 200 ='是字符串而非數(shù)學公式,Python把它視為字符串,請自行解釋上述打印結果。

輸入

現(xiàn)在,你已經可以用print()輸出你想要的結果了。但是,如果要讓用戶從電腦輸入一些字符怎么辦?Python提供了一個input(),可以讓用戶輸入字符串,并存放到一個變量里。比如輸入用戶的名字:

>>> name = input()
Michael

當你輸入name = input()并按下回車后,Python交互式命令行就在等待你的輸入了。這時,你可以輸入任意字符,然后按回車后完成輸入。

輸入完成后,不會有任何提示,Python交互式命令行又回到>>>狀態(tài)了。那我們剛才輸入的內容到哪去了?答案是存放到name變量里了??梢灾苯虞斎?code>name查看變量內容:

>>> name
'Michael'

什么是變量?請回憶初中數(shù)學所學的代數(shù)基礎知識:

設正方形的邊長為a,則正方形的面積為a x a。把邊長a看做一個變量,我們就可以根據(jù)a的值計算正方形的面積,比如:

若a=2,則面積為a x a = 2 x 2 = 4;

若a=3.5,則面積為a x a = 3.5 x 3.5 = 12.25。

在計算機程序中,變量不僅可以為整數(shù)或浮點數(shù),還可以是字符串,因此,name作為一個變量就是一個字符串。

要打印出name變量的內容,除了直接寫name然后按回車外,還可以用print()函數(shù):

>>> print(name)
Michael

有了輸入和輸出,我們就可以把上次打印'hello, world'的程序改成有點意義的程序了:

name = input()
print('hello,', name)

運行上面的程序,第一行代碼會讓用戶輸入任意字符作為自己的名字,然后存入name變量中;第二行代碼會根據(jù)用戶的名字向用戶說hello,比如輸入Michael

C:\Workspace> python hello.py
Michael
hello, Michael

但是程序運行的時候,沒有任何提示信息告訴用戶:“嘿,趕緊輸入你的名字”,這樣顯得很不友好。幸好,input()可以讓你顯示一個字符串來提示用戶,于是我們把代碼改成:

name = input('please enter your name: ')
print('hello,', name)

再次運行這個程序,你會發(fā)現(xiàn),程序一運行,會首先打印出please enter your name:,這樣,用戶就可以根據(jù)提示,輸入名字后,得到hello, xxx的輸出:

C:\Workspace> python hello.py
please enter your name: Michael
hello, Michael

每次運行該程序,根據(jù)用戶輸入的不同,輸出結果也會不同。

在命令行下,輸入和輸出就是這么簡單。

小結

任何計算機程序都是為了執(zhí)行一個特定的任務,有了輸入,用戶才能告訴計算機程序所需的信息,有了輸出,程序運行后才能告訴用戶任務的結果。

輸入是Input,輸出是Output,因此,我們把輸入輸出統(tǒng)稱為Input/Output,或者簡寫為IO。

input()print()是在命令行下面最基本的輸入和輸出,但是,用戶也可以通過其他更高級的圖形界面完成輸入和輸出,比如,在網頁上的一個文本框輸入自己的名字,點擊“確定”后在網頁上看到輸出信息。

練習

請利用print()輸出1024 * 768 = xxx

# -*- coding: utf-8 -*-
----
print(???)
參考源碼

do_input.py

Python基礎

Python是一種計算機編程語言。計算機編程語言和我們日常使用的自然語言有所不同,最大的區(qū)別就是,自然語言在不同的語境下有不同的理解,而計算機要根據(jù)編程語言執(zhí)行任務,就必須保證編程語言寫出的程序決不能有歧義,所以,任何一種編程語言都有自己的一套語法,編譯器或者解釋器就是負責把符合語法的程序代碼轉換成CPU能夠執(zhí)行的機器碼,然后執(zhí)行。Python也不例外。

Python的語法比較簡單,采用縮進方式,寫出來的代碼就像下面的樣子:

# print absolute value of an integer:
a = 100
if a >= 0:print(a)
else:print(-a)

#開頭的語句是注釋,注釋是給人看的,可以是任意內容,解釋器會忽略掉注釋。其他每一行都是一個語句,當語句以冒號:結尾時,縮進的語句視為代碼塊。

縮進有利有弊。好處是強迫你寫出格式化的代碼,但沒有規(guī)定縮進是幾個空格還是Tab。按照約定俗成的管理,應該始終堅持使用4個空格的縮進。

縮進的另一個好處是強迫你寫出縮進較少的代碼,你會傾向于把一段很長的代碼拆分成若干函數(shù),從而得到縮進較少的代碼。

縮進的壞處就是“復制-粘貼”功能失效了,這是最坑爹的地方。當你重構代碼時,粘貼過去的代碼必須重新檢查縮進是否正確。此外,IDE很難像格式化Java代碼那樣格式化Python代碼。

最后,請務必注意,Python程序是大小寫敏感的,如果寫錯了大小寫,程序會報錯。

小結

Python使用縮進來組織代碼塊,請務必遵守約定俗成的習慣,堅持使用4個空格的縮進。

在文本編輯器中,需要設置把Tab自動轉換為4個空格,確保不混用Tab和空格。

數(shù)據(jù)類型和變量

數(shù)據(jù)類型

計算機顧名思義就是可以做數(shù)學計算的機器,因此,計算機程序理所當然地可以處理各種數(shù)值。但是,計算機能處理的遠不止數(shù)值,還可以處理文本、圖形、音頻、視頻、網頁等各種各樣的數(shù)據(jù),不同的數(shù)據(jù),需要定義不同的數(shù)據(jù)類型。在Python中,能夠直接處理的數(shù)據(jù)類型有以下幾種:

整數(shù)

Python可以處理任意大小的整數(shù),當然包括負整數(shù),在程序中的表示方法和數(shù)學上的寫法一模一樣,例如:1100-80800,等等。

計算機由于使用二進制,所以,有時候用十六進制表示整數(shù)比較方便,十六進制用0x前綴和0-9,a-f表示,例如:0xff000xa5b4c3d2,等等。

浮點數(shù)

浮點數(shù)也就是小數(shù),之所以稱為浮點數(shù),是因為按照科學記數(shù)法表示時,一個浮點數(shù)的小數(shù)點位置是可變的,比如,1.23x109和12.3x108是完全相等的。浮點數(shù)可以用數(shù)學寫法,如1.233.14-9.01,等等。但是對于很大或很小的浮點數(shù),就必須用科學計數(shù)法表示,把10用e替代,1.23x109就是1.23e9,或者12.3e8,0.000012可以寫成1.2e-5,等等。

整數(shù)和浮點數(shù)在計算機內部存儲的方式是不同的,整數(shù)運算永遠是精確的(除法難道也是精確的?是的!),而浮點數(shù)運算則可能會有四舍五入的誤差。

字符串

字符串是以單引號'或雙引號"括起來的任意文本,比如'abc'"xyz"等等。請注意,''""本身只是一種表示方式,不是字符串的一部分,因此,字符串'abc'只有abc這3個字符。如果'本身也是一個字符,那就可以用""括起來,比如"I'm OK"包含的字符是I'm,空格,OK這6個字符。

如果字符串內部既包含'又包含"怎么辦?可以用轉義字符\來標識,比如:

'I\'m \"OK\"!'

表示的字符串內容是:

I'm "OK"!

轉義字符\可以轉義很多字符,比如\n表示換行,\t表示制表符,字符\本身也要轉義,所以\\表示的字符就是\,可以在Python的交互式命令行用print()打印字符串看看:

>>> print('I\'m ok.')
I'm ok.
>>> print('I\'m learning\nPython.')
I'm learning
Python.
>>> print('\\\n\\')
\
\

如果字符串里面有很多字符都需要轉義,就需要加很多\,為了簡化,Python還允許用r''表示''內部的字符串默認不轉義,可以自己試試:

>>> print('\\\t\\')
\       \
>>> print(r'\\\t\\')
\\\t\\

如果字符串內部有很多換行,用\n寫在一行里不好閱讀,為了簡化,Python允許用'''...'''的格式表示多行內容,可以自己試試:

>>> print('''line1
... line2
... line3''')
line1
line2
line3

上面是在交互式命令行內輸入,注意在輸入多行內容時,提示符由>>>變?yōu)?code>...,提示你可以接著上一行輸入。如果寫成程序,就是:

print('''line1
line2
line3''')

多行字符串'''...'''還可以在前面加上r使用,請自行測試。

布爾值

布爾值和布爾代數(shù)的表示完全一致,一個布爾值只有True、False兩種值,要么是True,要么是False,在Python中,可以直接用TrueFalse表示布爾值(請注意大小寫),也可以通過布爾運算計算出來:

>>> True
True
>>> False
False
>>> 3 > 2
True
>>> 3 > 5
False

布爾值可以用and、ornot運算。

and運算是與運算,只有所有都為Trueand運算結果才是True

>>> True and True
True
>>> True and False
False
>>> False and False
False
>>> 5 > 3 and 3 > 1
True

or運算是或運算,只要其中有一個為Trueor運算結果就是True

>>> True or True
True
>>> True or False
True
>>> False or False
False
>>> 5 > 3 or 1 > 3
True

not運算是非運算,它是一個單目運算符,把True變成FalseFalse變成True

>>> not True
False
>>> not False
True
>>> not 1 > 2
True

布爾值經常用在條件判斷中,比如:

if age >= 18:print('adult')
else:print('teenager')
空值

空值是Python里一個特殊的值,用None表示。None不能理解為0,因為0是有意義的,而None是一個特殊的空值。

此外,Python還提供了列表、字典等多種數(shù)據(jù)類型,還允許創(chuàng)建自定義數(shù)據(jù)類型,我們后面會繼續(xù)講到。

變量

變量的概念基本上和初中代數(shù)的方程變量是一致的,只是在計算機程序中,變量不僅可以是數(shù)字,還可以是任意數(shù)據(jù)類型。

變量在程序中就是用一個變量名表示了,變量名必須是大小寫英文、數(shù)字和_的組合,且不能用數(shù)字開頭,比如:

a = 1

變量a是一個整數(shù)。

t_007 = 'T007'

變量t_007是一個字符串。

Answer = True

變量Answer是一個布爾值True

在Python中,等號=是賦值語句,可以把任意數(shù)據(jù)類型賦值給變量,同一個變量可以反復賦值,而且可以是不同類型的變量,例如:

a = 123 # a是整數(shù)
print(a)
a = 'ABC' # a變?yōu)樽址?print(a)

這種變量本身類型不固定的語言稱之為動態(tài)語言,與之對應的是靜態(tài)語言。靜態(tài)語言在定義變量時必須指定變量類型,如果賦值的時候類型不匹配,就會報錯。例如Java是靜態(tài)語言,賦值語句如下(// 表示注釋):

int a = 123; // a是整數(shù)類型變量
a = "ABC"; // 錯誤:不能把字符串賦給整型變量

和靜態(tài)語言相比,動態(tài)語言更靈活,就是這個原因。

請不要把賦值語句的等號等同于數(shù)學的等號。比如下面的代碼:

x = 10
x = x + 2

如果從數(shù)學上理解x = x + 2那無論如何是不成立的,在程序中,賦值語句先計算右側的表達式x + 2,得到結果12,再賦給變量x。由于x之前的值是10,重新賦值后,x的值變成12。

最后,理解變量在計算機內存中的表示也非常重要。當我們寫:

a = 'ABC'

時,Python解釋器干了兩件事情:

  1. 在內存中創(chuàng)建了一個'ABC'的字符串;

  2. 在內存中創(chuàng)建了一個名為a的變量,并把它指向'ABC'

也可以把一個變量a賦值給另一個變量b,這個操作實際上是把變量b指向變量a所指向的數(shù)據(jù),例如下面的代碼:

a = 'ABC'
b = a
a = 'XYZ'
print(b)

最后一行打印出變量b的內容到底是'ABC'呢還是'XYZ'?如果從數(shù)學意義上理解,就會錯誤地得出ba相同,也應該是'XYZ',但實際上b的值是'ABC',讓我們一行一行地執(zhí)行代碼,就可以看到到底發(fā)生了什么事:

執(zhí)行a = 'ABC',解釋器創(chuàng)建了字符串'ABC'和變量a,并把a指向'ABC'

py-var-code-1

執(zhí)行b = a,解釋器創(chuàng)建了變量b,并把b指向a指向的字符串'ABC'

py-var-code-2

執(zhí)行a = 'XYZ',解釋器創(chuàng)建了字符串'XYZ',并把a的指向改為'XYZ',但b并沒有更改:

py-var-code-3

所以,最后打印變量b的結果自然是'ABC'了。

常量

所謂常量就是不能變的變量,比如常用的數(shù)學常數(shù)π就是一個常量。在Python中,通常用全部大寫的變量名表示常量:

PI = 3.14159265359

但事實上PI仍然是一個變量,Python根本沒有任何機制保證PI不會被改變,所以,用全部大寫的變量名表示常量只是一個習慣上的用法,如果你一定要改變變量PI的值,也沒人能攔住你。

最后解釋一下整數(shù)的除法為什么也是精確的。在Python中,有兩種除法,一種除法是/

>>> 10 / 3
3.3333333333333335

/除法計算結果是浮點數(shù),即使是兩個整數(shù)恰好整除,結果也是浮點數(shù):

>>> 9 / 3
3.0

還有一種除法是//,稱為地板除,兩個整數(shù)的除法仍然是整數(shù):

>>> 10 // 3
3

你沒有看錯,整數(shù)的地板除//永遠是整數(shù),即使除不盡。要做精確的除法,使用/就可以。

因為//除法只取結果的整數(shù)部分,所以Python還提供一個余數(shù)運算,可以得到兩個整數(shù)相除的余數(shù):

>>> 10 % 3
1

無論整數(shù)做//除法還是取余數(shù),結果永遠是整數(shù),所以,整數(shù)運算結果永遠是精確的。

練習

請打印出以下變量的值:

n = 123
f = 456.789
s1 = 'Hello, world'
s2 = 'Hello, \'Adam\''
s3 = r'Hello, "Bart"'
s4 = r'''Hello,
Lisa!'''
小結

Python支持多種數(shù)據(jù)類型,在計算機內部,可以把任何數(shù)據(jù)都看成一個“對象”,而變量就是在程序中用來指向這些數(shù)據(jù)對象的,對變量賦值就是把數(shù)據(jù)和變量給關聯(lián)起來。

注意:Python的整數(shù)沒有大小限制,而某些語言的整數(shù)根據(jù)其存儲長度是有大小限制的,例如Java對32位整數(shù)的范圍限制在-2147483648-2147483647。

Python的浮點數(shù)也沒有大小限制,但是超出一定范圍就直接表示為inf(無限大)。

字符串和編碼

字符編碼

我們已經講過了,字符串也是一種數(shù)據(jù)類型,但是,字符串比較特殊的是還有一個編碼問題。

因為計算機只能處理數(shù)字,如果要處理文本,就必須先把文本轉換為數(shù)字才能處理。最早的計算機在設計時采用8個比特(bit)作為一個字節(jié)(byte),所以,一個字節(jié)能表示的最大的整數(shù)就是255(二進制11111111=十進制255),如果要表示更大的整數(shù),就必須用更多的字節(jié)。比如兩個字節(jié)可以表示的最大整數(shù)是65535,4個字節(jié)可以表示的最大整數(shù)是4294967295。

由于計算機是美國人發(fā)明的,因此,最早只有127個字母被編碼到計算機里,也就是大小寫英文字母、數(shù)字和一些符號,這個編碼表被稱為ASCII編碼,比如大寫字母A的編碼是65,小寫字母z的編碼是122。

但是要處理中文顯然一個字節(jié)是不夠的,至少需要兩個字節(jié),而且還不能和ASCII編碼沖突,所以,中國制定了GB2312編碼,用來把中文編進去。

你可以想得到的是,全世界有上百種語言,日本把日文編到Shift_JIS里,韓國把韓文編到Euc-kr里,各國有各國的標準,就會不可避免地出現(xiàn)沖突,結果就是,在多語言混合的文本中,顯示出來會有亂碼。

char-encoding-problem

因此,Unicode應運而生。Unicode把所有語言都統(tǒng)一到一套編碼里,這樣就不會再有亂碼問題了。

Unicode標準也在不斷發(fā)展,但最常用的是用兩個字節(jié)表示一個字符(如果要用到非常偏僻的字符,就需要4個字節(jié))?,F(xiàn)代操作系統(tǒng)和大多數(shù)編程語言都直接支持Unicode。

現(xiàn)在,捋一捋ASCII編碼和Unicode編碼的區(qū)別:ASCII編碼是1個字節(jié),而Unicode編碼通常是2個字節(jié)。

字母A用ASCII編碼是十進制的65,二進制的01000001

字符0用ASCII編碼是十進制的48,二進制的00110000,注意字符'0'和整數(shù)0是不同的;

漢字已經超出了ASCII編碼的范圍,用Unicode編碼是十進制的20013,二進制的01001110 00101101。

你可以猜測,如果把ASCII編碼的A用Unicode編碼,只需要在前面補0就可以,因此,A的Unicode編碼是00000000 01000001

新的問題又出現(xiàn)了:如果統(tǒng)一成Unicode編碼,亂碼問題從此消失了。但是,如果你寫的文本基本上全部是英文的話,用Unicode編碼比ASCII編碼需要多一倍的存儲空間,在存儲和傳輸上就十分不劃算。

所以,本著節(jié)約的精神,又出現(xiàn)了把Unicode編碼轉化為“可變長編碼”的UTF-8編碼。UTF-8編碼把一個Unicode字符根據(jù)不同的數(shù)字大小編碼成1-6個字節(jié),常用的英文字母被編碼成1個字節(jié),漢字通常是3個字節(jié),只有很生僻的字符才會被編碼成4-6個字節(jié)。如果你要傳輸?shù)奈谋景罅坑⑽淖址?#xff0c;用UTF-8編碼就能節(jié)省空間:

字符ASCIIUnicodeUTF-8
A0100000100000000 0100000101000001
x01001110 0010110111100100 10111000 10101101

從上面的表格還可以發(fā)現(xiàn),UTF-8編碼有一個額外的好處,就是ASCII編碼實際上可以被看成是UTF-8編碼的一部分,所以,大量只支持ASCII編碼的歷史遺留軟件可以在UTF-8編碼下繼續(xù)工作。

搞清楚了ASCII、Unicode和UTF-8的關系,我們就可以總結一下現(xiàn)在計算機系統(tǒng)通用的字符編碼工作方式:

在計算機內存中,統(tǒng)一使用Unicode編碼,當需要保存到硬盤或者需要傳輸?shù)臅r候,就轉換為UTF-8編碼。

用記事本編輯的時候,從文件讀取的UTF-8字符被轉換為Unicode字符到內存里,編輯完成后,保存的時候再把Unicode轉換為UTF-8保存到文件:

rw-file-utf-8

瀏覽網頁的時候,服務器會把動態(tài)生成的Unicode內容轉換為UTF-8再傳輸?shù)綖g覽器:

web-utf-8

所以你看到很多網頁的源碼上會有類似<meta charset="UTF-8" />的信息,表示該網頁正是用的UTF-8編碼。

Python的字符串

搞清楚了令人頭疼的字符編碼問題后,我們再來研究Python的字符串。

在最新的Python 3版本中,字符串是以Unicode編碼的,也就是說,Python的字符串支持多語言,例如:

>>> print('包含中文的str')
包含中文的str

對于單個字符的編碼,Python提供了ord()函數(shù)獲取字符的整數(shù)表示,chr()函數(shù)把編碼轉換為對應的字符:

>>> ord('A')
65
>>> ord('中')
20013
>>> chr(66)
'B'
>>> chr(25991)
'文'

如果知道字符的整數(shù)編碼,還可以用十六進制這么寫str

>>> '\u4e2d\u6587'
'中文'

兩種寫法完全是等價的。

由于Python的字符串類型是str,在內存中以Unicode表示,一個字符對應若干個字節(jié)。如果要在網絡上傳輸,或者保存到磁盤上,就需要把str變?yōu)橐宰止?jié)為單位的bytes。

Python對bytes類型的數(shù)據(jù)用帶b前綴的單引號或雙引號表示:

x = b'ABC'

要注意區(qū)分'ABC'b'ABC',前者是str,后者雖然內容顯示得和前者一樣,但bytes的每個字符都只占用一個字節(jié)。

以Unicode表示的str通過encode()方法可以編碼為指定的bytes,例如:

>>> 'ABC'.encode('ascii')
b'ABC'
>>> '中文'.encode('utf-8')
b'\xe4\xb8\xad\xe6\x96\x87'
>>> '中文'.encode('ascii')
Traceback (most recent call last):File "<stdin>", line 1, in <module>
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)

純英文的str可以用ASCII編碼為bytes,內容是一樣的,含有中文的str可以用UTF-8編碼為bytes。含有中文的str無法用ASCII編碼,因為中文編碼的范圍超過了ASCII編碼的范圍,Python會報錯。

bytes中,無法顯示為ASCII字符的字節(jié),用\x##顯示。

反過來,如果我們從網絡或磁盤上讀取了字節(jié)流,那么讀到的數(shù)據(jù)就是bytes。要把bytes變?yōu)?code>str,就需要用decode()方法:

>>> b'ABC'.decode('ascii')
'ABC'
>>> b'\xe4\xb8\xad\xe6\x96\x87'.decode('utf-8')
'中文'

要計算str包含多少個字符,可以用len()函數(shù):

>>> len('ABC')
3
>>> len('中文')
2

len()函數(shù)計算的是str的字符數(shù),如果換成byteslen()函數(shù)就計算字節(jié)數(shù):

>>> len(b'ABC')
3
>>> len(b'\xe4\xb8\xad\xe6\x96\x87')
6
>>> len('中文'.encode('utf-8'))
6

可見,1個中文字符經過UTF-8編碼后通常會占用3個字節(jié),而1個英文字符只占用1個字節(jié)。

在操作字符串時,我們經常遇到strbytes的互相轉換。為了避免亂碼問題,應當始終堅持使用UTF-8編碼對strbytes進行轉換。

由于Python源代碼也是一個文本文件,所以,當你的源代碼中包含中文的時候,在保存源代碼時,就需要務必指定保存為UTF-8編碼。當Python解釋器讀取源代碼時,為了讓它按UTF-8編碼讀取,我們通常在文件開頭寫上這兩行:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

第一行注釋是為了告訴Linux/OS X系統(tǒng),這是一個Python可執(zhí)行程序,Windows系統(tǒng)會忽略這個注釋;

第二行注釋是為了告訴Python解釋器,按照UTF-8編碼讀取源代碼,否則,你在源代碼中寫的中文輸出可能會有亂碼。

申明了UTF-8編碼并不意味著你的.py文件就是UTF-8編碼的,必須并且要確保文本編輯器正在使用UTF-8 without BOM編碼:

set-encoding-in-notepad++

如果.py文件本身使用UTF-8編碼,并且也申明了# -*- coding: utf-8 -*-,打開命令提示符測試就可以正常顯示中文:

py-chinese-test-in-cmd

格式化

最后一個常見的問題是如何輸出格式化的字符串。我們經常會輸出類似'親愛的xxx你好!你xx月的話費是xx,余額是xx'之類的字符串,而xxx的內容都是根據(jù)變量變化的,所以,需要一種簡便的格式化字符串的方式。

py-str-format

在Python中,采用的格式化方式和C語言是一致的,用%實現(xiàn),舉例如下:

>>> 'Hello, %s' % 'world'
'Hello, world'
>>> 'Hi, %s, you have $%d.' % ('Michael', 1000000)
'Hi, Michael, you have $1000000.'

你可能猜到了,%運算符就是用來格式化字符串的。在字符串內部,%s表示用字符串替換,%d表示用整數(shù)替換,有幾個%?占位符,后面就跟幾個變量或者值,順序要對應好。如果只有一個%?,括號可以省略。

常見的占位符有:

%d整數(shù)
%f浮點數(shù)
%s字符串
%x十六進制整數(shù)

其中,格式化整數(shù)和浮點數(shù)還可以指定是否補0和整數(shù)與小數(shù)的位數(shù):

>>> '%2d-%02d' % (3, 1)
' 3-01'
>>> '%.2f' % 3.1415926
'3.14'

如果你不太確定應該用什么,%s永遠起作用,它會把任何數(shù)據(jù)類型轉換為字符串:

>>> 'Age: %s. Gender: %s' % (25, True)
'Age: 25. Gender: True'

有些時候,字符串里面的%是一個普通字符怎么辦?這個時候就需要轉義,用%%來表示一個%

>>> 'growth rate: %d %%' % 7
'growth rate: 7 %'
練習

小明的成績從去年的72分提升到了今年的85分,請計算小明成績提升的百分點,并用字符串格式化顯示出'xx.x%',只保留小數(shù)點后1位:

# -*- coding: utf-8 -*-s1 = 72
s2 = 85
----
r = ???
print('???' % r)
小結

Python 3的字符串使用Unicode,直接支持多語言。

str和bytes互相轉換時,需要指定編碼。最常用的編碼是UTF-8。Python當然也支持其他編碼方式,比如把Unicode編碼成GB2312:

>>> '中文'.encode('gb2312')
'\xd6\xd0\xce\xc4'

但這種方式純屬自找麻煩,如果沒有特殊業(yè)務要求,請牢記僅使用UTF-8編碼。

格式化字符串的時候,可以用Python的交互式命令行測試,方便快捷。

參考源碼

the_string.py

使用list和tuple

list

Python內置的一種數(shù)據(jù)類型是列表:list。list是一種有序的集合,可以隨時添加和刪除其中的元素。

比如,列出班里所有同學的名字,就可以用一個list表示:

>>> classmates = ['Michael', 'Bob', 'Tracy']
>>> classmates
['Michael', 'Bob', 'Tracy']

變量classmates就是一個list。用len()函數(shù)可以獲得list元素的個數(shù):

>>> len(classmates)
3

用索引來訪問list中每一個位置的元素,記得索引是從0開始的:

>>> classmates[0]
'Michael'
>>> classmates[1]
'Bob'
>>> classmates[2]
'Tracy'
>>> classmates[3]
Traceback (most recent call last):File "<stdin>", line 1, in <module>
IndexError: list index out of range

當索引超出了范圍時,Python會報一個IndexError錯誤,所以,要確保索引不要越界,記得最后一個元素的索引是len(classmates) - 1。

如果要取最后一個元素,除了計算索引位置外,還可以用-1做索引,直接獲取最后一個元素:

>>> classmates[-1]
'Tracy'

以此類推,可以獲取倒數(shù)第2個、倒數(shù)第3個:

>>> classmates[-2]
'Bob'
>>> classmates[-3]
'Michael'
>>> classmates[-4]
Traceback (most recent call last):File "<stdin>", line 1, in <module>
IndexError: list index out of range

當然,倒數(shù)第4個就越界了。

list是一個可變的有序表,所以,可以往list中追加元素到末尾:

>>> classmates.append('Adam')
>>> classmates
['Michael', 'Bob', 'Tracy', 'Adam']

也可以把元素插入到指定的位置,比如索引號為1的位置:

>>> classmates.insert(1, 'Jack')
>>> classmates
['Michael', 'Jack', 'Bob', 'Tracy', 'Adam']

要刪除list末尾的元素,用pop()方法:

>>> classmates.pop()
'Adam'
>>> classmates
['Michael', 'Jack', 'Bob', 'Tracy']

要刪除指定位置的元素,用pop(i)方法,其中i是索引位置:

>>> classmates.pop(1)
'Jack'
>>> classmates
['Michael', 'Bob', 'Tracy']

要把某個元素替換成別的元素,可以直接賦值給對應的索引位置:

>>> classmates[1] = 'Sarah'
>>> classmates
['Michael', 'Sarah', 'Tracy']

list里面的元素的數(shù)據(jù)類型也可以不同,比如:

>>> L = ['Apple', 123, True]

list元素也可以是另一個list,比如:

>>> s = ['python', 'java', ['asp', 'php'], 'scheme']
>>> len(s)
4

要注意s只有4個元素,其中s[2]又是一個list,如果拆開寫就更容易理解了:

>>> p = ['asp', 'php']
>>> s = ['python', 'java', p, 'scheme']

要拿到'php'可以寫p[1]或者s[2][1],因此s可以看成是一個二維數(shù)組,類似的還有三維、四維……數(shù)組,不過很少用到。

如果一個list中一個元素也沒有,就是一個空的list,它的長度為0:

>>> L = []
>>> len(L)
0
tuple

另一種有序列表叫元組:tuple。tuple和list非常類似,但是tuple一旦初始化就不能修改,比如同樣是列出同學的名字:

>>> classmates = ('Michael', 'Bob', 'Tracy')

現(xiàn)在,classmates這個tuple不能變了,它也沒有append(),insert()這樣的方法。其他獲取元素的方法和list是一樣的,你可以正常地使用classmates[0]classmates[-1],但不能賦值成另外的元素。

不可變的tuple有什么意義?因為tuple不可變,所以代碼更安全。如果可能,能用tuple代替list就盡量用tuple。

tuple的陷阱:當你定義一個tuple時,在定義的時候,tuple的元素就必須被確定下來,比如:

>>> t = (1, 2)
>>> t
(1, 2)

如果要定義一個空的tuple,可以寫成()

>>> t = ()
>>> t
()

但是,要定義一個只有1個元素的tuple,如果你這么定義:

>>> t = (1)
>>> t
1

定義的不是tuple,是1這個數(shù)!這是因為括號()既可以表示tuple,又可以表示數(shù)學公式中的小括號,這就產生了歧義,因此,Python規(guī)定,這種情況下,按小括號進行計算,計算結果自然是1。

所以,只有1個元素的tuple定義時必須加一個逗號,,來消除歧義:

>>> t = (1,)
>>> t
(1,)

Python在顯示只有1個元素的tuple時,也會加一個逗號,,以免你誤解成數(shù)學計算意義上的括號。

最后來看一個“可變的”tuple:

>>> t = ('a', 'b', ['A', 'B'])
>>> t[2][0] = 'X'
>>> t[2][1] = 'Y'
>>> t
('a', 'b', ['X', 'Y'])

這個tuple定義的時候有3個元素,分別是'a''b'和一個list。不是說tuple一旦定義后就不可變了嗎?怎么后來又變了?

別急,我們先看看定義的時候tuple包含的3個元素:

tuple-0

當我們把list的元素'A''B'修改為'X''Y'后,tuple變?yōu)?#xff1a;

tuple-1

表面上看,tuple的元素確實變了,但其實變的不是tuple的元素,而是list的元素。tuple一開始指向的list并沒有改成別的list,所以,tuple所謂的“不變”是說,tuple的每個元素,指向永遠不變。即指向'a',就不能改成指向'b',指向一個list,就不能改成指向其他對象,但指向的這個list本身是可變的!

理解了“指向不變”后,要創(chuàng)建一個內容也不變的tuple怎么做?那就必須保證tuple的每一個元素本身也不能變。

練習

請用索引取出下面list的指定元素:

# -*- coding: utf-8 -*-L = [['Apple', 'Google', 'Microsoft'],['Java', 'Python', 'Ruby', 'PHP'],['Adam', 'Bart', 'Lisa']
]
----
# 打印Apple:
print(?)
# 打印Python:
print(?)
# 打印Lisa:
print(?)
小結

list和tuple是Python內置的有序集合,一個可變,一個不可變。根據(jù)需要來選擇使用它們。

參考源碼

the_list.py

the_tuple.py

條件判斷

條件判斷

計算機之所以能做很多自動化的任務,因為它可以自己做條件判斷。

比如,輸入用戶年齡,根據(jù)年齡打印不同的內容,在Python程序中,用if語句實現(xiàn):

age = 20
if age >= 18:print('your age is', age)print('adult')

根據(jù)Python的縮進規(guī)則,如果if語句判斷是True,就把縮進的兩行print語句執(zhí)行了,否則,什么也不做。

也可以給if添加一個else語句,意思是,如果if判斷是False,不要執(zhí)行if的內容,去把else執(zhí)行了:

age = 3
if age >= 18:print('your age is', age)print('adult')
else:print('your age is', age)print('teenager')

注意不要少寫了冒號:

當然上面的判斷是很粗略的,完全可以用elif做更細致的判斷:

age = 3
if age >= 18:print('adult')
elif age >= 6:print('teenager')
else:print('kid')

elifelse if的縮寫,完全可以有多個elif,所以if語句的完整形式就是:

if <條件判斷1>:<執(zhí)行1>
elif <條件判斷2>:<執(zhí)行2>
elif <條件判斷3>:<執(zhí)行3>
else:<執(zhí)行4>

if語句執(zhí)行有個特點,它是從上往下判斷,如果在某個判斷上是True,把該判斷對應的語句執(zhí)行后,就忽略掉剩下的elifelse,所以,請測試并解釋為什么下面的程序打印的是teenager

age = 20
if age >= 6:print('teenager')
elif age >= 18:print('adult')
else:print('kid')

if判斷條件還可以簡寫,比如寫:

if x:print('True')

只要x是非零數(shù)值、非空字符串、非空list等,就判斷為True,否則為False。

再議 input

最后看一個有問題的條件判斷。很多同學會用input()讀取用戶的輸入,這樣可以自己輸入,程序運行得更有意思:

birth = input('birth: ')
if birth < 2000:print('00前')
else:print('00后')

輸入1982,結果報錯:

Traceback (most recent call last):File "<stdin>", line 1, in <module>
TypeError: unorderable types: str() > int()

這是因為input()返回的數(shù)據(jù)類型是strstr不能直接和整數(shù)比較,必須先把str轉換成整數(shù)。Python提供了int()函數(shù)來完成這件事情:

s = input('birth: ')
birth = int(s)
if birth < 2000:print('00前')
else:print('00后')

再次運行,就可以得到正確地結果。但是,如果輸入abc呢?又會得到一個錯誤信息:

Traceback (most recent call last):File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: 'abc'

原來int()函數(shù)發(fā)現(xiàn)一個字符串并不是合法的數(shù)字時就會報錯,程序就退出了。

如何檢查并捕獲程序運行期的錯誤呢?后面的錯誤和調試會講到。

練習

小明身高1.75,體重80.5kg。請根據(jù)BMI公式(體重除以身高的平方)幫小明計算他的BMI指數(shù),并根據(jù)BMI指數(shù):

  • 低于18.5:過輕
  • 18.5-25:正常
  • 25-28:過重
  • 28-32:肥胖
  • 高于32:嚴重肥胖

if-elif判斷并打印結果:

# -*- coding: utf-8 -*-height = 1.75
weight = 80.5
----
bmi = ???
if ???:pass
小結

條件判斷可以讓計算機自己做選擇,Python的if...elif...else很靈活。

python-if

參考源碼

do_if.py

循環(huán)

循環(huán)

要計算1+2+3,我們可以直接寫表達式:

>>> 1 + 2 + 3
6

要計算1+2+3+...+10,勉強也能寫出來。

但是,要計算1+2+3+...+10000,直接寫表達式就不可能了。

為了讓計算機能計算成千上萬次的重復運算,我們就需要循環(huán)語句。

Python的循環(huán)有兩種,一種是for...in循環(huán),依次把list或tuple中的每個元素迭代出來,看例子:

names = ['Michael', 'Bob', 'Tracy']
for name in names:print(name)

執(zhí)行這段代碼,會依次打印names的每一個元素:

Michael
Bob
Tracy

所以for x in ...循環(huán)就是把每個元素代入變量x,然后執(zhí)行縮進塊的語句。

再比如我們想計算1-10的整數(shù)之和,可以用一個sum變量做累加:

sum = 0
for x in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]:sum = sum + x
print(sum)

如果要計算1-100的整數(shù)之和,從1寫到100有點困難,幸好Python提供一個range()函數(shù),可以生成一個整數(shù)序列,再通過list()函數(shù)可以轉換為list。比如range(5)生成的序列是從0開始小于5的整數(shù):

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

range(101)就可以生成0-100的整數(shù)序列,計算如下:

sum = 0
for x in range(101):sum = sum + x
print(sum)

請自行運行上述代碼,看看結果是不是當年高斯同學心算出的5050。

第二種循環(huán)是while循環(huán),只要條件滿足,就不斷循環(huán),條件不滿足時退出循環(huán)。比如我們要計算100以內所有奇數(shù)之和,可以用while循環(huán)實現(xiàn):

sum = 0
n = 99
while n > 0:sum = sum + nn = n - 2
print(sum)

在循環(huán)內部變量n不斷自減,直到變?yōu)?code>-1時,不再滿足while條件,循環(huán)退出。

練習

請利用循環(huán)依次對list中的每個名字打印出Hello, xxx!

# -*- coding: utf-8 -*-
L = ['Bart', 'Lisa', 'Adam']
小結

循環(huán)是讓計算機做重復任務的有效的方法,有些時候,如果代碼寫得有問題,會讓程序陷入“死循環(huán)”,也就是永遠循環(huán)下去。這時可以用Ctrl+C退出程序,或者強制結束Python進程。

請試寫一個死循環(huán)程序。

參考源碼

do_for.py

do_while.py

使用dict和set

dict

Python內置了字典:dict的支持,dict全稱dictionary,在其他語言中也稱為map,使用鍵-值(key-value)存儲,具有極快的查找速度。

舉個例子,假設要根據(jù)同學的名字查找對應的成績,如果用list實現(xiàn),需要兩個list:

names = ['Michael', 'Bob', 'Tracy']
scores = [95, 75, 85]

給定一個名字,要查找對應的成績,就先要在names中找到對應的位置,再從scores取出對應的成績,list越長,耗時越長。

如果用dict實現(xiàn),只需要一個“名字”-“成績”的對照表,直接根據(jù)名字查找成績,無論這個表有多大,查找速度都不會變慢。用Python寫一個dict如下:

>>> d = {'Michael': 95, 'Bob': 75, 'Tracy': 85}
>>> d['Michael']
95

為什么dict查找速度這么快?因為dict的實現(xiàn)原理和查字典是一樣的。假設字典包含了1萬個漢字,我們要查某一個字,一個辦法是把字典從第一頁往后翻,直到找到我們想要的字為止,這種方法就是在list中查找元素的方法,list越大,查找越慢。

第二種方法是先在字典的索引表里(比如部首表)查這個字對應的頁碼,然后直接翻到該頁,找到這個字。無論找哪個字,這種查找速度都非???#xff0c;不會隨著字典大小的增加而變慢。

dict就是第二種實現(xiàn)方式,給定一個名字,比如'Michael',dict在內部就可以直接計算出Michael對應的存放成績的“頁碼”,也就是95這個數(shù)字存放的內存地址,直接取出來,所以速度非??臁?/p>

你可以猜到,這種key-value存儲方式,在放進去的時候,必須根據(jù)key算出value的存放位置,這樣,取的時候才能根據(jù)key直接拿到value。

把數(shù)據(jù)放入dict的方法,除了初始化時指定外,還可以通過key放入:

>>> d['Adam'] = 67
>>> d['Adam']
67

由于一個key只能對應一個value,所以,多次對一個key放入value,后面的值會把前面的值沖掉:

>>> d['Jack'] = 90
>>> d['Jack']
90
>>> d['Jack'] = 88
>>> d['Jack']
88

如果key不存在,dict就會報錯:

>>> d['Thomas']
Traceback (most recent call last):File "<stdin>", line 1, in <module>
KeyError: 'Thomas'

要避免key不存在的錯誤,有兩種辦法,一是通過in判斷key是否存在:

>>> 'Thomas' in d
False

二是通過dict提供的get方法,如果key不存在,可以返回None,或者自己指定的value:

>>> d.get('Thomas')
>>> d.get('Thomas', -1)
-1

注意:返回None的時候Python的交互式命令行不顯示結果。

要刪除一個key,用pop(key)方法,對應的value也會從dict中刪除:

>>> d.pop('Bob')
75
>>> d
{'Michael': 95, 'Tracy': 85}

請務必注意,dict內部存放的順序和key放入的順序是沒有關系的。

和list比較,dict有以下幾個特點:

  1. 查找和插入的速度極快,不會隨著key的增加而增加;
  2. 需要占用大量的內存,內存浪費多。

而list相反:

  1. 查找和插入的時間隨著元素的增加而增加;
  2. 占用空間小,浪費內存很少。

所以,dict是用空間來換取時間的一種方法。

dict可以用在需要高速查找的很多地方,在Python代碼中幾乎無處不在,正確使用dict非常重要,需要牢記的第一條就是dict的key必須是不可變對象。

這是因為dict根據(jù)key來計算value的存儲位置,如果每次計算相同的key得出的結果不同,那dict內部就完全混亂了。這個通過key計算位置的算法稱為哈希算法(Hash)。

要保證hash的正確性,作為key的對象就不能變。在Python中,字符串、整數(shù)等都是不可變的,因此,可以放心地作為key。而list是可變的,就不能作為key:

>>> key = [1, 2, 3]
>>> d[key] = 'a list'
Traceback (most recent call last):File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
set

set和dict類似,也是一組key的集合,但不存儲value。由于key不能重復,所以,在set中,沒有重復的key。

要創(chuàng)建一個set,需要提供一個list作為輸入集合:

>>> s = set([1, 2, 3])
>>> s
{1, 2, 3}

注意,傳入的參數(shù)[1, 2, 3]是一個list,而顯示的{1, 2, 3}只是告訴你這個set內部有1,2,3這3個元素,顯示的順序也不表示set是有序的。。

重復元素在set中自動被過濾:

>>> s = set([1, 1, 2, 2, 3, 3])
>>> s
{1, 2, 3}

通過add(key)方法可以添加元素到set中,可以重復添加,但不會有效果:

>>> s.add(4)
>>> s
{1, 2, 3, 4}
>>> s.add(4)
>>> s
{1, 2, 3, 4}

通過remove(key)方法可以刪除元素:

>>> s.remove(4)
>>> s
{1, 2, 3}

set可以看成數(shù)學意義上的無序和無重復元素的集合,因此,兩個set可以做數(shù)學意義上的交集、并集等操作:

>>> s1 = set([1, 2, 3])
>>> s2 = set([2, 3, 4])
>>> s1 & s2
{2, 3}
>>> s1 | s2
{1, 2, 3, 4}

set和dict的唯一區(qū)別僅在于沒有存儲對應的value,但是,set的原理和dict一樣,所以,同樣不可以放入可變對象,因為無法判斷兩個可變對象是否相等,也就無法保證set內部“不會有重復元素”。試試把list放入set,看看是否會報錯。

再議不可變對象

上面我們講了,str是不變對象,而list是可變對象。

對于可變對象,比如list,對list進行操作,list內部的內容是會變化的,比如:

>>> a = ['c', 'b', 'a']
>>> a.sort()
>>> a
['a', 'b', 'c']

而對于不可變對象,比如str,對str進行操作呢:

>>> a = 'abc'
>>> a.replace('a', 'A')
'Abc'
>>> a
'abc'

雖然字符串有個replace()方法,也確實變出了'Abc',但變量a最后仍是'abc',應該怎么理解呢?

我們先把代碼改成下面這樣:

>>> a = 'abc'
>>> b = a.replace('a', 'A')
>>> b
'Abc'
>>> a
'abc'

要始終牢記的是,a是變量,而'abc'才是字符串對象!有些時候,我們經常說,對象a的內容是'abc',但其實是指,a本身是一個變量,它指向的對象的內容才是'abc'

a-to-str

當我們調用a.replace('a', 'A')時,實際上調用方法replace是作用在字符串對象'abc'上的,而這個方法雖然名字叫replace,但卻沒有改變字符串'abc'的內容。相反,replace方法創(chuàng)建了一個新字符串'Abc'并返回,如果我們用變量b指向該新字符串,就容易理解了,變量a仍指向原有的字符串'abc',但變量b卻指向新字符串'Abc'了:

a-b-to-2-strs

所以,對于不變對象來說,調用對象自身的任意方法,也不會改變該對象自身的內容。相反,這些方法會創(chuàng)建新的對象并返回,這樣,就保證了不可變對象本身永遠是不可變的。

小結

使用key-value存儲結構的dict在Python中非常有用,選擇不可變對象作為key很重要,最常用的key是字符串。

tuple雖然是不變對象,但試試把(1, 2, 3)(1, [2, 3])放入dict或set中,并解釋結果。

參考源碼

the_dict.py

the_set.py

函數(shù)

我們知道圓的面積計算公式為:

S = πr2

當我們知道半徑r的值時,就可以根據(jù)公式計算出面積。假設我們需要計算3個不同大小的圓的面積:

r1 = 12.34
r2 = 9.08
r3 = 73.1
s1 = 3.14 * r1 * r1
s2 = 3.14 * r2 * r2
s3 = 3.14 * r3 * r3

當代碼出現(xiàn)有規(guī)律的重復的時候,你就需要當心了,每次寫3.14 * x * x不僅很麻煩,而且,如果要把3.14改成3.14159265359的時候,得全部替換。

有了函數(shù),我們就不再每次寫s = 3.14 * x * x,而是寫成更有意義的函數(shù)調用s = area_of_circle(x),而函數(shù)area_of_circle本身只需要寫一次,就可以多次調用。

基本上所有的高級語言都支持函數(shù),Python也不例外。Python不但能非常靈活地定義函數(shù),而且本身內置了很多有用的函數(shù),可以直接調用。

抽象

抽象是數(shù)學中非常常見的概念。舉個例子:

計算數(shù)列的和,比如:1 + 2 + 3 + ... + 100,寫起來十分不方便,于是數(shù)學家發(fā)明了求和符號∑,可以把1 + 2 + 3 + ... + 100記作:

100

n

n=1

這種抽象記法非常強大,因為我們看到 ∑ 就可以理解成求和,而不是還原成低級的加法運算。

而且,這種抽象記法是可擴展的,比如:

100

(n2+1)

n=1

還原成加法運算就變成了:

(1 x 1 + 1) + (2 x 2 + 1) + (3 x 3 + 1) + ... + (100 x 100 + 1)

可見,借助抽象,我們才能不關心底層的具體計算過程,而直接在更高的層次上思考問題。

寫計算機程序也是一樣,函數(shù)就是最基本的一種代碼抽象的方式。

調用函數(shù)

Python內置了很多有用的函數(shù),我們可以直接調用。

要調用一個函數(shù),需要知道函數(shù)的名稱和參數(shù),比如求絕對值的函數(shù)abs,只有一個參數(shù)??梢灾苯訌腜ython的官方網站查看文檔:

http://docs.python.org/3/library/functions.html#abs

也可以在交互式命令行通過help(abs)查看abs函數(shù)的幫助信息。

調用abs函數(shù):

>>> abs(100)
100
>>> abs(-20)
20
>>> abs(12.34)
12.34

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

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

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

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

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

>>> max(1, 2)
2
>>> max(2, 3, 1, -5)
3
數(shù)據(jù)類型轉換

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

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

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

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

請利用Python內置的hex()函數(shù)把一個整數(shù)轉換成十六進制表示的字符串:

# -*- coding: utf-8 -*-n1 = 255
n2 = 1000
----
print(???)
小結

調用Python的函數(shù),需要根據(jù)函數(shù)定義,傳入正確的參數(shù)。如果函數(shù)調用出錯,一定要學會看錯誤信息,所以英文很重要!

參考源碼

call_func.py

定義函數(shù)

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

我們以自定義一個求絕對值的my_abs函數(shù)為例:

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

請自行測試并調用my_abs看看返回結果是否正確。

請注意,函數(shù)體內部的語句在執(zhí)行時,一旦執(zhí)行到return時,函數(shù)就執(zhí)行完畢,并將結果返回。因此,函數(shù)內部通過條件判斷和循環(huán)可以實現(xiàn)非常復雜的邏輯。

如果沒有return語句,函數(shù)執(zhí)行完畢后也會返回結果,只是結果為None。

return None可以簡寫為return。

在Python交互環(huán)境中定義函數(shù)時,注意Python會出現(xiàn)...的提示。函數(shù)定義結束后需要按兩次回車重新回到>>>提示符下:

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

import的用法在后續(xù)模塊一節(jié)中會詳細介紹。

空函數(shù)

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

def nop():pass

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

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

if age >= 18:pass

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

參數(shù)檢查

調用函數(shù)時,如果參數(shù)個數(shù)不對,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

但是如果參數(shù)類型不對,Python解釋器就無法幫我們檢查。試試my_abs和內置函數(shù)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'

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

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

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

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

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

錯誤和異常處理將在后續(xù)講到。

返回多個值

函數(shù)可以返回多個值嗎?答案是肯定的。

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

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

import math語句表示導入math包,并允許后續(xù)代碼引用math包里的sin、cos等函數(shù)。

然后,我們就可以同時獲得返回值:

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

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

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

原來返回值是一個tuple!但是,在語法上,返回一個tuple可以省略括號,而多個變量可以同時接收一個tuple,按位置賦給對應的值,所以,Python的函數(shù)返回多值其實就是返回一個tuple,但寫起來更方便。

小結

定義函數(shù)時,需要確定函數(shù)名和參數(shù)個數(shù);

如果有必要,可以先對參數(shù)的數(shù)據(jù)類型做檢查;

函數(shù)體內部可以用return隨時返回函數(shù)結果;

函數(shù)執(zhí)行完畢也沒有return語句時,自動return None。

函數(shù)可以同時返回多個值,但其實就是一個tuple。

練習

請定義一個函數(shù)quadratic(a, b, c),接收3個參數(shù),返回一元二次方程:

ax2?+ bx + c = 0

的兩個解。

提示:計算平方根可以調用math.sqrt()函數(shù):

>>> import math
>>> math.sqrt(2)
1.4142135623730951
# -*- coding: utf-8 -*-import mathdef quadratic(a, b, c):
----pass
----
# 測試:
print(quadratic(2, 3, 1)) # => (-0.5, -1.0)
print(quadratic(1, 3, -4)) # => (1.0, -4.0)
參考源碼

def_func.py

函數(shù)的參數(shù)

定義函數(shù)的時候,我們把參數(shù)的名字和位置確定下來,函數(shù)的接口定義就完成了。對于函數(shù)的調用者來說,只需要知道如何傳遞正確的參數(shù),以及函數(shù)將返回什么樣的值就夠了,函數(shù)內部的復雜邏輯被封裝起來,調用者無需了解。

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

位置參數(shù)

我們先寫一個計算x2的函數(shù):

def power(x):return x * x

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

當我們調用power函數(shù)時,必須傳入有且僅有的一個參數(shù)x

>>> power(5)
25
>>> power(15)
225

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

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

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

對于這個修改后的power(x, n)函數(shù),可以計算任意n次方:

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

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

默認參數(shù)

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

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

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

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

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

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

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

而對于n > 2的其他情況,就必須明確地傳入n,比如power(5, 3)

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

一是必選參數(shù)在前,默認參數(shù)在后,否則Python的解釋器會報錯(思考一下為什么默認參數(shù)不能放在必選參數(shù)前面);

二是如何設置默認參數(shù)。

當函數(shù)有多個參數(shù)時,把變化大的參數(shù)放前面,變化小的參數(shù)放后面。變化小的參數(shù)就可以作為默認參數(shù)。

使用默認參數(shù)有什么好處?最大的好處是能降低調用函數(shù)的難度。

舉個例子,我們寫個一年級小學生注冊的函數(shù),需要傳入namegender兩個參數(shù):

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

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

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

如果要繼續(xù)傳入年齡、城市等信息怎么辦?這樣會使得調用函數(shù)的復雜度大大增加。

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

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

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

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

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

enroll('Bob', 'M', 7)
enroll('Adam', 'M', city='Tianjin')

可見,默認參數(shù)降低了函數(shù)調用的難度,而一旦需要更復雜的調用時,又可以傳遞更多的參數(shù)來實現(xiàn)。無論是簡單調用還是復雜調用,函數(shù)只需要定義一個。

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

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

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

先定義一個函數(shù),傳入一個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']

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

>>> add_end()
['END']

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

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

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

原因解釋如下:

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

所以,定義默認參數(shù)要牢記一點:默認參數(shù)必須指向不變對象!

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

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

現(xiàn)在,無論調用多少次,都不會有問題:

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

為什么要設計strNone這樣的不變對象呢?因為不變對象一旦創(chuàng)建,對象內部的數(shù)據(jù)就不能修改,這樣就減少了由于修改數(shù)據(jù)導致的錯誤。此外,由于對象不變,多任務環(huán)境下同時讀取對象不需要加鎖,同時讀一點問題都沒有。我們在編寫程序時,如果可以設計一個不變對象,那就盡量設計成不變對象。

可變參數(shù)

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

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

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

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

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

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

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

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

所以,我們把函數(shù)的參數(shù)改為可變參數(shù):

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

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

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

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

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

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

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

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

關鍵字參數(shù)

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

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

函數(shù)person除了必選參數(shù)nameage外,還接受關鍵字參數(shù)kw。在調用該函數(shù)時,可以只傳入必選參數(shù):

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

也可以傳入任意個數(shù)的關鍵字參數(shù):

>>> 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'}

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

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

>>> 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用關鍵字參數(shù)傳入到函數(shù)的**kw參數(shù),kw將獲得一個dict,注意kw獲得的dict是extra的一份拷貝,對kw的改動不會影響到函數(shù)外的extra。

命名關鍵字參數(shù)

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

仍以person()函數(shù)為例,我們希望檢查是否有cityjob參數(shù):

def person(name, age, **kw):if 'city' in kw:# 有city參數(shù)passif 'job' in kw:# 有job參數(shù)passprint('name:', name, 'age:', age, 'other:', kw)

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

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

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

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

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

調用方式如下:

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

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

>>> person('Jack', 24, 'Beijing', 'Engineer')
Traceback (most recent call last):File "<stdin>", line 1, in <module>
TypeError: person() takes 2 positional arguments but 4 were given

由于調用時缺少參數(shù)名cityjob,Python解釋器把這4個參數(shù)均視為位置參數(shù),但person()函數(shù)僅接受2個位置參數(shù)。

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

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

由于命名關鍵字參數(shù)city具有默認值,調用時,可不傳入city參數(shù):

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

使用命名關鍵字參數(shù)時,要特別注意,*不是參數(shù),而是特殊分隔符。如果缺少*,Python解釋器將無法識別位置參數(shù)和命名關鍵字參數(shù):

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

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

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

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)

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

>>> 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,你也可以調用上述函數(shù):

>>> 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': '#'}

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

小結

Python的函數(shù)具有非常靈活的參數(shù)形態(tài),既可以實現(xiàn)簡單的調用,又可以傳入非常復雜的參數(shù)。

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

要注意定義可變參數(shù)和關鍵字參數(shù)的語法:

*args是可變參數(shù),args接收的是一個tuple;

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

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

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

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

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

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

定義命名的關鍵字參數(shù)不要忘了寫分隔符*,否則定義的將是位置參數(shù)。

參考源碼

var_args.py

kw_args.py

遞歸函數(shù)

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

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

fact(n) = n! = 1 x 2 x 3 x ... x (n-1) x n = (n-1)! x n = fact(n-1) x n

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

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

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

上面就是一個遞歸函數(shù)。可以試試:

>>> fact(1)
1
>>> fact(5)
120
>>> fact(100)
93326215443944152681699238856266700490715968264381621468592963895217599993229915608941463976156518286253697920827223758251185210916864000000000000000000000000

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

===> 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

遞歸函數(shù)的優(yōu)點是定義簡單,邏輯清晰。理論上,所有的遞歸函數(shù)都可以寫成循環(huán)的方式,但循環(huán)的邏輯不如遞歸清晰。

使用遞歸函數(shù)需要注意防止棧溢出。在計算機中,函數(shù)調用是通過棧(stack)這種數(shù)據(jù)結構實現(xiàn)的,每當進入一個函數(shù)調用,棧就會加一層棧幀,每當函數(shù)返回,棧就會減一層棧幀。由于棧的大小不是無限的,所以,遞歸調用的次數(shù)過多,會導致棧溢出??梢栽囋?code>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

解決遞歸調用棧溢出的方法是通過尾遞歸優(yōu)化,事實上尾遞歸和循環(huán)的效果是一樣的,所以,把循環(huán)看成是一種特殊的尾遞歸函數(shù)也是可以的。

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

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

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

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

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

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

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

小結

使用遞歸函數(shù)的優(yōu)點是邏輯簡單清晰,缺點是過深的調用會導致棧溢出。

針對尾遞歸優(yōu)化的語言可以通過尾遞歸防止棧溢出。尾遞歸事實上和循環(huán)是等價的,沒有循環(huán)語句的編程語言只能通過尾遞歸實現(xiàn)循環(huán)。

Python標準的解釋器沒有針對尾遞歸做優(yōu)化,任何遞歸函數(shù)都存在棧溢出的問題。

練習

漢諾塔的移動可以用遞歸函數(shù)非常簡單地實現(xiàn)。

請編寫move(n, a, b, c)函數(shù),它接收參數(shù)n,表示3個柱子A、B、C中第1個柱子A的盤子數(shù)量,然后打印出把所有盤子從A借助B移動到C的方法,例如:

def move(n, a, b, c):
----pass
----
# 期待輸出:
# A --> C
# A --> B
# C --> B
# A --> C
# B --> A
# B --> C
# A --> C
move(3, 'A', 'B', 'C')
參考源碼

recur.py

高級特性

掌握了Python的數(shù)據(jù)類型、語句和函數(shù),基本上就可以編寫出很多有用的程序了。

比如構造一個1, 3, 5, 7, ..., 99的列表,可以通過循環(huán)實現(xiàn):

L = []
n = 1
while n <= 99:L.append(n)n = n + 2

取list的前一半的元素,也可以通過循環(huán)實現(xiàn)。

但是在Python中,代碼不是越多越好,而是越少越好。代碼不是越復雜越好,而是越簡單越好。

基于這一思想,我們來介紹Python中非常有用的高級特性,1行代碼能實現(xiàn)的功能,決不寫5行代碼。請始終牢記,代碼越少,開發(fā)效率越高。

切片

取一個list或tuple的部分元素是非常常見的操作。比如,一個list如下:

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

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

笨辦法:

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

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

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

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

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

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

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

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

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

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

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

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

類似的,既然Python支持L[-1]取倒數(shù)第一個元素,那么它同樣支持倒數(shù)切片,試試:

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

記住倒數(shù)第一個元素的索引是-1

切片操作十分有用。我們先創(chuàng)建一個0-99的數(shù)列:

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

可以通過切片輕松取出某一段數(shù)列。比如前10個數(shù):

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

后10個數(shù):

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

前11-20個數(shù):

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

前10個數(shù),每兩個取一個:

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

所有數(shù),每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,唯一區(qū)別是tuple不可變。因此,tuple也可以用切片操作,只是操作的結果仍是tuple:

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

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

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

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

小結

有了切片操作,很多地方循環(huán)就不再需要了。Python的切片非常靈活,一行代碼就可以實現(xiàn)很多行循環(huán)才能完成的操作。

參考源碼

do_slice.py

迭代

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

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

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

可以看出,Python的for循環(huán)抽象程度要高于Java的for循環(huán),因為Python的for循環(huán)不僅可以用在list或tuple上,還可以作用在其他可迭代對象上。

list這種數(shù)據(jù)類型雖然有下標,但很多其他數(shù)據(jù)類型是沒有下標的,但是,只要是可迭代對象,無論有無下標,都可以迭代,比如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循環(huán):

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

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

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

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

最后一個小問題,如果要對list實現(xiàn)類似Java那樣的下標循環(huán)怎么辦?Python內置的enumerate函數(shù)可以把一個list變成索引-元素對,這樣就可以在for循環(huán)中同時迭代索引和元素本身:

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

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

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

任何可迭代對象都可以作用于for循環(huán),包括我們自定義的數(shù)據(jù)類型,只要符合迭代條件,就可以使用for循環(huán)。

參考源碼

do_iter.py

列表生成式

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

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

>>> list(range(1, 11))
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

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

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

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

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

寫列表生成式時,把要生成的元素x * x放到前面,后面跟for循環(huán),就可以把list創(chuàng)建出來,十分有用,多寫幾次,很快就可以熟悉這種語法。

for循環(huán)后面還可以加上if判斷,這樣我們就可以篩選出僅偶數(shù)的平方:

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

還可以使用兩層循環(huán),可以生成全排列:

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

三層和三層以上的循環(huán)就很少用到了。

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

>>> 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循環(huán)其實可以同時使用兩個甚至多個變量,比如dictitems()可以同時迭代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']
練習

如果list中既包含字符串,又包含整數(shù),由于非字符串類型沒有lower()方法,所以列表生成式會報錯:

>>> L = ['Hello', 'World', 18, 'Apple', None]
>>> [s.lower() for s in L]
Traceback (most recent call last):File "<stdin>", line 1, in <module>File "<stdin>", line 1, in <listcomp>
AttributeError: 'int' object has no attribute 'lower'

使用內建的isinstance函數(shù)可以判斷一個變量是不是字符串:

>>> x = 'abc'
>>> y = 123
>>> isinstance(x, str)
True
>>> isinstance(y, str)
False

請修改列表生成式,通過添加if語句保證列表生成式能正確地執(zhí)行:

# -*- coding: utf-8 -*-L1 = ['Hello', 'World', 18, 'Apple', None]
----
L2 = ???
----
# 期待輸出: ['hello', 'world', 'apple']
print(L2)
小結

運用列表生成式,可以快速生成list,可以通過一個list推導出另一個list,而代碼卻十分簡潔。

參考源碼

do_listcompr.py

生成器

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

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

要創(chuàng)建一個generator,有很多種方法。第一種方法很簡單,只要把一個列表生成式的[]改成(),就創(chuàng)建了一個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>

創(chuàng)建Lg的區(qū)別僅在于最外層的[]()L是一個list,而g是一個generator。

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

如果要一個一個打印出來,可以通過next()函數(shù)獲得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)實在是太變態(tài)了,正確的方法是使用for循環(huán),因為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

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

generator非常強大。如果推算的算法比較復雜,用類似列表生成式的for循環(huán)無法實現(xiàn)的時候,還可以用函數(shù)來實現(xiàn)。

比如,著名的斐波拉契數(shù)列(Fibonacci),除第一個和第二個數(shù)外,任意一個數(shù)都可由前兩個數(shù)相加得到:

1, 1, 2, 3, 5, 8, 13, 21, 34, ...

斐波拉契數(shù)列用列表生成式寫不出來,但是,用函數(shù)把它打印出來卻很容易:

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

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

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

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

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

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

這就是定義generator的另一種方法。如果一個函數(shù)定義中包含yield關鍵字,那么這個函數(shù)就不再是一個普通函數(shù),而是一個generator:

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

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

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

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

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

>>> o = odd()
>>> next(o)
step 1
1
>>> next(o)
step 2
3
>>> next(o)
step 3
5
>>> next(o)
Traceback (most recent call last):File "<stdin>", line 1, in <module>
StopIteration

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

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

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

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

但是用for循環(huán)調用generator時,發(fā)現(xiàn)拿不到generator的return語句的返回值。如果想要拿到返回值,必須捕獲StopIteration錯誤,返回值包含在StopIterationvalue中:

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

關于如何捕獲錯誤,后面的錯誤處理還會詳細講解。

練習

楊輝三角定義如下:

          11   11   2   11   3   3   11   4   6   4   1
1   5   10  10  5   1

把每一行看做一個list,試寫一個generator,不斷輸出下一行的list:

# -*- coding: utf-8 -*-def triangles():
----pass
----
# 期待輸出:
# [1]
# [1, 1]
# [1, 2, 1]
# [1, 3, 3, 1]
# [1, 4, 6, 4, 1]
# [1, 5, 10, 10, 5, 1]
# [1, 6, 15, 20, 15, 6, 1]
# [1, 7, 21, 35, 35, 21, 7, 1]
# [1, 8, 28, 56, 70, 56, 28, 8, 1]
# [1, 9, 36, 84, 126, 126, 84, 36, 9, 1]
n = 0
for t in triangles():print(t)n = n + 1if n == 10:break
小結

generator是非常強大的工具,在Python中,可以簡單地把列表生成式改成generator,也可以通過函數(shù)實現(xiàn)復雜邏輯的generator。

要理解generator的工作原理,它是在for循環(huán)的過程中不斷計算出下一個元素,并在適當?shù)臈l件結束for循環(huán)。對于函數(shù)改成的generator來說,遇到return語句或者執(zhí)行到函數(shù)體最后一行語句,就是結束generator的指令,for循環(huán)隨之結束。

請注意區(qū)分普通函數(shù)和generator函數(shù),普通函數(shù)調用直接返回結果:

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

generator函數(shù)的“調用”實際返回一個generator對象:

>>> g = fib(6)
>>> g
<generator object fib at 0x1022ef948>
參考源碼

do_generator.py

迭代器

我們已經知道,可以直接作用于for循環(huán)的數(shù)據(jù)類型有以下幾種:

一類是集合數(shù)據(jù)類型,如listtuple、dictset、str等;

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

這些可以直接作用于for循環(huán)的對象統(tǒng)稱為可迭代對象:Iterable

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

>>> from collections 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循環(huán),還可以被next()函數(shù)不斷調用并返回下一個值,直到最后拋出StopIteration錯誤表示無法繼續(xù)返回下一個值了。

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

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

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

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

listdict、strIterable變成Iterator可以使用iter()函數(shù):

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

你可能會問,為什么list、dictstr等數(shù)據(jù)類型不是Iterator

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

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

小結

凡是可作用于for循環(huán)的對象都是Iterable類型;

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

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

Python的for循環(huán)本質上就是通過不斷調用next()函數(shù)實現(xiàn)的,例如:

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

實際上完全等價于:

# 首先獲得Iterator對象:
it = iter([1, 2, 3, 4, 5])
# 循環(huán):
while True:try:# 獲得下一個值:x = next(it)except StopIteration:# 遇到StopIteration就退出循環(huán)break
參考源碼

do_iter.py

函數(shù)式編程

函數(shù)是Python內建支持的一種封裝,我們通過把大段代碼拆成函數(shù),通過一層一層的函數(shù)調用,就可以把復雜任務分解成簡單的任務,這種分解可以稱之為面向過程的程序設計。函數(shù)就是面向過程的程序設計的基本單元。

而函數(shù)式編程(請注意多了一個“式”字)——Functional Programming,雖然也可以歸結到面向過程的程序設計,但其思想更接近數(shù)學計算。

我們首先要搞明白計算機(Computer)和計算(Compute)的概念。

在計算機的層次上,CPU執(zhí)行的是加減乘除的指令代碼,以及各種條件判斷和跳轉指令,所以,匯編語言是最貼近計算機的語言。

而計算則指數(shù)學意義上的計算,越是抽象的計算,離計算機硬件越遠。

對應到編程語言,就是越低級的語言,越貼近計算機,抽象程度低,執(zhí)行效率高,比如C語言;越高級的語言,越貼近計算,抽象程度高,執(zhí)行效率低,比如Lisp語言。

函數(shù)式編程就是一種抽象程度很高的編程范式,純粹的函數(shù)式編程語言編寫的函數(shù)沒有變量,因此,任意一個函數(shù),只要輸入是確定的,輸出就是確定的,這種純函數(shù)我們稱之為沒有副作用。而允許使用變量的程序設計語言,由于函數(shù)內部的變量狀態(tài)不確定,同樣的輸入,可能得到不同的輸出,因此,這種函數(shù)是有副作用的。

函數(shù)式編程的一個特點就是,允許把函數(shù)本身作為參數(shù)傳入另一個函數(shù),還允許返回一個函數(shù)!

Python對函數(shù)式編程提供部分支持。由于Python允許使用變量,因此,Python不是純函數(shù)式編程語言。

高階函數(shù)

高階函數(shù)英文叫Higher-order function。什么是高階函數(shù)?我們以實際代碼為例子,一步一步深入概念。

變量可以指向函數(shù)

以Python內置的求絕對值的函數(shù)abs()為例,調用該函數(shù)用以下代碼:

>>> abs(-10)
10

但是,如果只寫abs呢?

>>> abs
<built-in function abs>

可見,abs(-10)是函數(shù)調用,而abs是函數(shù)本身。

要獲得函數(shù)調用結果,我們可以把結果賦值給變量:

>>> x = abs(-10)
>>> x
10

但是,如果把函數(shù)本身賦值給變量呢?

>>> f = abs
>>> f
<built-in function abs>

結論:函數(shù)本身也可以賦值給變量,即:變量可以指向函數(shù)。

如果一個變量指向了一個函數(shù),那么,可否通過該變量來調用這個函數(shù)?用代碼驗證一下:

>>> f = abs
>>> f(-10)
10

成功!說明變量f現(xiàn)在已經指向了abs函數(shù)本身。直接調用abs()函數(shù)和調用變量f()完全相同。

函數(shù)名也是變量

那么函數(shù)名是什么呢?函數(shù)名其實就是指向函數(shù)的變量!對于abs()這個函數(shù),完全可以把函數(shù)名abs看成變量,它指向一個可以計算絕對值的函數(shù)!

如果把abs指向其他對象,會有什么情況發(fā)生?

>>> abs = 10
>>> abs(-10)
Traceback (most recent call last):File "<stdin>", line 1, in <module>
TypeError: 'int' object is not callable

abs指向10后,就無法通過abs(-10)調用該函數(shù)了!因為abs這個變量已經不指向求絕對值函數(shù)而是指向一個整數(shù)10

當然實際代碼絕對不能這么寫,這里是為了說明函數(shù)名也是變量。要恢復abs函數(shù),請重啟Python交互環(huán)境。

注:由于abs函數(shù)實際上是定義在__builtin__模塊中的,所以要讓修改abs變量的指向在其它模塊也生效,要用__builtin__.abs = 10

傳入函數(shù)

既然變量可以指向函數(shù),函數(shù)的參數(shù)能接收變量,那么一個函數(shù)就可以接收另一個函數(shù)作為參數(shù),這種函數(shù)就稱之為高階函數(shù)。

一個最簡單的高階函數(shù):

def add(x, y, f):return f(x) + f(y)

當我們調用add(-5, 6, abs)時,參數(shù)xyf分別接收-56abs,根據(jù)函數(shù)定義,我們可以推導計算過程為:

x = -5
y = 6
f = abs
f(x) + f(y) ==> abs(-5) + abs(6) ==> 11
return 11

用代碼驗證一下:

>>> add(-5, 6, abs)
11

編寫高階函數(shù),就是讓函數(shù)的參數(shù)能夠接收別的函數(shù)。

小結

把函數(shù)作為參數(shù)傳入,這樣的函數(shù)稱為高階函數(shù),函數(shù)式編程就是指這種高度抽象的編程范式。

map/reduce

Python內建了map()reduce()函數(shù)。

如果你讀過Google的那篇大名鼎鼎的論文“MapReduce: Simplified Data Processing on Large Clusters”,你就能大概明白map/reduce的概念。

我們先看map。map()函數(shù)接收兩個參數(shù),一個是函數(shù),一個是Iterablemap將傳入的函數(shù)依次作用到序列的每個元素,并把結果作為新的Iterator返回。

舉例說明,比如我們有一個函數(shù)f(x)=x2,要把這個函數(shù)作用在一個list?[1, 2, 3, 4, 5, 6, 7, 8, 9]上,就可以用map()實現(xiàn)如下:

map

現(xiàn)在,我們用Python代碼實現(xiàn):

>>> def f(x):
...     return x * x
...
>>> r = map(f, [1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> list(r)
[1, 4, 9, 16, 25, 36, 49, 64, 81]

map()傳入的第一個參數(shù)是f,即函數(shù)對象本身。由于結果r是一個IteratorIterator是惰性序列,因此通過list()函數(shù)讓它把整個序列都計算出來并返回一個list。

你可能會想,不需要map()函數(shù),寫一個循環(huán),也可以計算出結果:

L = []
for n in [1, 2, 3, 4, 5, 6, 7, 8, 9]:L.append(f(n))
print(L)

的確可以,但是,從上面的循環(huán)代碼,能一眼看明白“把f(x)作用在list的每一個元素并把結果生成一個新的list”嗎?

所以,map()作為高階函數(shù),事實上它把運算規(guī)則抽象了,因此,我們不但可以計算簡單的f(x)=x2,還可以計算任意復雜的函數(shù),比如,把這個list所有數(shù)字轉為字符串:

>>> list(map(str, [1, 2, 3, 4, 5, 6, 7, 8, 9]))
['1', '2', '3', '4', '5', '6', '7', '8', '9']

只需要一行代碼。

再看reduce的用法。reduce把一個函數(shù)作用在一個序列[x1, x2, x3, ...]上,這個函數(shù)必須接收兩個參數(shù),reduce把結果繼續(xù)和序列的下一個元素做累積計算,其效果就是:

reduce(f, [x1, x2, x3, x4]) = f(f(f(x1, x2), x3), x4)

比方說對一個序列求和,就可以用reduce實現(xiàn):

>>> from functools import reduce
>>> def add(x, y):
...     return x + y
...
>>> reduce(add, [1, 3, 5, 7, 9])
25

當然求和運算可以直接用Python內建函數(shù)sum(),沒必要動用reduce

但是如果要把序列[1, 3, 5, 7, 9]變換成整數(shù)13579reduce就可以派上用場:

>>> from functools import reduce
>>> def fn(x, y):
...     return x * 10 + y
...
>>> reduce(fn, [1, 3, 5, 7, 9])
13579

這個例子本身沒多大用處,但是,如果考慮到字符串str也是一個序列,對上面的例子稍加改動,配合map(),我們就可以寫出把str轉換為int的函數(shù):

>>> from functools import reduce
>>> def fn(x, y):
...     return x * 10 + y
...
>>> def char2num(s):
...     return {'0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9}[s]
...
>>> reduce(fn, map(char2num, '13579'))
13579

整理成一個str2int的函數(shù)就是:

from functools import reducedef str2int(s):def fn(x, y):return x * 10 + ydef char2num(s):return {'0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9}[s]return reduce(fn, map(char2num, s))

還可以用lambda函數(shù)進一步簡化成:

from functools import reducedef char2num(s):return {'0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9}[s]def str2int(s):return reduce(lambda x, y: x * 10 + y, map(char2num, s))

也就是說,假設Python沒有提供int()函數(shù),你完全可以自己寫一個把字符串轉化為整數(shù)的函數(shù),而且只需要幾行代碼!

lambda函數(shù)的用法在后面介紹。

練習

利用map()函數(shù),把用戶輸入的不規(guī)范的英文名字,變?yōu)槭鬃帜复髮?#xff0c;其他小寫的規(guī)范名字。輸入:['adam', 'LISA', 'barT'],輸出:['Adam', 'Lisa', 'Bart']

# -*- coding: utf-8 -*-
----
def normalize(name):pass
----
# 測試:
L1 = ['adam', 'LISA', 'barT']
L2 = list(map(normalize, L1))
print(L2)

Python提供的sum()函數(shù)可以接受一個list并求和,請編寫一個prod()函數(shù),可以接受一個list并利用reduce()求積:

# -*- coding: utf-8 -*-from functools import reducedef prod(L):
----pass
----
print('3 * 5 * 7 * 9 =', prod([3, 5, 7, 9]))

利用mapreduce編寫一個str2float函數(shù),把字符串'123.456'轉換成浮點數(shù)123.456

# -*- coding: utf-8 -*-from functools import reducedef str2float(s):
----pass
----
print('str2float(\'123.456\') =', str2float('123.456'))
參考代碼

do_map.py

do_reduce.py

filter

Python內建的filter()函數(shù)用于過濾序列。

map()類似,filter()也接收一個函數(shù)和一個序列。和map()不同的時,filter()把傳入的函數(shù)依次作用于每個元素,然后根據(jù)返回值是True還是False決定保留還是丟棄該元素。

例如,在一個list中,刪掉偶數(shù),只保留奇數(shù),可以這么寫:

def is_odd(n):return n % 2 == 1list(filter(is_odd, [1, 2, 4, 5, 6, 9, 10, 15]))
# 結果: [1, 5, 9, 15]

把一個序列中的空字符串刪掉,可以這么寫:

def not_empty(s):return s and s.strip()list(filter(not_empty, ['A', '', 'B', None, 'C', '  ']))
# 結果: ['A', 'B', 'C']

可見用filter()這個高階函數(shù),關鍵在于正確實現(xiàn)一個“篩選”函數(shù)。

注意到filter()函數(shù)返回的是一個Iterator,也就是一個惰性序列,所以要強迫filter()完成計算結果,需要用list()函數(shù)獲得所有結果并返回list。

用filter求素數(shù)

計算素數(shù)的一個方法是埃氏篩法,它的算法理解起來非常簡單:

首先,列出從2開始的所有自然數(shù),構造一個序列:

2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...

取序列的第一個數(shù)2,它一定是素數(shù),然后用2把序列的2的倍數(shù)篩掉:

3,?4, 5,?6, 7,?8, 9,?10, 11,?12, 13,?14, 15,?16, 17,?18, 19,?20, ...

取新序列的第一個數(shù)3,它一定是素數(shù),然后用3把序列的3的倍數(shù)篩掉:

5,?6, 7,?8,?9,?10, 11,?12, 13,?14,?15,?16, 17,?18, 19,?20, ...

取新序列的第一個數(shù)5,然后用5把序列的5的倍數(shù)篩掉:

7,?8,?9,?10, 11,?12, 13,?14,?15,?16, 17,?18, 19,?20, ...

不斷篩下去,就可以得到所有的素數(shù)。

用Python來實現(xiàn)這個算法,可以先構造一個從3開始的奇數(shù)序列:

def _odd_iter():n = 1while True:n = n + 2yield n

注意這是一個生成器,并且是一個無限序列。

然后定義一個篩選函數(shù):

def _not_divisible(n):return lambda x: x % n > 0

最后,定義一個生成器,不斷返回下一個素數(shù):

def primes():yield 2it = _odd_iter() # 初始序列while True:n = next(it) # 返回序列的第一個數(shù)yield nit = filter(_not_divisible(n), it) # 構造新序列

這個生成器先返回第一個素數(shù)2,然后,利用filter()不斷產生篩選后的新的序列。

由于primes()也是一個無限序列,所以調用時需要設置一個退出循環(huán)的條件:

# 打印1000以內的素數(shù):
for n in primes():if n < 1000:print(n)else:break

注意到Iterator是惰性計算的序列,所以我們可以用Python表示“全體自然數(shù)”,“全體素數(shù)”這樣的序列,而代碼非常簡潔。

練習

回數(shù)是指從左向右讀和從右向左讀都是一樣的數(shù),例如12321909。請利用filter()濾掉非回數(shù):

# -*- coding: utf-8 -*-def is_palindrome(n):
----pass
----
# 測試:
output = filter(is_palindrome, range(1, 1000))
print(list(output))
小結

filter()的作用是從一個序列中篩出符合條件的元素。由于filter()使用了惰性計算,所以只有在取filter()結果的時候,才會真正篩選并每次返回下一個篩出的元素。

參考源碼

do_filter.py

prime_numbers.py

sorted

排序算法

排序也是在程序中經常用到的算法。無論使用冒泡排序還是快速排序,排序的核心是比較兩個元素的大小。如果是數(shù)字,我們可以直接比較,但如果是字符串或者兩個dict呢?直接比較數(shù)學上的大小是沒有意義的,因此,比較的過程必須通過函數(shù)抽象出來。

Python內置的sorted()函數(shù)就可以對list進行排序:

>>> sorted([36, 5, -12, 9, -21])
[-21, -12, 5, 9, 36]

此外,sorted()函數(shù)也是一個高階函數(shù),它還可以接收一個key函數(shù)來實現(xiàn)自定義的排序,例如按絕對值大小排序:

>>> sorted([36, 5, -12, 9, -21], key=abs)
[5, 9, -12, -21, 36]

key指定的函數(shù)將作用于list的每一個元素上,并根據(jù)key函數(shù)返回的結果進行排序。對比原始的list和經過key=abs處理過的list:

list = [36, 5, -12, 9, -21]keys = [36, 5,  12, 9,  21]

然后sorted()函數(shù)按照keys進行排序,并按照對應關系返回list相應的元素:

keys排序結果 => [5, 9,  12,  21, 36]|  |    |    |   |
最終結果     => [5, 9, -12, -21, 36]

我們再看一個字符串排序的例子:

>>> sorted(['bob', 'about', 'Zoo', 'Credit'])
['Credit', 'Zoo', 'about', 'bob']

默認情況下,對字符串排序,是按照ASCII的大小比較的,由于'Z' < 'a',結果,大寫字母Z會排在小寫字母a的前面。

現(xiàn)在,我們提出排序應該忽略大小寫,按照字母序排序。要實現(xiàn)這個算法,不必對現(xiàn)有代碼大加改動,只要我們能用一個key函數(shù)把字符串映射為忽略大小寫排序即可。忽略大小寫來比較兩個字符串,實際上就是先把字符串都變成大寫(或者都變成小寫),再比較。

這樣,我們給sorted傳入key函數(shù),即可實現(xiàn)忽略大小寫的排序:

>>> sorted(['bob', 'about', 'Zoo', 'Credit'], key=str.lower)
['about', 'bob', 'Credit', 'Zoo']

要進行反向排序,不必改動key函數(shù),可以傳入第三個參數(shù)reverse=True

>>> sorted(['bob', 'about', 'Zoo', 'Credit'], key=str.lower, reverse=True)
['Zoo', 'Credit', 'bob', 'about']

從上述例子可以看出,高階函數(shù)的抽象能力是非常強大的,而且,核心代碼可以保持得非常簡潔。

小結

sorted()也是一個高階函數(shù)。用sorted()排序的關鍵在于實現(xiàn)一個映射函數(shù)。

練習

假設我們用一組tuple表示學生名字和成績:

L = [('Bob', 75), ('Adam', 92), ('Bart', 66), ('Lisa', 88)]

請用sorted()對上述列表分別按名字排序:

# -*- coding: utf-8 -*-L = [('Bob', 75), ('Adam', 92), ('Bart', 66), ('Lisa', 88)]def by_name(t):
----pass
----
L2 = sorted(L, key=by_name)
print(L2)

再按成績從高到低排序:

# -*- coding: utf-8 -*-L = [('Bob', 75), ('Adam', 92), ('Bart', 66), ('Lisa', 88)]----
def by_score(t):passL2 = ???
----
print(L2)
參考源碼

do_sorted.py

返回函數(shù)

函數(shù)作為返回值

高階函數(shù)除了可以接受函數(shù)作為參數(shù)外,還可以把函數(shù)作為結果值返回。

我們來實現(xiàn)一個可變參數(shù)的求和。通常情況下,求和的函數(shù)是這樣定義的:

def calc_sum(*args):ax = 0for n in args:ax = ax + nreturn ax

但是,如果不需要立刻求和,而是在后面的代碼中,根據(jù)需要再計算怎么辦?可以不返回求和的結果,而是返回求和的函數(shù):

def lazy_sum(*args):def sum():ax = 0for n in args:ax = ax + nreturn axreturn sum

當我們調用lazy_sum()時,返回的并不是求和結果,而是求和函數(shù):

>>> f = lazy_sum(1, 3, 5, 7, 9)
>>> f
<function lazy_sum.<locals>.sum at 0x101c6ed90>

調用函數(shù)f時,才真正計算求和的結果:

>>> f()
25

在這個例子中,我們在函數(shù)lazy_sum中又定義了函數(shù)sum,并且,內部函數(shù)sum可以引用外部函數(shù)lazy_sum的參數(shù)和局部變量,當lazy_sum返回函數(shù)sum時,相關參數(shù)和變量都保存在返回的函數(shù)中,這種稱為“閉包(Closure)”的程序結構擁有極大的威力。

請再注意一點,當我們調用lazy_sum()時,每次調用都會返回一個新的函數(shù),即使傳入相同的參數(shù):

>>> f1 = lazy_sum(1, 3, 5, 7, 9)
>>> f2 = lazy_sum(1, 3, 5, 7, 9)
>>> f1==f2
False

f1()f2()的調用結果互不影響。

閉包

注意到返回的函數(shù)在其定義內部引用了局部變量args,所以,當一個函數(shù)返回了一個函數(shù)后,其內部的局部變量還被新函數(shù)引用,所以,閉包用起來簡單,實現(xiàn)起來可不容易。

另一個需要注意的問題是,返回的函數(shù)并沒有立刻執(zhí)行,而是直到調用了f()才執(zhí)行。我們來看一個例子:

def count():fs = []for i in range(1, 4):def f():return i*ifs.append(f)return fsf1, f2, f3 = count()

在上面的例子中,每次循環(huán),都創(chuàng)建了一個新的函數(shù),然后,把創(chuàng)建的3個函數(shù)都返回了。

你可能認為調用f1()f2()f3()結果應該是149,但實際結果是:

>>> f1()
9
>>> f2()
9
>>> f3()
9

全部都是9!原因就在于返回的函數(shù)引用了變量i,但它并非立刻執(zhí)行。等到3個函數(shù)都返回時,它們所引用的變量i已經變成了3,因此最終結果為9。

返回閉包時牢記的一點就是:返回函數(shù)不要引用任何循環(huán)變量,或者后續(xù)會發(fā)生變化的變量。

如果一定要引用循環(huán)變量怎么辦?方法是再創(chuàng)建一個函數(shù),用該函數(shù)的參數(shù)綁定循環(huán)變量當前的值,無論該循環(huán)變量后續(xù)如何更改,已綁定到函數(shù)參數(shù)的值不變:

def count():def f(j):def g():return j*jreturn gfs = []for i in range(1, 4):fs.append(f(i)) # f(i)立刻被執(zhí)行,因此i的當前值被傳入f()return fs

再看看結果:

>>> f1, f2, f3 = count()
>>> f1()
1
>>> f2()
4
>>> f3()
9

缺點是代碼較長,可利用lambda函數(shù)縮短代碼。

小結

一個函數(shù)可以返回一個計算結果,也可以返回一個函數(shù)。

返回一個函數(shù)時,牢記該函數(shù)并未執(zhí)行,返回函數(shù)中不要引用任何可能會變化的變量。

參考源碼

return_func.py

匿名函數(shù)

當我們在傳入函數(shù)時,有些時候,不需要顯式地定義函數(shù),直接傳入匿名函數(shù)更方便。

在Python中,對匿名函數(shù)提供了有限支持。還是以map()函數(shù)為例,計算f(x)=x2時,除了定義一個f(x)的函數(shù)外,還可以直接傳入匿名函數(shù):

>>> list(map(lambda x: x * x, [1, 2, 3, 4, 5, 6, 7, 8, 9]))
[1, 4, 9, 16, 25, 36, 49, 64, 81]

通過對比可以看出,匿名函數(shù)lambda x: x * x實際上就是:

def f(x):return x * x

關鍵字lambda表示匿名函數(shù),冒號前面的x表示函數(shù)參數(shù)。

匿名函數(shù)有個限制,就是只能有一個表達式,不用寫return,返回值就是該表達式的結果。

用匿名函數(shù)有個好處,因為函數(shù)沒有名字,不必擔心函數(shù)名沖突。此外,匿名函數(shù)也是一個函數(shù)對象,也可以把匿名函數(shù)賦值給一個變量,再利用變量來調用該函數(shù):

>>> f = lambda x: x * x
>>> f
<function <lambda> at 0x101c6ef28>
>>> f(5)
25

同樣,也可以把匿名函數(shù)作為返回值返回,比如:

def build(x, y):return lambda: x * x + y * y
小結

Python對匿名函數(shù)的支持有限,只有一些簡單的情況下可以使用匿名函數(shù)。

裝飾器

由于函數(shù)也是一個對象,而且函數(shù)對象可以被賦值給變量,所以,通過變量也能調用該函數(shù)。

>>> def now():
...     print('2015-3-25')
...
>>> f = now
>>> f()
2015-3-25

函數(shù)對象有一個__name__屬性,可以拿到函數(shù)的名字:

>>> now.__name__
'now'
>>> f.__name__
'now'

現(xiàn)在,假設我們要增強now()函數(shù)的功能,比如,在函數(shù)調用前后自動打印日志,但又不希望修改now()函數(shù)的定義,這種在代碼運行期間動態(tài)增加功能的方式,稱之為“裝飾器”(Decorator)。

本質上,decorator就是一個返回函數(shù)的高階函數(shù)。所以,我們要定義一個能打印日志的decorator,可以定義如下:

def log(func):def wrapper(*args, **kw):print('call %s():' % func.__name__)return func(*args, **kw)return wrapper

觀察上面的log,因為它是一個decorator,所以接受一個函數(shù)作為參數(shù),并返回一個函數(shù)。我們要借助Python的@語法,把decorator置于函數(shù)的定義處:

@log
def now():print('2015-3-25')

調用now()函數(shù),不僅會運行now()函數(shù)本身,還會在運行now()函數(shù)前打印一行日志:

>>> now()
call now():
2015-3-25

@log放到now()函數(shù)的定義處,相當于執(zhí)行了語句:

now = log(now)

由于log()是一個decorator,返回一個函數(shù),所以,原來的now()函數(shù)仍然存在,只是現(xiàn)在同名的now變量指向了新的函數(shù),于是調用now()將執(zhí)行新函數(shù),即在log()函數(shù)中返回的wrapper()函數(shù)。

wrapper()函數(shù)的參數(shù)定義是(*args, **kw),因此,wrapper()函數(shù)可以接受任意參數(shù)的調用。在wrapper()函數(shù)內,首先打印日志,再緊接著調用原始函數(shù)。

如果decorator本身需要傳入參數(shù),那就需要編寫一個返回decorator的高階函數(shù),寫出來會更復雜。比如,要自定義log的文本:

def log(text):def decorator(func):def wrapper(*args, **kw):print('%s %s():' % (text, func.__name__))return func(*args, **kw)return wrapperreturn decorator

這個3層嵌套的decorator用法如下:

@log('execute')
def now():print('2015-3-25')

執(zhí)行結果如下:

>>> now()
execute now():
2015-3-25

和兩層嵌套的decorator相比,3層嵌套的效果是這樣的:

>>> now = log('execute')(now)

我們來剖析上面的語句,首先執(zhí)行log('execute'),返回的是decorator函數(shù),再調用返回的函數(shù),參數(shù)是now函數(shù),返回值最終是wrapper函數(shù)。

以上兩種decorator的定義都沒有問題,但還差最后一步。因為我們講了函數(shù)也是對象,它有__name__等屬性,但你去看經過decorator裝飾之后的函數(shù),它們的__name__已經從原來的'now'變成了'wrapper'

>>> now.__name__
'wrapper'

因為返回的那個wrapper()函數(shù)名字就是'wrapper',所以,需要把原始函數(shù)的__name__等屬性復制到wrapper()函數(shù)中,否則,有些依賴函數(shù)簽名的代碼執(zhí)行就會出錯。

不需要編寫wrapper.__name__ = func.__name__這樣的代碼,Python內置的functools.wraps就是干這個事的,所以,一個完整的decorator的寫法如下:

import functoolsdef log(func):@functools.wraps(func)def wrapper(*args, **kw):print('call %s():' % func.__name__)return func(*args, **kw)return wrapper

或者針對帶參數(shù)的decorator:

import functoolsdef log(text):def decorator(func):@functools.wraps(func)def wrapper(*args, **kw):print('%s %s():' % (text, func.__name__))return func(*args, **kw)return wrapperreturn decorator

import functools是導入functools模塊。模塊的概念稍候講解。現(xiàn)在,只需記住在定義wrapper()的前面加上@functools.wraps(func)即可。

小結

在面向對象(OOP)的設計模式中,decorator被稱為裝飾模式。OOP的裝飾模式需要通過繼承和組合來實現(xiàn),而Python除了能支持OOP的decorator外,直接從語法層次支持decorator。Python的decorator可以用函數(shù)實現(xiàn),也可以用類實現(xiàn)。

decorator可以增強函數(shù)的功能,定義起來雖然有點復雜,但使用起來非常靈活和方便。

請編寫一個decorator,能在函數(shù)調用的前后打印出'begin call''end call'的日志。

再思考一下能否寫出一個@log的decorator,使它既支持:

@log
def f():pass

又支持:

@log('execute')
def f():pass
參考源碼

decorator.py

偏函數(shù)

Python的functools模塊提供了很多有用的功能,其中一個就是偏函數(shù)(Partial function)。要注意,這里的偏函數(shù)和數(shù)學意義上的偏函數(shù)不一樣。

在介紹函數(shù)參數(shù)的時候,我們講到,通過設定參數(shù)的默認值,可以降低函數(shù)調用的難度。而偏函數(shù)也可以做到這一點。舉例如下:

int()函數(shù)可以把字符串轉換為整數(shù),當僅傳入字符串時,int()函數(shù)默認按十進制轉換:

>>> int('12345')
12345

int()函數(shù)還提供額外的base參數(shù),默認值為10。如果傳入base參數(shù),就可以做N進制的轉換:

>>> int('12345', base=8)
5349
>>> int('12345', 16)
74565

假設要轉換大量的二進制字符串,每次都傳入int(x, base=2)非常麻煩,于是,我們想到,可以定義一個int2()的函數(shù),默認把base=2傳進去:

def int2(x, base=2):return int(x, base)

這樣,我們轉換二進制就非常方便了:

>>> int2('1000000')
64
>>> int2('1010101')
85

functools.partial就是幫助我們創(chuàng)建一個偏函數(shù)的,不需要我們自己定義int2(),可以直接使用下面的代碼創(chuàng)建一個新的函數(shù)int2

>>> import functools
>>> int2 = functools.partial(int, base=2)
>>> int2('1000000')
64
>>> int2('1010101')
85

所以,簡單總結functools.partial的作用就是,把一個函數(shù)的某些參數(shù)給固定住(也就是設置默認值),返回一個新的函數(shù),調用這個新函數(shù)會更簡單。

注意到上面的新的int2函數(shù),僅僅是把base參數(shù)重新設定默認值為2,但也可以在函數(shù)調用時傳入其他值:

>>> int2('1000000', base=10)
1000000

最后,創(chuàng)建偏函數(shù)時,實際上可以接收函數(shù)對象、*args**kw這3個參數(shù),當傳入:

int2 = functools.partial(int, base=2)

實際上固定了int()函數(shù)的關鍵字參數(shù)base,也就是:

int2('10010')

相當于:

kw = { 'base': 2 }
int('10010', **kw)

當傳入:

max2 = functools.partial(max, 10)

實際上會把10作為*args的一部分自動加到左邊,也就是:

max2(5, 6, 7)

相當于:

args = (10, 5, 6, 7)
max(*args)

結果為10。

小結

當函數(shù)的參數(shù)個數(shù)太多,需要簡化時,使用functools.partial可以創(chuàng)建一個新的函數(shù),這個新函數(shù)可以固定住原函數(shù)的部分參數(shù),從而在調用時更簡單。

參考源碼

do_partial.py

模塊

在計算機程序的開發(fā)過程中,隨著程序代碼越寫越多,在一個文件里代碼就會越來越長,越來越不容易維護。

為了編寫可維護的代碼,我們把很多函數(shù)分組,分別放到不同的文件里,這樣,每個文件包含的代碼就相對較少,很多編程語言都采用這種組織代碼的方式。在Python中,一個.py文件就稱之為一個模塊(Module)。

使用模塊有什么好處?

最大的好處是大大提高了代碼的可維護性。其次,編寫代碼不必從零開始。當一個模塊編寫完畢,就可以被其他地方引用。我們在編寫程序的時候,也經常引用其他模塊,包括Python內置的模塊和來自第三方的模塊。

使用模塊還可以避免函數(shù)名和變量名沖突。相同名字的函數(shù)和變量完全可以分別存在不同的模塊中,因此,我們自己在編寫模塊時,不必考慮名字會與其他模塊沖突。但是也要注意,盡量不要與內置函數(shù)名字沖突。點這里查看Python的所有內置函數(shù)。

你也許還想到,如果不同的人編寫的模塊名相同怎么辦?為了避免模塊名沖突,Python又引入了按目錄來組織模塊的方法,稱為包(Package)。

舉個例子,一個abc.py的文件就是一個名字叫abc的模塊,一個xyz.py的文件就是一個名字叫xyz的模塊。

現(xiàn)在,假設我們的abcxyz這兩個模塊名字與其他模塊沖突了,于是我們可以通過包來組織模塊,避免沖突。方法是選擇一個頂層包名,比如mycompany,按照如下目錄存放:

mycompany

引入了包以后,只要頂層的包名不與別人沖突,那所有模塊都不會與別人沖突?,F(xiàn)在,abc.py模塊的名字就變成了mycompany.abc,類似的,xyz.py的模塊名變成了mycompany.xyz

請注意,每一個包目錄下面都會有一個__init__.py的文件,這個文件是必須存在的,否則,Python就把這個目錄當成普通目錄,而不是一個包。__init__.py可以是空文件,也可以有Python代碼,因為__init__.py本身就是一個模塊,而它的模塊名就是mycompany。

類似的,可以有多級目錄,組成多級層次的包結構。比如如下的目錄結構:

mycompany-web

文件www.py的模塊名就是mycompany.web.www,兩個文件utils.py的模塊名分別是mycompany.utilsmycompany.web.utils

自己創(chuàng)建模塊時要注意命名,不能和Python自帶的模塊名稱沖突。例如,系統(tǒng)自帶了sys模塊,自己的模塊就不可命名為sys.py,否則將無法導入系統(tǒng)自帶的sys模塊。

mycompany.web也是一個模塊,請指出該模塊對應的.py文件。

使用模塊

Python本身就內置了很多非常有用的模塊,只要安裝完畢,這些模塊就可以立刻使用。

我們以內建的sys模塊為例,編寫一個hello的模塊:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-' a test module '__author__ = 'Michael Liao'import sysdef test():args = sys.argvif len(args)==1:print('Hello, world!')elif len(args)==2:print('Hello, %s!' % args[1])else:print('Too many arguments!')if __name__=='__main__':test()

第1行和第2行是標準注釋,第1行注釋可以讓這個hello.py文件直接在Unix/Linux/Mac上運行,第2行注釋表示.py文件本身使用標準UTF-8編碼;

第4行是一個字符串,表示模塊的文檔注釋,任何模塊代碼的第一個字符串都被視為模塊的文檔注釋;

第6行使用__author__變量把作者寫進去,這樣當你公開源代碼后別人就可以瞻仰你的大名;

以上就是Python模塊的標準文件模板,當然也可以全部刪掉不寫,但是,按標準辦事肯定沒錯。

后面開始就是真正的代碼部分。

你可能注意到了,使用sys模塊的第一步,就是導入該模塊:

import sys

導入sys模塊后,我們就有了變量sys指向該模塊,利用sys這個變量,就可以訪問sys模塊的所有功能。

sys模塊有一個argv變量,用list存儲了命令行的所有參數(shù)。argv至少有一個元素,因為第一個參數(shù)永遠是該.py文件的名稱,例如:

運行python3 hello.py獲得的sys.argv就是['hello.py']

運行python3 hello.py Michael獲得的sys.argv就是['hello.py', 'Michael]。

最后,注意到這兩行代碼:

if __name__=='__main__':test()

當我們在命令行運行hello模塊文件時,Python解釋器把一個特殊變量__name__置為__main__,而如果在其他地方導入該hello模塊時,if判斷將失敗,因此,這種if測試可以讓一個模塊通過命令行運行時執(zhí)行一些額外的代碼,最常見的就是運行測試。

我們可以用命令行運行hello.py看看效果:

$ python3 hello.py
Hello, world!
$ python hello.py Michael
Hello, Michael!

如果啟動Python交互環(huán)境,再導入hello模塊:

$ python3
Python 3.4.3 (v3.4.3:9b73f1c3e601, Feb 23 2015, 02:52:03) 
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import hello
>>>

導入時,沒有打印Hello, word!,因為沒有執(zhí)行test()函數(shù)。

調用hello.test()時,才能打印出Hello, word!

>>> hello.test()
Hello, world!
作用域

在一個模塊中,我們可能會定義很多函數(shù)和變量,但有的函數(shù)和變量我們希望給別人使用,有的函數(shù)和變量我們希望僅僅在模塊內部使用。在Python中,是通過_前綴來實現(xiàn)的。

正常的函數(shù)和變量名是公開的(public),可以被直接引用,比如:abcx123PI等;

類似__xxx__這樣的變量是特殊變量,可以被直接引用,但是有特殊用途,比如上面的__author____name__就是特殊變量,hello模塊定義的文檔注釋也可以用特殊變量__doc__訪問,我們自己的變量一般不要用這種變量名;

類似_xxx__xxx這樣的函數(shù)或變量就是非公開的(private),不應該被直接引用,比如_abc__abc等;

之所以我們說,private函數(shù)和變量“不應該”被直接引用,而不是“不能”被直接引用,是因為Python并沒有一種方法可以完全限制訪問private函數(shù)或變量,但是,從編程習慣上不應該引用private函數(shù)或變量。

private函數(shù)或變量不應該被別人引用,那它們有什么用呢?請看例子:

def _private_1(name):return 'Hello, %s' % namedef _private_2(name):return 'Hi, %s' % namedef greeting(name):if len(name) > 3:return _private_1(name)else:return _private_2(name)

我們在模塊里公開greeting()函數(shù),而把內部邏輯用private函數(shù)隱藏起來了,這樣,調用greeting()函數(shù)不用關心內部的private函數(shù)細節(jié),這也是一種非常有用的代碼封裝和抽象的方法,即:

外部不需要引用的函數(shù)全部定義成private,只有外部需要引用的函數(shù)才定義為public。

安裝第三方模塊

在Python中,安裝第三方模塊,是通過包管理工具pip完成的。

如果你正在使用Mac或Linux,安裝pip本身這個步驟就可以跳過了。

如果你正在使用Windows,請參考安裝Python一節(jié)的內容,確保安裝時勾選了pipAdd python.exe to Path。

在命令提示符窗口下嘗試運行pip,如果Windows提示未找到命令,可以重新運行安裝程序添加pip

注意:Mac或Linux上有可能并存Python 3.x和Python 2.x,因此對應的pip命令是pip3。

現(xiàn)在,讓我們來安裝一個第三方庫——Python Imaging Library,這是Python下非常強大的處理圖像的工具庫。不過,PIL目前只支持到Python 2.7,并且有年頭沒有更新了,因此,基于PIL的Pillow項目開發(fā)非?;钴S,并且支持最新的Python 3。

一般來說,第三方庫都會在Python官方的pypi.python.org網站注冊,要安裝一個第三方庫,必須先知道該庫的名稱,可以在官網或者pypi上搜索,比如Pillow的名稱叫Pillow,因此,安裝Pillow的命令就是:

pip install Pillow

耐心等待下載并安裝后,就可以使用Pillow了。

有了Pillow,處理圖片易如反掌。隨便找個圖片生成縮略圖:

>>> from PIL import Image
>>> im = Image.open('test.png')
>>> print(im.format, im.size, im.mode)
PNG (400, 300) RGB
>>> im.thumbnail((200, 100))
>>> im.save('thumb.jpg', 'JPEG')

其他常用的第三方庫還有MySQL的驅動:mysql-connector-python,用于科學計算的NumPy庫:numpy,用于生成文本的模板工具Jinja2,等等。

模塊搜索路徑

當我們試圖加載一個模塊時,Python會在指定的路徑下搜索對應的.py文件,如果找不到,就會報錯:

>>> import mymodule
Traceback (most recent call last):File "<stdin>", line 1, in <module>
ImportError: No module named mymodule

默認情況下,Python解釋器會搜索當前目錄、所有已安裝的內置模塊和第三方模塊,搜索路徑存放在sys模塊的path變量中:

>>> import sys
>>> sys.path
['', '/Library/Frameworks/Python.framework/Versions/3.4/lib/python34.zip', '/Library/Frameworks/Python.framework/Versions/3.4/lib/python3.4', '/Library/Frameworks/Python.framework/Versions/3.4/lib/python3.4/plat-darwin', '/Library/Frameworks/Python.framework/Versions/3.4/lib/python3.4/lib-dynload', '/Library/Frameworks/Python.framework/Versions/3.4/lib/python3.4/site-packages']

如果我們要添加自己的搜索目錄,有兩種方法:

一是直接修改sys.path,添加要搜索的目錄:

>>> import sys
>>> sys.path.append('/Users/michael/my_py_scripts')

這種方法是在運行時修改,運行結束后失效。

第二種方法是設置環(huán)境變量PYTHONPATH,該環(huán)境變量的內容會被自動添加到模塊搜索路徑中。設置方式與設置Path環(huán)境變量類似。注意只需要添加你自己的搜索路徑,Python自己本身的搜索路徑不受影響。

面向對象編程

面向對象編程——Object Oriented Programming,簡稱OOP,是一種程序設計思想。OOP把對象作為程序的基本單元,一個對象包含了數(shù)據(jù)和操作數(shù)據(jù)的函數(shù)。

面向過程的程序設計把計算機程序視為一系列的命令集合,即一組函數(shù)的順序執(zhí)行。為了簡化程序設計,面向過程把函數(shù)繼續(xù)切分為子函數(shù),即把大塊函數(shù)通過切割成小塊函數(shù)來降低系統(tǒng)的復雜度。

而面向對象的程序設計把計算機程序視為一組對象的集合,而每個對象都可以接收其他對象發(fā)過來的消息,并處理這些消息,計算機程序的執(zhí)行就是一系列消息在各個對象之間傳遞。

在Python中,所有數(shù)據(jù)類型都可以視為對象,當然也可以自定義對象。自定義的對象數(shù)據(jù)類型就是面向對象中的類(Class)的概念。

我們以一個例子來說明面向過程和面向對象在程序流程上的不同之處。

假設我們要處理學生的成績表,為了表示一個學生的成績,面向過程的程序可以用一個dict表示:

std1 = { 'name': 'Michael', 'score': 98 }
std2 = { 'name': 'Bob', 'score': 81 }

而處理學生成績可以通過函數(shù)實現(xiàn),比如打印學生的成績:

def print_score(std):print('%s: %s' % (std['name'], std['score']))

如果采用面向對象的程序設計思想,我們首選思考的不是程序的執(zhí)行流程,而是Student這種數(shù)據(jù)類型應該被視為一個對象,這個對象擁有namescore這兩個屬性(Property)。如果要打印一個學生的成績,首先必須創(chuàng)建出這個學生對應的對象,然后,給對象發(fā)一個print_score消息,讓對象自己把自己的數(shù)據(jù)打印出來。

class Student(object):def __init__(self, name, score):self.name = nameself.score = scoredef print_score(self):print('%s: %s' % (self.name, self.score))

給對象發(fā)消息實際上就是調用對象對應的關聯(lián)函數(shù),我們稱之為對象的方法(Method)。面向對象的程序寫出來就像這樣:

bart = Student('Bart Simpson', 59)
lisa = Student('Lisa Simpson', 87)
bart.print_score()
lisa.print_score()

面向對象的設計思想是從自然界中來的,因為在自然界中,類(Class)和實例(Instance)的概念是很自然的。Class是一種抽象概念,比如我們定義的Class——Student,是指學生這個概念,而實例(Instance)則是一個個具體的Student,比如,Bart Simpson和Lisa Simpson是兩個具體的Student。

所以,面向對象的設計思想是抽象出Class,根據(jù)Class創(chuàng)建Instance。

面向對象的抽象程度又比函數(shù)要高,因為一個Class既包含數(shù)據(jù),又包含操作數(shù)據(jù)的方法。

小結

數(shù)據(jù)封裝、繼承和多態(tài)是面向對象的三大特點,我們后面會詳細講解。

類和實例

面向對象最重要的概念就是類(Class)和實例(Instance),必須牢記類是抽象的模板,比如Student類,而實例是根據(jù)類創(chuàng)建出來的一個個具體的“對象”,每個對象都擁有相同的方法,但各自的數(shù)據(jù)可能不同。

仍以Student類為例,在Python中,定義類是通過class關鍵字:

class Student(object):pass

class后面緊接著是類名,即Student,類名通常是大寫開頭的單詞,緊接著是(object),表示該類是從哪個類繼承下來的,繼承的概念我們后面再講,通常,如果沒有合適的繼承類,就使用object類,這是所有類最終都會繼承的類。

定義好了Student類,就可以根據(jù)Student類創(chuàng)建出Student的實例,創(chuàng)建實例是通過類名+()實現(xiàn)的:

>>> bart = Student()
>>> bart
<__main__.Student object at 0x10a67a590>
>>> Student
<class '__main__.Student'>

可以看到,變量bart指向的就是一個Student的實例,后面的0x10a67a590是內存地址,每個object的地址都不一樣,而Student本身則是一個類。

可以自由地給一個實例變量綁定屬性,比如,給實例bart綁定一個name屬性:

>>> bart.name = 'Bart Simpson'
>>> bart.name
'Bart Simpson'

由于類可以起到模板的作用,因此,可以在創(chuàng)建實例的時候,把一些我們認為必須綁定的屬性強制填寫進去。通過定義一個特殊的__init__方法,在創(chuàng)建實例的時候,就把namescore等屬性綁上去:

class Student(object):def __init__(self, name, score):self.name = nameself.score = score

注意到__init__方法的第一個參數(shù)永遠是self,表示創(chuàng)建的實例本身,因此,在__init__方法內部,就可以把各種屬性綁定到self,因為self就指向創(chuàng)建的實例本身。

有了__init__方法,在創(chuàng)建實例的時候,就不能傳入空的參數(shù)了,必須傳入與__init__方法匹配的參數(shù),但self不需要傳,Python解釋器自己會把實例變量傳進去:

>>> bart = Student('Bart Simpson', 59)
>>> bart.name
'Bart Simpson'
>>> bart.score
59

和普通的函數(shù)相比,在類中定義的函數(shù)只有一點不同,就是第一個參數(shù)永遠是實例變量self,并且,調用時,不用傳遞該參數(shù)。除此之外,類的方法和普通函數(shù)沒有什么區(qū)別,所以,你仍然可以用默認參數(shù)、可變參數(shù)、關鍵字參數(shù)和命名關鍵字參數(shù)。

數(shù)據(jù)封裝

面向對象編程的一個重要特點就是數(shù)據(jù)封裝。在上面的Student類中,每個實例就擁有各自的namescore這些數(shù)據(jù)。我們可以通過函數(shù)來訪問這些數(shù)據(jù),比如打印一個學生的成績:

>>> def print_score(std):
...     print('%s: %s' % (std.name, std.score))
...
>>> print_score(bart)
Bart Simpson: 59

但是,既然Student實例本身就擁有這些數(shù)據(jù),要訪問這些數(shù)據(jù),就沒有必要從外面的函數(shù)去訪問,可以直接在Student類的內部定義訪問數(shù)據(jù)的函數(shù),這樣,就把“數(shù)據(jù)”給封裝起來了。這些封裝數(shù)據(jù)的函數(shù)是和Student類本身是關聯(lián)起來的,我們稱之為類的方法:

class Student(object):def __init__(self, name, score):self.name = nameself.score = scoredef print_score(self):print('%s: %s' % (self.name, self.score))

要定義一個方法,除了第一個參數(shù)是self外,其他和普通函數(shù)一樣。要調用一個方法,只需要在實例變量上直接調用,除了self不用傳遞,其他參數(shù)正常傳入:

>>> bart.print_score()
Bart Simpson: 59

這樣一來,我們從外部看Student類,就只需要知道,創(chuàng)建實例需要給出namescore,而如何打印,都是在Student類的內部定義的,這些數(shù)據(jù)和邏輯被“封裝”起來了,調用很容易,但卻不用知道內部實現(xiàn)的細節(jié)。

封裝的另一個好處是可以給Student類增加新的方法,比如get_grade

class Student(object):...def get_grade(self):if self.score >= 90:return 'A'elif self.score >= 60:return 'B'else:return 'C'

同樣的,get_grade方法可以直接在實例變量上調用,不需要知道內部實現(xiàn)細節(jié):

>>> bart.get_grade()
'C'
小結

類是創(chuàng)建實例的模板,而實例則是一個一個具體的對象,各個實例擁有的數(shù)據(jù)都互相獨立,互不影響;

方法就是與實例綁定的函數(shù),和普通函數(shù)不同,方法可以直接訪問實例的數(shù)據(jù);

通過在實例上調用方法,我們就直接操作了對象內部的數(shù)據(jù),但無需知道方法內部的實現(xiàn)細節(jié)。

和靜態(tài)語言不同,Python允許對實例變量綁定任何數(shù)據(jù),也就是說,對于兩個實例變量,雖然它們都是同一個類的不同實例,但擁有的變量名稱都可能不同:

>>> bart = Student('Bart Simpson', 59)
>>> lisa = Student('Lisa Simpson', 87)
>>> bart.age = 8
>>> bart.age
8
>>> lisa.age
Traceback (most recent call last):File "<stdin>", line 1, in <module>
AttributeError: 'Student' object has no attribute 'age'
參考源碼

student.py

訪問限制

在Class內部,可以有屬性和方法,而外部代碼可以通過直接調用實例變量的方法來操作數(shù)據(jù),這樣,就隱藏了內部的復雜邏輯。

但是,從前面Student類的定義來看,外部代碼還是可以自由地修改一個實例的name、score屬性:

>>> bart = Student('Bart Simpson', 98)
>>> bart.score
98
>>> bart.score = 59
>>> bart.score
59

如果要讓內部屬性不被外部訪問,可以把屬性的名稱前加上兩個下劃線__,在Python中,實例的變量名如果以__開頭,就變成了一個私有變量(private),只有內部可以訪問,外部不能訪問,所以,我們把Student類改一改:

class Student(object):def __init__(self, name, score):self.__name = nameself.__score = scoredef print_score(self):print('%s: %s' % (self.__name, self.__score))

改完后,對于外部代碼來說,沒什么變動,但是已經無法從外部訪問實例變量.__name實例變量.__score了:

>>> bart = Student('Bart Simpson', 98)
>>> bart.__name
Traceback (most recent call last):File "<stdin>", line 1, in <module>
AttributeError: 'Student' object has no attribute '__name'

這樣就確保了外部代碼不能隨意修改對象內部的狀態(tài),這樣通過訪問限制的保護,代碼更加健壯。

但是如果外部代碼要獲取name和score怎么辦?可以給Student類增加get_nameget_score這樣的方法:

class Student(object):...def get_name(self):return self.__namedef get_score(self):return self.__score

如果又要允許外部代碼修改score怎么辦?可以再給Student類增加set_score方法:

class Student(object):...def set_score(self, score):self.__score = score

你也許會問,原先那種直接通過bart.score = 59也可以修改啊,為什么要定義一個方法大費周折?因為在方法中,可以對參數(shù)做檢查,避免傳入無效的參數(shù):

class Student(object):...def set_score(self, score):if 0 <= score <= 100:self.__score = scoreelse:raise ValueError('bad score')

需要注意的是,在Python中,變量名類似__xxx__的,也就是以雙下劃線開頭,并且以雙下劃線結尾的,是特殊變量,特殊變量是可以直接訪問的,不是private變量,所以,不能用__name__、__score__這樣的變量名。

有些時候,你會看到以一個下劃線開頭的實例變量名,比如_name,這樣的實例變量外部是可以訪問的,但是,按照約定俗成的規(guī)定,當你看到這樣的變量時,意思就是,“雖然我可以被訪問,但是,請把我視為私有變量,不要隨意訪問”。

雙下劃線開頭的實例變量是不是一定不能從外部訪問呢?其實也不是。不能直接訪問__name是因為Python解釋器對外把__name變量改成了_Student__name,所以,仍然可以通過_Student__name來訪問__name變量:

>>> bart._Student__name
'Bart Simpson'

但是強烈建議你不要這么干,因為不同版本的Python解釋器可能會把__name改成不同的變量名。

總的來說就是,Python本身沒有任何機制阻止你干壞事,一切全靠自覺。

參考源碼

protected_student.py

繼承和多態(tài)

在OOP程序設計中,當我們定義一個class的時候,可以從某個現(xiàn)有的class繼承,新的class稱為子類(Subclass),而被繼承的class稱為基類、父類或超類(Base class、Super class)。

比如,我們已經編寫了一個名為Animal的class,有一個run()方法可以直接打印:

class Animal(object):def run(self):print('Animal is running...')

當我們需要編寫DogCat類時,就可以直接從Animal類繼承:

class Dog(Animal):passclass Cat(Animal):pass

對于Dog來說,Animal就是它的父類,對于Animal來說,Dog就是它的子類。CatDog類似。

繼承有什么好處?最大的好處是子類獲得了父類的全部功能。由于Animial實現(xiàn)了run()方法,因此,DogCat作為它的子類,什么事也沒干,就自動擁有了run()方法:

dog = Dog()
dog.run()cat = Cat()
cat.run()

運行結果如下:

Animal is running...
Animal is running...

當然,也可以對子類增加一些方法,比如Dog類:

class Dog(Animal):def run(self):print('Dog is running...')def eat(self):print('Eating meat...')

繼承的第二個好處需要我們對代碼做一點改進。你看到了,無論是Dog還是Cat,它們run()的時候,顯示的都是Animal is running...,符合邏輯的做法是分別顯示Dog is running...Cat is running...,因此,對DogCat類改進如下:

class Dog(Animal):def run(self):print('Dog is running...')class Cat(Animal):def run(self):print('Cat is running...')

再次運行,結果如下:

Dog is running...
Cat is running...

當子類和父類都存在相同的run()方法時,我們說,子類的run()覆蓋了父類的run(),在代碼運行的時候,總是會調用子類的run()。這樣,我們就獲得了繼承的另一個好處:多態(tài)。

要理解什么是多態(tài),我們首先要對數(shù)據(jù)類型再作一點說明。當我們定義一個class的時候,我們實際上就定義了一種數(shù)據(jù)類型。我們定義的數(shù)據(jù)類型和Python自帶的數(shù)據(jù)類型,比如str、list、dict沒什么兩樣:

a = list() # a是list類型
b = Animal() # b是Animal類型
c = Dog() # c是Dog類型

判斷一個變量是否是某個類型可以用isinstance()判斷:

>>> isinstance(a, list)
True
>>> isinstance(b, Animal)
True
>>> isinstance(c, Dog)
True

看來a、b、c確實對應著list、Animal、Dog這3種類型。

但是等等,試試:

>>> isinstance(c, Animal)
True

看來c不僅僅是Dogc還是Animal

不過仔細想想,這是有道理的,因為Dog是從Animal繼承下來的,當我們創(chuàng)建了一個Dog的實例c時,我們認為c的數(shù)據(jù)類型是Dog沒錯,但c同時也是Animal也沒錯,Dog本來就是Animal的一種!

所以,在繼承關系中,如果一個實例的數(shù)據(jù)類型是某個子類,那它的數(shù)據(jù)類型也可以被看做是父類。但是,反過來就不行:

>>> b = Animal()
>>> isinstance(b, Dog)
False

Dog可以看成Animal,但Animal不可以看成Dog

要理解多態(tài)的好處,我們還需要再編寫一個函數(shù),這個函數(shù)接受一個Animal類型的變量:

def run_twice(animal):animal.run()animal.run()

當我們傳入Animal的實例時,run_twice()就打印出:

>>> run_twice(Animal())
Animal is running...
Animal is running...

當我們傳入Dog的實例時,run_twice()就打印出:

>>> run_twice(Dog())
Dog is running...
Dog is running...

當我們傳入Cat的實例時,run_twice()就打印出:

>>> run_twice(Cat())
Cat is running...
Cat is running...

看上去沒啥意思,但是仔細想想,現(xiàn)在,如果我們再定義一個Tortoise類型,也從Animal派生:

class Tortoise(Animal):def run(self):print('Tortoise is running slowly...')

當我們調用run_twice()時,傳入Tortoise的實例:

>>> run_twice(Tortoise())
Tortoise is running slowly...
Tortoise is running slowly...

你會發(fā)現(xiàn),新增一個Animal的子類,不必對run_twice()做任何修改,實際上,任何依賴Animal作為參數(shù)的函數(shù)或者方法都可以不加修改地正常運行,原因就在于多態(tài)。

多態(tài)的好處就是,當我們需要傳入DogCat、Tortoise……時,我們只需要接收Animal類型就可以了,因為Dog、Cat、Tortoise……都是Animal類型,然后,按照Animal類型進行操作即可。由于Animal類型有run()方法,因此,傳入的任意類型,只要是Animal類或者子類,就會自動調用實際類型的run()方法,這就是多態(tài)的意思:

對于一個變量,我們只需要知道它是Animal類型,無需確切地知道它的子類型,就可以放心地調用run()方法,而具體調用的run()方法是作用在AnimalDog、Cat還是Tortoise對象上,由運行時該對象的確切類型決定,這就是多態(tài)真正的威力:調用方只管調用,不管細節(jié),而當我們新增一種Animal的子類時,只要確保run()方法編寫正確,不用管原來的代碼是如何調用的。這就是著名的“開閉”原則:

對擴展開放:允許新增Animal子類;

對修改封閉:不需要修改依賴Animal類型的run_twice()等函數(shù)。

繼承還可以一級一級地繼承下來,就好比從爺爺?shù)桨职?、再到兒子這樣的關系。而任何類,最終都可以追溯到根類object,這些繼承關系看上去就像一顆倒著的樹。比如如下的繼承樹:

class-inheritance

靜態(tài)語言 vs 動態(tài)語言

對于靜態(tài)語言(例如Java)來說,如果需要傳入Animal類型,則傳入的對象必須是Animal類型或者它的子類,否則,將無法調用run()方法。

對于Python這樣的動態(tài)語言來說,則不一定需要傳入Animal類型。我們只需要保證傳入的對象有一個run()方法就可以了:

class Timer(object):def run(self):print('Start...')

這就是動態(tài)語言的“鴨子類型”,它并不要求嚴格的繼承體系,一個對象只要“看起來像鴨子,走起路來像鴨子”,那它就可以被看做是鴨子。

Python的“file-like object“就是一種鴨子類型。對真正的文件對象,它有一個read()方法,返回其內容。但是,許多對象,只要有read()方法,都被視為“file-like object“。許多函數(shù)接收的參數(shù)就是“file-like object“,你不一定要傳入真正的文件對象,完全可以傳入任何實現(xiàn)了read()方法的對象。

小結

繼承可以把父類的所有功能都直接拿過來,這樣就不必重零做起,子類只需要新增自己特有的方法,也可以把父類不適合的方法覆蓋重寫。

動態(tài)語言的鴨子類型特點決定了繼承不像靜態(tài)語言那樣是必須的。

參考源碼

animals.py

獲取對象信息

當我們拿到一個對象的引用時,如何知道這個對象是什么類型、有哪些方法呢?

使用type()

首先,我們來判斷對象類型,使用type()函數(shù):

基本類型都可以用type()判斷:

>>> type(123)
<class 'int'>
>>> type('str')
<class 'str'>
>>> type(None)
<type(None) 'NoneType'>

如果一個變量指向函數(shù)或者類,也可以用type()判斷:

>>> type(abs)
<class 'builtin_function_or_method'>
>>> type(a)
<class '__main__.Animal'>

但是type()函數(shù)返回的是什么類型呢?它返回對應的Class類型。如果我們要在if語句中判斷,就需要比較兩個變量的type類型是否相同:

>>> type(123)==type(456)
True
>>> type(123)==int
True
>>> type('abc')==type('123')
True
>>> type('abc')==str
True
>>> type('abc')==type(123)
False

判斷基本數(shù)據(jù)類型可以直接寫intstr等,但如果要判斷一個對象是否是函數(shù)怎么辦?可以使用types模塊中定義的常量:

>>> import types
>>> def fn():
...     pass
...
>>> type(fn)==types.FunctionType
True
>>> type(abs)==types.BuiltinFunctionType
True
>>> type(lambda x: x)==types.LambdaType
True
>>> type((x for x in range(10)))==types.GeneratorType
True
使用isinstance()

對于class的繼承關系來說,使用type()就很不方便。我們要判斷class的類型,可以使用isinstance()函數(shù)。

我們回顧上次的例子,如果繼承關系是:

object -> Animal -> Dog -> Husky

那么,isinstance()就可以告訴我們,一個對象是否是某種類型。先創(chuàng)建3種類型的對象:

>>> a = Animal()
>>> d = Dog()
>>> h = Husky()

然后,判斷:

>>> isinstance(h, Husky)
True

沒有問題,因為h變量指向的就是Husky對象。

再判斷:

>>> isinstance(h, Dog)
True

h雖然自身是Husky類型,但由于Husky是從Dog繼承下來的,所以,h也還是Dog類型。換句話說,isinstance()判斷的是一個對象是否是該類型本身,或者位于該類型的父繼承鏈上。

因此,我們可以確信,h還是Animal類型:

>>> isinstance(h, Animal)
True

同理,實際類型是Dog的d也是Animal類型:

>>> isinstance(d, Dog) and isinstance(d, Animal)
True

但是,d不是Husky類型:

>>> isinstance(d, Husky)
False

能用type()判斷的基本類型也可以用isinstance()判斷:

>>> isinstance('a', str)
True
>>> isinstance(123, int)
True
>>> isinstance(b'a', bytes)
True

并且還可以判斷一個變量是否是某些類型中的一種,比如下面的代碼就可以判斷是否是list或者tuple:

>>> isinstance([1, 2, 3], (list, tuple))
True
>>> isinstance((1, 2, 3), (list, tuple))
True
使用dir()

如果要獲得一個對象的所有屬性和方法,可以使用dir()函數(shù),它返回一個包含字符串的list,比如,獲得一個str對象的所有屬性和方法:

>>> dir('ABC')
['__add__', '__class__', '__contains__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__getnewargs__', '__gt__', '__hash__', '__init__', '__iter__', '__le__', '__len__', '__lt__', '__mod__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__rmod__', '__rmul__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'capitalize', 'casefold', 'center', 'count', 'encode', 'endswith', 'expandtabs', 'find', 'format', 'format_map', 'index', 'isalnum', 'isalpha', 'isdecimal', 'isdigit', 'isidentifier', 'islower', 'isnumeric', 'isprintable', 'isspace', 'istitle', 'isupper', 'join', 'ljust', 'lower', 'lstrip', 'maketrans', 'partition', 'replace', 'rfind', 'rindex', 'rjust', 'rpartition', 'rsplit', 'rstrip', 'split', 'splitlines', 'startswith', 'strip', 'swapcase', 'title', 'translate', 'upper', 'zfill']

類似__xxx__的屬性和方法在Python中都是有特殊用途的,比如__len__方法返回長度。在Python中,如果你調用len()函數(shù)試圖獲取一個對象的長度,實際上,在len()函數(shù)內部,它自動去調用該對象的__len__()方法,所以,下面的代碼是等價的:

>>> len('ABC')
3
>>> 'ABC'.__len__()
3

我們自己寫的類,如果也想用len(myObj)的話,就自己寫一個__len__()方法:

>>> class MyDog(object):
...     def __len__(self):
...         return 100
...
>>> dog = MyDog()
>>> len(dog)
100

剩下的都是普通屬性或方法,比如lower()返回小寫的字符串:

>>> 'ABC'.lower()
'abc'

僅僅把屬性和方法列出來是不夠的,配合getattr()、setattr()以及hasattr(),我們可以直接操作一個對象的狀態(tài):

>>> class MyObject(object):
...     def __init__(self):
...         self.x = 9
...     def power(self):
...         return self.x * self.x
...
>>> obj = MyObject()

緊接著,可以測試該對象的屬性:

>>> hasattr(obj, 'x') # 有屬性'x'嗎?
True
>>> obj.x
9
>>> hasattr(obj, 'y') # 有屬性'y'嗎?
False
>>> setattr(obj, 'y', 19) # 設置一個屬性'y'
>>> hasattr(obj, 'y') # 有屬性'y'嗎?
True
>>> getattr(obj, 'y') # 獲取屬性'y'
19
>>> obj.y # 獲取屬性'y'
19

如果試圖獲取不存在的屬性,會拋出AttributeError的錯誤:

>>> getattr(obj, 'z') # 獲取屬性'z'
Traceback (most recent call last):File "<stdin>", line 1, in <module>
AttributeError: 'MyObject' object has no attribute 'z'

可以傳入一個default參數(shù),如果屬性不存在,就返回默認值:

>>> getattr(obj, 'z', 404) # 獲取屬性'z',如果不存在,返回默認值404
404

也可以獲得對象的方法:

>>> hasattr(obj, 'power') # 有屬性'power'嗎?
True
>>> getattr(obj, 'power') # 獲取屬性'power'
<bound method MyObject.power of <__main__.MyObject object at 0x10077a6a0>>
>>> fn = getattr(obj, 'power') # 獲取屬性'power'并賦值到變量fn
>>> fn # fn指向obj.power
<bound method MyObject.power of <__main__.MyObject object at 0x10077a6a0>>
>>> fn() # 調用fn()與調用obj.power()是一樣的
81
小結

通過內置的一系列函數(shù),我們可以對任意一個Python對象進行剖析,拿到其內部的數(shù)據(jù)。要注意的是,只有在不知道對象信息的時候,我們才會去獲取對象信息。如果可以直接寫:

sum = obj.x + obj.y

就不要寫:

sum = getattr(obj, 'x') + getattr(obj, 'y')

一個正確的用法的例子如下:

def readImage(fp):if hasattr(fp, 'read'):return readData(fp)return None

假設我們希望從文件流fp中讀取圖像,我們首先要判斷該fp對象是否存在read方法,如果存在,則該對象是一個流,如果不存在,則無法讀取。hasattr()就派上了用場。

請注意,在Python這類動態(tài)語言中,根據(jù)鴨子類型,有read()方法,不代表該fp對象就是一個文件流,它也可能是網絡流,也可能是內存中的一個字節(jié)流,但只要read()方法返回的是有效的圖像數(shù)據(jù),就不影響讀取圖像的功能。

參考源碼

get_type.py

attrs.py

實例屬性和類屬性

由于Python是動態(tài)語言,根據(jù)類創(chuàng)建的實例可以任意綁定屬性。

給實例綁定屬性的方法是通過實例變量,或者通過self變量:

class Student(object):def __init__(self, name):self.name = names = Student('Bob')
s.score = 90

但是,如果Student類本身需要綁定一個屬性呢?可以直接在class中定義屬性,這種屬性是類屬性,歸Student類所有:

class Student(object):name = 'Student'

當我們定義了一個類屬性后,這個屬性雖然歸類所有,但類的所有實例都可以訪問到。來測試一下:

>>> class Student(object):
...     name = 'Student'
...
>>> s = Student() # 創(chuàng)建實例s
>>> print(s.name) # 打印name屬性,因為實例并沒有name屬性,所以會繼續(xù)查找class的name屬性
Student
>>> print(Student.name) # 打印類的name屬性
Student
>>> s.name = 'Michael' # 給實例綁定name屬性
>>> print(s.name) # 由于實例屬性優(yōu)先級比類屬性高,因此,它會屏蔽掉類的name屬性
Michael
>>> print(Student.name) # 但是類屬性并未消失,用Student.name仍然可以訪問
Student
>>> del s.name # 如果刪除實例的name屬性
>>> print(s.name) # 再次調用s.name,由于實例的name屬性沒有找到,類的name屬性就顯示出來了
Student

從上面的例子可以看出,在編寫程序的時候,千萬不要把實例屬性和類屬性使用相同的名字,因為相同名稱的實例屬性將屏蔽掉類屬性,但是當你刪除實例屬性后,再使用相同的名稱,訪問到的將是類屬性。

面向對象高級編程

數(shù)據(jù)封裝、繼承和多態(tài)只是面向對象程序設計中最基礎的3個概念。在Python中,面向對象還有很多高級特性,允許我們寫出非常強大的功能。

我們會討論多重繼承、定制類、元類等概念。

使用__slots__

正常情況下,當我們定義了一個class,創(chuàng)建了一個class的實例后,我們可以給該實例綁定任何屬性和方法,這就是動態(tài)語言的靈活性。先定義class:

class Student(object):pass

然后,嘗試給實例綁定一個屬性:

>>> s = Student()
>>> s.name = 'Michael' # 動態(tài)給實例綁定一個屬性
>>> print(s.name)
Michael

還可以嘗試給實例綁定一個方法:

>>> def set_age(self, age): # 定義一個函數(shù)作為實例方法
...     self.age = age
...
>>> from types import MethodType
>>> s.set_age = MethodType(set_age, s) # 給實例綁定一個方法
>>> s.set_age(25) # 調用實例方法
>>> s.age # 測試結果
25

但是,給一個實例綁定的方法,對另一個實例是不起作用的:

>>> s2 = Student() # 創(chuàng)建新的實例
>>> s2.set_age(25) # 嘗試調用方法
Traceback (most recent call last):File "<stdin>", line 1, in <module>
AttributeError: 'Student' object has no attribute 'set_age'

為了給所有實例都綁定方法,可以給class綁定方法:

>>> def set_score(self, score):
...     self.score = score
...
>>> Student.set_score = MethodType(set_score, Student)

給class綁定方法后,所有實例均可調用:

>>> s.set_score(100)
>>> s.score
100
>>> s2.set_score(99)
>>> s2.score
99

通常情況下,上面的set_score方法可以直接定義在class中,但動態(tài)綁定允許我們在程序運行的過程中動態(tài)給class加上功能,這在靜態(tài)語言中很難實現(xiàn)。

使用__slots__

但是,如果我們想要限制實例的屬性怎么辦?比如,只允許對Student實例添加nameage屬性。

為了達到限制的目的,Python允許在定義class的時候,定義一個特殊的__slots__變量,來限制該class實例能添加的屬性:

class Student(object):__slots__ = ('name', 'age') # 用tuple定義允許綁定的屬性名稱

然后,我們試試:

>>> s = Student() # 創(chuàng)建新的實例
>>> s.name = 'Michael' # 綁定屬性'name'
>>> s.age = 25 # 綁定屬性'age'
>>> s.score = 99 # 綁定屬性'score'
Traceback (most recent call last):File "<stdin>", line 1, in <module>
AttributeError: 'Student' object has no attribute 'score'

由于'score'沒有被放到__slots__中,所以不能綁定score屬性,試圖綁定score將得到AttributeError的錯誤。

使用__slots__要注意,__slots__定義的屬性僅對當前類實例起作用,對繼承的子類是不起作用的:

>>> class GraduateStudent(Student):
...     pass
...
>>> g = GraduateStudent()
>>> g.score = 9999

除非在子類中也定義__slots__,這樣,子類實例允許定義的屬性就是自身的__slots__加上父類的__slots__。

參考源碼

use_slots.py

使用@property

在綁定屬性時,如果我們直接把屬性暴露出去,雖然寫起來很簡單,但是,沒辦法檢查參數(shù),導致可以把成績隨便改:

s = Student()
s.score = 9999

這顯然不合邏輯。為了限制score的范圍,可以通過一個set_score()方法來設置成績,再通過一個get_score()來獲取成績,這樣,在set_score()方法里,就可以檢查參數(shù):

class Student(object):def get_score(self):return self._scoredef set_score(self, value):if not isinstance(value, int):raise ValueError('score must be an integer!')if value < 0 or value > 100:raise ValueError('score must between 0 ~ 100!')self._score = value

現(xiàn)在,對任意的Student實例進行操作,就不能隨心所欲地設置score了:

>>> s = Student()
>>> s.set_score(60) # ok!
>>> s.get_score()
60
>>> s.set_score(9999)
Traceback (most recent call last):...
ValueError: score must between 0 ~ 100!

但是,上面的調用方法又略顯復雜,沒有直接用屬性這么直接簡單。

有沒有既能檢查參數(shù),又可以用類似屬性這樣簡單的方式來訪問類的變量呢?對于追求完美的Python程序員來說,這是必須要做到的!

還記得裝飾器(decorator)可以給函數(shù)動態(tài)加上功能嗎?對于類的方法,裝飾器一樣起作用。Python內置的@property裝飾器就是負責把一個方法變成屬性調用的:

class Student(object):@propertydef score(self):return self._score@score.setterdef score(self, value):if not isinstance(value, int):raise ValueError('score must be an integer!')if value < 0 or value > 100:raise ValueError('score must between 0 ~ 100!')self._score = value

@property的實現(xiàn)比較復雜,我們先考察如何使用。把一個getter方法變成屬性,只需要加上@property就可以了,此時,@property本身又創(chuàng)建了另一個裝飾器@score.setter,負責把一個setter方法變成屬性賦值,于是,我們就擁有一個可控的屬性操作:

>>> s = Student()
>>> s.score = 60 # OK,實際轉化為s.set_score(60)
>>> s.score # OK,實際轉化為s.get_score()
60
>>> s.score = 9999
Traceback (most recent call last):...
ValueError: score must between 0 ~ 100!

注意到這個神奇的@property,我們在對實例屬性操作的時候,就知道該屬性很可能不是直接暴露的,而是通過getter和setter方法來實現(xiàn)的。

還可以定義只讀屬性,只定義getter方法,不定義setter方法就是一個只讀屬性:

class Student(object):@propertydef birth(self):return self._birth@birth.setterdef birth(self, value):self._birth = value@propertydef age(self):return 2015 - self._birth

上面的birth是可讀寫屬性,而age就是一個只讀屬性,因為age可以根據(jù)birth和當前時間計算出來。

小結

@property廣泛應用在類的定義中,可以讓調用者寫出簡短的代碼,同時保證對參數(shù)進行必要的檢查,這樣,程序運行時就減少了出錯的可能性。

練習

請利用@property給一個Screen對象加上widthheight屬性,以及一個只讀屬性resolution

# -*- coding: utf-8 -*-class Screen(object):
----pass
----
# test:
s = Screen()
s.width = 1024
s.height = 768
print(s.resolution)
assert s.resolution == 786432, '1024 * 768 = %d ?' % s.resolution
參考源碼

use_property.py

多重繼承

繼承是面向對象編程的一個重要的方式,因為通過繼承,子類就可以擴展父類的功能。

回憶一下Animal類層次的設計,假設我們要實現(xiàn)以下4種動物:

  • Dog - 狗狗;
  • Bat - 蝙蝠;
  • Parrot - 鸚鵡;
  • Ostrich - 鴕鳥。

如果按照哺乳動物和鳥類歸類,我們可以設計出這樣的類的層次:

animal-mb

但是如果按照“能跑”和“能飛”來歸類,我們就應該設計出這樣的類的層次:

animal-rf

如果要把上面的兩種分類都包含進來,我們就得設計更多的層次:

  • 哺乳類:能跑的哺乳類,能飛的哺乳類;
  • 鳥類:能跑的鳥類,能飛的鳥類。

這么一來,類的層次就復雜了:

animal-mb-rf

如果要再增加“寵物類”和“非寵物類”,這么搞下去,類的數(shù)量會呈指數(shù)增長,很明顯這樣設計是不行的。

正確的做法是采用多重繼承。首先,主要的類層次仍按照哺乳類和鳥類設計:

class Animal(object):pass# 大類:
class Mammal(Animal):passclass Bird(Animal):pass# 各種動物:
class Dog(Mammal):passclass Bat(Mammal):passclass Parrot(Bird):passclass Ostrich(Bird):pass

現(xiàn)在,我們要給動物再加上RunnableFlyable的功能,只需要先定義好RunnableFlyable的類:

class Runnable(object):def run(self):print('Running...')class Flyable(object):def fly(self):print('Flying...')

對于需要Runnable功能的動物,就多繼承一個Runnable,例如Dog

class Dog(Mammal, Runnable):pass

對于需要Flyable功能的動物,就多繼承一個Flyable,例如Bat

class Bat(Mammal, Flyable):pass

通過多重繼承,一個子類就可以同時獲得多個父類的所有功能。

MixIn

在設計類的繼承關系時,通常,主線都是單一繼承下來的,例如,Ostrich繼承自Bird。但是,如果需要“混入”額外的功能,通過多重繼承就可以實現(xiàn),比如,讓Ostrich除了繼承自Bird外,再同時繼承Runnable。這種設計通常稱之為MixIn。

為了更好地看出繼承關系,我們把RunnableFlyable改為RunnableMixInFlyableMixIn。類似的,你還可以定義出肉食動物CarnivorousMixIn和植食動物HerbivoresMixIn,讓某個動物同時擁有好幾個MixIn:

class Dog(Mammal, RunnableMixIn, CarnivorousMixIn):pass

MixIn的目的就是給一個類增加多個功能,這樣,在設計類的時候,我們優(yōu)先考慮通過多重繼承來組合多個MixIn的功能,而不是設計多層次的復雜的繼承關系。

Python自帶的很多庫也使用了MixIn。舉個例子,Python自帶了TCPServerUDPServer這兩類網絡服務,而要同時服務多個用戶就必須使用多進程或多線程模型,這兩種模型由ForkingMixInThreadingMixIn提供。通過組合,我們就可以創(chuàng)造出合適的服務來。

比如,編寫一個多進程模式的TCP服務,定義如下:

class MyTCPServer(TCPServer, ForkingMixIn):pass

編寫一個多線程模式的UDP服務,定義如下:

class MyUDPServer(UDPServer, ThreadingMixIn):pass

如果你打算搞一個更先進的協(xié)程模型,可以編寫一個CoroutineMixIn

class MyTCPServer(TCPServer, CoroutineMixIn):pass

這樣一來,我們不需要復雜而龐大的繼承鏈,只要選擇組合不同的類的功能,就可以快速構造出所需的子類。

小結

由于Python允許使用多重繼承,因此,MixIn就是一種常見的設計。

只允許單一繼承的語言(如Java)不能使用MixIn的設計。

定制類

看到類似__slots__這種形如__xxx__的變量或者函數(shù)名就要注意,這些在Python中是有特殊用途的。

__slots__我們已經知道怎么用了,__len__()方法我們也知道是為了能讓class作用于len()函數(shù)。

除此之外,Python的class中還有許多這樣有特殊用途的函數(shù),可以幫助我們定制類。

__str__

我們先定義一個Student類,打印一個實例:

>>> class Student(object):
...     def __init__(self, name):
...         self.name = name
...
>>> print(Student('Michael'))
<__main__.Student object at 0x109afb190>

打印出一堆<__main__.Student object at 0x109afb190>,不好看。

怎么才能打印得好看呢?只需要定義好__str__()方法,返回一個好看的字符串就可以了:

>>> class Student(object):
...     def __init__(self, name):
...         self.name = name
...     def __str__(self):
...         return 'Student object (name: %s)' % self.name
...
>>> print(Student('Michael'))
Student object (name: Michael)

這樣打印出來的實例,不但好看,而且容易看出實例內部重要的數(shù)據(jù)。

但是細心的朋友會發(fā)現(xiàn)直接敲變量不用print,打印出來的實例還是不好看:

>>> s = Student('Michael')
>>> s
<__main__.Student object at 0x109afb310>

這是因為直接顯示變量調用的不是__str__(),而是__repr__(),兩者的區(qū)別是__str__()返回用戶看到的字符串,而__repr__()返回程序開發(fā)者看到的字符串,也就是說,__repr__()是為調試服務的。

解決辦法是再定義一個__repr__()。但是通常__str__()__repr__()代碼都是一樣的,所以,有個偷懶的寫法:

class Student(object):def __init__(self, name):self.name = namedef __str__(self):return 'Student object (name=%s)' % self.name__repr__ = __str__
__iter__

如果一個類想被用于for ... in循環(huán),類似list或tuple那樣,就必須實現(xiàn)一個__iter__()方法,該方法返回一個迭代對象,然后,Python的for循環(huán)就會不斷調用該迭代對象的__next__()方法拿到循環(huán)的下一個值,直到遇到StopIteration錯誤時退出循環(huán)。

我們以斐波那契數(shù)列為例,寫一個Fib類,可以作用于for循環(huán):

class Fib(object):def __init__(self):self.a, self.b = 0, 1 # 初始化兩個計數(shù)器a,bdef __iter__(self):return self # 實例本身就是迭代對象,故返回自己def __next__(self):self.a, self.b = self.b, self.a + self.b # 計算下一個值if self.a > 100000: # 退出循環(huán)的條件raise StopIteration();return self.a # 返回下一個值

現(xiàn)在,試試把Fib實例作用于for循環(huán):

>>> for n in Fib():
...     print(n)
...
1
1
2
3
5
...
46368
75025
__getitem__

Fib實例雖然能作用于for循環(huán),看起來和list有點像,但是,把它當成list來使用還是不行,比如,取第5個元素:

>>> Fib()[5]
Traceback (most recent call last):File "<stdin>", line 1, in <module>
TypeError: 'Fib' object does not support indexing

要表現(xiàn)得像list那樣按照下標取出元素,需要實現(xiàn)__getitem__()方法:

class Fib(object):def __getitem__(self, n):a, b = 1, 1for x in range(n):a, b = b, a + breturn a

現(xiàn)在,就可以按下標訪問數(shù)列的任意一項了:

>>> f = Fib()
>>> f[0]
1
>>> f[1]
1
>>> f[2]
2
>>> f[3]
3
>>> f[10]
89
>>> f[100]
573147844013817084101

但是list有個神奇的切片方法:

>>> list(range(100))[5:10]
[5, 6, 7, 8, 9]

對于Fib卻報錯。原因是__getitem__()傳入的參數(shù)可能是一個int,也可能是一個切片對象slice,所以要做判斷:

class Fib(object):def __getitem__(self, n):if isinstance(n, int): # n是索引a, b = 1, 1for x in range(n):a, b = b, a + breturn aif isinstance(n, slice): # n是切片start = n.startstop = n.stopif start is None:start = 0a, b = 1, 1L = []for x in range(stop):if x >= start:L.append(a)a, b = b, a + breturn L

現(xiàn)在試試Fib的切片:

>>> f = Fib()
>>> f[0:5]
[1, 1, 2, 3, 5]
>>> f[:10]
[1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

但是沒有對step參數(shù)作處理:

>>> f[:10:2]
[1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]

也沒有對負數(shù)作處理,所以,要正確實現(xiàn)一個__getitem__()還是有很多工作要做的。

此外,如果把對象看成dict__getitem__()的參數(shù)也可能是一個可以作key的object,例如str。

與之對應的是__setitem__()方法,把對象視作list或dict來對集合賦值。最后,還有一個__delitem__()方法,用于刪除某個元素。

總之,通過上面的方法,我們自己定義的類表現(xiàn)得和Python自帶的list、tuple、dict沒什么區(qū)別,這完全歸功于動態(tài)語言的“鴨子類型”,不需要強制繼承某個接口。

__getattr__

正常情況下,當我們調用類的方法或屬性時,如果不存在,就會報錯。比如定義Student類:

class Student(object):def __init__(self):self.name = 'Michael'

調用name屬性,沒問題,但是,調用不存在的score屬性,就有問題了:

>>> s = Student()
>>> print(s.name)
Michael
>>> print(s.score)
Traceback (most recent call last):...
AttributeError: 'Student' object has no attribute 'score'

錯誤信息很清楚地告訴我們,沒有找到score這個attribute。

要避免這個錯誤,除了可以加上一個score屬性外,Python還有另一個機制,那就是寫一個__getattr__()方法,動態(tài)返回一個屬性。修改如下:

class Student(object):def __init__(self):self.name = 'Michael'def __getattr__(self, attr):if attr=='score':return 99

當調用不存在的屬性時,比如score,Python解釋器會試圖調用__getattr__(self, 'score')來嘗試獲得屬性,這樣,我們就有機會返回score的值:

>>> s = Student()
>>> s.name
'Michael'
>>> s.score
99

返回函數(shù)也是完全可以的:

class Student(object):def __getattr__(self, attr):if attr=='age':return lambda: 25

只是調用方式要變?yōu)?#xff1a;

>>> s.age()
25

注意,只有在沒有找到屬性的情況下,才調用__getattr__,已有的屬性,比如name,不會在__getattr__中查找。

此外,注意到任意調用如s.abc都會返回None,這是因為我們定義的__getattr__默認返回就是None。要讓class只響應特定的幾個屬性,我們就要按照約定,拋出AttributeError的錯誤:

class Student(object):def __getattr__(self, attr):if attr=='age':return lambda: 25raise AttributeError('\'Student\' object has no attribute \'%s\'' % attr)

這實際上可以把一個類的所有屬性和方法調用全部動態(tài)化處理了,不需要任何特殊手段。

這種完全動態(tài)調用的特性有什么實際作用呢?作用就是,可以針對完全動態(tài)的情況作調用。

舉個例子:

現(xiàn)在很多網站都搞REST API,比如新浪微博、豆瓣啥的,調用API的URL類似:

  • http://api.server/user/friends
  • http://api.server/user/timeline/list

如果要寫SDK,給每個URL對應的API都寫一個方法,那得累死,而且,API一旦改動,SDK也要改。

利用完全動態(tài)的__getattr__,我們可以寫出一個鏈式調用:

class Chain(object):def __init__(self, path=''):self._path = pathdef __getattr__(self, path):return Chain('%s/%s' % (self._path, path))def __str__(self):return self._path__repr__ = __str__

試試:

>>> Chain().status.user.timeline.list
'/status/user/timeline/list'

這樣,無論API怎么變,SDK都可以根據(jù)URL實現(xiàn)完全動態(tài)的調用,而且,不隨API的增加而改變!

還有些REST API會把參數(shù)放到URL中,比如GitHub的API:

GET /users/:user/repos

調用時,需要把:user替換為實際用戶名。如果我們能寫出這樣的鏈式調用:

Chain().users('michael').repos

就可以非常方便地調用API了。有興趣的童鞋可以試試寫出來。

__call__

一個對象實例可以有自己的屬性和方法,當我們調用實例方法時,我們用instance.method()來調用。能不能直接在實例本身上調用呢?在Python中,答案是肯定的。

任何類,只需要定義一個__call__()方法,就可以直接對實例進行調用。請看示例:

class Student(object):def __init__(self, name):self.name = namedef __call__(self):print('My name is %s.' % self.name)

調用方式如下:

>>> s = Student('Michael')
>>> s() # self參數(shù)不要傳入
My name is Michael.

__call__()還可以定義參數(shù)。對實例進行直接調用就好比對一個函數(shù)進行調用一樣,所以你完全可以把對象看成函數(shù),把函數(shù)看成對象,因為這兩者之間本來就沒啥根本的區(qū)別。

如果你把對象看成函數(shù),那么函數(shù)本身其實也可以在運行期動態(tài)創(chuàng)建出來,因為類的實例都是運行期創(chuàng)建出來的,這么一來,我們就模糊了對象和函數(shù)的界限。

那么,怎么判斷一個變量是對象還是函數(shù)呢?其實,更多的時候,我們需要判斷一個對象是否能被調用,能被調用的對象就是一個Callable對象,比如函數(shù)和我們上面定義的帶有__call__()的類實例:

>>> callable(Student())
True
>>> callable(max)
True
>>> callable([1, 2, 3])
False
>>> callable(None)
False
>>> callable('str')
False

通過callable()函數(shù),我們就可以判斷一個對象是否是“可調用”對象。

小結

Python的class允許定義許多定制方法,可以讓我們非常方便地生成特定的類。

本節(jié)介紹的是最常用的幾個定制方法,還有很多可定制的方法,請參考Python的官方文檔。

參考源碼

special_str.py

special_iter.py

special_getitem.py

special_getattr.py

special_call.py

使用枚舉類

當我們需要定義常量時,一個辦法是用大寫變量通過整數(shù)來定義,例如月份:

JAN = 1
FEB = 2
MAR = 3
...
NOV = 11
DEC = 12

好處是簡單,缺點是類型是int,并且仍然是變量。

更好的方法是為這樣的枚舉類型定義一個class類型,然后,每個常量都是class的一個唯一實例。Python提供了Enum類來實現(xiàn)這個功能:

from enum import EnumMonth = Enum('Month', ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'))

這樣我們就獲得了Month類型的枚舉類,可以直接使用Month.Jan來引用一個常量,或者枚舉它的所有成員:

for name, member in Month.__members__.items():print(name, '=>', member, ',', member.value)

value屬性則是自動賦給成員的int常量,默認從1開始計數(shù)。

如果需要更精確地控制枚舉類型,可以從Enum派生出自定義類:

from enum import Enum, unique@unique
class Weekday(Enum):Sun = 0 # Sun的value被設定為0Mon = 1Tue = 2Wed = 3Thu = 4Fri = 5Sat = 6

@unique裝飾器可以幫助我們檢查保證沒有重復值。

訪問這些枚舉類型可以有若干種方法:

>>> day1 = Weekday.Mon
>>> print(day1)
Weekday.Mon
>>> print(Weekday.Tue)
Weekday.Tue
>>> print(Weekday['Tue'])
Weekday.Tue
>>> print(Weekday.Tue.value)
2
>>> print(day1 == Weekday.Mon)
True
>>> print(day1 == Weekday.Tue)
False
>>> print(Weekday(1))
Weekday.Mon
>>> print(day1 == Weekday(1))
True
>>> Weekday(7)
Traceback (most recent call last):...
ValueError: 7 is not a valid Weekday
>>> for name, member in Weekday.__members__.items():
...     print(name, '=>', member)
...
Sun => Weekday.Sun
Mon => Weekday.Mon
Tue => Weekday.Tue
Wed => Weekday.Wed
Thu => Weekday.Thu
Fri => Weekday.Fri
Sat => Weekday.Sat

可見,既可以用成員名稱引用枚舉常量,又可以直接根據(jù)value的值獲得枚舉常量。

小結

Enum可以把一組相關常量定義在一個class中,且class不可變,而且成員可以直接比較。

參考源碼

use_enum.py

使用元類

type()

動態(tài)語言和靜態(tài)語言最大的不同,就是函數(shù)和類的定義,不是編譯時定義的,而是運行時動態(tài)創(chuàng)建的。

比方說我們要定義一個Hello的class,就寫一個hello.py模塊:

class Hello(object):def hello(self, name='world'):print('Hello, %s.' % name)

當Python解釋器載入hello模塊時,就會依次執(zhí)行該模塊的所有語句,執(zhí)行結果就是動態(tài)創(chuàng)建出一個Hello的class對象,測試如下:

>>> from hello import Hello
>>> h = Hello()
>>> h.hello()
Hello, world.
>>> print(type(Hello))
<class 'type'>
>>> print(type(h))
<class 'hello.Hello'>

type()函數(shù)可以查看一個類型或變量的類型,Hello是一個class,它的類型就是type,而h是一個實例,它的類型就是class?Hello

我們說class的定義是運行時動態(tài)創(chuàng)建的,而創(chuàng)建class的方法就是使用type()函數(shù)。

type()函數(shù)既可以返回一個對象的類型,又可以創(chuàng)建出新的類型,比如,我們可以通過type()函數(shù)創(chuàng)建出Hello類,而無需通過class Hello(object)...的定義:

>>> def fn(self, name='world'): # 先定義函數(shù)
...     print('Hello, %s.' % name)
...
>>> Hello = type('Hello', (object,), dict(hello=fn)) # 創(chuàng)建Hello class
>>> h = Hello()
>>> h.hello()
Hello, world.
>>> print(type(Hello))
<class 'type'>
>>> print(type(h))
<class '__main__.Hello'>

要創(chuàng)建一個class對象,type()函數(shù)依次傳入3個參數(shù):

  1. class的名稱;
  2. 繼承的父類集合,注意Python支持多重繼承,如果只有一個父類,別忘了tuple的單元素寫法;
  3. class的方法名稱與函數(shù)綁定,這里我們把函數(shù)fn綁定到方法名hello上。

通過type()函數(shù)創(chuàng)建的類和直接寫class是完全一樣的,因為Python解釋器遇到class定義時,僅僅是掃描一下class定義的語法,然后調用type()函數(shù)創(chuàng)建出class。

正常情況下,我們都用class Xxx...來定義類,但是,type()函數(shù)也允許我們動態(tài)創(chuàng)建出類來,也就是說,動態(tài)語言本身支持運行期動態(tài)創(chuàng)建類,這和靜態(tài)語言有非常大的不同,要在靜態(tài)語言運行期創(chuàng)建類,必須構造源代碼字符串再調用編譯器,或者借助一些工具生成字節(jié)碼實現(xiàn),本質上都是動態(tài)編譯,會非常復雜。

metaclass

除了使用type()動態(tài)創(chuàng)建類以外,要控制類的創(chuàng)建行為,還可以使用metaclass。

metaclass,直譯為元類,簡單的解釋就是:

當我們定義了類以后,就可以根據(jù)這個類創(chuàng)建出實例,所以:先定義類,然后創(chuàng)建實例。

但是如果我們想創(chuàng)建出類呢?那就必須根據(jù)metaclass創(chuàng)建出類,所以:先定義metaclass,然后創(chuàng)建類。

連接起來就是:先定義metaclass,就可以創(chuàng)建類,最后創(chuàng)建實例。

所以,metaclass允許你創(chuàng)建類或者修改類。換句話說,你可以把類看成是metaclass創(chuàng)建出來的“實例”。

metaclass是Python面向對象里最難理解,也是最難使用的魔術代碼。正常情況下,你不會碰到需要使用metaclass的情況,所以,以下內容看不懂也沒關系,因為基本上你不會用到。

我們先看一個簡單的例子,這個metaclass可以給我們自定義的MyList增加一個add方法:

定義ListMetaclass,按照默認習慣,metaclass的類名總是以Metaclass結尾,以便清楚地表示這是一個metaclass:

# metaclass是類的模板,所以必須從`type`類型派生:
class ListMetaclass(type):def __new__(cls, name, bases, attrs):attrs['add'] = lambda self, value: self.append(value)return type.__new__(cls, name, bases, attrs)

有了ListMetaclass,我們在定義類的時候還要指示使用ListMetaclass來定制類,傳入關鍵字參數(shù)metaclass

class MyList(list, metaclass=ListMetaclass):pass

當我們傳入關鍵字參數(shù)metaclass時,魔術就生效了,它指示Python解釋器在創(chuàng)建MyList時,要通過ListMetaclass.__new__()來創(chuàng)建,在此,我們可以修改類的定義,比如,加上新的方法,然后,返回修改后的定義。

__new__()方法接收到的參數(shù)依次是:

  1. 當前準備創(chuàng)建的類的對象;

  2. 類的名字;

  3. 類繼承的父類集合;

  4. 類的方法集合。

測試一下MyList是否可以調用add()方法:

>>> L = MyList()
>>> L.add(1)
>> L
[1]

而普通的list沒有add()方法:

>>> L2 = list()
>>> L2.add(1)
Traceback (most recent call last):File "<stdin>", line 1, in <module>
AttributeError: 'list' object has no attribute 'add'

動態(tài)修改有什么意義?直接在MyList定義中寫上add()方法不是更簡單嗎?正常情況下,確實應該直接寫,通過metaclass修改純屬變態(tài)。

但是,總會遇到需要通過metaclass修改類定義的。ORM就是一個典型的例子。

ORM全稱“Object Relational Mapping”,即對象-關系映射,就是把關系數(shù)據(jù)庫的一行映射為一個對象,也就是一個類對應一個表,這樣,寫代碼更簡單,不用直接操作SQL語句。

要編寫一個ORM框架,所有的類都只能動態(tài)定義,因為只有使用者才能根據(jù)表的結構定義出對應的類來。

讓我們來嘗試編寫一個ORM框架。

編寫底層模塊的第一步,就是先把調用接口寫出來。比如,使用者如果使用這個ORM框架,想定義一個User類來操作對應的數(shù)據(jù)庫表User,我們期待他寫出這樣的代碼:

class User(Model):# 定義類的屬性到列的映射:id = IntegerField('id')name = StringField('username')email = StringField('email')password = StringField('password')# 創(chuàng)建一個實例:
u = User(id=12345, name='Michael', email='test@orm.org', password='my-pwd')
# 保存到數(shù)據(jù)庫:
u.save()

其中,父類Model和屬性類型StringField、IntegerField是由ORM框架提供的,剩下的魔術方法比如save()全部由metaclass自動完成。雖然metaclass的編寫會比較復雜,但ORM的使用者用起來卻異常簡單。

現(xiàn)在,我們就按上面的接口來實現(xiàn)該ORM。

首先來定義Field類,它負責保存數(shù)據(jù)庫表的字段名和字段類型:

class Field(object):def __init__(self, name, column_type):self.name = nameself.column_type = column_typedef __str__(self):return '<%s:%s>' % (self.__class__.__name__, self.name)

Field的基礎上,進一步定義各種類型的Field,比如StringFieldIntegerField等等:

class StringField(Field):def __init__(self, name):super(StringField, self).__init__(name, 'varchar(100)')class IntegerField(Field):def __init__(self, name):super(IntegerField, self).__init__(name, 'bigint')

下一步,就是編寫最復雜的ModelMetaclass了:

class ModelMetaclass(type):def __new__(cls, name, bases, attrs):if name=='Model':return type.__new__(cls, name, bases, attrs)print('Found model: %s' % name)mappings = dict()for k, v in attrs.items():if isinstance(v, Field):print('Found mapping: %s ==> %s' % (k, v))mappings[k] = vfor k in mappings.keys():attrs.pop(k)attrs['__mappings__'] = mappings # 保存屬性和列的映射關系attrs['__table__'] = name # 假設表名和類名一致return type.__new__(cls, name, bases, attrs)

以及基類Model

class Model(dict, metaclass=ModelMetaclass):def __init__(self, **kw):super(Model, self).__init__(**kw)def __getattr__(self, key):try:return self[key]except KeyError:raise AttributeError(r"'Model' object has no attribute '%s'" % key)def __setattr__(self, key, value):self[key] = valuedef save(self):fields = []params = []args = []for k, v in self.__mappings__.items():fields.append(v.name)params.append('?')args.append(getattr(self, k, None))sql = 'insert into %s (%s) values (%s)' % (self.__table__, ','.join(fields), ','.join(params))print('SQL: %s' % sql)print('ARGS: %s' % str(args))

當用戶定義一個class User(Model)時,Python解釋器首先在當前類User的定義中查找metaclass,如果沒有找到,就繼續(xù)在父類Model中查找metaclass,找到了,就使用Model中定義的metaclassModelMetaclass來創(chuàng)建User類,也就是說,metaclass可以隱式地繼承到子類,但子類自己卻感覺不到。

ModelMetaclass中,一共做了幾件事情:

  1. 排除掉對Model類的修改;

  2. 在當前類(比如User)中查找定義的類的所有屬性,如果找到一個Field屬性,就把它保存到一個__mappings__的dict中,同時從類屬性中刪除該Field屬性,否則,容易造成運行時錯誤(實例的屬性會遮蓋類的同名屬性);

  3. 把表名保存到__table__中,這里簡化為表名默認為類名。

Model類中,就可以定義各種操作數(shù)據(jù)庫的方法,比如save()delete()find()update等等。

我們實現(xiàn)了save()方法,把一個實例保存到數(shù)據(jù)庫中。因為有表名,屬性到字段的映射和屬性值的集合,就可以構造出INSERT語句。

編寫代碼試試:

u = User(id=12345, name='Michael', email='test@orm.org', password='my-pwd')
u.save()

輸出如下:

Found model: User
Found mapping: email ==> <StringField:email>
Found mapping: password ==> <StringField:password>
Found mapping: id ==> <IntegerField:uid>
Found mapping: name ==> <StringField:username>
SQL: insert into User (password,email,username,id) values (?,?,?,?)
ARGS: ['my-pwd', 'test@orm.org', 'Michael', 12345]

可以看到,save()方法已經打印出了可執(zhí)行的SQL語句,以及參數(shù)列表,只需要真正連接到數(shù)據(jù)庫,執(zhí)行該SQL語句,就可以完成真正的功能。

不到100行代碼,我們就通過metaclass實現(xiàn)了一個精簡的ORM框架。

小結

metaclass是Python中非常具有魔術性的對象,它可以改變類創(chuàng)建時的行為。這種強大的功能使用起來務必小心。

參考源碼

create_class_on_the_fly.py

use_metaclass.py

orm.py

錯誤、調試和測試

在程序運行過程中,總會遇到各種各樣的錯誤。

有的錯誤是程序編寫有問題造成的,比如本來應該輸出整數(shù)結果輸出了字符串,這種錯誤我們通常稱之為bug,bug是必須修復的。

有的錯誤是用戶輸入造成的,比如讓用戶輸入email地址,結果得到一個空字符串,這種錯誤可以通過檢查用戶輸入來做相應的處理。

還有一類錯誤是完全無法在程序運行過程中預測的,比如寫入文件的時候,磁盤滿了,寫不進去了,或者從網絡抓取數(shù)據(jù),網絡突然斷掉了。這類錯誤也稱為異常,在程序中通常是必須處理的,否則,程序會因為各種問題終止并退出。

Python內置了一套異常處理機制,來幫助我們進行錯誤處理。

此外,我們也需要跟蹤程序的執(zhí)行,查看變量的值是否正確,這個過程稱為調試。Python的pdb可以讓我們以單步方式執(zhí)行代碼。

最后,編寫測試也很重要。有了良好的測試,就可以在程序修改后反復運行,確保程序輸出符合我們編寫的測試。

錯誤處理

在程序運行的過程中,如果發(fā)生了錯誤,可以事先約定返回一個錯誤代碼,這樣,就可以知道是否有錯,以及出錯的原因。在操作系統(tǒng)提供的調用中,返回錯誤碼非常常見。比如打開文件的函數(shù)open(),成功時返回文件描述符(就是一個整數(shù)),出錯時返回-1。

用錯誤碼來表示是否出錯十分不便,因為函數(shù)本身應該返回的正常結果和錯誤碼混在一起,造成調用者必須用大量的代碼來判斷是否出錯:

def foo():r = some_function()if r==(-1):return (-1)# do somethingreturn rdef bar():r = foo()if r==(-1):print('Error')else:pass

一旦出錯,還要一級一級上報,直到某個函數(shù)可以處理該錯誤(比如,給用戶輸出一個錯誤信息)。

所以高級語言通常都內置了一套try...except...finally...的錯誤處理機制,Python也不例外。

try

讓我們用一個例子來看看try的機制:

try:print('try...')r = 10 / 0print('result:', r)
except ZeroDivisionError as e:print('except:', e)
finally:print('finally...')
print('END')

當我們認為某些代碼可能會出錯時,就可以用try來運行這段代碼,如果執(zhí)行出錯,則后續(xù)代碼不會繼續(xù)執(zhí)行,而是直接跳轉至錯誤處理代碼,即except語句塊,執(zhí)行完except后,如果有finally語句塊,則執(zhí)行finally語句塊,至此,執(zhí)行完畢。

上面的代碼在計算10 / 0時會產生一個除法運算錯誤:

try...
except: division by zero
finally...
END

從輸出可以看到,當錯誤發(fā)生時,后續(xù)語句print('result:', r)不會被執(zhí)行,except由于捕獲到ZeroDivisionError,因此被執(zhí)行。最后,finally語句被執(zhí)行。然后,程序繼續(xù)按照流程往下走。

如果把除數(shù)0改成2,則執(zhí)行結果如下:

try...
result: 5
finally...
END

由于沒有錯誤發(fā)生,所以except語句塊不會被執(zhí)行,但是finally如果有,則一定會被執(zhí)行(可以沒有finally語句)。

你還可以猜測,錯誤應該有很多種類,如果發(fā)生了不同類型的錯誤,應該由不同的except語句塊處理。沒錯,可以有多個except來捕獲不同類型的錯誤:

try:print('try...')r = 10 / int('a')print('result:', r)
except ValueError as e:print('ValueError:', e)
except ZeroDivisionError as e:print('ZeroDivisionError:', e)
finally:print('finally...')
print('END')

int()函數(shù)可能會拋出ValueError,所以我們用一個except捕獲ValueError,用另一個except捕獲ZeroDivisionError。

此外,如果沒有錯誤發(fā)生,可以在except語句塊后面加一個else,當沒有錯誤發(fā)生時,會自動執(zhí)行else語句:

try:print('try...')r = 10 / int('2')print('result:', r)
except ValueError as e:print('ValueError:', e)
except ZeroDivisionError as e:print('ZeroDivisionError:', e)
else:print('no error!')
finally:print('finally...')
print('END')

Python的錯誤其實也是class,所有的錯誤類型都繼承自BaseException,所以在使用except時需要注意的是,它不但捕獲該類型的錯誤,還把其子類也“一網打盡”。比如:

try:foo()
except ValueError as e:print('ValueError')
except UnicodeError as e:print('UnicodeError')

第二個except永遠也捕獲不到UnicodeError,因為UnicodeErrorValueError的子類,如果有,也被第一個except給捕獲了。

Python所有的錯誤都是從BaseException類派生的,常見的錯誤類型和繼承關系看這里:

https://docs.python.org/3/library/exceptions.html#exception-hierarchy

使用try...except捕獲錯誤還有一個巨大的好處,就是可以跨越多層調用,比如函數(shù)main()調用foo()foo()調用bar(),結果bar()出錯了,這時,只要main()捕獲到了,就可以處理:

def foo(s):return 10 / int(s)def bar(s):return foo(s) * 2def main():try:bar('0')except Exception as e:print('Error:', e)finally:print('finally...')

也就是說,不需要在每個可能出錯的地方去捕獲錯誤,只要在合適的層次去捕獲錯誤就可以了。這樣一來,就大大減少了寫try...except...finally的麻煩。

調用堆棧

如果錯誤沒有被捕獲,它就會一直往上拋,最后被Python解釋器捕獲,打印一個錯誤信息,然后程序退出。來看看err.py

# err.py:
def foo(s):return 10 / int(s)def bar(s):return foo(s) * 2def main():bar('0')main()

執(zhí)行,結果如下:

$ python3 err.py
Traceback (most recent call last):File "err.py", line 11, in <module>main()File "err.py", line 9, in mainbar('0')File "err.py", line 6, in barreturn foo(s) * 2File "err.py", line 3, in fooreturn 10 / int(s)
ZeroDivisionError: division by zero

出錯并不可怕,可怕的是不知道哪里出錯了。解讀錯誤信息是定位錯誤的關鍵。我們從上往下可以看到整個錯誤的調用函數(shù)鏈:

錯誤信息第1行:

Traceback (most recent call last):

告訴我們這是錯誤的跟蹤信息。

第2~3行:

  File "err.py", line 11, in <module>main()

調用main()出錯了,在代碼文件err.py的第11行代碼,但原因是第9行:

  File "err.py", line 9, in mainbar('0')

調用bar('0')出錯了,在代碼文件err.py的第9行代碼,但原因是第6行:

  File "err.py", line 6, in barreturn foo(s) * 2

原因是return foo(s) * 2這個語句出錯了,但這還不是最終原因,繼續(xù)往下看:

  File "err.py", line 3, in fooreturn 10 / int(s)

原因是return 10 / int(s)這個語句出錯了,這是錯誤產生的源頭,因為下面打印了:

ZeroDivisionError: integer division or modulo by zero

根據(jù)錯誤類型ZeroDivisionError,我們判斷,int(s)本身并沒有出錯,但是int(s)返回0,在計算10 / 0時出錯,至此,找到錯誤源頭。

記錄錯誤

如果不捕獲錯誤,自然可以讓Python解釋器來打印出錯誤堆棧,但程序也被結束了。既然我們能捕獲錯誤,就可以把錯誤堆棧打印出來,然后分析錯誤原因,同時,讓程序繼續(xù)執(zhí)行下去。

Python內置的logging模塊可以非常容易地記錄錯誤信息:

# err_logging.pyimport loggingdef foo(s):return 10 / int(s)def bar(s):return foo(s) * 2def main():try:bar('0')except Exception as e:logging.exception(e)main()
print('END')

同樣是出錯,但程序打印完錯誤信息后會繼續(xù)執(zhí)行,并正常退出:

$ python3 err_logging.py
ERROR:root:division by zero
Traceback (most recent call last):File "err_logging.py", line 13, in mainbar('0')File "err_logging.py", line 9, in barreturn foo(s) * 2File "err_logging.py", line 6, in fooreturn 10 / int(s)
ZeroDivisionError: division by zero
END

通過配置,logging還可以把錯誤記錄到日志文件里,方便事后排查。

拋出錯誤

因為錯誤是class,捕獲一個錯誤就是捕獲到該class的一個實例。因此,錯誤并不是憑空產生的,而是有意創(chuàng)建并拋出的。Python的內置函數(shù)會拋出很多類型的錯誤,我們自己編寫的函數(shù)也可以拋出錯誤。

如果要拋出錯誤,首先根據(jù)需要,可以定義一個錯誤的class,選擇好繼承關系,然后,用raise語句拋出一個錯誤的實例:

# err_raise.py
class FooError(ValueError):passdef foo(s):n = int(s)if n==0:raise FooError('invalid value: %s' % s)return 10 / nfoo('0')

執(zhí)行,可以最后跟蹤到我們自己定義的錯誤:

$ python3 err_raise.py 
Traceback (most recent call last):File "err_throw.py", line 11, in <module>foo('0')File "err_throw.py", line 8, in fooraise FooError('invalid value: %s' % s)
__main__.FooError: invalid value: 0

只有在必要的時候才定義我們自己的錯誤類型。如果可以選擇Python已有的內置的錯誤類型(比如ValueErrorTypeError),盡量使用Python內置的錯誤類型。

最后,我們來看另一種錯誤處理的方式:

# err_reraise.pydef foo(s):n = int(s)if n==0:raise ValueError('invalid value: %s' % s)return 10 / ndef bar():try:foo('0')except ValueError as e:print('ValueError!')raisebar()

bar()函數(shù)中,我們明明已經捕獲了錯誤,但是,打印一個ValueError!后,又把錯誤通過raise語句拋出去了,這不有病么?

其實這種錯誤處理方式不但沒病,而且相當常見。捕獲錯誤目的只是記錄一下,便于后續(xù)追蹤。但是,由于當前函數(shù)不知道應該怎么處理該錯誤,所以,最恰當?shù)姆绞绞抢^續(xù)往上拋,讓頂層調用者去處理。好比一個員工處理不了一個問題時,就把問題拋給他的老板,如果他的老板也處理不了,就一直往上拋,最終會拋給CEO去處理。

raise語句如果不帶參數(shù),就會把當前錯誤原樣拋出。此外,在exceptraise一個Error,還可以把一種類型的錯誤轉化成另一種類型:

try:10 / 0
except ZeroDivisionError:raise ValueError('input error!')

只要是合理的轉換邏輯就可以,但是,決不應該把一個IOError轉換成毫不相干的ValueError。

小結

Python內置的try...except...finally用來處理錯誤十分方便。出錯時,會分析錯誤信息并定位錯誤發(fā)生的代碼位置才是最關鍵的。

程序也可以主動拋出錯誤,讓調用者來處理相應的錯誤。但是,應該在文檔中寫清楚可能會拋出哪些錯誤,以及錯誤產生的原因。

參考源碼

do_try.py

err.py

err_logging.py

err_raise.py

err_reraise.py

調試

程序能一次寫完并正常運行的概率很小,基本不超過1%。總會有各種各樣的bug需要修正。有的bug很簡單,看看錯誤信息就知道,有的bug很復雜,我們需要知道出錯時,哪些變量的值是正確的,哪些變量的值是錯誤的,因此,需要一整套調試程序的手段來修復bug。

第一種方法簡單直接粗暴有效,就是用print()把可能有問題的變量打印出來看看:

def foo(s):n = int(s)print('>>> n = %d' % n)return 10 / ndef main():foo('0')main()

執(zhí)行后在輸出中查找打印的變量值:

$ python3 err.py
>>> n = 0
Traceback (most recent call last):...
ZeroDivisionError: integer division or modulo by zero

print()最大的壞處是將來還得刪掉它,想想程序里到處都是print(),運行結果也會包含很多垃圾信息。所以,我們又有第二種方法。

斷言

凡是用print()來輔助查看的地方,都可以用斷言(assert)來替代:

def foo(s):n = int(s)assert n != 0, 'n is zero!'return 10 / ndef main():foo('0')

assert的意思是,表達式n != 0應該是True,否則,根據(jù)程序運行的邏輯,后面的代碼肯定會出錯。

如果斷言失敗,assert語句本身就會拋出AssertionError

$ python3 err.py
Traceback (most recent call last):...
AssertionError: n is zero!

程序中如果到處充斥著assert,和print()相比也好不到哪去。不過,啟動Python解釋器時可以用-O參數(shù)來關閉assert

$ python3 -O err.py
Traceback (most recent call last):...
ZeroDivisionError: division by zero

關閉后,你可以把所有的assert語句當成pass來看。

logging

print()替換為logging是第3種方式,和assert比,logging不會拋出錯誤,而且可以輸出到文件:

import loggings = '0'
n = int(s)
logging.info('n = %d' % n)
print(10 / n)

logging.info()就可以輸出一段文本。運行,發(fā)現(xiàn)除了ZeroDivisionError,沒有任何信息。怎么回事?

別急,在import logging之后添加一行配置再試試:

import logging
logging.basicConfig(level=logging.INFO)

看到輸出了:

$ python3 err.py
INFO:root:n = 0
Traceback (most recent call last):File "err.py", line 8, in <module>print(10 / n)
ZeroDivisionError: division by zero

這就是logging的好處,它允許你指定記錄信息的級別,有debuginfowarningerror等幾個級別,當我們指定level=INFO時,logging.debug就不起作用了。同理,指定level=WARNING后,debuginfo就不起作用了。這樣一來,你可以放心地輸出不同級別的信息,也不用刪除,最后統(tǒng)一控制輸出哪個級別的信息。

logging的另一個好處是通過簡單的配置,一條語句可以同時輸出到不同的地方,比如console和文件。

pdb

第4種方式是啟動Python的調試器pdb,讓程序以單步方式運行,可以隨時查看運行狀態(tài)。我們先準備好程序:

# err.py
s = '0'
n = int(s)
print(10 / n)

然后啟動:

$ python3 -m pdb err.py
> /Users/michael/Github/learn-python3/samples/debug/err.py(2)<module>()
-> s = '0'

以參數(shù)-m pdb啟動后,pdb定位到下一步要執(zhí)行的代碼-> s = '0'。輸入命令l來查看代碼:

(Pdb) l1     # err.py2  -> s = '0'3     n = int(s)4     print(10 / n)

輸入命令n可以單步執(zhí)行代碼:

(Pdb) n
> /Users/michael/Github/learn-python3/samples/debug/err.py(3)<module>()
-> n = int(s)
(Pdb) n
> /Users/michael/Github/learn-python3/samples/debug/err.py(4)<module>()
-> print(10 / n)

任何時候都可以輸入命令p 變量名來查看變量:

(Pdb) p s
'0'
(Pdb) p n
0

輸入命令q結束調試,退出程序:

(Pdb) q

這種通過pdb在命令行調試的方法理論上是萬能的,但實在是太麻煩了,如果有一千行代碼,要運行到第999行得敲多少命令啊。還好,我們還有另一種調試方法。

pdb.set_trace()

這個方法也是用pdb,但是不需要單步執(zhí)行,我們只需要import pdb,然后,在可能出錯的地方放一個pdb.set_trace(),就可以設置一個斷點:

# err.py
import pdbs = '0'
n = int(s)
pdb.set_trace() # 運行到這里會自動暫停
print(10 / n)

運行代碼,程序會自動在pdb.set_trace()暫停并進入pdb調試環(huán)境,可以用命令p查看變量,或者用命令c繼續(xù)運行:

$ python3 err.py 
> /Users/michael/Github/learn-python3/samples/debug/err.py(7)<module>()
-> print(10 / n)
(Pdb) p n
0
(Pdb) c
Traceback (most recent call last):File "err.py", line 7, in <module>print(10 / n)
ZeroDivisionError: division by zero

這個方式比直接啟動pdb單步調試效率要高很多,但也高不到哪去。

IDE

如果要比較爽地設置斷點、單步執(zhí)行,就需要一個支持調試功能的IDE。目前比較好的Python IDE有PyCharm:

http://www.jetbrains.com/pycharm/

另外,Eclipse加上pydev插件也可以調試Python程序。

小結

寫程序最痛苦的事情莫過于調試,程序往往會以你意想不到的流程來運行,你期待執(zhí)行的語句其實根本沒有執(zhí)行,這時候,就需要調試了。

雖然用IDE調試起來比較方便,但是最后你會發(fā)現(xiàn),logging才是終極武器。

參考源碼

do_assert.py

do_logging.py

do_pdb.py

單元測試

如果你聽說過“測試驅動開發(fā)”(TDD:Test-Driven Development),單元測試就不陌生。

單元測試是用來對一個模塊、一個函數(shù)或者一個類來進行正確性檢驗的測試工作。

比如對函數(shù)abs(),我們可以編寫出以下幾個測試用例:

  1. 輸入正數(shù),比如1、1.2、0.99,期待返回值與輸入相同;

  2. 輸入負數(shù),比如-1-1.2、-0.99,期待返回值與輸入相反;

  3. 輸入0,期待返回0

  4. 輸入非數(shù)值類型,比如None[]、{},期待拋出TypeError

把上面的測試用例放到一個測試模塊里,就是一個完整的單元測試。

如果單元測試通過,說明我們測試的這個函數(shù)能夠正常工作。如果單元測試不通過,要么函數(shù)有bug,要么測試條件輸入不正確,總之,需要修復使單元測試能夠通過。

單元測試通過后有什么意義呢?如果我們對abs()函數(shù)代碼做了修改,只需要再跑一遍單元測試,如果通過,說明我們的修改不會對abs()函數(shù)原有的行為造成影響,如果測試不通過,說明我們的修改與原有行為不一致,要么修改代碼,要么修改測試。

這種以測試為驅動的開發(fā)模式最大的好處就是確保一個程序模塊的行為符合我們設計的測試用例。在將來修改的時候,可以極大程度地保證該模塊行為仍然是正確的。

我們來編寫一個Dict類,這個類的行為和dict一致,但是可以通過屬性來訪問,用起來就像下面這樣:

>>> d = Dict(a=1, b=2)
>>> d['a']
1
>>> d.a
1

mydict.py代碼如下:

class Dict(dict):def __init__(self, **kw):super().__init__(**kw)def __getattr__(self, key):try:return self[key]except KeyError:raise AttributeError(r"'Dict' object has no attribute '%s'" % key)def __setattr__(self, key, value):self[key] = value

為了編寫單元測試,我們需要引入Python自帶的unittest模塊,編寫mydict_test.py如下:

import unittestfrom mydict import Dictclass TestDict(unittest.TestCase):def test_init(self):d = Dict(a=1, b='test')self.assertEqual(d.a, 1)self.assertEqual(d.b, 'test')self.assertTrue(isinstance(d, dict))def test_key(self):d = Dict()d['key'] = 'value'self.assertEqual(d.key, 'value')def test_attr(self):d = Dict()d.key = 'value'self.assertTrue('key' in d)self.assertEqual(d['key'], 'value')def test_keyerror(self):d = Dict()with self.assertRaises(KeyError):value = d['empty']def test_attrerror(self):d = Dict()with self.assertRaises(AttributeError):value = d.empty

編寫單元測試時,我們需要編寫一個測試類,從unittest.TestCase繼承。

test開頭的方法就是測試方法,不以test開頭的方法不被認為是測試方法,測試的時候不會被執(zhí)行。

對每一類測試都需要編寫一個test_xxx()方法。由于unittest.TestCase提供了很多內置的條件判斷,我們只需要調用這些方法就可以斷言輸出是否是我們所期望的。最常用的斷言就是assertEqual()

self.assertEqual(abs(-1), 1) # 斷言函數(shù)返回的結果與1相等

另一種重要的斷言就是期待拋出指定類型的Error,比如通過d['empty']訪問不存在的key時,斷言會拋出KeyError

with self.assertRaises(KeyError):value = d['empty']

而通過d.empty訪問不存在的key時,我們期待拋出AttributeError

with self.assertRaises(AttributeError):value = d.empty
運行單元測試

一旦編寫好單元測試,我們就可以運行單元測試。最簡單的運行方式是在mydict_test.py的最后加上兩行代碼:

if __name__ == '__main__':unittest.main()

這樣就可以把mydict_test.py當做正常的python腳本運行:

$ python3 mydict_test.py

另一種方法是在命令行通過參數(shù)-m unittest直接運行單元測試:

$ python3 -m unittest mydict_test
.....
----------------------------------------------------------------------
Ran 5 tests in 0.000sOK

這是推薦的做法,因為這樣可以一次批量運行很多單元測試,并且,有很多工具可以自動來運行這些單元測試。

setUp與tearDown

可以在單元測試中編寫兩個特殊的setUp()tearDown()方法。這兩個方法會分別在每調用一個測試方法的前后分別被執(zhí)行。

setUp()tearDown()方法有什么用呢?設想你的測試需要啟動一個數(shù)據(jù)庫,這時,就可以在setUp()方法中連接數(shù)據(jù)庫,在tearDown()方法中關閉數(shù)據(jù)庫,這樣,不必在每個測試方法中重復相同的代碼:

class TestDict(unittest.TestCase):def setUp(self):print('setUp...')def tearDown(self):print('tearDown...')

可以再次運行測試看看每個測試方法調用前后是否會打印出setUp...tearDown...。

小結

單元測試可以有效地測試某個程序模塊的行為,是未來重構代碼的信心保證。

單元測試的測試用例要覆蓋常用的輸入組合、邊界條件和異常。

單元測試代碼要非常簡單,如果測試代碼太復雜,那么測試代碼本身就可能有bug。

單元測試通過了并不意味著程序就沒有bug了,但是不通過程序肯定有bug。

參考源碼

mydict.py

mydict_test.py

文檔測試

如果你經常閱讀Python的官方文檔,可以看到很多文檔都有示例代碼。比如re模塊就帶了很多示例代碼:

>>> import re
>>> m = re.search('(?<=abc)def', 'abcdef')
>>> m.group(0)
'def'

可以把這些示例代碼在Python的交互式環(huán)境下輸入并執(zhí)行,結果與文檔中的示例代碼顯示的一致。

這些代碼與其他說明可以寫在注釋中,然后,由一些工具來自動生成文檔。既然這些代碼本身就可以粘貼出來直接運行,那么,可不可以自動執(zhí)行寫在注釋中的這些代碼呢?

答案是肯定的。

當我們編寫注釋時,如果寫上這樣的注釋:

def abs(n):'''Function to get absolute value of number.Example:>>> abs(1)1>>> abs(-1)1>>> abs(0)0'''return n if n >= 0 else (-n)

無疑更明確地告訴函數(shù)的調用者該函數(shù)的期望輸入和輸出。

并且,Python內置的“文檔測試”(doctest)模塊可以直接提取注釋中的代碼并執(zhí)行測試。

doctest嚴格按照Python交互式命令行的輸入和輸出來判斷測試結果是否正確。只有測試異常的時候,可以用...表示中間一大段煩人的輸出。

讓我們用doctest來測試上次編寫的Dict類:

# mydict2.py
class Dict(dict):'''Simple dict but also support access as x.y style.>>> d1 = Dict()>>> d1['x'] = 100>>> d1.x100>>> d1.y = 200>>> d1['y']200>>> d2 = Dict(a=1, b=2, c='3')>>> d2.c'3'>>> d2['empty']Traceback (most recent call last):...KeyError: 'empty'>>> d2.emptyTraceback (most recent call last):...AttributeError: 'Dict' object has no attribute 'empty''''def __init__(self, **kw):super(Dict, self).__init__(**kw)def __getattr__(self, key):try:return self[key]except KeyError:raise AttributeError(r"'Dict' object has no attribute '%s'" % key)def __setattr__(self, key, value):self[key] = valueif __name__=='__main__':import doctestdoctest.testmod()

運行python3 mydict2.py

$ python3 mydict2.py

什么輸出也沒有。這說明我們編寫的doctest運行都是正確的。如果程序有問題,比如把__getattr__()方法注釋掉,再運行就會報錯:

$ python3 mydict2.py
**********************************************************************
File "/Users/michael/Github/learn-python3/samples/debug/mydict2.py", line 10, in __main__.Dict
Failed example:d1.x
Exception raised:Traceback (most recent call last):...AttributeError: 'Dict' object has no attribute 'x'
**********************************************************************
File "/Users/michael/Github/learn-python3/samples/debug/mydict2.py", line 16, in __main__.Dict
Failed example:d2.c
Exception raised:Traceback (most recent call last):...AttributeError: 'Dict' object has no attribute 'c'
**********************************************************************
1 items had failures:2 of   9 in __main__.Dict
***Test Failed*** 2 failures.

注意到最后3行代碼。當模塊正常導入時,doctest不會被執(zhí)行。只有在命令行直接運行時,才執(zhí)行doctest。所以,不必擔心doctest會在非測試環(huán)境下執(zhí)行。

練習

對函數(shù)fact(n)編寫doctest并執(zhí)行:

# -*- coding: utf-8 -*-def fact(n):'''
----
----'''if n < 1:raise ValueError()if n == 1:return 1return n * fact(n - 1)if __name__ == '__main__':import doctestdoctest.testmod()
小結

doctest非常有用,不但可以用來測試,還可以直接作為示例代碼。通過某些文檔生成工具,就可以自動把包含doctest的注釋提取出來。用戶看文檔的時候,同時也看到了doctest。

參考源碼

mydict2.py

IO編程

IO在計算機中指Input/Output,也就是輸入和輸出。由于程序和運行時數(shù)據(jù)是在內存中駐留,由CPU這個超快的計算核心來執(zhí)行,涉及到數(shù)據(jù)交換的地方,通常是磁盤、網絡等,就需要IO接口。

比如你打開瀏覽器,訪問新浪首頁,瀏覽器這個程序就需要通過網絡IO獲取新浪的網頁。瀏覽器首先會發(fā)送數(shù)據(jù)給新浪服務器,告訴它我想要首頁的HTML,這個動作是往外發(fā)數(shù)據(jù),叫Output,隨后新浪服務器把網頁發(fā)過來,這個動作是從外面接收數(shù)據(jù),叫Input。所以,通常,程序完成IO操作會有Input和Output兩個數(shù)據(jù)流。當然也有只用一個的情況,比如,從磁盤讀取文件到內存,就只有Input操作,反過來,把數(shù)據(jù)寫到磁盤文件里,就只是一個Output操作。

IO編程中,Stream(流)是一個很重要的概念,可以把流想象成一個水管,數(shù)據(jù)就是水管里的水,但是只能單向流動。Input Stream就是數(shù)據(jù)從外面(磁盤、網絡)流進內存,Output Stream就是數(shù)據(jù)從內存流到外面去。對于瀏覽網頁來說,瀏覽器和新浪服務器之間至少需要建立兩根水管,才可以既能發(fā)數(shù)據(jù),又能收數(shù)據(jù)。

由于CPU和內存的速度遠遠高于外設的速度,所以,在IO編程中,就存在速度嚴重不匹配的問題。舉個例子來說,比如要把100M的數(shù)據(jù)寫入磁盤,CPU輸出100M的數(shù)據(jù)只需要0.01秒,可是磁盤要接收這100M數(shù)據(jù)可能需要10秒,怎么辦呢?有兩種辦法:

第一種是CPU等著,也就是程序暫停執(zhí)行后續(xù)代碼,等100M的數(shù)據(jù)在10秒后寫入磁盤,再接著往下執(zhí)行,這種模式稱為同步IO;

另一種方法是CPU不等待,只是告訴磁盤,“您老慢慢寫,不著急,我接著干別的事去了”,于是,后續(xù)代碼可以立刻接著執(zhí)行,這種模式稱為異步IO。

同步和異步的區(qū)別就在于是否等待IO執(zhí)行的結果。好比你去麥當勞點餐,你說“來個漢堡”,服務員告訴你,對不起,漢堡要現(xiàn)做,需要等5分鐘,于是你站在收銀臺前面等了5分鐘,拿到漢堡再去逛商場,這是同步IO。

你說“來個漢堡”,服務員告訴你,漢堡需要等5分鐘,你可以先去逛商場,等做好了,我們再通知你,這樣你可以立刻去干別的事情(逛商場),這是異步IO。

很明顯,使用異步IO來編寫程序性能會遠遠高于同步IO,但是異步IO的缺點是編程模型復雜。想想看,你得知道什么時候通知你“漢堡做好了”,而通知你的方法也各不相同。如果是服務員跑過來找到你,這是回調模式,如果服務員發(fā)短信通知你,你就得不停地檢查手機,這是輪詢模式??傊?#xff0c;異步IO的復雜度遠遠高于同步IO。

操作IO的能力都是由操作系統(tǒng)提供的,每一種編程語言都會把操作系統(tǒng)提供的低級C接口封裝起來方便使用,Python也不例外。我們后面會詳細討論Python的IO編程接口。

注意,本章的IO編程都是同步模式,異步IO由于復雜度太高,后續(xù)涉及到服務器端程序開發(fā)時我們再討論。

文件讀寫

讀寫文件是最常見的IO操作。Python內置了讀寫文件的函數(shù),用法和C是兼容的。

讀寫文件前,我們先必須了解一下,在磁盤上讀寫文件的功能都是由操作系統(tǒng)提供的,現(xiàn)代操作系統(tǒng)不允許普通的程序直接操作磁盤,所以,讀寫文件就是請求操作系統(tǒng)打開一個文件對象(通常稱為文件描述符),然后,通過操作系統(tǒng)提供的接口從這個文件對象中讀取數(shù)據(jù)(讀文件),或者把數(shù)據(jù)寫入這個文件對象(寫文件)。

讀文件

要以讀文件的模式打開一個文件對象,使用Python內置的open()函數(shù),傳入文件名和標示符:

>>> f = open('/Users/michael/test.txt', 'r')

標示符'r'表示讀,這樣,我們就成功地打開了一個文件。

如果文件不存在,open()函數(shù)就會拋出一個IOError的錯誤,并且給出錯誤碼和詳細的信息告訴你文件不存在:

>>> f=open('/Users/michael/notfound.txt', 'r')
Traceback (most recent call last):File "<stdin>", line 1, in <module>
FileNotFoundError: [Errno 2] No such file or directory: '/Users/michael/notfound.txt'

如果文件打開成功,接下來,調用read()方法可以一次讀取文件的全部內容,Python把內容讀到內存,用一個str對象表示:

>>> f.read()
'Hello, world!'

最后一步是調用close()方法關閉文件。文件使用完畢后必須關閉,因為文件對象會占用操作系統(tǒng)的資源,并且操作系統(tǒng)同一時間能打開的文件數(shù)量也是有限的:

>>> f.close()

由于文件讀寫時都有可能產生IOError,一旦出錯,后面的f.close()就不會調用。所以,為了保證無論是否出錯都能正確地關閉文件,我們可以使用try ... finally來實現(xiàn):

try:f = open('/path/to/file', 'r')print(f.read())
finally:if f:f.close()

但是每次都這么寫實在太繁瑣,所以,Python引入了with語句來自動幫我們調用close()方法:

with open('/path/to/file', 'r') as f:print(f.read())

這和前面的try ... finally是一樣的,但是代碼更佳簡潔,并且不必調用f.close()方法。

調用read()會一次性讀取文件的全部內容,如果文件有10G,內存就爆了,所以,要保險起見,可以反復調用read(size)方法,每次最多讀取size個字節(jié)的內容。另外,調用readline()可以每次讀取一行內容,調用readlines()一次讀取所有內容并按行返回list。因此,要根據(jù)需要決定怎么調用。

如果文件很小,read()一次性讀取最方便;如果不能確定文件大小,反復調用read(size)比較保險;如果是配置文件,調用readlines()最方便:

for line in f.readlines():print(line.strip()) # 把末尾的'\n'刪掉
file-like Object

open()函數(shù)返回的這種有個read()方法的對象,在Python中統(tǒng)稱為file-like Object。除了file外,還可以是內存的字節(jié)流,網絡流,自定義流等等。file-like Object不要求從特定類繼承,只要寫個read()方法就行。

StringIO就是在內存中創(chuàng)建的file-like Object,常用作臨時緩沖。

二進制文件

前面講的默認都是讀取文本文件,并且是UTF-8編碼的文本文件。要讀取二進制文件,比如圖片、視頻等等,用'rb'模式打開文件即可:

>>> f = open('/Users/michael/test.jpg', 'rb')
>>> f.read()
b'\xff\xd8\xff\xe1\x00\x18Exif\x00\x00...' # 十六進制表示的字節(jié)
字符編碼

要讀取非UTF-8編碼的文本文件,需要給open()函數(shù)傳入encoding參數(shù),例如,讀取GBK編碼的文件:

>>> f = open('/Users/michael/gbk.txt', 'r', encoding='gbk')
>>> f.read()
'測試'

遇到有些編碼不規(guī)范的文件,你可能會遇到UnicodeDecodeError,因為在文本文件中可能夾雜了一些非法編碼的字符。遇到這種情況,open()函數(shù)還接收一個errors參數(shù),表示如果遇到編碼錯誤后如何處理。最簡單的方式是直接忽略:

>>> f = open('/Users/michael/gbk.txt', 'r', encoding='gbk', errors='ignore')
寫文件

寫文件和讀文件是一樣的,唯一區(qū)別是調用open()函數(shù)時,傳入標識符'w'或者'wb'表示寫文本文件或寫二進制文件:

>>> f = open('/Users/michael/test.txt', 'w')
>>> f.write('Hello, world!')
>>> f.close()

你可以反復調用write()來寫入文件,但是務必要調用f.close()來關閉文件。當我們寫文件時,操作系統(tǒng)往往不會立刻把數(shù)據(jù)寫入磁盤,而是放到內存緩存起來,空閑的時候再慢慢寫入。只有調用close()方法時,操作系統(tǒng)才保證把沒有寫入的數(shù)據(jù)全部寫入磁盤。忘記調用close()的后果是數(shù)據(jù)可能只寫了一部分到磁盤,剩下的丟失了。所以,還是用with語句來得保險:

with open('/Users/michael/test.txt', 'w') as f:f.write('Hello, world!')

要寫入特定編碼的文本文件,請給open()函數(shù)傳入encoding參數(shù),將字符串自動轉換成指定編碼。

小結

在Python中,文件讀寫是通過open()函數(shù)打開的文件對象完成的。使用with語句操作文件IO是個好習慣。

參考源碼

with_file.py

StringIO和BytesIO

StringIO

很多時候,數(shù)據(jù)讀寫不一定是文件,也可以在內存中讀寫。

StringIO顧名思義就是在內存中讀寫str。

要把str寫入StringIO,我們需要先創(chuàng)建一個StringIO,然后,像文件一樣寫入即可:

>>> from io import StringIO
>>> f = StringIO()
>>> f.write('hello')
5
>>> f.write(' ')
1
>>> f.write('world!')
6
>>> print(f.getvalue())
hello world!

getvalue()方法用于獲得寫入后的str。

要讀取StringIO,可以用一個str初始化StringIO,然后,像讀文件一樣讀取:

>>> from io import StringIO
>>> f = StringIO('Hello!\nHi!\nGoodbye!')
>>> while True:
...     s = f.readline()
...     if s == '':
...         break
...     print(s.strip())
...
Hello!
Hi!
Goodbye!
BytesIO

StringIO操作的只能是str,如果要操作二進制數(shù)據(jù),就需要使用BytesIO。

BytesIO實現(xiàn)了在內存中讀寫bytes,我們創(chuàng)建一個BytesIO,然后寫入一些bytes:

>>> from io import BytesIO
>>> f = BytesIO()
>>> f.write('中文'.encode('utf-8'))
6
>>> print(f.getvalue())
b'\xe4\xb8\xad\xe6\x96\x87'

請注意,寫入的不是str,而是經過UTF-8編碼的bytes。

和StringIO類似,可以用一個bytes初始化BytesIO,然后,像讀文件一樣讀取:

>>> from io import StringIO
>>> f = BytesIO(b'\xe4\xb8\xad\xe6\x96\x87')
>>> f.read()
b'\xe4\xb8\xad\xe6\x96\x87'
小結

StringIO和BytesIO是在內存中操作str和bytes的方法,使得和讀寫文件具有一致的接口。

參考源碼

do_stringio.py

do_bytesio.py

操作文件和目錄

如果我們要操作文件、目錄,可以在命令行下面輸入操作系統(tǒng)提供的各種命令來完成。比如dir、cp等命令。

如果要在Python程序中執(zhí)行這些目錄和文件的操作怎么辦?其實操作系統(tǒng)提供的命令只是簡單地調用了操作系統(tǒng)提供的接口函數(shù),Python內置的os模塊也可以直接調用操作系統(tǒng)提供的接口函數(shù)。

打開Python交互式命令行,我們來看看如何使用os模塊的基本功能:

>>> import os
>>> os.name # 操作系統(tǒng)類型
'posix'

如果是posix,說明系統(tǒng)是Linux、UnixMac OS X,如果是nt,就是Windows系統(tǒng)。

要獲取詳細的系統(tǒng)信息,可以調用uname()函數(shù):

>>> os.uname()
posix.uname_result(sysname='Darwin', nodename='MichaelMacPro.local', release='14.3.0', version='Darwin Kernel Version 14.3.0: Mon Mar 23 11:59:05 PDT 2015; root:xnu-2782.20.48~5/RELEASE_X86_64', machine='x86_64')

注意uname()函數(shù)在Windows上不提供,也就是說,os模塊的某些函數(shù)是跟操作系統(tǒng)相關的。

環(huán)境變量

在操作系統(tǒng)中定義的環(huán)境變量,全部保存在os.environ這個變量中,可以直接查看:

>>> os.environ
environ({'VERSIONER_PYTHON_PREFER_32_BIT': 'no', 'TERM_PROGRAM_VERSION': '326', 'LOGNAME': 'michael', 'USER': 'michael', 'PATH': '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/X11/bin:/usr/local/mysql/bin', ...})

要獲取某個環(huán)境變量的值,可以調用os.environ.get('key')

>>> os.environ.get('PATH')
'/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/X11/bin:/usr/local/mysql/bin'
>>> os.environ.get('x', 'default')
'default'
操作文件和目錄

操作文件和目錄的函數(shù)一部分放在os模塊中,一部分放在os.path模塊中,這一點要注意一下。查看、創(chuàng)建和刪除目錄可以這么調用:

# 查看當前目錄的絕對路徑:
>>> os.path.abspath('.')
'/Users/michael'
# 在某個目錄下創(chuàng)建一個新目錄,首先把新目錄的完整路徑表示出來:
>>> os.path.join('/Users/michael', 'testdir')
'/Users/michael/testdir'
# 然后創(chuàng)建一個目錄:
>>> os.mkdir('/Users/michael/testdir')
# 刪掉一個目錄:
>>> os.rmdir('/Users/michael/testdir')

把兩個路徑合成一個時,不要直接拼字符串,而要通過os.path.join()函數(shù),這樣可以正確處理不同操作系統(tǒng)的路徑分隔符。在Linux/Unix/Mac下,os.path.join()返回這樣的字符串:

part-1/part-2

而Windows下會返回這樣的字符串:

part-1\part-2

同樣的道理,要拆分路徑時,也不要直接去拆字符串,而要通過os.path.split()函數(shù),這樣可以把一個路徑拆分為兩部分,后一部分總是最后級別的目錄或文件名:

>>> os.path.split('/Users/michael/testdir/file.txt')
('/Users/michael/testdir', 'file.txt')

os.path.splitext()可以直接讓你得到文件擴展名,很多時候非常方便:

>>> os.path.splitext('/path/to/file.txt')
('/path/to/file', '.txt')

這些合并、拆分路徑的函數(shù)并不要求目錄和文件要真實存在,它們只對字符串進行操作。

文件操作使用下面的函數(shù)。假定當前目錄下有一個test.txt文件:

# 對文件重命名:
>>> os.rename('test.txt', 'test.py')
# 刪掉文件:
>>> os.remove('test.py')

但是復制文件的函數(shù)居然在os模塊中不存在!原因是復制文件并非由操作系統(tǒng)提供的系統(tǒng)調用。理論上講,我們通過上一節(jié)的讀寫文件可以完成文件復制,只不過要多寫很多代碼。

幸運的是shutil模塊提供了copyfile()的函數(shù),你還可以在shutil模塊中找到很多實用函數(shù),它們可以看做是os模塊的補充。

最后看看如何利用Python的特性來過濾文件。比如我們要列出當前目錄下的所有目錄,只需要一行代碼:

>>> [x for x in os.listdir('.') if os.path.isdir(x)]
['.lein', '.local', '.m2', '.npm', '.ssh', '.Trash', '.vim', 'Applications', 'Desktop', ...]

要列出所有的.py文件,也只需一行代碼:

>>> [x for x in os.listdir('.') if os.path.isfile(x) and os.path.splitext(x)[1]=='.py']
['apis.py', 'config.py', 'models.py', 'pymonitor.py', 'test_db.py', 'urls.py', 'wsgiapp.py']

是不是非常簡潔?

小結

Python的os模塊封裝了操作系統(tǒng)的目錄和文件操作,要注意這些函數(shù)有的在os模塊中,有的在os.path模塊中。

練習
  1. 利用os模塊編寫一個能實現(xiàn)dir -l輸出的程序。

  2. 編寫一個程序,能在當前目錄以及當前目錄的所有子目錄下查找文件名包含指定字符串的文件,并打印出相對路徑。

參考源碼

do_dir

序列化

在程序運行的過程中,所有的變量都是在內存中,比如,定義一個dict:

d = dict(name='Bob', age=20, score=88)

可以隨時修改變量,比如把name改成'Bill',但是一旦程序結束,變量所占用的內存就被操作系統(tǒng)全部回收。如果沒有把修改后的'Bill'存儲到磁盤上,下次重新運行程序,變量又被初始化為'Bob'

我們把變量從內存中變成可存儲或傳輸?shù)倪^程稱之為序列化,在Python中叫pickling,在其他語言中也被稱之為serialization,marshalling,flattening等等,都是一個意思。

序列化之后,就可以把序列化后的內容寫入磁盤,或者通過網絡傳輸?shù)絼e的機器上。

反過來,把變量內容從序列化的對象重新讀到內存里稱之為反序列化,即unpickling。

Python提供了pickle模塊來實現(xiàn)序列化。

首先,我們嘗試把一個對象序列化并寫入文件:

>>> import pickle
>>> d = dict(name='Bob', age=20, score=88)
>>> pickle.dumps(d)
b'\x80\x03}q\x00(X\x03\x00\x00\x00ageq\x01K\x14X\x05\x00\x00\x00scoreq\x02KXX\x04\x00\x00\x00nameq\x03X\x03\x00\x00\x00Bobq\x04u.'

pickle.dumps()方法把任意對象序列化成一個bytes,然后,就可以把這個bytes寫入文件?;蛘哂昧硪粋€方法pickle.dump()直接把對象序列化后寫入一個file-like Object:

>>> f = open('dump.txt', 'wb')
>>> pickle.dump(d, f)
>>> f.close()

看看寫入的dump.txt文件,一堆亂七八糟的內容,這些都是Python保存的對象內部信息。

當我們要把對象從磁盤讀到內存時,可以先把內容讀到一個bytes,然后用pickle.loads()方法反序列化出對象,也可以直接用pickle.load()方法從一個file-like Object中直接反序列化出對象。我們打開另一個Python命令行來反序列化剛才保存的對象:

>>> f = open('dump.txt', 'rb')
>>> d = pickle.load(f)
>>> f.close()
>>> d
{'age': 20, 'score': 88, 'name': 'Bob'}

變量的內容又回來了!

當然,這個變量和原來的變量是完全不相干的對象,它們只是內容相同而已。

Pickle的問題和所有其他編程語言特有的序列化問題一樣,就是它只能用于Python,并且可能不同版本的Python彼此都不兼容,因此,只能用Pickle保存那些不重要的數(shù)據(jù),不能成功地反序列化也沒關系。

JSON

如果我們要在不同的編程語言之間傳遞對象,就必須把對象序列化為標準格式,比如XML,但更好的方法是序列化為JSON,因為JSON表示出來就是一個字符串,可以被所有語言讀取,也可以方便地存儲到磁盤或者通過網絡傳輸。JSON不僅是標準格式,并且比XML更快,而且可以直接在Web頁面中讀取,非常方便。

JSON表示的對象就是標準的JavaScript語言的對象,JSON和Python內置的數(shù)據(jù)類型對應如下:

JSON類型Python類型
{}dict
[]list
"string"str
1234.56int或float
true/falseTrue/False
nullNone

Python內置的json模塊提供了非常完善的Python對象到JSON格式的轉換。我們先看看如何把Python對象變成一個JSON:

>>> import json
>>> d = dict(name='Bob', age=20, score=88)
>>> json.dumps(d)
'{"age": 20, "score": 88, "name": "Bob"}'

dumps()方法返回一個str,內容就是標準的JSON。類似的,dump()方法可以直接把JSON寫入一個file-like Object。

要把JSON反序列化為Python對象,用loads()或者對應的load()方法,前者把JSON的字符串反序列化,后者從file-like Object中讀取字符串并反序列化:

>>> json_str = '{"age": 20, "score": 88, "name": "Bob"}'
>>> json.loads(json_str)
{'age': 20, 'score': 88, 'name': 'Bob'}

由于JSON標準規(guī)定JSON編碼是UTF-8,所以我們總是能正確地在Python的str與JSON的字符串之間轉換。

JSON進階

Python的dict對象可以直接序列化為JSON的{},不過,很多時候,我們更喜歡用class表示對象,比如定義Student類,然后序列化:

import jsonclass Student(object):def __init__(self, name, age, score):self.name = nameself.age = ageself.score = scores = Student('Bob', 20, 88)
print(json.dumps(s))

運行代碼,毫不留情地得到一個TypeError

Traceback (most recent call last):...
TypeError: <__main__.Student object at 0x10603cc50> is not JSON serializable

錯誤的原因是Student對象不是一個可序列化為JSON的對象。

如果連class的實例對象都無法序列化為JSON,這肯定不合理!

別急,我們仔細看看dumps()方法的參數(shù)列表,可以發(fā)現(xiàn),除了第一個必須的obj參數(shù)外,dumps()方法還提供了一大堆的可選參數(shù):

https://docs.python.org/3/library/json.html#json.dumps

這些可選參數(shù)就是讓我們來定制JSON序列化。前面的代碼之所以無法把Student類實例序列化為JSON,是因為默認情況下,dumps()方法不知道如何將Student實例變?yōu)橐粋€JSON的{}對象。

可選參數(shù)default就是把任意一個對象變成一個可序列為JSON的對象,我們只需要為Student專門寫一個轉換函數(shù),再把函數(shù)傳進去即可:

def student2dict(std):return {'name': std.name,'age': std.age,'score': std.score}

這樣,Student實例首先被student2dict()函數(shù)轉換成dict,然后再被順利序列化為JSON:

>>> print(json.dumps(s, default=student2dict))
{"age": 20, "name": "Bob", "score": 88}

不過,下次如果遇到一個Teacher類的實例,照樣無法序列化為JSON。我們可以偷個懶,把任意class的實例變?yōu)?code>dict:

print(json.dumps(s, default=lambda obj: obj.__dict__))

因為通常class的實例都有一個__dict__屬性,它就是一個dict,用來存儲實例變量。也有少數(shù)例外,比如定義了__slots__的class。

同樣的道理,如果我們要把JSON反序列化為一個Student對象實例,loads()方法首先轉換出一個dict對象,然后,我們傳入的object_hook函數(shù)負責把dict轉換為Student實例:

def dict2student(d):return Student(d['name'], d['age'], d['score'])

運行結果如下:

>>> json_str = '{"age": 20, "score": 88, "name": "Bob"}'
>>> print(json.loads(json_str, object_hook=dict2student))
<__main__.Student object at 0x10cd3c190>

打印出的是反序列化的Student實例對象。

小結

Python語言特定的序列化模塊是pickle,但如果要把序列化搞得更通用、更符合Web標準,就可以使用json模塊。

json模塊的dumps()loads()函數(shù)是定義得非常好的接口的典范。當我們使用時,只需要傳入一個必須的參數(shù)。但是,當默認的序列化或反序列機制不滿足我們的要求時,我們又可以傳入更多的參數(shù)來定制序列化或反序列化的規(guī)則,既做到了接口簡單易用,又做到了充分的擴展性和靈活性。

參考源碼

use_pickle.py

use_json.py

進程和線程

很多同學都聽說過,現(xiàn)代操作系統(tǒng)比如Mac OS X,UNIX,Linux,Windows等,都是支持“多任務”的操作系統(tǒng)。

什么叫“多任務”呢?簡單地說,就是操作系統(tǒng)可以同時運行多個任務。打個比方,你一邊在用瀏覽器上網,一邊在聽MP3,一邊在用Word趕作業(yè),這就是多任務,至少同時有3個任務正在運行。還有很多任務悄悄地在后臺同時運行著,只是桌面上沒有顯示而已。

現(xiàn)在,多核CPU已經非常普及了,但是,即使過去的單核CPU,也可以執(zhí)行多任務。由于CPU執(zhí)行代碼都是順序執(zhí)行的,那么,單核CPU是怎么執(zhí)行多任務的呢?

答案就是操作系統(tǒng)輪流讓各個任務交替執(zhí)行,任務1執(zhí)行0.01秒,切換到任務2,任務2執(zhí)行0.01秒,再切換到任務3,執(zhí)行0.01秒……這樣反復執(zhí)行下去。表面上看,每個任務都是交替執(zhí)行的,但是,由于CPU的執(zhí)行速度實在是太快了,我們感覺就像所有任務都在同時執(zhí)行一樣。

真正的并行執(zhí)行多任務只能在多核CPU上實現(xiàn),但是,由于任務數(shù)量遠遠多于CPU的核心數(shù)量,所以,操作系統(tǒng)也會自動把很多任務輪流調度到每個核心上執(zhí)行。

對于操作系統(tǒng)來說,一個任務就是一個進程(Process),比如打開一個瀏覽器就是啟動一個瀏覽器進程,打開一個記事本就啟動了一個記事本進程,打開兩個記事本就啟動了兩個記事本進程,打開一個Word就啟動了一個Word進程。

有些進程還不止同時干一件事,比如Word,它可以同時進行打字、拼寫檢查、打印等事情。在一個進程內部,要同時干多件事,就需要同時運行多個“子任務”,我們把進程內的這些“子任務”稱為線程(Thread)。

由于每個進程至少要干一件事,所以,一個進程至少有一個線程。當然,像Word這種復雜的進程可以有多個線程,多個線程可以同時執(zhí)行,多線程的執(zhí)行方式和多進程是一樣的,也是由操作系統(tǒng)在多個線程之間快速切換,讓每個線程都短暫地交替運行,看起來就像同時執(zhí)行一樣。當然,真正地同時執(zhí)行多線程需要多核CPU才可能實現(xiàn)。

我們前面編寫的所有的Python程序,都是執(zhí)行單任務的進程,也就是只有一個線程。如果我們要同時執(zhí)行多個任務怎么辦?

有兩種解決方案:

一種是啟動多個進程,每個進程雖然只有一個線程,但多個進程可以一塊執(zhí)行多個任務。

還有一種方法是啟動一個進程,在一個進程內啟動多個線程,這樣,多個線程也可以一塊執(zhí)行多個任務。

當然還有第三種方法,就是啟動多個進程,每個進程再啟動多個線程,這樣同時執(zhí)行的任務就更多了,當然這種模型更復雜,實際很少采用。

總結一下就是,多任務的實現(xiàn)有3種方式:

  • 多進程模式;
  • 多線程模式;
  • 多進程+多線程模式。

同時執(zhí)行多個任務通常各個任務之間并不是沒有關聯(lián)的,而是需要相互通信和協(xié)調,有時,任務1必須暫停等待任務2完成后才能繼續(xù)執(zhí)行,有時,任務3和任務4又不能同時執(zhí)行,所以,多進程和多線程的程序的復雜度要遠遠高于我們前面寫的單進程單線程的程序。

因為復雜度高,調試困難,所以,不是迫不得已,我們也不想編寫多任務。但是,有很多時候,沒有多任務還真不行。想想在電腦上看電影,就必須由一個線程播放視頻,另一個線程播放音頻,否則,單線程實現(xiàn)的話就只能先把視頻播放完再播放音頻,或者先把音頻播放完再播放視頻,這顯然是不行的。

Python既支持多進程,又支持多線程,我們會討論如何編寫這兩種多任務程序。

小結

線程是最小的執(zhí)行單元,而進程由至少一個線程組成。如何調度進程和線程,完全由操作系統(tǒng)決定,程序自己不能決定什么時候執(zhí)行,執(zhí)行多長時間。

多進程和多線程的程序涉及到同步、數(shù)據(jù)共享的問題,編寫起來更復雜。

多進程

要讓Python程序實現(xiàn)多進程(multiprocessing),我們先了解操作系統(tǒng)的相關知識。

Unix/Linux操作系統(tǒng)提供了一個fork()系統(tǒng)調用,它非常特殊。普通的函數(shù)調用,調用一次,返回一次,但是fork()調用一次,返回兩次,因為操作系統(tǒng)自動把當前進程(稱為父進程)復制了一份(稱為子進程),然后,分別在父進程和子進程內返回。

子進程永遠返回0,而父進程返回子進程的ID。這樣做的理由是,一個父進程可以fork出很多子進程,所以,父進程要記下每個子進程的ID,而子進程只需要調用getppid()就可以拿到父進程的ID。

Python的os模塊封裝了常見的系統(tǒng)調用,其中就包括fork,可以在Python程序中輕松創(chuàng)建子進程:

import osprint('Process (%s) start...' % os.getpid())
# Only works on Unix/Linux/Mac:
pid = os.fork()
if pid == 0:print('I am child process (%s) and my parent is %s.' % (os.getpid(), os.getppid()))
else:print('I (%s) just created a child process (%s).' % (os.getpid(), pid))

運行結果如下:

Process (876) start...
I (876) just created a child process (877).
I am child process (877) and my parent is 876.

由于Windows沒有fork調用,上面的代碼在Windows上無法運行。由于Mac系統(tǒng)是基于BSD(Unix的一種)內核,所以,在Mac下運行是沒有問題的,推薦大家用Mac學Python!

有了fork調用,一個進程在接到新任務時就可以復制出一個子進程來處理新任務,常見的Apache服務器就是由父進程監(jiān)聽端口,每當有新的http請求時,就fork出子進程來處理新的http請求。

multiprocessing

如果你打算編寫多進程的服務程序,Unix/Linux無疑是正確的選擇。由于Windows沒有fork調用,難道在Windows上無法用Python編寫多進程的程序?

由于Python是跨平臺的,自然也應該提供一個跨平臺的多進程支持。multiprocessing模塊就是跨平臺版本的多進程模塊。

multiprocessing模塊提供了一個Process類來代表一個進程對象,下面的例子演示了啟動一個子進程并等待其結束:

from multiprocessing import Process
import os# 子進程要執(zhí)行的代碼
def run_proc(name):print('Run child process %s (%s)...' % (name, os.getpid()))if __name__=='__main__':print('Parent process %s.' % os.getpid())p = Process(target=run_proc, args=('test',))print('Child process will start.')p.start()p.join()print('Child process end.')

執(zhí)行結果如下:

Parent process 928.
Process will start.
Run child process test (929)...
Process end.

創(chuàng)建子進程時,只需要傳入一個執(zhí)行函數(shù)和函數(shù)的參數(shù),創(chuàng)建一個Process實例,用start()方法啟動,這樣創(chuàng)建進程比fork()還要簡單。

join()方法可以等待子進程結束后再繼續(xù)往下運行,通常用于進程間的同步。

Pool

如果要啟動大量的子進程,可以用進程池的方式批量創(chuàng)建子進程:

from multiprocessing import Pool
import os, time, randomdef long_time_task(name):print('Run task %s (%s)...' % (name, os.getpid()))start = time.time()time.sleep(random.random() * 3)end = time.time()print('Task %s runs %0.2f seconds.' % (name, (end - start)))if __name__=='__main__':print('Parent process %s.' % os.getpid())p = Pool(4)for i in range(5):p.apply_async(long_time_task, args=(i,))print('Waiting for all subprocesses done...')p.close()p.join()print('All subprocesses done.')

執(zhí)行結果如下:

Parent process 669.
Waiting for all subprocesses done...
Run task 0 (671)...
Run task 1 (672)...
Run task 2 (673)...
Run task 3 (674)...
Task 2 runs 0.14 seconds.
Run task 4 (673)...
Task 1 runs 0.27 seconds.
Task 3 runs 0.86 seconds.
Task 0 runs 1.41 seconds.
Task 4 runs 1.91 seconds.
All subprocesses done.

代碼解讀:

Pool對象調用join()方法會等待所有子進程執(zhí)行完畢,調用join()之前必須先調用close(),調用close()之后就不能繼續(xù)添加新的Process了。

請注意輸出的結果,task?0123是立刻執(zhí)行的,而task?4要等待前面某個task完成后才執(zhí)行,這是因為Pool的默認大小在我的電腦上是4,因此,最多同時執(zhí)行4個進程。這是Pool有意設計的限制,并不是操作系統(tǒng)的限制。如果改成:

p = Pool(5)

就可以同時跑5個進程。

由于Pool的默認大小是CPU的核數(shù),如果你不幸擁有8核CPU,你要提交至少9個子進程才能看到上面的等待效果。

子進程

很多時候,子進程并不是自身,而是一個外部進程。我們創(chuàng)建了子進程后,還需要控制子進程的輸入和輸出。

subprocess模塊可以讓我們非常方便地啟動一個子進程,然后控制其輸入和輸出。

下面的例子演示了如何在Python代碼中運行命令nslookup www.python.org,這和命令行直接運行的效果是一樣的:

import subprocessprint('$ nslookup www.python.org')
r = subprocess.call(['nslookup', 'www.python.org'])
print('Exit code:', r)

運行結果:

$ nslookup www.python.org
Server:        192.168.19.4
Address:    192.168.19.4#53Non-authoritative answer:
www.python.org    canonical name = python.map.fastly.net.
Name:    python.map.fastly.net
Address: 199.27.79.223Exit code: 0

如果子進程還需要輸入,則可以通過communicate()方法輸入:

import subprocessprint('$ nslookup')
p = subprocess.Popen(['nslookup'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, err = p.communicate(b'set q=mx\npython.org\nexit\n')
print(output.decode('utf-8'))
print('Exit code:', p.returncode)

上面的代碼相當于在命令行執(zhí)行命令nslookup,然后手動輸入:

set q=mx
python.org
exit

運行結果如下:

$ nslookup
Server:        192.168.19.4
Address:    192.168.19.4#53Non-authoritative answer:
python.org    mail exchanger = 50 mail.python.org.Authoritative answers can be found from:
mail.python.org    internet address = 82.94.164.166
mail.python.org    has AAAA address 2001:888:2000:d::a6Exit code: 0
進程間通信

Process之間肯定是需要通信的,操作系統(tǒng)提供了很多機制來實現(xiàn)進程間的通信。Python的multiprocessing模塊包裝了底層的機制,提供了Queue、Pipes等多種方式來交換數(shù)據(jù)。

我們以Queue為例,在父進程中創(chuàng)建兩個子進程,一個往Queue里寫數(shù)據(jù),一個從Queue里讀數(shù)據(jù):

from multiprocessing import Process, Queue
import os, time, random# 寫數(shù)據(jù)進程執(zhí)行的代碼:
def write(q):print('Process to write: %s' % os.getpid())for value in ['A', 'B', 'C']:print('Put %s to queue...' % value)q.put(value)time.sleep(random.random())# 讀數(shù)據(jù)進程執(zhí)行的代碼:
def read(q):print('Process to read: %s' % os.getpid())while True:value = q.get(True)print('Get %s from queue.' % value)if __name__=='__main__':# 父進程創(chuàng)建Queue,并傳給各個子進程:q = Queue()pw = Process(target=write, args=(q,))pr = Process(target=read, args=(q,))# 啟動子進程pw,寫入:pw.start()# 啟動子進程pr,讀取:pr.start()# 等待pw結束:pw.join()# pr進程里是死循環(huán),無法等待其結束,只能強行終止:pr.terminate()

運行結果如下:

Process to write: 50563
Put A to queue...
Process to read: 50564
Get A from queue.
Put B to queue...
Get B from queue.
Put C to queue...
Get C from queue.

在Unix/Linux下,multiprocessing模塊封裝了fork()調用,使我們不需要關注fork()的細節(jié)。由于Windows沒有fork調用,因此,multiprocessing需要“模擬”出fork的效果,父進程所有Python對象都必須通過pickle序列化再傳到子進程去,所有,如果multiprocessing在Windows下調用失敗了,要先考慮是不是pickle失敗了。

小結

在Unix/Linux下,可以使用fork()調用實現(xiàn)多進程。

要實現(xiàn)跨平臺的多進程,可以使用multiprocessing模塊。

進程間通信是通過Queue、Pipes等實現(xiàn)的。

參考源碼

do_folk.py

multi_processing.py

pooled_processing.py

do_subprocess.py

do_queue.py

多線程

多任務可以由多進程完成,也可以由一個進程內的多線程完成。

我們前面提到了進程是由若干線程組成的,一個進程至少有一個線程。

由于線程是操作系統(tǒng)直接支持的執(zhí)行單元,因此,高級語言通常都內置多線程的支持,Python也不例外,并且,Python的線程是真正的Posix Thread,而不是模擬出來的線程。

Python的標準庫提供了兩個模塊:_threadthreading_thread是低級模塊,threading是高級模塊,對_thread進行了封裝。絕大多數(shù)情況下,我們只需要使用threading這個高級模塊。

啟動一個線程就是把一個函數(shù)傳入并創(chuàng)建Thread實例,然后調用start()開始執(zhí)行:

import time, threading# 新線程執(zhí)行的代碼:
def loop():print('thread %s is running...' % threading.current_thread().name)n = 0while n < 5:n = n + 1print('thread %s >>> %s' % (threading.current_thread().name, n))time.sleep(1)print('thread %s ended.' % threading.current_thread().name)print('thread %s is running...' % threading.current_thread().name)
t = threading.Thread(target=loop, name='LoopThread')
t.start()
t.join()
print('thread %s ended.' % threading.current_thread().name)

執(zhí)行結果如下:

thread MainThread is running...
thread LoopThread is running...
thread LoopThread >>> 1
thread LoopThread >>> 2
thread LoopThread >>> 3
thread LoopThread >>> 4
thread LoopThread >>> 5
thread LoopThread ended.
thread MainThread ended.

由于任何進程默認就會啟動一個線程,我們把該線程稱為主線程,主線程又可以啟動新的線程,Python的threading模塊有個current_thread()函數(shù),它永遠返回當前線程的實例。主線程實例的名字叫MainThread,子線程的名字在創(chuàng)建時指定,我們用LoopThread命名子線程。名字僅僅在打印時用來顯示,完全沒有其他意義,如果不起名字Python就自動給線程命名為Thread-1Thread-2……

Lock

多線程和多進程最大的不同在于,多進程中,同一個變量,各自有一份拷貝存在于每個進程中,互不影響,而多線程中,所有變量都由所有線程共享,所以,任何一個變量都可以被任何一個線程修改,因此,線程之間共享數(shù)據(jù)最大的危險在于多個線程同時改一個變量,把內容給改亂了。

來看看多個線程同時操作一個變量怎么把內容給改亂了:

import time, threading# 假定這是你的銀行存款:
balance = 0def change_it(n):# 先存后取,結果應該為0:global balancebalance = balance + nbalance = balance - ndef run_thread(n):for i in range(100000):change_it(n)t1 = threading.Thread(target=run_thread, args=(5,))
t2 = threading.Thread(target=run_thread, args=(8,))
t1.start()
t2.start()
t1.join()
t2.join()
print(balance)

我們定義了一個共享變量balance,初始值為0,并且啟動兩個線程,先存后取,理論上結果應該為0,但是,由于線程的調度是由操作系統(tǒng)決定的,當t1、t2交替執(zhí)行時,只要循環(huán)次數(shù)足夠多,balance的結果就不一定是0了。

原因是因為高級語言的一條語句在CPU執(zhí)行時是若干條語句,即使一個簡單的計算:

balance = balance + n

也分兩步:

  1. 計算balance + n,存入臨時變量中;
  2. 將臨時變量的值賦給balance

也就是可以看成:

x = balance + n
balance = x

由于x是局部變量,兩個線程各自都有自己的x,當代碼正常執(zhí)行時:

初始值 balance = 0t1: x1 = balance + 5 # x1 = 0 + 5 = 5
t1: balance = x1     # balance = 5
t1: x1 = balance - 5 # x1 = 5 - 5 = 0
t1: balance = x1     # balance = 0t2: x2 = balance + 8 # x2 = 0 + 8 = 8
t2: balance = x2     # balance = 8
t2: x2 = balance - 8 # x2 = 8 - 8 = 0
t2: balance = x2     # balance = 0結果 balance = 0

但是t1和t2是交替運行的,如果操作系統(tǒng)以下面的順序執(zhí)行t1、t2:

初始值 balance = 0t1: x1 = balance + 5  # x1 = 0 + 5 = 5t2: x2 = balance + 8  # x2 = 0 + 8 = 8
t2: balance = x2      # balance = 8t1: balance = x1      # balance = 5
t1: x1 = balance - 5  # x1 = 5 - 5 = 0
t1: balance = x1      # balance = 0t2: x2 = balance - 8  # x2 = 0 - 8 = -8
t2: balance = x2   # balance = -8結果 balance = -8

究其原因,是因為修改balance需要多條語句,而執(zhí)行這幾條語句時,線程可能中斷,從而導致多個線程把同一個對象的內容改亂了。

兩個線程同時一存一取,就可能導致余額不對,你肯定不希望你的銀行存款莫名其妙地變成了負數(shù),所以,我們必須確保一個線程在修改balance的時候,別的線程一定不能改。

如果我們要確保balance計算正確,就要給change_it()上一把鎖,當某個線程開始執(zhí)行change_it()時,我們說,該線程因為獲得了鎖,因此其他線程不能同時執(zhí)行change_it(),只能等待,直到鎖被釋放后,獲得該鎖以后才能改。由于鎖只有一個,無論多少線程,同一時刻最多只有一個線程持有該鎖,所以,不會造成修改的沖突。創(chuàng)建一個鎖就是通過threading.Lock()來實現(xiàn):

balance = 0
lock = threading.Lock()def run_thread(n):for i in range(100000):# 先要獲取鎖:lock.acquire()try:# 放心地改吧:change_it(n)finally:# 改完了一定要釋放鎖:lock.release()

當多個線程同時執(zhí)行lock.acquire()時,只有一個線程能成功地獲取鎖,然后繼續(xù)執(zhí)行代碼,其他線程就繼續(xù)等待直到獲得鎖為止。

獲得鎖的線程用完后一定要釋放鎖,否則那些苦苦等待鎖的線程將永遠等待下去,成為死線程。所以我們用try...finally來確保鎖一定會被釋放。

鎖的好處就是確保了某段關鍵代碼只能由一個線程從頭到尾完整地執(zhí)行,壞處當然也很多,首先是阻止了多線程并發(fā)執(zhí)行,包含鎖的某段代碼實際上只能以單線程模式執(zhí)行,效率就大大地下降了。其次,由于可以存在多個鎖,不同的線程持有不同的鎖,并試圖獲取對方持有的鎖時,可能會造成死鎖,導致多個線程全部掛起,既不能執(zhí)行,也無法結束,只能靠操作系統(tǒng)強制終止。

多核CPU

如果你不幸擁有一個多核CPU,你肯定在想,多核應該可以同時執(zhí)行多個線程。

如果寫一個死循環(huán)的話,會出現(xiàn)什么情況呢?

打開Mac OS X的Activity Monitor,或者Windows的Task Manager,都可以監(jiān)控某個進程的CPU使用率。

我們可以監(jiān)控到一個死循環(huán)線程會100%占用一個CPU。

如果有兩個死循環(huán)線程,在多核CPU中,可以監(jiān)控到會占用200%的CPU,也就是占用兩個CPU核心。

要想把N核CPU的核心全部跑滿,就必須啟動N個死循環(huán)線程。

試試用Python寫個死循環(huán):

import threading, multiprocessingdef loop():x = 0while True:x = x ^ 1for i in range(multiprocessing.cpu_count()):t = threading.Thread(target=loop)t.start()

啟動與CPU核心數(shù)量相同的N個線程,在4核CPU上可以監(jiān)控到CPU占用率僅有102%,也就是僅使用了一核。

但是用C、C++或Java來改寫相同的死循環(huán),直接可以把全部核心跑滿,4核就跑到400%,8核就跑到800%,為什么Python不行呢?

因為Python的線程雖然是真正的線程,但解釋器執(zhí)行代碼時,有一個GIL鎖:Global Interpreter Lock,任何Python線程執(zhí)行前,必須先獲得GIL鎖,然后,每執(zhí)行100條字節(jié)碼,解釋器就自動釋放GIL鎖,讓別的線程有機會執(zhí)行。這個GIL全局鎖實際上把所有線程的執(zhí)行代碼都給上了鎖,所以,多線程在Python中只能交替執(zhí)行,即使100個線程跑在100核CPU上,也只能用到1個核。

GIL是Python解釋器設計的歷史遺留問題,通常我們用的解釋器是官方實現(xiàn)的CPython,要真正利用多核,除非重寫一個不帶GIL的解釋器。

所以,在Python中,可以使用多線程,但不要指望能有效利用多核。如果一定要通過多線程利用多核,那只能通過C擴展來實現(xiàn),不過這樣就失去了Python簡單易用的特點。

不過,也不用過于擔心,Python雖然不能利用多線程實現(xiàn)多核任務,但可以通過多進程實現(xiàn)多核任務。多個Python進程有各自獨立的GIL鎖,互不影響。

小結

多線程編程,模型復雜,容易發(fā)生沖突,必須用鎖加以隔離,同時,又要小心死鎖的發(fā)生。

Python解釋器由于設計時有GIL全局鎖,導致了多線程無法利用多核。多線程的并發(fā)在Python中就是一個美麗的夢。

參考源碼

multi_threading.py

do_lock.py

ThreadLocal

在多線程環(huán)境下,每個線程都有自己的數(shù)據(jù)。一個線程使用自己的局部變量比使用全局變量好,因為局部變量只有線程自己能看見,不會影響其他線程,而全局變量的修改必須加鎖。

但是局部變量也有問題,就是在函數(shù)調用的時候,傳遞起來很麻煩:

def process_student(name):std = Student(name)# std是局部變量,但是每個函數(shù)都要用它,因此必須傳進去:do_task_1(std)do_task_2(std)def do_task_1(std):do_subtask_1(std)do_subtask_2(std)def do_task_2(std):do_subtask_2(std)do_subtask_2(std)

每個函數(shù)一層一層調用都這么傳參數(shù)那還得了?用全局變量?也不行,因為每個線程處理不同的Student對象,不能共享。

如果用一個全局dict存放所有的Student對象,然后以thread自身作為key獲得線程對應的Student對象如何?

global_dict = {}def std_thread(name):std = Student(name)# 把std放到全局變量global_dict中:global_dict[threading.current_thread()] = stddo_task_1()do_task_2()def do_task_1():# 不傳入std,而是根據(jù)當前線程查找:std = global_dict[threading.current_thread()]...def do_task_2():# 任何函數(shù)都可以查找出當前線程的std變量:std = global_dict[threading.current_thread()]...

這種方式理論上是可行的,它最大的優(yōu)點是消除了std對象在每層函數(shù)中的傳遞問題,但是,每個函數(shù)獲取std的代碼有點丑。

有沒有更簡單的方式?

ThreadLocal應運而生,不用查找dictThreadLocal幫你自動做這件事:

import threading# 創(chuàng)建全局ThreadLocal對象:
local_school = threading.local()def process_student():# 獲取當前線程關聯(lián)的student:std = local_school.studentprint('Hello, %s (in %s)' % (std, threading.current_thread().name))def process_thread(name):# 綁定ThreadLocal的student:local_school.student = nameprocess_student()t1 = threading.Thread(target= process_thread, args=('Alice',), name='Thread-A')
t2 = threading.Thread(target= process_thread, args=('Bob',), name='Thread-B')
t1.start()
t2.start()
t1.join()
t2.join()

執(zhí)行結果:

Hello, Alice (in Thread-A)
Hello, Bob (in Thread-B)

全局變量local_school就是一個ThreadLocal對象,每個Thread對它都可以讀寫student屬性,但互不影響。你可以把local_school看成全局變量,但每個屬性如local_school.student都是線程的局部變量,可以任意讀寫而互不干擾,也不用管理鎖的問題,ThreadLocal內部會處理。

可以理解為全局變量local_school是一個dict,不但可以用local_school.student,還可以綁定其他變量,如local_school.teacher等等。

ThreadLocal最常用的地方就是為每個線程綁定一個數(shù)據(jù)庫連接,HTTP請求,用戶身份信息等,這樣一個線程的所有調用到的處理函數(shù)都可以非常方便地訪問這些資源。

小結

一個ThreadLocal變量雖然是全局變量,但每個線程都只能讀寫自己線程的獨立副本,互不干擾。ThreadLocal解決了參數(shù)在一個線程中各個函數(shù)之間互相傳遞的問題。

參考源碼

use_threadlocal.py

進程 vs. 線程

我們介紹了多進程和多線程,這是實現(xiàn)多任務最常用的兩種方式?,F(xiàn)在,我們來討論一下這兩種方式的優(yōu)缺點。

首先,要實現(xiàn)多任務,通常我們會設計Master-Worker模式,Master負責分配任務,Worker負責執(zhí)行任務,因此,多任務環(huán)境下,通常是一個Master,多個Worker。

如果用多進程實現(xiàn)Master-Worker,主進程就是Master,其他進程就是Worker。

如果用多線程實現(xiàn)Master-Worker,主線程就是Master,其他線程就是Worker。

多進程模式最大的優(yōu)點就是穩(wěn)定性高,因為一個子進程崩潰了,不會影響主進程和其他子進程。(當然主進程掛了所有進程就全掛了,但是Master進程只負責分配任務,掛掉的概率低)著名的Apache最早就是采用多進程模式。

多進程模式的缺點是創(chuàng)建進程的代價大,在Unix/Linux系統(tǒng)下,用fork調用還行,在Windows下創(chuàng)建進程開銷巨大。另外,操作系統(tǒng)能同時運行的進程數(shù)也是有限的,在內存和CPU的限制下,如果有幾千個進程同時運行,操作系統(tǒng)連調度都會成問題。

多線程模式通常比多進程快一點,但是也快不到哪去,而且,多線程模式致命的缺點就是任何一個線程掛掉都可能直接造成整個進程崩潰,因為所有線程共享進程的內存。在Windows上,如果一個線程執(zhí)行的代碼出了問題,你經??梢钥吹竭@樣的提示:“該程序執(zhí)行了非法操作,即將關閉”,其實往往是某個線程出了問題,但是操作系統(tǒng)會強制結束整個進程。

在Windows下,多線程的效率比多進程要高,所以微軟的IIS服務器默認采用多線程模式。由于多線程存在穩(wěn)定性的問題,IIS的穩(wěn)定性就不如Apache。為了緩解這個問題,IIS和Apache現(xiàn)在又有多進程+多線程的混合模式,真是把問題越搞越復雜。

線程切換

無論是多進程還是多線程,只要數(shù)量一多,效率肯定上不去,為什么呢?

我們打個比方,假設你不幸正在準備中考,每天晚上需要做語文、數(shù)學、英語、物理、化學這5科的作業(yè),每項作業(yè)耗時1小時。

如果你先花1小時做語文作業(yè),做完了,再花1小時做數(shù)學作業(yè),這樣,依次全部做完,一共花5小時,這種方式稱為單任務模型,或者批處理任務模型。

假設你打算切換到多任務模型,可以先做1分鐘語文,再切換到數(shù)學作業(yè),做1分鐘,再切換到英語,以此類推,只要切換速度足夠快,這種方式就和單核CPU執(zhí)行多任務是一樣的了,以幼兒園小朋友的眼光來看,你就正在同時寫5科作業(yè)。

但是,切換作業(yè)是有代價的,比如從語文切到數(shù)學,要先收拾桌子上的語文書本、鋼筆(這叫保存現(xiàn)場),然后,打開數(shù)學課本、找出圓規(guī)直尺(這叫準備新環(huán)境),才能開始做數(shù)學作業(yè)。操作系統(tǒng)在切換進程或者線程時也是一樣的,它需要先保存當前執(zhí)行的現(xiàn)場環(huán)境(CPU寄存器狀態(tài)、內存頁等),然后,把新任務的執(zhí)行環(huán)境準備好(恢復上次的寄存器狀態(tài),切換內存頁等),才能開始執(zhí)行。這個切換過程雖然很快,但是也需要耗費時間。如果有幾千個任務同時進行,操作系統(tǒng)可能就主要忙著切換任務,根本沒有多少時間去執(zhí)行任務了,這種情況最常見的就是硬盤狂響,點窗口無反應,系統(tǒng)處于假死狀態(tài)。

所以,多任務一旦多到一個限度,就會消耗掉系統(tǒng)所有的資源,結果效率急劇下降,所有任務都做不好。

計算密集型 vs. IO密集型

是否采用多任務的第二個考慮是任務的類型。我們可以把任務分為計算密集型和IO密集型。

計算密集型任務的特點是要進行大量的計算,消耗CPU資源,比如計算圓周率、對視頻進行高清解碼等等,全靠CPU的運算能力。這種計算密集型任務雖然也可以用多任務完成,但是任務越多,花在任務切換的時間就越多,CPU執(zhí)行任務的效率就越低,所以,要最高效地利用CPU,計算密集型任務同時進行的數(shù)量應當?shù)扔贑PU的核心數(shù)。

計算密集型任務由于主要消耗CPU資源,因此,代碼運行效率至關重要。Python這樣的腳本語言運行效率很低,完全不適合計算密集型任務。對于計算密集型任務,最好用C語言編寫。

第二種任務的類型是IO密集型,涉及到網絡、磁盤IO的任務都是IO密集型任務,這類任務的特點是CPU消耗很少,任務的大部分時間都在等待IO操作完成(因為IO的速度遠遠低于CPU和內存的速度)。對于IO密集型任務,任務越多,CPU效率越高,但也有一個限度。常見的大部分任務都是IO密集型任務,比如Web應用。

IO密集型任務執(zhí)行期間,99%的時間都花在IO上,花在CPU上的時間很少,因此,用運行速度極快的C語言替換用Python這樣運行速度極低的腳本語言,完全無法提升運行效率。對于IO密集型任務,最合適的語言就是開發(fā)效率最高(代碼量最少)的語言,腳本語言是首選,C語言最差。

異步IO

考慮到CPU和IO之間巨大的速度差異,一個任務在執(zhí)行的過程中大部分時間都在等待IO操作,單進程單線程模型會導致別的任務無法并行執(zhí)行,因此,我們才需要多進程模型或者多線程模型來支持多任務并發(fā)執(zhí)行。

現(xiàn)代操作系統(tǒng)對IO操作已經做了巨大的改進,最大的特點就是支持異步IO。如果充分利用操作系統(tǒng)提供的異步IO支持,就可以用單進程單線程模型來執(zhí)行多任務,這種全新的模型稱為事件驅動模型,Nginx就是支持異步IO的Web服務器,它在單核CPU上采用單進程模型就可以高效地支持多任務。在多核CPU上,可以運行多個進程(數(shù)量與CPU核心數(shù)相同),充分利用多核CPU。由于系統(tǒng)總的進程數(shù)量十分有限,因此操作系統(tǒng)調度非常高效。用異步IO編程模型來實現(xiàn)多任務是一個主要的趨勢。

對應到Python語言,單進程的異步編程模型稱為協(xié)程,有了協(xié)程的支持,就可以基于事件驅動編寫高效的多任務程序。我們會在后面討論如何編寫協(xié)程。

分布式進程

在Thread和Process中,應當優(yōu)選Process,因為Process更穩(wěn)定,而且,Process可以分布到多臺機器上,而Thread最多只能分布到同一臺機器的多個CPU上。

Python的multiprocessing模塊不但支持多進程,其中managers子模塊還支持把多進程分布到多臺機器上。一個服務進程可以作為調度者,將任務分布到其他多個進程中,依靠網絡通信。由于managers模塊封裝很好,不必了解網絡通信的細節(jié),就可以很容易地編寫分布式多進程程序。

舉個例子:如果我們已經有一個通過Queue通信的多進程程序在同一臺機器上運行,現(xiàn)在,由于處理任務的進程任務繁重,希望把發(fā)送任務的進程和處理任務的進程分布到兩臺機器上。怎么用分布式進程實現(xiàn)?

原有的Queue可以繼續(xù)使用,但是,通過managers模塊把Queue通過網絡暴露出去,就可以讓其他機器的進程訪問Queue了。

我們先看服務進程,服務進程負責啟動Queue,把Queue注冊到網絡上,然后往Queue里面寫入任務:

# task_master.pyimport random, time, queue
from multiprocessing.managers import BaseManager# 發(fā)送任務的隊列:
task_queue = queue.Queue()
# 接收結果的隊列:
result_queue = queue.Queue()# 從BaseManager繼承的QueueManager:
class QueueManager(BaseManager):pass# 把兩個Queue都注冊到網絡上, callable參數(shù)關聯(lián)了Queue對象:
QueueManager.register('get_task_queue', callable=lambda: task_queue)
QueueManager.register('get_result_queue', callable=lambda: result_queue)
# 綁定端口5000, 設置驗證碼'abc':
manager = QueueManager(address=('', 5000), authkey=b'abc')
# 啟動Queue:
manager.start()
# 獲得通過網絡訪問的Queue對象:
task = manager.get_task_queue()
result = manager.get_result_queue()
# 放幾個任務進去:
for i in range(10):n = random.randint(0, 10000)print('Put task %d...' % n)task.put(n)
# 從result隊列讀取結果:
print('Try get results...')
for i in range(10):r = result.get(timeout=10)print('Result: %s' % r)
# 關閉:
manager.shutdown()
print('master exit.')

請注意,當我們在一臺機器上寫多進程程序時,創(chuàng)建的Queue可以直接拿來用,但是,在分布式多進程環(huán)境下,添加任務到Queue不可以直接對原始的task_queue進行操作,那樣就繞過了QueueManager的封裝,必須通過manager.get_task_queue()獲得的Queue接口添加。

然后,在另一臺機器上啟動任務進程(本機上啟動也可以):

# task_worker.pyimport time, sys, queue
from multiprocessing.managers import BaseManager# 創(chuàng)建類似的QueueManager:
class QueueManager(BaseManager):pass# 由于這個QueueManager只從網絡上獲取Queue,所以注冊時只提供名字:
QueueManager.register('get_task_queue')
QueueManager.register('get_result_queue')# 連接到服務器,也就是運行task_master.py的機器:
server_addr = '127.0.0.1'
print('Connect to server %s...' % server_addr)
# 端口和驗證碼注意保持與task_master.py設置的完全一致:
m = QueueManager(address=(server_addr, 5000), authkey=b'abc')
# 從網絡連接:
m.connect()
# 獲取Queue的對象:
task = m.get_task_queue()
result = m.get_result_queue()
# 從task隊列取任務,并把結果寫入result隊列:
for i in range(10):try:n = task.get(timeout=1)print('run task %d * %d...' % (n, n))r = '%d * %d = %d' % (n, n, n*n)time.sleep(1)result.put(r)except Queue.Empty:print('task queue is empty.')
# 處理結束:
print('worker exit.')

任務進程要通過網絡連接到服務進程,所以要指定服務進程的IP。

現(xiàn)在,可以試試分布式進程的工作效果了。先啟動task_master.py服務進程:

$ python3 task_master.py 
Put task 3411...
Put task 1605...
Put task 1398...
Put task 4729...
Put task 5300...
Put task 7471...
Put task 68...
Put task 4219...
Put task 339...
Put task 7866...
Try get results...

task_master.py進程發(fā)送完任務后,開始等待result隊列的結果?,F(xiàn)在啟動task_worker.py進程:

$ python3 task_worker.py
Connect to server 127.0.0.1...
run task 3411 * 3411...
run task 1605 * 1605...
run task 1398 * 1398...
run task 4729 * 4729...
run task 5300 * 5300...
run task 7471 * 7471...
run task 68 * 68...
run task 4219 * 4219...
run task 339 * 339...
run task 7866 * 7866...
worker exit.

task_worker.py進程結束,在task_master.py進程中會繼續(xù)打印出結果:

Result: 3411 * 3411 = 11634921
Result: 1605 * 1605 = 2576025
Result: 1398 * 1398 = 1954404
Result: 4729 * 4729 = 22363441
Result: 5300 * 5300 = 28090000
Result: 7471 * 7471 = 55815841
Result: 68 * 68 = 4624
Result: 4219 * 4219 = 17799961
Result: 339 * 339 = 114921
Result: 7866 * 7866 = 61873956

這個簡單的Master/Worker模型有什么用?其實這就是一個簡單但真正的分布式計算,把代碼稍加改造,啟動多個worker,就可以把任務分布到幾臺甚至幾十臺機器上,比如把計算n*n的代碼換成發(fā)送郵件,就實現(xiàn)了郵件隊列的異步發(fā)送。

Queue對象存儲在哪?注意到task_worker.py中根本沒有創(chuàng)建Queue的代碼,所以,Queue對象存儲在task_master.py進程中:

task_master_worker

Queue之所以能通過網絡訪問,就是通過QueueManager實現(xiàn)的。由于QueueManager管理的不止一個Queue,所以,要給每個Queue的網絡調用接口起個名字,比如get_task_queue。

authkey有什么用?這是為了保證兩臺機器正常通信,不被其他機器惡意干擾。如果task_worker.pyauthkeytask_master.pyauthkey不一致,肯定連接不上。

小結

Python的分布式進程接口簡單,封裝良好,適合需要把繁重任務分布到多臺機器的環(huán)境下。

注意Queue的作用是用來傳遞任務和接收結果,每個任務的描述數(shù)據(jù)量要盡量小。比如發(fā)送一個處理日志文件的任務,就不要發(fā)送幾百兆的日志文件本身,而是發(fā)送日志文件存放的完整路徑,由Worker進程再去共享的磁盤上讀取文件。

參考源碼

task_master.py

task_worker.py

正則表達式

字符串是編程時涉及到的最多的一種數(shù)據(jù)結構,對字符串進行操作的需求幾乎無處不在。比如判斷一個字符串是否是合法的Email地址,雖然可以編程提取@前后的子串,再分別判斷是否是單詞和域名,但這樣做不但麻煩,而且代碼難以復用。

正則表達式是一種用來匹配字符串的強有力的武器。它的設計思想是用一種描述性的語言來給字符串定義一個規(guī)則,凡是符合規(guī)則的字符串,我們就認為它“匹配”了,否則,該字符串就是不合法的。

所以我們判斷一個字符串是否是合法的Email的方法是:

  1. 創(chuàng)建一個匹配Email的正則表達式;

  2. 用該正則表達式去匹配用戶的輸入來判斷是否合法。

因為正則表達式也是用字符串表示的,所以,我們要首先了解如何用字符來描述字符。

在正則表達式中,如果直接給出字符,就是精確匹配。用\d可以匹配一個數(shù)字,\w可以匹配一個字母或數(shù)字,所以:

  • '00\d'可以匹配'007',但無法匹配'00A'

  • '\d\d\d'可以匹配'010'

  • '\w\w\d'可以匹配'py3'

.可以匹配任意字符,所以:

  • 'py.'可以匹配'pyc'、'pyo'、'py!'等等。

要匹配變長的字符,在正則表達式中,用*表示任意個字符(包括0個),用+表示至少一個字符,用?表示0個或1個字符,用{n}表示n個字符,用{n,m}表示n-m個字符:

來看一個復雜的例子:\d{3}\s+\d{3,8}。

我們來從左到右解讀一下:

  1. \d{3}表示匹配3個數(shù)字,例如'010'

  2. \s可以匹配一個空格(也包括Tab等空白符),所以\s+表示至少有一個空格,例如匹配' '' '等;

  3. \d{3,8}表示3-8個數(shù)字,例如'1234567'。

綜合起來,上面的正則表達式可以匹配以任意個空格隔開的帶區(qū)號的電話號碼。

如果要匹配'010-12345'這樣的號碼呢?由于'-'是特殊字符,在正則表達式中,要用'\'轉義,所以,上面的正則是\d{3}\-\d{3,8}。

但是,仍然無法匹配'010 - 12345',因為帶有空格。所以我們需要更復雜的匹配方式。

進階

要做更精確地匹配,可以用[]表示范圍,比如:

  • [0-9a-zA-Z\_]可以匹配一個數(shù)字、字母或者下劃線;

  • [0-9a-zA-Z\_]+可以匹配至少由一個數(shù)字、字母或者下劃線組成的字符串,比如'a100''0_Z''Py3000'等等;

  • [a-zA-Z\_][0-9a-zA-Z\_]*可以匹配由字母或下劃線開頭,后接任意個由一個數(shù)字、字母或者下劃線組成的字符串,也就是Python合法的變量;

  • [a-zA-Z\_][0-9a-zA-Z\_]{0, 19}更精確地限制了變量的長度是1-20個字符(前面1個字符+后面最多19個字符)。

A|B可以匹配A或B,所以[P|p]ython可以匹配'Python'或者'python'

^表示行的開頭,^\d表示必須以數(shù)字開頭。

$表示行的結束,\d$表示必須以數(shù)字結束。

你可能注意到了,py也可以匹配'python',但是加上^py$就變成了整行匹配,就只能匹配'py'了。

re模塊

有了準備知識,我們就可以在Python中使用正則表達式了。Python提供re模塊,包含所有正則表達式的功能。由于Python的字符串本身也用\轉義,所以要特別注意:

s = 'ABC\\-001' # Python的字符串
# 對應的正則表達式字符串變成:
# 'ABC\-001'

因此我們強烈建議使用Python的r前綴,就不用考慮轉義的問題了:

s = r'ABC\-001' # Python的字符串
# 對應的正則表達式字符串不變:
# 'ABC\-001'

先看看如何判斷正則表達式是否匹配:

>>> import re
>>> re.match(r'^\d{3}\-\d{3,8}$', '010-12345')
<_sre.SRE_Match object; span=(0, 9), match='010-12345'>
>>> re.match(r'^\d{3}\-\d{3,8}$', '010 12345')
>>>

match()方法判斷是否匹配,如果匹配成功,返回一個Match對象,否則返回None。常見的判斷方法就是:

test = '用戶輸入的字符串'
if re.match(r'正則表達式', test):print('ok')
else:print('failed')
切分字符串

用正則表達式切分字符串比用固定的字符更靈活,請看正常的切分代碼:

>>> 'a b   c'.split(' ')
['a', 'b', '', '', 'c']

嗯,無法識別連續(xù)的空格,用正則表達式試試:

>>> re.split(r'\s+', 'a b   c')
['a', 'b', 'c']

無論多少個空格都可以正常分割。加入,試試:

>>> re.split(r'[\s\,]+', 'a,b, c  d')
['a', 'b', 'c', 'd']

再加入;試試:

>>> re.split(r'[\s\,\;]+', 'a,b;; c  d')
['a', 'b', 'c', 'd']

如果用戶輸入了一組標簽,下次記得用正則表達式來把不規(guī)范的輸入轉化成正確的數(shù)組。

分組

除了簡單地判斷是否匹配之外,正則表達式還有提取子串的強大功能。用()表示的就是要提取的分組(Group)。比如:

^(\d{3})-(\d{3,8})$分別定義了兩個組,可以直接從匹配的字符串中提取出區(qū)號和本地號碼:

>>> m = re.match(r'^(\d{3})-(\d{3,8})$', '010-12345')
>>> m
<_sre.SRE_Match object; span=(0, 9), match='010-12345'>
>>> m.group(0)
'010-12345'
>>> m.group(1)
'010'
>>> m.group(2)
'12345'

如果正則表達式中定義了組,就可以在Match對象上用group()方法提取出子串來。

注意到group(0)永遠是原始字符串,group(1)、group(2)……表示第1、2、……個子串。

提取子串非常有用。來看一個更兇殘的例子:

>>> t = '19:05:30'
>>> m = re.match(r'^(0[0-9]|1[0-9]|2[0-3]|[0-9])\:(0[0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9]|[0-9])\:(0[0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9]|[0-9])$', t)
>>> m.groups()
('19', '05', '30')

這個正則表達式可以直接識別合法的時間。但是有些時候,用正則表達式也無法做到完全驗證,比如識別日期:

'^(0[1-9]|1[0-2]|[0-9])-(0[1-9]|1[0-9]|2[0-9]|3[0-1]|[0-9])$'

對于'2-30''4-31'這樣的非法日期,用正則還是識別不了,或者說寫出來非常困難,這時就需要程序配合識別了。

貪婪匹配

最后需要特別指出的是,正則匹配默認是貪婪匹配,也就是匹配盡可能多的字符。舉例如下,匹配出數(shù)字后面的0

>>> re.match(r'^(\d+)(0*)$', '102300').groups()
('102300', '')

由于\d+采用貪婪匹配,直接把后面的0全部匹配了,結果0*只能匹配空字符串了。

必須讓\d+采用非貪婪匹配(也就是盡可能少匹配),才能把后面的0匹配出來,加個?就可以讓\d+采用非貪婪匹配:

>>> re.match(r'^(\d+?)(0*)$', '102300').groups()
('1023', '00')
編譯

當我們在Python中使用正則表達式時,re模塊內部會干兩件事情:

  1. 編譯正則表達式,如果正則表達式的字符串本身不合法,會報錯;

  2. 用編譯后的正則表達式去匹配字符串。

如果一個正則表達式要重復使用幾千次,出于效率的考慮,我們可以預編譯該正則表達式,接下來重復使用時就不需要編譯這個步驟了,直接匹配:

>>> import re
# 編譯:
>>> re_telephone = re.compile(r'^(\d{3})-(\d{3,8})$')
# 使用:
>>> re_telephone.match('010-12345').groups()
('010', '12345')
>>> re_telephone.match('010-8086').groups()
('010', '8086')

編譯后生成Regular Expression對象,由于該對象自己包含了正則表達式,所以調用對應的方法時不用給出正則字符串。

小結

正則表達式非常強大,要在短短的一節(jié)里講完是不可能的。要講清楚正則的所有內容,可以寫一本厚厚的書了。如果你經常遇到正則表達式的問題,你可能需要一本正則表達式的參考書。

練習

請嘗試寫一個驗證Email地址的正則表達式。版本一應該可以驗證出類似的Email:

someone@gmail.com
bill.gates@microsoft.com

版本二可以驗證并提取出帶名字的Email地址:

<Tom Paris> tom@voyager.org
參考源碼

regex.py

常用內建模塊

Python之所以自稱“batteries included”,就是因為內置了許多非常有用的模塊,無需額外安裝和配置,即可直接使用。

本章將介紹一些常用的內建模塊。

datetime

datetime是Python處理日期和時間的標準庫。

獲取當前日期和時間

我們先看如何獲取當前日期和時間:

>>> from datetime import datetime
>>> now = datetime.now() # 獲取當前datetime
>>> print(now)
2015-05-18 16:28:07.198690
>>> print(type(now))
<class 'datetime.datetime'>

注意到datetime是模塊,datetime模塊還包含一個datetime類,通過from datetime import datetime導入的才是datetime這個類。

如果僅導入import datetime,則必須引用全名datetime.datetime。

datetime.now()返回當前日期和時間,其類型是datetime。

獲取指定日期和時間

要指定某個日期和時間,我們直接用參數(shù)構造一個datetime

>>> from datetime import datetime
>>> dt = datetime(2015, 4, 19, 12, 20) # 用指定日期時間創(chuàng)建datetime
>>> print(dt)
2015-04-19 12:20:00
datetime轉換為timestamp

在計算機中,時間實際上是用數(shù)字表示的。我們把1970年1月1日 00:00:00 UTC+00:00時區(qū)的時刻稱為epoch time,記為0(1970年以前的時間timestamp為負數(shù)),當前時間就是相對于epoch time的秒數(shù),稱為timestamp。

你可以認為:

timestamp = 0 = 1970-1-1 00:00:00 UTC+0:00

對應的北京時間是:

timestamp = 0 = 1970-1-1 08:00:00 UTC+8:00

可見timestamp的值與時區(qū)毫無關系,因為timestamp一旦確定,其UTC時間就確定了,轉換到任意時區(qū)的時間也是完全確定的,這就是為什么計算機存儲的當前時間是以timestamp表示的,因為全球各地的計算機在任意時刻的timestamp都是完全相同的(假定時間已校準)。

把一個datetime類型轉換為timestamp只需要簡單調用timestamp()方法:

>>> from datetime import datetime
>>> dt = datetime(2015, 4, 19, 12, 20) # 用指定日期時間創(chuàng)建datetime
>>> dt.timestamp() # 把timestamp轉換為datetime
1429417200.0

注意Python的timestamp是一個浮點數(shù)。如果有小數(shù)位,小數(shù)位表示毫秒數(shù)。

某些編程語言(如Java和JavaScript)的timestamp使用整數(shù)表示毫秒數(shù),這種情況下只需要把timestamp除以1000就得到Python的浮點表示方法。

timestamp轉換為datetime

要把timestamp轉換為datetime,使用datetime提供的fromtimestamp()方法:

>>> from datetime import datetime
>>> t = 1429417200.0
>>> print(datetime.fromtimestamp(t))
2015-04-19 12:20:00

注意到timestamp是一個浮點數(shù),它沒有時區(qū)的概念,而datetime是有時區(qū)的。上述轉換是在timestamp和本地時間做轉換。

本地時間是指當前操作系統(tǒng)設定的時區(qū)。例如北京時區(qū)是東8區(qū),則本地時間:

2015-04-19 12:20:00

實際上就是UTC+8:00時區(qū)的時間:

2015-04-19 12:20:00 UTC+8:00

而此刻的格林威治標準時間與北京時間差了8小時,也就是UTC+0:00時區(qū)的時間應該是:

2015-04-19 04:20:00 UTC+0:00

timestamp也可以直接被轉換到UTC標準時區(qū)的時間:

>>> from datetime import datetime
>>> t = 1429417200.0
>>> print(datetime.fromtimestamp(t)) # 本地時間
2015-04-19 12:20:00
>>> print(datetime.utcfromtimestamp(t)) # UTC時間
2015-04-19 04:20:00
str轉換為datetime

很多時候,用戶輸入的日期和時間是字符串,要處理日期和時間,首先必須把str轉換為datetime。轉換方法是通過datetime.strptime()實現(xiàn),需要一個日期和時間的格式化字符串:

>>> from datetime import datetime
>>> cday = datetime.strptime('2015-6-1 18:19:59', '%Y-%m-%d %H:%M:%S')
>>> print(cday)
2015-06-01 18:19:59

字符串'%Y-%m-%d %H:%M:%S'規(guī)定了日期和時間部分的格式。詳細的說明請參考Python文檔。

注意轉換后的datetime是沒有時區(qū)信息的。

datetime轉換為str

如果已經有了datetime對象,要把它格式化為字符串顯示給用戶,就需要轉換為str,轉換方法是通過strftime()實現(xiàn)的,同樣需要一個日期和時間的格式化字符串:

>>> from datetime import datetime
>>> now = datetime.now()
>>> print(now.strftime('%a, %b %d %H:%M'))
Mon, May 05 16:28
datetime加減

對日期和時間進行加減實際上就是把datetime往后或往前計算,得到新的datetime。加減可以直接用+-運算符,不過需要導入timedelta這個類:

>>> from datetime import datetime, timedelta
>>> now = datetime.now()
>>> now
datetime.datetime(2015, 5, 18, 16, 57, 3, 540997)
>>> now + timedelta(hours=10)
datetime.datetime(2015, 5, 19, 2, 57, 3, 540997)
>>> now - timedelta(days=1)
datetime.datetime(2015, 5, 17, 16, 57, 3, 540997)
>>> now + timedelta(days=2, hours=12)
datetime.datetime(2015, 5, 21, 4, 57, 3, 540997)

可見,使用timedelta你可以很容易地算出前幾天和后幾天的時刻。

本地時間轉換為UTC時間

本地時間是指系統(tǒng)設定時區(qū)的時間,例如北京時間是UTC+8:00時區(qū)的時間,而UTC時間指UTC+0:00時區(qū)的時間。

一個datetime類型有一個時區(qū)屬性tzinfo,但是默認為None,所以無法區(qū)分這個datetime到底是哪個時區(qū),除非強行給datetime設置一個時區(qū):

>>> from datetime import datetime, timedelta, timezone
>>> tz_utc_8 = timezone(timedelta(hours=8)) # 創(chuàng)建時區(qū)UTC+8:00
>>> now = datetime.now()
>>> now
datetime.datetime(2015, 5, 18, 17, 2, 10, 871012)
>>> dt = now.replace(tzinfo=tz_utc_8) # 強制設置為UTC+8:00
>>> dt
datetime.datetime(2015, 5, 18, 17, 2, 10, 871012, tzinfo=datetime.timezone(datetime.timedelta(0, 28800)))

如果系統(tǒng)時區(qū)恰好是UTC+8:00,那么上述代碼就是正確的,否則,不能強制設置為UTC+8:00時區(qū)。

時區(qū)轉換

我們可以先通過utcnow()拿到當前的UTC時間,再轉換為任意時區(qū)的時間:

# 拿到UTC時間,并強制設置時區(qū)為UTC+0:00:
>>> utc_dt = datetime.utcnow().replace(tzinfo=timezone.utc)
>>> print(utc_dt)
2015-05-18 09:05:12.377316+00:00
# astimezone()將轉換時區(qū)為北京時間:
>>> bj_dt = utc_dt.astimezone(timezone(timedelta(hours=8)))
>>> print(bj_dt)
2015-05-18 17:05:12.377316+08:00
# astimezone()將轉換時區(qū)為東京時間:
>>> tokyo_dt = utc_dt.astimezone(timezone(timedelta(hours=9)))
>>> print(tokyo_dt)
2015-05-18 18:05:12.377316+09:00
# astimezone()將bj_dt轉換時區(qū)為東京時間:
>>> tokyo_dt2 = bj_dt.astimezone(timezone(timedelta(hours=9)))
>>> print(tokyo_dt2)
2015-05-18 18:05:12.377316+09:00

時區(qū)轉換的關鍵在于,拿到一個datetime時,要獲知其正確的時區(qū),然后強制設置時區(qū),作為基準時間。

利用帶時區(qū)的datetime,通過astimezone()方法,可以轉換到任意時區(qū)。

注:不是必須從UTC+0:00時區(qū)轉換到其他時區(qū),任何帶時區(qū)的datetime都可以正確轉換,例如上述bj_dttokyo_dt的轉換。

小結

datetime表示的時間需要時區(qū)信息才能確定一個特定的時間,否則只能視為本地時間。

如果要存儲datetime,最佳方法是將其轉換為timestamp再存儲,因為timestamp的值與時區(qū)完全無關。

練習

假設你獲取了用戶輸入的日期和時間如2015-1-21 9:01:30,以及一個時區(qū)信息如UTC+5:00,均是str,請編寫一個函數(shù)將其轉換為timestamp:

# -*- coding:utf-8 -*-import re
from datetime import datetime, timezone, timedeltadef to_timestamp(dt_str, tz_str):
----pass
----
# 測試:t1 = to_timestamp('2015-6-1 08:10:30', 'UTC+7:00')
assert t1 == 1433121030.0, t1t2 = to_timestamp('2015-5-31 16:10:30', 'UTC-09:00')
assert t2 == 1433121030.0, t2print('Pass')
參考源碼

use_datetime.py

collections

collections是Python內建的一個集合模塊,提供了許多有用的集合類。

namedtuple

我們知道tuple可以表示不變集合,例如,一個點的二維坐標就可以表示成:

>>> p = (1, 2)

但是,看到(1, 2),很難看出這個tuple是用來表示一個坐標的。

定義一個class又小題大做了,這時,namedtuple就派上了用場:

>>> from collections import namedtuple
>>> Point = namedtuple('Point', ['x', 'y'])
>>> p = Point(1, 2)
>>> p.x
1
>>> p.y
2

namedtuple是一個函數(shù),它用來創(chuàng)建一個自定義的tuple對象,并且規(guī)定了tuple元素的個數(shù),并可以用屬性而不是索引來引用tuple的某個元素。

這樣一來,我們用namedtuple可以很方便地定義一種數(shù)據(jù)類型,它具備tuple的不變性,又可以根據(jù)屬性來引用,使用十分方便。

可以驗證創(chuàng)建的Point對象是tuple的一種子類:

>>> isinstance(p, Point)
True
>>> isinstance(p, tuple)
True

類似的,如果要用坐標和半徑表示一個圓,也可以用namedtuple定義:

# namedtuple('名稱', [屬性list]):
Circle = namedtuple('Circle', ['x', 'y', 'r'])
deque

使用list存儲數(shù)據(jù)時,按索引訪問元素很快,但是插入和刪除元素就很慢了,因為list是線性存儲,數(shù)據(jù)量大的時候,插入和刪除效率很低。

deque是為了高效實現(xiàn)插入和刪除操作的雙向列表,適合用于隊列和棧:

>>> from collections import deque
>>> q = deque(['a', 'b', 'c'])
>>> q.append('x')
>>> q.appendleft('y')
>>> q
deque(['y', 'a', 'b', 'c', 'x'])

deque除了實現(xiàn)list的append()pop()外,還支持appendleft()popleft(),這樣就可以非常高效地往頭部添加或刪除元素。

defaultdict

使用dict時,如果引用的Key不存在,就會拋出KeyError。如果希望key不存在時,返回一個默認值,就可以用defaultdict

>>> from collections import defaultdict
>>> dd = defaultdict(lambda: 'N/A')
>>> dd['key1'] = 'abc'
>>> dd['key1'] # key1存在
'abc'
>>> dd['key2'] # key2不存在,返回默認值
'N/A'

注意默認值是調用函數(shù)返回的,而函數(shù)在創(chuàng)建defaultdict對象時傳入。

除了在Key不存在時返回默認值,defaultdict的其他行為跟dict是完全一樣的。

OrderedDict

使用dict時,Key是無序的。在對dict做迭代時,我們無法確定Key的順序。

如果要保持Key的順序,可以用OrderedDict

>>> from collections import OrderedDict
>>> d = dict([('a', 1), ('b', 2), ('c', 3)])
>>> d # dict的Key是無序的
{'a': 1, 'c': 3, 'b': 2}
>>> od = OrderedDict([('a', 1), ('b', 2), ('c', 3)])
>>> od # OrderedDict的Key是有序的
OrderedDict([('a', 1), ('b', 2), ('c', 3)])

注意,OrderedDict的Key會按照插入的順序排列,不是Key本身排序:

>>> od = OrderedDict()
>>> od['z'] = 1
>>> od['y'] = 2
>>> od['x'] = 3
>>> list(od.keys()) # 按照插入的Key的順序返回
['z', 'y', 'x']

OrderedDict可以實現(xiàn)一個FIFO(先進先出)的dict,當容量超出限制時,先刪除最早添加的Key:

from collections import OrderedDictclass LastUpdatedOrderedDict(OrderedDict):def __init__(self, capacity):super(LastUpdatedOrderedDict, self).__init__()self._capacity = capacitydef __setitem__(self, key, value):containsKey = 1 if key in self else 0if len(self) - containsKey >= self._capacity:last = self.popitem(last=False)print('remove:', last)if containsKey:del self[key]print('set:', (key, value))else:print('add:', (key, value))OrderedDict.__setitem__(self, key, value)
Counter

Counter是一個簡單的計數(shù)器,例如,統(tǒng)計字符出現(xiàn)的個數(shù):

>>> from collections import Counter
>>> c = Counter()
>>> for ch in 'programming':
...     c[ch] = c[ch] + 1
...
>>> c
Counter({'g': 2, 'm': 2, 'r': 2, 'a': 1, 'i': 1, 'o': 1, 'n': 1, 'p': 1})

Counter實際上也是dict的一個子類,上面的結果可以看出,字符'g''m'、'r'各出現(xiàn)了兩次,其他字符各出現(xiàn)了一次。

小結

collections模塊提供了一些有用的集合類,可以根據(jù)需要選用。

參考源碼

use_collections.py

base64

Base64是一種用64個字符來表示任意二進制數(shù)據(jù)的方法。

用記事本打開exejpg、pdf這些文件時,我們都會看到一大堆亂碼,因為二進制文件包含很多無法顯示和打印的字符,所以,如果要讓記事本這樣的文本處理軟件能處理二進制數(shù)據(jù),就需要一個二進制到字符串的轉換方法。Base64是一種最常見的二進制編碼方法。

Base64的原理很簡單,首先,準備一個包含64個字符的數(shù)組:

['A', 'B', 'C', ... 'a', 'b', 'c', ... '0', '1', ... '+', '/']

然后,對二進制數(shù)據(jù)進行處理,每3個字節(jié)一組,一共是3x8=24bit,劃為4組,每組正好6個bit:

base64-encode

這樣我們得到4個數(shù)字作為索引,然后查表,獲得相應的4個字符,就是編碼后的字符串。

所以,Base64編碼會把3字節(jié)的二進制數(shù)據(jù)編碼為4字節(jié)的文本數(shù)據(jù),長度增加33%,好處是編碼后的文本數(shù)據(jù)可以在郵件正文、網頁等直接顯示。

如果要編碼的二進制數(shù)據(jù)不是3的倍數(shù),最后會剩下1個或2個字節(jié)怎么辦?Base64用\x00字節(jié)在末尾補足后,再在編碼的末尾加上1個或2個=號,表示補了多少字節(jié),解碼的時候,會自動去掉。

Python內置的base64可以直接進行base64的編解碼:

>>> import base64
>>> base64.b64encode(b'binary\x00string')
b'YmluYXJ5AHN0cmluZw=='
>>> base64.b64decode(b'YmluYXJ5AHN0cmluZw==')
b'binary\x00string'

由于標準的Base64編碼后可能出現(xiàn)字符+/,在URL中就不能直接作為參數(shù),所以又有一種"url safe"的base64編碼,其實就是把字符+/分別變成-_

>>> base64.b64encode(b'i\xb7\x1d\xfb\xef\xff')
b'abcd++//'
>>> base64.urlsafe_b64encode(b'i\xb7\x1d\xfb\xef\xff')
b'abcd--__'
>>> base64.urlsafe_b64decode('abcd--__')
b'i\xb7\x1d\xfb\xef\xff'

還可以自己定義64個字符的排列順序,這樣就可以自定義Base64編碼,不過,通常情況下完全沒有必要。

Base64是一種通過查表的編碼方法,不能用于加密,即使使用自定義的編碼表也不行。

Base64適用于小段內容的編碼,比如數(shù)字證書簽名、Cookie的內容等。

由于=字符也可能出現(xiàn)在Base64編碼中,但=用在URL、Cookie里面會造成歧義,所以,很多Base64編碼后會把=去掉:

# 標準Base64:
'abcd' -> 'YWJjZA=='
# 自動去掉=:
'abcd' -> 'YWJjZA'

去掉=后怎么解碼呢?因為Base64是把3個字節(jié)變?yōu)?個字節(jié),所以,Base64編碼的長度永遠是4的倍數(shù),因此,需要加上=把Base64字符串的長度變?yōu)?的倍數(shù),就可以正常解碼了。

小結

Base64是一種任意二進制到文本字符串的編碼方法,常用于在URL、Cookie、網頁中傳輸少量二進制數(shù)據(jù)。

練習

請寫一個能處理去掉=的base64解碼函數(shù):

# -*- coding: utf-8 -*-import base64def safe_base64_decode(s):
----pass
----
# 測試:
assert b'abcd' == safe_base64_decode(b'YWJjZA=='), safe_base64_decode('YWJjZA==')
assert b'abcd' == safe_base64_decode(b'YWJjZA'), safe_base64_decode('YWJjZA')
print('Pass')
參考源碼

do_base64.py

struct

準確地講,Python沒有專門處理字節(jié)的數(shù)據(jù)類型。但由于str既是字符串,又可以表示字節(jié),所以,字節(jié)數(shù)組=str。而在C語言中,我們可以很方便地用struct、union來處理字節(jié),以及字節(jié)和int,float的轉換。

在Python中,比方說要把一個32位無符號整數(shù)變成字節(jié),也就是4個長度的bytes,你得配合位運算符這么寫:

>>> n = 10240099
>>> b1 = (n & 0xff000000) >> 24
>>> b2 = (n & 0xff0000) >> 16
>>> b3 = (n & 0xff00) >> 8
>>> b4 = n & 0xff
>>> bs = bytes([b1, b2, b3, b4])
>>> bs
b'\x00\x9c@c'

非常麻煩。如果換成浮點數(shù)就無能為力了。

好在Python提供了一個struct模塊來解決bytes和其他二進制數(shù)據(jù)類型的轉換。

structpack函數(shù)把任意數(shù)據(jù)類型變成bytes

>>> import struct
>>> struct.pack('>I', 10240099)
b'\x00\x9c@c'

pack的第一個參數(shù)是處理指令,'>I'的意思是:

>表示字節(jié)順序是big-endian,也就是網絡序,I表示4字節(jié)無符號整數(shù)。

后面的參數(shù)個數(shù)要和處理指令一致。

unpackbytes變成相應的數(shù)據(jù)類型:

>>> struct.unpack('>IH', b'\xf0\xf0\xf0\xf0\x80\x80')
(4042322160, 32896)

根據(jù)>IH的說明,后面的bytes依次變?yōu)?code>I:4字節(jié)無符號整數(shù)和H:2字節(jié)無符號整數(shù)。

所以,盡管Python不適合編寫底層操作字節(jié)流的代碼,但在對性能要求不高的地方,利用struct就方便多了。

struct模塊定義的數(shù)據(jù)類型可以參考Python官方文檔:

https://docs.python.org/3/library/struct.html#format-characters

Windows的位圖文件(.bmp)是一種非常簡單的文件格式,我們來用struct分析一下。

首先找一個bmp文件,沒有的話用“畫圖”畫一個。

讀入前30個字節(jié)來分析:

>>> s = b'\x42\x4d\x38\x8c\x0a\x00\x00\x00\x00\x00\x36\x00\x00\x00\x28\x00\x00\x00\x80\x02\x00\x00\x68\x01\x00\x00\x01\x00\x18\x00'

BMP格式采用小端方式存儲數(shù)據(jù),文件頭的結構按順序如下:

兩個字節(jié):'BM'表示Windows位圖,'BA'表示OS/2位圖; 一個4字節(jié)整數(shù):表示位圖大小; 一個4字節(jié)整數(shù):保留位,始終為0; 一個4字節(jié)整數(shù):實際圖像的偏移量; 一個4字節(jié)整數(shù):Header的字節(jié)數(shù); 一個4字節(jié)整數(shù):圖像寬度; 一個4字節(jié)整數(shù):圖像高度; 一個2字節(jié)整數(shù):始終為1; 一個2字節(jié)整數(shù):顏色數(shù)。

所以,組合起來用unpack讀取:

>>> struct.unpack('<ccIIIIIIHH', s)
(b'B', b'M', 691256, 0, 54, 40, 640, 360, 1, 24)

結果顯示,b'B'、b'M'說明是Windows位圖,位圖大小為640x360,顏色數(shù)為24。

請編寫一個bmpinfo.py,可以檢查任意文件是否是位圖文件,如果是,打印出圖片大小和顏色數(shù)。

參考源碼

check_bmp.py

hashlib

摘要算法簡介

Python的hashlib提供了常見的摘要算法,如MD5,SHA1等等。

什么是摘要算法呢?摘要算法又稱哈希算法、散列算法。它通過一個函數(shù),把任意長度的數(shù)據(jù)轉換為一個長度固定的數(shù)據(jù)串(通常用16進制的字符串表示)。

舉個例子,你寫了一篇文章,內容是一個字符串'how to use python hashlib - by Michael',并附上這篇文章的摘要是'2d73d4f15c0db7f5ecb321b6a65e5d6d'。如果有人篡改了你的文章,并發(fā)表為'how to use python hashlib - by Bob',你可以一下子指出Bob篡改了你的文章,因為根據(jù)'how to use python hashlib - by Bob'計算出的摘要不同于原始文章的摘要。

可見,摘要算法就是通過摘要函數(shù)f()對任意長度的數(shù)據(jù)data計算出固定長度的摘要digest,目的是為了發(fā)現(xiàn)原始數(shù)據(jù)是否被人篡改過。

摘要算法之所以能指出數(shù)據(jù)是否被篡改過,就是因為摘要函數(shù)是一個單向函數(shù),計算f(data)很容易,但通過digest反推data卻非常困難。而且,對原始數(shù)據(jù)做一個bit的修改,都會導致計算出的摘要完全不同。

我們以常見的摘要算法MD5為例,計算出一個字符串的MD5值:

import hashlibmd5 = hashlib.md5()
md5.update('how to use md5 in python hashlib?'.encode('utf-8'))
print(md5.hexdigest())

計算結果如下:

d26a53750bc40b38b65a520292f69306

如果數(shù)據(jù)量很大,可以分塊多次調用update(),最后計算的結果是一樣的:

import hashlibmd5 = hashlib.md5()
md5.update('how to use md5 in '.encode('utf-8'))
md5.update('python hashlib?'.encode('utf-8'))
print(md5.hexdigest())

試試改動一個字母,看看計算的結果是否完全不同。

MD5是最常見的摘要算法,速度很快,生成結果是固定的128 bit字節(jié),通常用一個32位的16進制字符串表示。

另一種常見的摘要算法是SHA1,調用SHA1和調用MD5完全類似:

import hashlibsha1 = hashlib.sha1()
sha1.update('how to use sha1 in '.encode('utf-8'))
sha1.update('python hashlib?'.encode('utf-8'))
print(sha1.hexdigest())

SHA1的結果是160 bit字節(jié),通常用一個40位的16進制字符串表示。

比SHA1更安全的算法是SHA256和SHA512,不過越安全的算法不僅越慢,而且摘要長度更長。

有沒有可能兩個不同的數(shù)據(jù)通過某個摘要算法得到了相同的摘要?完全有可能,因為任何摘要算法都是把無限多的數(shù)據(jù)集合映射到一個有限的集合中。這種情況稱為碰撞,比如Bob試圖根據(jù)你的摘要反推出一篇文章'how to learn hashlib in python - by Bob',并且這篇文章的摘要恰好和你的文章完全一致,這種情況也并非不可能出現(xiàn),但是非常非常困難。

摘要算法應用

摘要算法能應用到什么地方?舉個常用例子:

任何允許用戶登錄的網站都會存儲用戶登錄的用戶名和口令。如何存儲用戶名和口令呢?方法是存到數(shù)據(jù)庫表中:

name    | password
--------+----------
michael | 123456
bob     | abc999
alice   | alice2008

如果以明文保存用戶口令,如果數(shù)據(jù)庫泄露,所有用戶的口令就落入黑客的手里。此外,網站運維人員是可以訪問數(shù)據(jù)庫的,也就是能獲取到所有用戶的口令。

正確的保存口令的方式是不存儲用戶的明文口令,而是存儲用戶口令的摘要,比如MD5:

username | password
---------+---------------------------------
michael  | e10adc3949ba59abbe56e057f20f883e
bob      | 878ef96e86145580c38c87f0410ad153
alice    | 99b1c2188db85afee403b1536010c2c9

當用戶登錄時,首先計算用戶輸入的明文口令的MD5,然后和數(shù)據(jù)庫存儲的MD5對比,如果一致,說明口令輸入正確,如果不一致,口令肯定錯誤。

練習

根據(jù)用戶輸入的口令,計算出存儲在數(shù)據(jù)庫中的MD5口令:

def calc_md5(password):pass

存儲MD5的好處是即使運維人員能訪問數(shù)據(jù)庫,也無法獲知用戶的明文口令。

設計一個驗證用戶登錄的函數(shù),根據(jù)用戶輸入的口令是否正確,返回True或False:

db = {'michael': 'e10adc3949ba59abbe56e057f20f883e','bob': '878ef96e86145580c38c87f0410ad153','alice': '99b1c2188db85afee403b1536010c2c9'
}def login(user, password):pass

采用MD5存儲口令是否就一定安全呢?也不一定。假設你是一個黑客,已經拿到了存儲MD5口令的數(shù)據(jù)庫,如何通過MD5反推用戶的明文口令呢?暴力破解費事費力,真正的黑客不會這么干。

考慮這么個情況,很多用戶喜歡用123456888888password這些簡單的口令,于是,黑客可以事先計算出這些常用口令的MD5值,得到一個反推表:

'e10adc3949ba59abbe56e057f20f883e': '123456'
'21218cca77804d2ba1922c33e0151105': '888888'
'5f4dcc3b5aa765d61d8327deb882cf99': 'password'

這樣,無需破解,只需要對比數(shù)據(jù)庫的MD5,黑客就獲得了使用常用口令的用戶賬號。

對于用戶來講,當然不要使用過于簡單的口令。但是,我們能否在程序設計上對簡單口令加強保護呢?

由于常用口令的MD5值很容易被計算出來,所以,要確保存儲的用戶口令不是那些已經被計算出來的常用口令的MD5,這一方法通過對原始口令加一個復雜字符串來實現(xiàn),俗稱“加鹽”:

def calc_md5(password):return get_md5(password + 'the-Salt')

經過Salt處理的MD5口令,只要Salt不被黑客知道,即使用戶輸入簡單口令,也很難通過MD5反推明文口令。

但是如果有兩個用戶都使用了相同的簡單口令比如123456,在數(shù)據(jù)庫中,將存儲兩條相同的MD5值,這說明這兩個用戶的口令是一樣的。有沒有辦法讓使用相同口令的用戶存儲不同的MD5呢?

如果假定用戶無法修改登錄名,就可以通過把登錄名作為Salt的一部分來計算MD5,從而實現(xiàn)相同口令的用戶也存儲不同的MD5。

練習

根據(jù)用戶輸入的登錄名和口令模擬用戶注冊,計算更安全的MD5:

db = {}def register(username, password):db[username] = get_md5(password + username + 'the-Salt')

然后,根據(jù)修改后的MD5算法實現(xiàn)用戶登錄的驗證:

def login(username, password):pass
小結

摘要算法在很多地方都有廣泛的應用。要注意摘要算法不是加密算法,不能用于加密(因為無法通過摘要反推明文),只能用于防篡改,但是它的單向計算特性決定了可以在不存儲明文口令的情況下驗證用戶口令。

參考源碼

use_hashlib.py

itertools

Python的內建模塊itertools提供了非常有用的用于操作迭代對象的函數(shù)。

首先,我們看看itertools提供的幾個“無限”迭代器:

>>> import itertools
>>> natuals = itertools.count(1)
>>> for n in natuals:
...     print(n)
...
1
2
3
...

因為count()會創(chuàng)建一個無限的迭代器,所以上述代碼會打印出自然數(shù)序列,根本停不下來,只能按Ctrl+C退出。

cycle()會把傳入的一個序列無限重復下去:

>>> import itertools
>>> cs = itertools.cycle('ABC') # 注意字符串也是序列的一種
>>> for c in cs:
...     print(c)
...
'A'
'B'
'C'
'A'
'B'
'C'
...

同樣停不下來。

repeat()負責把一個元素無限重復下去,不過如果提供第二個參數(shù)就可以限定重復次數(shù):

>>> ns = itertools.repeat('A', 3)
>>> for n in ns:
...     print(n)
...
A
A
A

無限序列只有在for迭代時才會無限地迭代下去,如果只是創(chuàng)建了一個迭代對象,它不會事先把無限個元素生成出來,事實上也不可能在內存中創(chuàng)建無限多個元素。

無限序列雖然可以無限迭代下去,但是通常我們會通過takewhile()等函數(shù)根據(jù)條件判斷來截取出一個有限的序列:

>>> natuals = itertools.count(1)
>>> ns = itertools.takewhile(lambda x: x <= 10, natuals)
>>> list(ns)
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

itertools提供的幾個迭代器操作函數(shù)更加有用:

chain()

chain()可以把一組迭代對象串聯(lián)起來,形成一個更大的迭代器:

>>> for c in itertools.chain('ABC', 'XYZ'):
...     print(c)
# 迭代效果:'A' 'B' 'C' 'X' 'Y' 'Z'
groupby()

groupby()把迭代器中相鄰的重復元素挑出來放在一起:

>>> for key, group in itertools.groupby('AAABBBCCAAA'):
...     print(key, list(group))
...
A ['A', 'A', 'A']
B ['B', 'B', 'B']
C ['C', 'C']
A ['A', 'A', 'A']

實際上挑選規(guī)則是通過函數(shù)完成的,只要作用于函數(shù)的兩個元素返回的值相等,這兩個元素就被認為是在一組的,而函數(shù)返回值作為組的key。如果我們要忽略大小寫分組,就可以讓元素'A''a'都返回相同的key:

>>> for key, group in itertools.groupby('AaaBBbcCAAa', lambda c: c.upper()):
...     print(key, list(group))
...
A ['A', 'a', 'a']
B ['B', 'B', 'b']
C ['c', 'C']
A ['A', 'A', 'a']
小結

itertools模塊提供的全部是處理迭代功能的函數(shù),它們的返回值不是list,而是Iterator,只有用for循環(huán)迭代的時候才真正計算。

參考源碼

use_itertools.py

XML

XML雖然比JSON復雜,在Web中應用也不如以前多了,不過仍有很多地方在用,所以,有必要了解如何操作XML。

DOM vs SAX

操作XML有兩種方法:DOM和SAX。DOM會把整個XML讀入內存,解析為樹,因此占用內存大,解析慢,優(yōu)點是可以任意遍歷樹的節(jié)點。SAX是流模式,邊讀邊解析,占用內存小,解析快,缺點是我們需要自己處理事件。

正常情況下,優(yōu)先考慮SAX,因為DOM實在太占內存。

在Python中使用SAX解析XML非常簡潔,通常我們關心的事件是start_elementend_elementchar_data,準備好這3個函數(shù),然后就可以解析xml了。

舉個例子,當SAX解析器讀到一個節(jié)點時:

<a href="/">python</a>

會產生3個事件:

  1. start_element事件,在讀取<a href="/">時;

  2. char_data事件,在讀取python時;

  3. end_element事件,在讀取</a>時。

用代碼實驗一下:

from xml.parsers.expat import ParserCreateclass DefaultSaxHandler(object):def start_element(self, name, attrs):print('sax:start_element: %s, attrs: %s' % (name, str(attrs)))def end_element(self, name):print('sax:end_element: %s' % name)def char_data(self, text):print('sax:char_data: %s' % text)xml = r'''<?xml version="1.0"?>
<ol><li><a href="/python">Python</a></li><li><a href="/ruby">Ruby</a></li>
</ol>
'''handler = DefaultSaxHandler()
parser = ParserCreate()
parser.StartElementHandler = handler.start_element
parser.EndElementHandler = handler.end_element
parser.CharacterDataHandler = handler.char_data
parser.Parse(xml)

需要注意的是讀取一大段字符串時,CharacterDataHandler可能被多次調用,所以需要自己保存起來,在EndElementHandler里面再合并。

除了解析XML外,如何生成XML呢?99%的情況下需要生成的XML結構都是非常簡單的,因此,最簡單也是最有效的生成XML的方法是拼接字符串:

L = []
L.append(r'<?xml version="1.0"?>')
L.append(r'<root>')
L.append(encode('some & data'))
L.append(r'</root>')
return ''.join(L)

如果要生成復雜的XML呢?建議你不要用XML,改成JSON。

小結

解析XML時,注意找出自己感興趣的節(jié)點,響應事件時,把節(jié)點數(shù)據(jù)保存起來。解析完畢后,就可以處理數(shù)據(jù)。

練習

請利用SAX編寫程序解析Yahoo的XML格式的天氣預報,獲取當天和第二天的天氣:

http://weather.yahooapis.com/forecastrss?u=c&w=2151330

參數(shù)w是城市代碼,要查詢某個城市代碼,可以在weather.yahoo.com搜索城市,瀏覽器地址欄的URL就包含城市代碼。

# -*- coding:utf-8 -*-from xml.parsers.expat import ParserCreate
----
class WeatherSaxHandler(object):passdef parse_weather(xml):return {'city': 'Beijing','country': 'China','today': {'text': 'Partly Cloudy','low': 20,'high': 33},'tomorrow': {'text': 'Sunny','low': 21,'high': 34}}
----
# 測試:
data = r'''<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<rss version="2.0" xmlns:yweather="http://xml.weather.yahoo.com/ns/rss/1.0" xmlns:geo="http://www.w3.org/2003/01/geo/wgs84_pos#"><channel><title>Yahoo! Weather - Beijing, CN</title><lastBuildDate>Wed, 27 May 2015 11:00 am CST</lastBuildDate><yweather:location city="Beijing" region="" country="China"/><yweather:units temperature="C" distance="km" pressure="mb" speed="km/h"/><yweather:wind chill="28" direction="180" speed="14.48" /><yweather:atmosphere humidity="53" visibility="2.61" pressure="1006.1" rising="0" /><yweather:astronomy sunrise="4:51 am" sunset="7:32 pm"/><item><geo:lat>39.91</geo:lat><geo:long>116.39</geo:long><pubDate>Wed, 27 May 2015 11:00 am CST</pubDate><yweather:condition text="Haze" code="21" temp="28" date="Wed, 27 May 2015 11:00 am CST" /><yweather:forecast day="Wed" date="27 May 2015" low="20" high="33" text="Partly Cloudy" code="30" /><yweather:forecast day="Thu" date="28 May 2015" low="21" high="34" text="Sunny" code="32" /><yweather:forecast day="Fri" date="29 May 2015" low="18" high="25" text="AM Showers" code="39" /><yweather:forecast day="Sat" date="30 May 2015" low="18" high="32" text="Sunny" code="32" /><yweather:forecast day="Sun" date="31 May 2015" low="20" high="37" text="Sunny" code="32" /></item></channel>
</rss>
'''
weather = parse_weather(data)
assert weather['city'] == 'Beijing', weather['city']
assert weather['country'] == 'China', weather['country']
assert weather['today']['text'] == 'Partly Cloudy', weather['today']['text']
assert weather['today']['low'] == 20, weather['today']['low']
assert weather['today']['high'] == 33, weather['today']['high']
assert weather['tomorrow']['text'] == 'Sunny', weather['tomorrow']['text']
assert weather['tomorrow']['low'] == 21, weather['tomorrow']['low']
assert weather['tomorrow']['high'] == 34, weather['tomorrow']['high']
print('Weather:', str(weather))
參考源碼

use_sax.py

HTMLParser

如果我們要編寫一個搜索引擎,第一步是用爬蟲把目標網站的頁面抓下來,第二步就是解析該HTML頁面,看看里面的內容到底是新聞、圖片還是視頻。

假設第一步已經完成了,第二步應該如何解析HTML呢?

HTML本質上是XML的子集,但是HTML的語法沒有XML那么嚴格,所以不能用標準的DOM或SAX來解析HTML。

好在Python提供了HTMLParser來非常方便地解析HTML,只需簡單幾行代碼:

from html.parser import HTMLParser
from html.entities import name2codepointclass MyHTMLParser(HTMLParser):def handle_starttag(self, tag, attrs):print('<%s>' % tag)def handle_endtag(self, tag):print('</%s>' % tag)def handle_startendtag(self, tag, attrs):print('<%s/>' % tag)def handle_data(self, data):print(data)def handle_comment(self, data):print('<!--', data, '-->')def handle_entityref(self, name):print('&%s;' % name)def handle_charref(self, name):print('&#%s;' % name)parser = MyHTMLParser()
parser.feed('''<html>
<head></head>
<body>
<!-- test html parser --><p>Some <a href=\"#\">html</a> HTML&nbsp;tutorial...<br>END</p>
</body></html>''')

feed()方法可以多次調用,也就是不一定一次把整個HTML字符串都塞進去,可以一部分一部分塞進去。

特殊字符有兩種,一種是英文表示的&nbsp;,一種是數(shù)字表示的&#1234;,這兩種字符都可以通過Parser解析出來。

小結

利用HTMLParser,可以把網頁中的文本、圖像等解析出來。

練習

找一個網頁,例如https://www.python.org/events/python-events/,用瀏覽器查看源碼并復制,然后嘗試解析一下HTML,輸出Python官網發(fā)布的會議時間、名稱和地點。

參考源碼

use_htmlparser.py

urllib

urllib提供了一系列用于操作URL的功能。

Get

urllib的request模塊可以非常方便地抓取URL內容,也就是發(fā)送一個GET請求到指定的頁面,然后返回HTTP的響應:

例如,對豆瓣的一個URLhttps://api.douban.com/v2/book/2129650進行抓取,并返回響應:

from urllib import requestwith request.urlopen('https://api.douban.com/v2/book/2129650') as f:data = f.read()print('Status:', f.status, f.reason)for k, v in f.getheaders():print('%s: %s' % (k, v))print('Data:', data.decode('utf-8'))

可以看到HTTP響應的頭和JSON數(shù)據(jù):

Status: 200 OK
Server: nginx
Date: Tue, 26 May 2015 10:02:27 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 2049
Connection: close
Expires: Sun, 1 Jan 2006 01:00:00 GMT
Pragma: no-cache
Cache-Control: must-revalidate, no-cache, private
X-DAE-Node: pidl1
Data: {"rating":{"max":10,"numRaters":16,"average":"7.4","min":0},"subtitle":"","author":["廖雪峰編著"],"pubdate":"2007-6","tags":[{"count":20,"name":"spring","title":"spring"}...}

如果我們要想模擬瀏覽器發(fā)送GET請求,就需要使用Request對象,通過往Request對象添加HTTP頭,我們就可以把請求偽裝成瀏覽器。例如,模擬iPhone 6去請求豆瓣首頁:

from urllib import requestreq = request.Request('http://www.douban.com/')
req.add_header('User-Agent', 'Mozilla/6.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/8.0 Mobile/10A5376e Safari/8536.25')
with request.urlopen(req) as f:print('Status:', f.status, f.reason)for k, v in f.getheaders():print('%s: %s' % (k, v))print('Data:', f.read().decode('utf-8'))

這樣豆瓣會返回適合iPhone的移動版網頁:

...<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0"><meta name="format-detection" content="telephone=no"><link rel="apple-touch-icon" sizes="57x57" href="http://img4.douban.com/pics/cardkit/launcher/57.png" />
...
Post

如果要以POST發(fā)送一個請求,只需要把參數(shù)data以bytes形式傳入。

我們模擬一個微博登錄,先讀取登錄的郵箱和口令,然后按照weibo.cn的登錄頁的格式以username=xxx&password=xxx的編碼傳入:

from urllib import request, parseprint('Login to weibo.cn...')
email = input('Email: ')
passwd = input('Password: ')
login_data = parse.urlencode([('username', email),('password', passwd),('entry', 'mweibo'),('client_id', ''),('savestate', '1'),('ec', ''),('pagerefer', 'https://passport.weibo.cn/signin/welcome?entry=mweibo&r=http%3A%2F%2Fm.weibo.cn%2F')
])req = request.Request('https://passport.weibo.cn/sso/login')
req.add_header('Origin', 'https://passport.weibo.cn')
req.add_header('User-Agent', 'Mozilla/6.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/8.0 Mobile/10A5376e Safari/8536.25')
req.add_header('Referer', 'https://passport.weibo.cn/signin/login?entry=mweibo&res=wel&wm=3349&r=http%3A%2F%2Fm.weibo.cn%2F')with request.urlopen(req, data=login_data.encode('utf-8')) as f:print('Status:', f.status, f.reason)for k, v in f.getheaders():print('%s: %s' % (k, v))print('Data:', f.read().decode('utf-8'))

如果登錄成功,我們獲得的響應如下:

Status: 200 OK
Server: nginx/1.2.0
...
Set-Cookie: SSOLoginState=1432620126; path=/; domain=weibo.cn
...
Data: {"retcode":20000000,"msg":"","data":{...,"uid":"1658384301"}}

如果登錄失敗,我們獲得的響應如下:

...
Data: {"retcode":50011015,"msg":"\u7528\u6237\u540d\u6216\u5bc6\u7801\u9519\u8bef","data":{"username":"example@python.org","errline":536}}
Handler

如果還需要更復雜的控制,比如通過一個Proxy去訪問網站,我們需要利用ProxyHandler來處理,示例代碼如下:

proxy_handler = urllib.request.ProxyHandler({'http': 'http://www.example.com:3128/'})
proxy_auth_handler = urllib.request.ProxyBasicAuthHandler()
proxy_auth_handler.add_password('realm', 'host', 'username', 'password')
opener = urllib.request.build_opener(proxy_handler, proxy_auth_handler)
with opener.open('http://www.example.com/login.html') as f:pass
小結

urllib提供的功能就是利用程序去執(zhí)行各種HTTP請求。如果要模擬瀏覽器完成特定功能,需要把請求偽裝成瀏覽器。偽裝的方法是先監(jiān)控瀏覽器發(fā)出的請求,再根據(jù)瀏覽器的請求頭來偽裝,User-Agent頭就是用來標識瀏覽器的。

練習

利用urllib讀取XML,將XML一節(jié)的數(shù)據(jù)由硬編碼改為由urllib獲取:

from urllib import request, parsedef fetch_xml(url):
----pass
----
# 測試
print(fetch_xml('http://weather.yahooapis.com/forecastrss?u=c&w=2151330'))
參考源碼

use_urllib.py

常用第三方模塊

除了內建的模塊外,Python還有大量的第三方模塊。

基本上,所有的第三方模塊都會在PyPI - the Python Package Index上注冊,只要找到對應的模塊名字,即可用pip安裝。

本章介紹常用的第三方模塊。

PIL

PIL:Python Imaging Library,已經是Python平臺事實上的圖像處理標準庫了。PIL功能非常強大,但API卻非常簡單易用。

由于PIL僅支持到Python 2.7,加上年久失修,于是一群志愿者在PIL的基礎上創(chuàng)建了兼容的版本,名字叫Pillow,支持最新Python 3.x,又加入了許多新特性,因此,我們可以直接安裝使用Pillow。

安裝Pillow

在命令行下直接通過pip安裝:

$ pip install pillow

如果遇到Permission denied安裝失敗,請加上sudo重試。

操作圖像

來看看最常見的圖像縮放操作,只需三四行代碼:

from PIL import Image# 打開一個jpg圖像文件,注意是當前路徑:
im = Image.open('test.jpg')
# 獲得圖像尺寸:
w, h = im.size
print('Original image size: %sx%s' % (w, h))
# 縮放到50%:
im.thumbnail((w//2, h//2))
print('Resize image to: %sx%s' % (w//2, h//2))
# 把縮放后的圖像用jpeg格式保存:
im.save('thumbnail.jpg', 'jpeg')

其他功能如切片、旋轉、濾鏡、輸出文字、調色板等一應俱全。

比如,模糊效果也只需幾行代碼:

from PIL import Image, ImageFilter# 打開一個jpg圖像文件,注意是當前路徑:
im = Image.open('test.jpg')
# 應用模糊濾鏡:
im2 = im.filter(ImageFilter.BLUR)
im2.save('blur.jpg', 'jpeg')

效果如下:

PIL-blur

PIL的ImageDraw提供了一系列繪圖方法,讓我們可以直接繪圖。比如要生成字母驗證碼圖片:

from PIL import Image, ImageDraw, ImageFont, ImageFilterimport random# 隨機字母:
def rndChar():return chr(random.randint(65, 90))# 隨機顏色1:
def rndColor():return (random.randint(64, 255), random.randint(64, 255), random.randint(64, 255))# 隨機顏色2:
def rndColor2():return (random.randint(32, 127), random.randint(32, 127), random.randint(32, 127))# 240 x 60:
width = 60 * 4
height = 60
image = Image.new('RGB', (width, height), (255, 255, 255))
# 創(chuàng)建Font對象:
font = ImageFont.truetype('Arial.ttf', 36)
# 創(chuàng)建Draw對象:
draw = ImageDraw.Draw(image)
# 填充每個像素:
for x in range(width):for y in range(height):draw.point((x, y), fill=rndColor())
# 輸出文字:
for t in range(4):draw.text((60 * t + 10, 10), rndChar(), font=font, fill=rndColor2())
# 模糊:
image = image.filter(ImageFilter.BLUR)
image.save('code.jpg', 'jpeg')

我們用隨機顏色填充背景,再畫上文字,最后對圖像進行模糊,得到驗證碼圖片如下:

驗證碼

如果運行的時候報錯:

IOError: cannot open resource

這是因為PIL無法定位到字體文件的位置,可以根據(jù)操作系統(tǒng)提供絕對路徑,比如:

'/Library/Fonts/Arial.ttf'

要詳細了解PIL的強大功能,請請參考Pillow官方文檔:

https://pillow.readthedocs.org/

小結

PIL提供了操作圖像的強大功能,可以通過簡單的代碼完成復雜的圖像處理。

參考源碼

https://github.com/michaelliao/learn-python3/blob/master/samples/packages/pil/use_pil_resize.py

https://github.com/michaelliao/learn-python3/blob/master/samples/packages/pil/use_pil_blur.py

https://github.com/michaelliao/learn-python3/blob/master/samples/packages/pil/use_pil_draw.py

virtualenv

在開發(fā)Python應用程序的時候,系統(tǒng)安裝的Python3只有一個版本:3.4。所有第三方的包都會被pip安裝到Python3的site-packages目錄下。

如果我們要同時開發(fā)多個應用程序,那這些應用程序都會共用一個Python,就是安裝在系統(tǒng)的Python 3。如果應用A需要jinja 2.7,而應用B需要jinja 2.6怎么辦?

這種情況下,每個應用可能需要各自擁有一套“獨立”的Python運行環(huán)境。virtualenv就是用來為一個應用創(chuàng)建一套“隔離”的Python運行環(huán)境。

首先,我們用pip安裝virtualenv:

$ pip3 install virtualenv

然后,假定我們要開發(fā)一個新的項目,需要一套獨立的Python運行環(huán)境,可以這么做:

第一步,創(chuàng)建目錄:

Mac:~ michael$ mkdir myproject
Mac:~ michael$ cd myproject/
Mac:myproject michael$

第二步,創(chuàng)建一個獨立的Python運行環(huán)境,命名為venv

Mac:myproject michael$ virtualenv --no-site-packages venv
Using base prefix '/usr/local/.../Python.framework/Versions/3.4'
New python executable in venv/bin/python3.4
Also creating executable in venv/bin/python
Installing setuptools, pip, wheel...done.

命令virtualenv就可以創(chuàng)建一個獨立的Python運行環(huán)境,我們還加上了參數(shù)--no-site-packages,這樣,已經安裝到系統(tǒng)Python環(huán)境中的所有第三方包都不會復制過來,這樣,我們就得到了一個不帶任何第三方包的“干凈”的Python運行環(huán)境。

新建的Python環(huán)境被放到當前目錄下的venv目錄。有了venv這個Python環(huán)境,可以用source進入該環(huán)境:

Mac:myproject michael$ source venv/bin/activate
(venv)Mac:myproject michael$

注意到命令提示符變了,有個(venv)前綴,表示當前環(huán)境是一個名為venv的Python環(huán)境。

下面正常安裝各種第三方包,并運行python命令:

(venv)Mac:myproject michael$ pip install jinja2
...
Successfully installed jinja2-2.7.3 markupsafe-0.23
(venv)Mac:myproject michael$ python myapp.py
...

venv環(huán)境下,用pip安裝的包都被安裝到venv這個環(huán)境下,系統(tǒng)Python環(huán)境不受任何影響。也就是說,venv環(huán)境是專門針對myproject這個應用創(chuàng)建的。

退出當前的venv環(huán)境,使用deactivate命令:

(venv)Mac:myproject michael$ deactivate 
Mac:myproject michael$

此時就回到了正常的環(huán)境,現(xiàn)在pippython均是在系統(tǒng)Python環(huán)境下執(zhí)行。

完全可以針對每個應用創(chuàng)建獨立的Python運行環(huán)境,這樣就可以對每個應用的Python環(huán)境進行隔離。

virtualenv是如何創(chuàng)建“獨立”的Python運行環(huán)境的呢?原理很簡單,就是把系統(tǒng)Python復制一份到virtualenv的環(huán)境,用命令source venv/bin/activate進入一個virtualenv環(huán)境時,virtualenv會修改相關環(huán)境變量,讓命令pythonpip均指向當前的virtualenv環(huán)境。

小結

virtualenv為應用提供了隔離的Python運行環(huán)境,解決了不同應用間多版本的沖突問題。

圖形界面

Python支持多種圖形界面的第三方庫,包括:

  • Tk

  • wxWidgets

  • Qt

  • GTK

等等。

但是Python自帶的庫是支持Tk的Tkinter,使用Tkinter,無需安裝任何包,就可以直接使用。本章簡單介紹如何使用Tkinter進行GUI編程。

Tkinter

我們來梳理一下概念:

我們編寫的Python代碼會調用內置的Tkinter,Tkinter封裝了訪問Tk的接口;

Tk是一個圖形庫,支持多個操作系統(tǒng),使用Tcl語言開發(fā);

Tk會調用操作系統(tǒng)提供的本地GUI接口,完成最終的GUI。

所以,我們的代碼只需要調用Tkinter提供的接口就可以了。

第一個GUI程序

使用Tkinter十分簡單,我們來編寫一個GUI版本的“Hello, world!”。

第一步是導入Tkinter包的所有內容:

from tkinter import *

第二步是從Frame派生一個Application類,這是所有Widget的父容器:

class Application(Frame):def __init__(self, master=None):Frame.__init__(self, master)self.pack()self.createWidgets()def createWidgets(self):self.helloLabel = Label(self, text='Hello, world!')self.helloLabel.pack()self.quitButton = Button(self, text='Quit', command=self.quit)self.quitButton.pack()

在GUI中,每個Button、Label、輸入框等,都是一個Widget。Frame則是可以容納其他Widget的Widget,所有的Widget組合起來就是一棵樹。

pack()方法把Widget加入到父容器中,并實現(xiàn)布局。pack()是最簡單的布局,grid()可以實現(xiàn)更復雜的布局。

createWidgets()方法中,我們創(chuàng)建一個Label和一個Button,當Button被點擊時,觸發(fā)self.quit()使程序退出。

第三步,實例化Application,并啟動消息循環(huán):

app = Application()
# 設置窗口標題:
app.master.title('Hello World')
# 主消息循環(huán):
app.mainloop()

GUI程序的主線程負責監(jiān)聽來自操作系統(tǒng)的消息,并依次處理每一條消息。因此,如果消息處理非常耗時,就需要在新線程中處理。

運行這個GUI程序,可以看到下面的窗口:

tk-hello-world

點擊“Quit”按鈕或者窗口的“x”結束程序。

輸入文本

我們再對這個GUI程序改進一下,加入一個文本框,讓用戶可以輸入文本,然后點按鈕后,彈出消息對話框。

from tkinter import *
import tkinter.messagebox as messageboxclass Application(Frame):def __init__(self, master=None):Frame.__init__(self, master)self.pack()self.createWidgets()def createWidgets(self):self.nameInput = Entry(self)self.nameInput.pack()self.alertButton = Button(self, text='Hello', command=self.hello)self.alertButton.pack()def hello(self):name = self.nameInput.get() or 'world'messagebox.showinfo('Message', 'Hello, %s' % name)app = Application()
# 設置窗口標題:
app.master.title('Hello World')
# 主消息循環(huán):
app.mainloop()

當用戶點擊按鈕時,觸發(fā)hello(),通過self.nameInput.get()獲得用戶輸入的文本后,使用tkMessageBox.showinfo()可以彈出消息對話框。

程序運行結果如下:

tk-say-hello

小結

Python內置的Tkinter可以滿足基本的GUI程序的要求,如果是非常復雜的GUI程序,建議用操作系統(tǒng)原生支持的語言和庫來編寫。

參考源碼

hello_gui.py

網絡編程

自從互聯(lián)網誕生以來,現(xiàn)在基本上所有的程序都是網絡程序,很少有單機版的程序了。

計算機網絡就是把各個計算機連接到一起,讓網絡中的計算機可以互相通信。網絡編程就是如何在程序中實現(xiàn)兩臺計算機的通信。

舉個例子,當你使用瀏覽器訪問新浪網時,你的計算機就和新浪的某臺服務器通過互聯(lián)網連接起來了,然后,新浪的服務器把網頁內容作為數(shù)據(jù)通過互聯(lián)網傳輸?shù)侥愕碾娔X上。

由于你的電腦上可能不止瀏覽器,還有QQ、Skype、Dropbox、郵件客戶端等,不同的程序連接的別的計算機也會不同,所以,更確切地說,網絡通信是兩臺計算機上的兩個進程之間的通信。比如,瀏覽器進程和新浪服務器上的某個Web服務進程在通信,而QQ進程是和騰訊的某個服務器上的某個進程在通信。

網絡通信就是兩個進程在通信

網絡編程對所有開發(fā)語言都是一樣的,Python也不例外。用Python進行網絡編程,就是在Python程序本身這個進程內,連接別的服務器進程的通信端口進行通信。

本章我們將詳細介紹Python網絡編程的概念和最主要的兩種網絡類型的編程。

TCP/IP簡介

雖然大家現(xiàn)在對互聯(lián)網很熟悉,但是計算機網絡的出現(xiàn)比互聯(lián)網要早很多。

計算機為了聯(lián)網,就必須規(guī)定通信協(xié)議,早期的計算機網絡,都是由各廠商自己規(guī)定一套協(xié)議,IBM、Apple和Microsoft都有各自的網絡協(xié)議,互不兼容,這就好比一群人有的說英語,有的說中文,有的說德語,說同一種語言的人可以交流,不同的語言之間就不行了。

為了把全世界的所有不同類型的計算機都連接起來,就必須規(guī)定一套全球通用的協(xié)議,為了實現(xiàn)互聯(lián)網這個目標,互聯(lián)網協(xié)議簇(Internet Protocol Suite)就是通用協(xié)議標準。Internet是由inter和net兩個單詞組合起來的,原意就是連接“網絡”的網絡,有了Internet,任何私有網絡,只要支持這個協(xié)議,就可以聯(lián)入互聯(lián)網。

因為互聯(lián)網協(xié)議包含了上百種協(xié)議標準,但是最重要的兩個協(xié)議是TCP和IP協(xié)議,所以,大家把互聯(lián)網的協(xié)議簡稱TCP/IP協(xié)議。

通信的時候,雙方必須知道對方的標識,好比發(fā)郵件必須知道對方的郵件地址?;ヂ?lián)網上每個計算機的唯一標識就是IP地址,類似123.123.123.123。如果一臺計算機同時接入到兩個或更多的網絡,比如路由器,它就會有兩個或多個IP地址,所以,IP地址對應的實際上是計算機的網絡接口,通常是網卡。

IP協(xié)議負責把數(shù)據(jù)從一臺計算機通過網絡發(fā)送到另一臺計算機。數(shù)據(jù)被分割成一小塊一小塊,然后通過IP包發(fā)送出去。由于互聯(lián)網鏈路復雜,兩臺計算機之間經常有多條線路,因此,路由器就負責決定如何把一個IP包轉發(fā)出去。IP包的特點是按塊發(fā)送,途徑多個路由,但不保證能到達,也不保證順序到達。

internet-computers

IP地址實際上是一個32位整數(shù)(稱為IPv4),以字符串表示的IP地址如192.168.0.1實際上是把32位整數(shù)按8位分組后的數(shù)字表示,目的是便于閱讀。

IPv6地址實際上是一個128位整數(shù),它是目前使用的IPv4的升級版,以字符串表示類似于2001:0db8:85a3:0042:1000:8a2e:0370:7334。

TCP協(xié)議則是建立在IP協(xié)議之上的。TCP協(xié)議負責在兩臺計算機之間建立可靠連接,保證數(shù)據(jù)包按順序到達。TCP協(xié)議會通過握手建立連接,然后,對每個IP包編號,確保對方按順序收到,如果包丟掉了,就自動重發(fā)。

許多常用的更高級的協(xié)議都是建立在TCP協(xié)議基礎上的,比如用于瀏覽器的HTTP協(xié)議、發(fā)送郵件的SMTP協(xié)議等。

一個IP包除了包含要傳輸?shù)臄?shù)據(jù)外,還包含源IP地址和目標IP地址,源端口和目標端口。

端口有什么作用?在兩臺計算機通信時,只發(fā)IP地址是不夠的,因為同一臺計算機上跑著多個網絡程序。一個IP包來了之后,到底是交給瀏覽器還是QQ,就需要端口號來區(qū)分。每個網絡程序都向操作系統(tǒng)申請唯一的端口號,這樣,兩個進程在兩臺計算機之間建立網絡連接就需要各自的IP地址和各自的端口號。

一個進程也可能同時與多個計算機建立鏈接,因此它會申請很多端口。

了解了TCP/IP協(xié)議的基本概念,IP地址和端口的概念,我們就可以開始進行網絡編程了。

TCP編程

Socket是網絡編程的一個抽象概念。通常我們用一個Socket表示“打開了一個網絡鏈接”,而打開一個Socket需要知道目標計算機的IP地址和端口號,再指定協(xié)議類型即可。

客戶端

大多數(shù)連接都是可靠的TCP連接。創(chuàng)建TCP連接時,主動發(fā)起連接的叫客戶端,被動響應連接的叫服務器。

舉個例子,當我們在瀏覽器中訪問新浪時,我們自己的計算機就是客戶端,瀏覽器會主動向新浪的服務器發(fā)起連接。如果一切順利,新浪的服務器接受了我們的連接,一個TCP連接就建立起來的,后面的通信就是發(fā)送網頁內容了。

所以,我們要創(chuàng)建一個基于TCP連接的Socket,可以這樣做:

# 導入socket庫:
import socket# 創(chuàng)建一個socket:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 建立連接:
s.connect(('www.sina.com.cn', 80))

創(chuàng)建Socket時,AF_INET指定使用IPv4協(xié)議,如果要用更先進的IPv6,就指定為AF_INET6。SOCK_STREAM指定使用面向流的TCP協(xié)議,這樣,一個Socket對象就創(chuàng)建成功,但是還沒有建立連接。

客戶端要主動發(fā)起TCP連接,必須知道服務器的IP地址和端口號。新浪網站的IP地址可以用域名www.sina.com.cn自動轉換到IP地址,但是怎么知道新浪服務器的端口號呢?

答案是作為服務器,提供什么樣的服務,端口號就必須固定下來。由于我們想要訪問網頁,因此新浪提供網頁服務的服務器必須把端口號固定在80端口,因為80端口是Web服務的標準端口。其他服務都有對應的標準端口號,例如SMTP服務是25端口,FTP服務是21端口,等等。端口號小于1024的是Internet標準服務的端口,端口號大于1024的,可以任意使用。

因此,我們連接新浪服務器的代碼如下:

s.connect(('www.sina.com.cn', 80))

注意參數(shù)是一個tuple,包含地址和端口號。

建立TCP連接后,我們就可以向新浪服務器發(fā)送請求,要求返回首頁的內容:

# 發(fā)送數(shù)據(jù):
s.send(b'GET / HTTP/1.1\r\nHost: www.sina.com.cn\r\nConnection: close\r\n\r\n')

TCP連接創(chuàng)建的是雙向通道,雙方都可以同時給對方發(fā)數(shù)據(jù)。但是誰先發(fā)誰后發(fā),怎么協(xié)調,要根據(jù)具體的協(xié)議來決定。例如,HTTP協(xié)議規(guī)定客戶端必須先發(fā)請求給服務器,服務器收到后才發(fā)數(shù)據(jù)給客戶端。

發(fā)送的文本格式必須符合HTTP標準,如果格式沒問題,接下來就可以接收新浪服務器返回的數(shù)據(jù)了:

# 接收數(shù)據(jù):
buffer = []
while True:# 每次最多接收1k字節(jié):d = s.recv(1024)if d:buffer.append(d)else:break
data = b''.join(buffer)

接收數(shù)據(jù)時,調用recv(max)方法,一次最多接收指定的字節(jié)數(shù),因此,在一個while循環(huán)中反復接收,直到recv()返回空數(shù)據(jù),表示接收完畢,退出循環(huán)。

當我們接收完數(shù)據(jù)后,調用close()方法關閉Socket,這樣,一次完整的網絡通信就結束了:

# 關閉連接:
s.close()

接收到的數(shù)據(jù)包括HTTP頭和網頁本身,我們只需要把HTTP頭和網頁分離一下,把HTTP頭打印出來,網頁內容保存到文件:

header, html = data.split(b'\r\n\r\n', 1)
print(header.decode('utf-8'))
# 把接收的數(shù)據(jù)寫入文件:
with open('sina.html', 'wb') as f:f.write(html)

現(xiàn)在,只需要在瀏覽器中打開這個sina.html文件,就可以看到新浪的首頁了。

服務器

和客戶端編程相比,服務器編程就要復雜一些。

服務器進程首先要綁定一個端口并監(jiān)聽來自其他客戶端的連接。如果某個客戶端連接過來了,服務器就與該客戶端建立Socket連接,隨后的通信就靠這個Socket連接了。

所以,服務器會打開固定端口(比如80)監(jiān)聽,每來一個客戶端連接,就創(chuàng)建該Socket連接。由于服務器會有大量來自客戶端的連接,所以,服務器要能夠區(qū)分一個Socket連接是和哪個客戶端綁定的。一個Socket依賴4項:服務器地址、服務器端口、客戶端地址、客戶端端口來唯一確定一個Socket。

但是服務器還需要同時響應多個客戶端的請求,所以,每個連接都需要一個新的進程或者新的線程來處理,否則,服務器一次就只能服務一個客戶端了。

我們來編寫一個簡單的服務器程序,它接收客戶端連接,把客戶端發(fā)過來的字符串加上Hello再發(fā)回去。

首先,創(chuàng)建一個基于IPv4和TCP協(xié)議的Socket:

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

然后,我們要綁定監(jiān)聽的地址和端口。服務器可能有多塊網卡,可以綁定到某一塊網卡的IP地址上,也可以用0.0.0.0綁定到所有的網絡地址,還可以用127.0.0.1綁定到本機地址。127.0.0.1是一個特殊的IP地址,表示本機地址,如果綁定到這個地址,客戶端必須同時在本機運行才能連接,也就是說,外部的計算機無法連接進來。

端口號需要預先指定。因為我們寫的這個服務不是標準服務,所以用9999這個端口號。請注意,小于1024的端口號必須要有管理員權限才能綁定:

# 監(jiān)聽端口:
s.bind(('127.0.0.1', 9999))

緊接著,調用listen()方法開始監(jiān)聽端口,傳入的參數(shù)指定等待連接的最大數(shù)量:

s.listen(5)
print('Waiting for connection...')

接下來,服務器程序通過一個永久循環(huán)來接受來自客戶端的連接,accept()會等待并返回一個客戶端的連接:

while True:# 接受一個新連接:sock, addr = s.accept()# 創(chuàng)建新線程來處理TCP連接:t = threading.Thread(target=tcplink, args=(sock, addr))t.start()

每個連接都必須創(chuàng)建新線程(或進程)來處理,否則,單線程在處理連接的過程中,無法接受其他客戶端的連接:

def tcplink(sock, addr):print('Accept new connection from %s:%s...' % addr)sock.send(b'Welcome!')while True:data = sock.recv(1024)time.sleep(1)if not data or data.decode('utf-8') == 'exit':breaksock.send(('Hello, %s!' % data.decode('utf-8')).encode('utf-8'))sock.close()print('Connection from %s:%s closed.' % addr)

連接建立后,服務器首先發(fā)一條歡迎消息,然后等待客戶端數(shù)據(jù),并加上Hello再發(fā)送給客戶端。如果客戶端發(fā)送了exit字符串,就直接關閉連接。

要測試這個服務器程序,我們還需要編寫一個客戶端程序:

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 建立連接:
s.connect(('127.0.0.1', 9999))
# 接收歡迎消息:
print(s.recv(1024).decode('utf-8'))
for data in [b'Michael', b'Tracy', b'Sarah']:# 發(fā)送數(shù)據(jù):s.send(data)print(s.recv(1024).decode('utf-8'))
s.send(b'exit')
s.close()

我們需要打開兩個命令行窗口,一個運行服務器程序,另一個運行客戶端程序,就可以看到效果了:

client-server

需要注意的是,客戶端程序運行完畢就退出了,而服務器程序會永遠運行下去,必須按Ctrl+C退出程序。

小結

用TCP協(xié)議進行Socket編程在Python中十分簡單,對于客戶端,要主動連接服務器的IP和指定端口,對于服務器,要首先監(jiān)聽指定端口,然后,對每一個新的連接,創(chuàng)建一個線程或進程來處理。通常,服務器程序會無限運行下去。

同一個端口,被一個Socket綁定了以后,就不能被別的Socket綁定了。

參考源碼

do_tcp.py

UDP編程

TCP是建立可靠連接,并且通信雙方都可以以流的形式發(fā)送數(shù)據(jù)。相對TCP,UDP則是面向無連接的協(xié)議。

使用UDP協(xié)議時,不需要建立連接,只需要知道對方的IP地址和端口號,就可以直接發(fā)數(shù)據(jù)包。但是,能不能到達就不知道了。

雖然用UDP傳輸數(shù)據(jù)不可靠,但它的優(yōu)點是和TCP比,速度快,對于不要求可靠到達的數(shù)據(jù),就可以使用UDP協(xié)議。

我們來看看如何通過UDP協(xié)議傳輸數(shù)據(jù)。和TCP類似,使用UDP的通信雙方也分為客戶端和服務器。服務器首先需要綁定端口:

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# 綁定端口:
s.bind(('127.0.0.1', 9999))

創(chuàng)建Socket時,SOCK_DGRAM指定了這個Socket的類型是UDP。綁定端口和TCP一樣,但是不需要調用listen()方法,而是直接接收來自任何客戶端的數(shù)據(jù):

print('Bind UDP on 9999...')
while True:# 接收數(shù)據(jù):data, addr = s.recvfrom(1024)print('Received from %s:%s.' % addr)s.sendto(b'Hello, %s!' % data, addr)

recvfrom()方法返回數(shù)據(jù)和客戶端的地址與端口,這樣,服務器收到數(shù)據(jù)后,直接調用sendto()就可以把數(shù)據(jù)用UDP發(fā)給客戶端。

注意這里省掉了多線程,因為這個例子很簡單。

客戶端使用UDP時,首先仍然創(chuàng)建基于UDP的Socket,然后,不需要調用connect(),直接通過sendto()給服務器發(fā)數(shù)據(jù):

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for data in [b'Michael', b'Tracy', b'Sarah']:# 發(fā)送數(shù)據(jù):s.sendto(data, ('127.0.0.1', 9999))# 接收數(shù)據(jù):print(s.recv(1024).decode('utf-8'))
s.close()

從服務器接收數(shù)據(jù)仍然調用recv()方法。

仍然用兩個命令行分別啟動服務器和客戶端測試,結果如下:

client-server

小結

UDP的使用與TCP類似,但是不需要建立連接。此外,服務器綁定UDP端口和TCP端口互不沖突,也就是說,UDP的9999端口與TCP的9999端口可以各自綁定。

參考源碼

udp_server.py

udp_client.py

電子郵件

Email的歷史比Web還要久遠,直到現(xiàn)在,Email也是互聯(lián)網上應用非常廣泛的服務。

幾乎所有的編程語言都支持發(fā)送和接收電子郵件,但是,先等等,在我們開始編寫代碼之前,有必要搞清楚電子郵件是如何在互聯(lián)網上運作的。

我們來看看傳統(tǒng)郵件是如何運作的。假設你現(xiàn)在在北京,要給一個香港的朋友發(fā)一封信,怎么做呢?

首先你得寫好信,裝進信封,寫上地址,貼上郵票,然后就近找個郵局,把信仍進去。

信件會從就近的小郵局轉運到大郵局,再從大郵局往別的城市發(fā),比如先發(fā)到天津,再走海運到達香港,也可能走京九線到香港,但是你不用關心具體路線,你只需要知道一件事,就是信件走得很慢,至少要幾天時間。

信件到達香港的某個郵局,也不會直接送到朋友的家里,因為郵局的叔叔是很聰明的,他怕你的朋友不在家,一趟一趟地白跑,所以,信件會投遞到你的朋友的郵箱里,郵箱可能在公寓的一層,或者家門口,直到你的朋友回家的時候檢查郵箱,發(fā)現(xiàn)信件后,就可以取到郵件了。

電子郵件的流程基本上也是按上面的方式運作的,只不過速度不是按天算,而是按秒算。

現(xiàn)在我們回到電子郵件,假設我們自己的電子郵件地址是me@163.com,對方的電子郵件地址是friend@sina.com(注意地址都是虛構的哈),現(xiàn)在我們用Outlook或者Foxmail之類的軟件寫好郵件,填上對方的Email地址,點“發(fā)送”,電子郵件就發(fā)出去了。這些電子郵件軟件被稱為MUA:Mail User Agent——郵件用戶代理。

Email從MUA發(fā)出去,不是直接到達對方電腦,而是發(fā)到MTA:Mail Transfer Agent——郵件傳輸代理,就是那些Email服務提供商,比如網易、新浪等等。由于我們自己的電子郵件是163.com,所以,Email首先被投遞到網易提供的MTA,再由網易的MTA發(fā)到對方服務商,也就是新浪的MTA。這個過程中間可能還會經過別的MTA,但是我們不關心具體路線,我們只關心速度。

Email到達新浪的MTA后,由于對方使用的是@sina.com的郵箱,因此,新浪的MTA會把Email投遞到郵件的最終目的地MDA:Mail Delivery Agent——郵件投遞代理。Email到達MDA后,就靜靜地躺在新浪的某個服務器上,存放在某個文件或特殊的數(shù)據(jù)庫里,我們將這個長期保存郵件的地方稱之為電子郵箱。

同普通郵件類似,Email不會直接到達對方的電腦,因為對方電腦不一定開機,開機也不一定聯(lián)網。對方要取到郵件,必須通過MUA從MDA上把郵件取到自己的電腦上。

所以,一封電子郵件的旅程就是:

發(fā)件人 -> MUA -> MTA -> MTA -> 若干個MTA -> MDA <- MUA <- 收件人

有了上述基本概念,要編寫程序來發(fā)送和接收郵件,本質上就是:

  1. 編寫MUA把郵件發(fā)到MTA;

  2. 編寫MUA從MDA上收郵件。

發(fā)郵件時,MUA和MTA使用的協(xié)議就是SMTP:Simple Mail Transfer Protocol,后面的MTA到另一個MTA也是用SMTP協(xié)議。

收郵件時,MUA和MDA使用的協(xié)議有兩種:POP:Post Office Protocol,目前版本是3,俗稱POP3;IMAP:Internet Message Access Protocol,目前版本是4,優(yōu)點是不但能取郵件,還可以直接操作MDA上存儲的郵件,比如從收件箱移到垃圾箱,等等。

郵件客戶端軟件在發(fā)郵件時,會讓你先配置SMTP服務器,也就是你要發(fā)到哪個MTA上。假設你正在使用163的郵箱,你就不能直接發(fā)到新浪的MTA上,因為它只服務新浪的用戶,所以,你得填163提供的SMTP服務器地址:smtp.163.com,為了證明你是163的用戶,SMTP服務器還要求你填寫郵箱地址和郵箱口令,這樣,MUA才能正常地把Email通過SMTP協(xié)議發(fā)送到MTA。

類似的,從MDA收郵件時,MDA服務器也要求驗證你的郵箱口令,確保不會有人冒充你收取你的郵件,所以,Outlook之類的郵件客戶端會要求你填寫POP3或IMAP服務器地址、郵箱地址和口令,這樣,MUA才能順利地通過POP或IMAP協(xié)議從MDA取到郵件。

在使用Python收發(fā)郵件前,請先準備好至少兩個電子郵件,如xxx@163.comxxx@sina.comxxx@qq.com等,注意兩個郵箱不要用同一家郵件服務商。

最后特別注意,目前大多數(shù)郵件服務商都需要手動打開SMTP發(fā)信和POP收信的功能,否則只允許在網頁登錄:

qqmail-setting

SMTP發(fā)送郵件

SMTP是發(fā)送郵件的協(xié)議,Python內置對SMTP的支持,可以發(fā)送純文本郵件、HTML郵件以及帶附件的郵件。

Python對SMTP支持有smtplibemail兩個模塊,email負責構造郵件,smtplib負責發(fā)送郵件。

首先,我們來構造一個最簡單的純文本郵件:

from email.mime.text import MIMEText
msg = MIMEText('hello, send by Python...', 'plain', 'utf-8')

注意到構造MIMEText對象時,第一個參數(shù)就是郵件正文,第二個參數(shù)是MIME的subtype,傳入'plain'表示純文本,最終的MIME就是'text/plain',最后一定要用utf-8編碼保證多語言兼容性。

然后,通過SMTP發(fā)出去:

# 輸入Email地址和口令:
from_addr = input('From: ')
password = input('Password: ')
# 輸入收件人地址:
to_addr = input('To: ')
# 輸入SMTP服務器地址:
smtp_server = input('SMTP server: ')import smtplib
server = smtplib.SMTP(smtp_server, 25) # SMTP協(xié)議默認端口是25
server.set_debuglevel(1)
server.login(from_addr, password)
server.sendmail(from_addr, [to_addr], msg.as_string())
server.quit()

我們用set_debuglevel(1)就可以打印出和SMTP服務器交互的所有信息。SMTP協(xié)議就是簡單的文本命令和響應。login()方法用來登錄SMTP服務器,sendmail()方法就是發(fā)郵件,由于可以一次發(fā)給多個人,所以傳入一個list,郵件正文是一個stras_string()MIMEText對象變成str。

如果一切順利,就可以在收件人信箱中收到我們剛發(fā)送的Email:

send-mail

仔細觀察,發(fā)現(xiàn)如下問題:

  1. 郵件沒有主題;
  2. 收件人的名字沒有顯示為友好的名字,比如Mr Green <green@example.com>
  3. 明明收到了郵件,卻提示不在收件人中。

這是因為郵件主題、如何顯示發(fā)件人、收件人等信息并不是通過SMTP協(xié)議發(fā)給MTA,而是包含在發(fā)給MTA的文本中的,所以,我們必須把From、ToSubject添加到MIMEText中,才是一封完整的郵件:

from email import encoders
from email.header import Header
from email.mime.text import MIMEText
from email.utils import parseaddr, formataddrimport smtplibdef _format_addr(s):name, addr = parseaddr(s)return formataddr((Header(name, 'utf-8').encode(), addr))from_addr = input('From: ')
password = input('Password: ')
to_addr = input('To: ')
smtp_server = input('SMTP server: ')msg = MIMEText('hello, send by Python...', 'plain', 'utf-8')
msg['From'] = _format_addr('Python愛好者 <%s>' % from_addr)
msg['To'] = _format_addr('管理員 <%s>' % to_addr)
msg['Subject'] = Header('來自SMTP的問候……', 'utf-8').encode()server = smtplib.SMTP(smtp_server, 25)
server.set_debuglevel(1)
server.login(from_addr, password)
server.sendmail(from_addr, [to_addr], msg.as_string())
server.quit()

我們編寫了一個函數(shù)_format_addr()來格式化一個郵件地址。注意不能簡單地傳入name <addr@example.com>,因為如果包含中文,需要通過Header對象進行編碼。

msg['To']接收的是字符串而不是list,如果有多個郵件地址,用,分隔即可。

再發(fā)送一遍郵件,就可以在收件人郵箱中看到正確的標題、發(fā)件人和收件人:

mail-with-header

你看到的收件人的名字很可能不是我們傳入的管理員,因為很多郵件服務商在顯示郵件時,會把收件人名字自動替換為用戶注冊的名字,但是其他收件人名字的顯示不受影響。

如果我們查看Email的原始內容,可以看到如下經過編碼的郵件頭:

From: =?utf-8?b?UHl0aG9u54ix5aW96ICF?= <xxxxxx@163.com>
To: =?utf-8?b?566h55CG5ZGY?= <xxxxxx@qq.com>
Subject: =?utf-8?b?5p2l6IeqU01UUOeahOmXruWAmeKApuKApg==?=

這就是經過Header對象編碼的文本,包含utf-8編碼信息和Base64編碼的文本。如果我們自己來手動構造這樣的編碼文本,顯然比較復雜。

發(fā)送HTML郵件

如果我們要發(fā)送HTML郵件,而不是普通的純文本文件怎么辦?方法很簡單,在構造MIMEText對象時,把HTML字符串傳進去,再把第二個參數(shù)由plain變?yōu)?code>html就可以了:

msg = MIMEText('<html><body><h1>Hello</h1>' +'<p>send by <a href="http://www.python.org">Python</a>...</p>' +'</body></html>', 'html', 'utf-8')

再發(fā)送一遍郵件,你將看到以HTML顯示的郵件:

html-mail

發(fā)送附件

如果Email中要加上附件怎么辦?帶附件的郵件可以看做包含若干部分的郵件:文本和各個附件本身,所以,可以構造一個MIMEMultipart對象代表郵件本身,然后往里面加上一個MIMEText作為郵件正文,再繼續(xù)往里面加上表示附件的MIMEBase對象即可:

# 郵件對象:
msg = MIMEMultipart()
msg['From'] = _format_addr('Python愛好者 <%s>' % from_addr)
msg['To'] = _format_addr('管理員 <%s>' % to_addr)
msg['Subject'] = Header('來自SMTP的問候……', 'utf-8').encode()# 郵件正文是MIMEText:
msg.attach(MIMEText('send with file...', 'plain', 'utf-8'))# 添加附件就是加上一個MIMEBase,從本地讀取一個圖片:
with open('/Users/michael/Downloads/test.png', 'rb') as f:# 設置附件的MIME和文件名,這里是png類型:mime = MIMEBase('image', 'png', filename='test.png')# 加上必要的頭信息:mime.add_header('Content-Disposition', 'attachment', filename='test.png')mime.add_header('Content-ID', '<0>')mime.add_header('X-Attachment-Id', '0')# 把附件的內容讀進來:mime.set_payload(f.read())# 用Base64編碼:encoders.encode_base64(mime)# 添加到MIMEMultipart:msg.attach(mime)

然后,按正常發(fā)送流程把msg(注意類型已變?yōu)?code>MIMEMultipart)發(fā)送出去,就可以收到如下帶附件的郵件:

mimemultipart

發(fā)送圖片

如果要把一個圖片嵌入到郵件正文中怎么做?直接在HTML郵件中鏈接圖片地址行不行?答案是,大部分郵件服務商都會自動屏蔽帶有外鏈的圖片,因為不知道這些鏈接是否指向惡意網站。

要把圖片嵌入到郵件正文中,我們只需按照發(fā)送附件的方式,先把郵件作為附件添加進去,然后,在HTML中通過引用src="cid:0"就可以把附件作為圖片嵌入了。如果有多個圖片,給它們依次編號,然后引用不同的cid:x即可。

把上面代碼加入MIMEMultipartMIMETextplain改為html,然后在適當?shù)奈恢靡脠D片:

msg.attach(MIMEText('<html><body><h1>Hello</h1>' +'<p><img src="cid:0"></p>' +'</body></html>', 'html', 'utf-8'))

再次發(fā)送,就可以看到圖片直接嵌入到郵件正文的效果:

email-inline-image

同時支持HTML和Plain格式

如果我們發(fā)送HTML郵件,收件人通過瀏覽器或者Outlook之類的軟件是可以正常瀏覽郵件內容的,但是,如果收件人使用的設備太古老,查看不了HTML郵件怎么辦?

辦法是在發(fā)送HTML的同時再附加一個純文本,如果收件人無法查看HTML格式的郵件,就可以自動降級查看純文本郵件。

利用MIMEMultipart就可以組合一個HTML和Plain,要注意指定subtype是alternative

msg = MIMEMultipart('alternative')
msg['From'] = ...
msg['To'] = ...
msg['Subject'] = ...msg.attach(MIMEText('hello', 'plain', 'utf-8'))
msg.attach(MIMEText('<html><body><h1>Hello</h1></body></html>', 'html', 'utf-8'))
# 正常發(fā)送msg對象...
加密SMTP

使用標準的25端口連接SMTP服務器時,使用的是明文傳輸,發(fā)送郵件的整個過程可能會被竊聽。要更安全地發(fā)送郵件,可以加密SMTP會話,實際上就是先創(chuàng)建SSL安全連接,然后再使用SMTP協(xié)議發(fā)送郵件。

某些郵件服務商,例如Gmail,提供的SMTP服務必須要加密傳輸。我們來看看如何通過Gmail提供的安全SMTP發(fā)送郵件。

必須知道,Gmail的SMTP端口是587,因此,修改代碼如下:

smtp_server = 'smtp.gmail.com'
smtp_port = 587
server = smtplib.SMTP(smtp_server, smtp_port)
server.starttls()
# 剩下的代碼和前面的一模一樣:
server.set_debuglevel(1)
...

只需要在創(chuàng)建SMTP對象后,立刻調用starttls()方法,就創(chuàng)建了安全連接。后面的代碼和前面的發(fā)送郵件代碼完全一樣。

如果因為網絡問題無法連接Gmail的SMTP服務器,請相信我們的代碼是沒有問題的,你需要對你的網絡設置做必要的調整。

小結

使用Python的smtplib發(fā)送郵件十分簡單,只要掌握了各種郵件類型的構造方法,正確設置好郵件頭,就可以順利發(fā)出。

構造一個郵件對象就是一個Messag對象,如果構造一個MIMEText對象,就表示一個文本郵件對象,如果構造一個MIMEImage對象,就表示一個作為附件的圖片,要把多個對象組合起來,就用MIMEMultipart對象,而MIMEBase可以表示任何對象。它們的繼承關系如下:

Message
+- MIMEBase+- MIMEMultipart+- MIMENonMultipart+- MIMEMessage+- MIMEText+- MIMEImage

這種嵌套關系就可以構造出任意復雜的郵件。你可以通過email.mime文檔查看它們所在的包以及詳細的用法。

參考源碼

send_mail.py

POP3收取郵件

SMTP用于發(fā)送郵件,如果要收取郵件呢?

收取郵件就是編寫一個MUA作為客戶端,從MDA把郵件獲取到用戶的電腦或者手機上。收取郵件最常用的協(xié)議是POP協(xié)議,目前版本號是3,俗稱POP3。

Python內置一個poplib模塊,實現(xiàn)了POP3協(xié)議,可以直接用來收郵件。

注意到POP3協(xié)議收取的不是一個已經可以閱讀的郵件本身,而是郵件的原始文本,這和SMTP協(xié)議很像,SMTP發(fā)送的也是經過編碼后的一大段文本。

要把POP3收取的文本變成可以閱讀的郵件,還需要用email模塊提供的各種類來解析原始文本,變成可閱讀的郵件對象。

所以,收取郵件分兩步:

第一步:用poplib把郵件的原始文本下載到本地;

第二部:用email解析原始文本,還原為郵件對象。

通過POP3下載郵件

POP3協(xié)議本身很簡單,以下面的代碼為例,我們來獲取最新的一封郵件內容:

import poplib# 輸入郵件地址, 口令和POP3服務器地址:
email = input('Email: ')
password = input('Password: ')
pop3_server = input('POP3 server: ')# 連接到POP3服務器:
server = poplib.POP3(pop3_server)
# 可以打開或關閉調試信息:
server.set_debuglevel(1)
# 可選:打印POP3服務器的歡迎文字:
print(server.getwelcome().decode('utf-8'))# 身份認證:
server.user(email)
server.pass_(password)# stat()返回郵件數(shù)量和占用空間:
print('Messages: %s. Size: %s' % server.stat())
# list()返回所有郵件的編號:
resp, mails, octets = server.list()
# 可以查看返回的列表類似[b'1 82923', b'2 2184', ...]
print(mails)# 獲取最新一封郵件, 注意索引號從1開始:
index = len(mails)
resp, lines, octets = server.retr(index)# lines存儲了郵件的原始文本的每一行,
# 可以獲得整個郵件的原始文本:
msg_content = b'\r\n'.join(lines).decode('utf-8')
# 稍后解析出郵件:
msg = Parser().parsestr(msg_content)# 可以根據(jù)郵件索引號直接從服務器刪除郵件:
# server.dele(index)
# 關閉連接:
server.quit()

用POP3獲取郵件其實很簡單,要獲取所有郵件,只需要循環(huán)使用retr()把每一封郵件內容拿到即可。真正麻煩的是把郵件的原始內容解析為可以閱讀的郵件對象。

解析郵件

解析郵件的過程和上一節(jié)構造郵件正好相反,因此,先導入必要的模塊:

from email.parser import Parser
from email.header import decode_header
from email.utils import parseaddrimport poplib

只需要一行代碼就可以把郵件內容解析為Message對象:

msg = Parser().parsestr(msg_content)

但是這個Message對象本身可能是一個MIMEMultipart對象,即包含嵌套的其他MIMEBase對象,嵌套可能還不止一層。

所以我們要遞歸地打印出Message對象的層次結構:

# indent用于縮進顯示:
def print_info(msg, indent=0):if indent == 0:for header in ['From', 'To', 'Subject']:value = msg.get(header, '')if value:if header=='Subject':value = decode_str(value)else:hdr, addr = parseaddr(value)name = decode_str(hdr)value = u'%s <%s>' % (name, addr)print('%s%s: %s' % ('  ' * indent, header, value))if (msg.is_multipart()):parts = msg.get_payload()for n, part in enumerate(parts):print('%spart %s' % ('  ' * indent, n))print('%s--------------------' % ('  ' * indent))print_info(part, indent + 1)else:content_type = msg.get_content_type()if content_type=='text/plain' or content_type=='text/html':content = msg.get_payload(decode=True)charset = guess_charset(msg)if charset:content = content.decode(charset)print('%sText: %s' % ('  ' * indent, content + '...'))else:print('%sAttachment: %s' % ('  ' * indent, content_type))

郵件的Subject或者Email中包含的名字都是經過編碼后的str,要正常顯示,就必須decode:

def decode_str(s):value, charset = decode_header(s)[0]if charset:value = value.decode(charset)return value

decode_header()返回一個list,因為像Cc、Bcc這樣的字段可能包含多個郵件地址,所以解析出來的會有多個元素。上面的代碼我們偷了個懶,只取了第一個元素。

文本郵件的內容也是str,還需要檢測編碼,否則,非UTF-8編碼的郵件都無法正常顯示:

def guess_charset(msg):charset = msg.get_charset()if charset is None:content_type = msg.get('Content-Type', '').lower()pos = content_type.find('charset=')if pos >= 0:charset = content_type[pos + 8:].strip()return charset

把上面的代碼整理好,我們就可以來試試收取一封郵件。先往自己的郵箱發(fā)一封郵件,然后用瀏覽器登錄郵箱,看看郵件收到沒,如果收到了,我們就來用Python程序把它收到本地:

pop3-sample-mail

運行程序,結果如下:

+OK Welcome to coremail Mail Pop3 Server (163coms[...])
Messages: 126. Size: 27228317From: Test <xxxxxx@qq.com>
To: Python愛好者 <xxxxxx@163.com>
Subject: 用POP3收取郵件
part 0
--------------------part 0--------------------Text: Python可以使用POP3收取郵件……...part 1--------------------Text: Python可以<a href="...">使用POP3</a>收取郵件……...
part 1
--------------------Attachment: application/octet-stream

我們從打印的結構可以看出,這封郵件是一個MIMEMultipart,它包含兩部分:第一部分又是一個MIMEMultipart,第二部分是一個附件。而內嵌的MIMEMultipart是一個alternative類型,它包含一個純文本格式的MIMEText和一個HTML格式的MIMEText

小結

用Python的poplib模塊收取郵件分兩步:第一步是用POP3協(xié)議把郵件獲取到本地,第二步是用email模塊把原始郵件解析為Message對象,然后,用適當?shù)男问桨燕]件內容展示給用戶即可。

參考源碼

fetch_mail.py

訪問數(shù)據(jù)庫

程序運行的時候,數(shù)據(jù)都是在內存中的。當程序終止的時候,通常都需要將數(shù)據(jù)保存到磁盤上,無論是保存到本地磁盤,還是通過網絡保存到服務器上,最終都會將數(shù)據(jù)寫入磁盤文件。

而如何定義數(shù)據(jù)的存儲格式就是一個大問題。如果我們自己來定義存儲格式,比如保存一個班級所有學生的成績單:

名字成績
Michael99
Bob85
Bart59
Lisa87

你可以用一個文本文件保存,一行保存一個學生,用,隔開:

Michael,99
Bob,85
Bart,59
Lisa,87

你還可以用JSON格式保存,也是文本文件:

[{"name":"Michael","score":99},{"name":"Bob","score":85},{"name":"Bart","score":59},{"name":"Lisa","score":87}
]

你還可以定義各種保存格式,但是問題來了:

存儲和讀取需要自己實現(xiàn),JSON還是標準,自己定義的格式就各式各樣了;

不能做快速查詢,只有把數(shù)據(jù)全部讀到內存中才能自己遍歷,但有時候數(shù)據(jù)的大小遠遠超過了內存(比如藍光電影,40GB的數(shù)據(jù)),根本無法全部讀入內存。

為了便于程序保存和讀取數(shù)據(jù),而且,能直接通過條件快速查詢到指定的數(shù)據(jù),就出現(xiàn)了數(shù)據(jù)庫(Database)這種專門用于集中存儲和查詢的軟件。

數(shù)據(jù)庫軟件誕生的歷史非常久遠,早在1950年數(shù)據(jù)庫就誕生了。經歷了網狀數(shù)據(jù)庫,層次數(shù)據(jù)庫,我們現(xiàn)在廣泛使用的關系數(shù)據(jù)庫是20世紀70年代基于關系模型的基礎上誕生的。

關系模型有一套復雜的數(shù)學理論,但是從概念上是十分容易理解的。舉個學校的例子:

假設某個XX省YY市ZZ縣第一實驗小學有3個年級,要表示出這3個年級,可以在Excel中用一個表格畫出來:

grade

每個年級又有若干個班級,要把所有班級表示出來,可以在Excel中再畫一個表格:

class

這兩個表格有個映射關系,就是根據(jù)Grade_ID可以在班級表中查找到對應的所有班級:

grade-classes

也就是Grade表的每一行對應Class表的多行,在關系數(shù)據(jù)庫中,這種基于表(Table)的一對多的關系就是關系數(shù)據(jù)庫的基礎。

根據(jù)某個年級的ID就可以查找所有班級的行,這種查詢語句在關系數(shù)據(jù)庫中稱為SQL語句,可以寫成:

SELECT * FROM classes WHERE grade_id = '1';

結果也是一個表:

---------+----------+----------
grade_id | class_id | name
---------+----------+----------
1        | 11       | 一年級一班
---------+----------+----------
1        | 12       | 一年級二班
---------+----------+----------
1        | 13       | 一年級三班
---------+----------+----------

類似的,Class表的一行記錄又可以關聯(lián)到Student表的多行記錄:

class-students

由于本教程不涉及到關系數(shù)據(jù)庫的詳細內容,如果你想從零學習關系數(shù)據(jù)庫和基本的SQL語句,推薦Coursera課程:

英文:https://www.coursera.org/course/db

中文:http://c.open.163.com/coursera/courseIntro.htm?cid=12

NoSQL

你也許還聽說過NoSQL數(shù)據(jù)庫,很多NoSQL宣傳其速度和規(guī)模遠遠超過關系數(shù)據(jù)庫,所以很多同學覺得有了NoSQL是否就不需要SQL了呢?千萬不要被他們忽悠了,連SQL都不明白怎么可能搞明白NoSQL呢?

數(shù)據(jù)庫類別

既然我們要使用關系數(shù)據(jù)庫,就必須選擇一個關系數(shù)據(jù)庫。目前廣泛使用的關系數(shù)據(jù)庫也就這么幾種:

付費的商用數(shù)據(jù)庫:

  • Oracle,典型的高富帥;

  • SQL Server,微軟自家產品,Windows定制???#xff1b;

  • DB2,IBM的產品,聽起來挺高端;

  • Sybase,曾經跟微軟是好基友,后來關系破裂,現(xiàn)在家境慘淡。

這些數(shù)據(jù)庫都是不開源而且付費的,最大的好處是花了錢出了問題可以找廠家解決,不過在Web的世界里,常常需要部署成千上萬的數(shù)據(jù)庫服務器,當然不能把大把大把的銀子扔給廠家,所以,無論是Google、Facebook,還是國內的BAT,無一例外都選擇了免費的開源數(shù)據(jù)庫:

  • MySQL,大家都在用,一般錯不了;

  • PostgreSQL,學術氣息有點重,其實挺不錯,但知名度沒有MySQL高;

  • sqlite,嵌入式數(shù)據(jù)庫,適合桌面和移動應用。

作為Python開發(fā)工程師,選擇哪個免費數(shù)據(jù)庫呢?當然是MySQL。因為MySQL普及率最高,出了錯,可以很容易找到解決方法。而且,圍繞MySQL有一大堆監(jiān)控和運維的工具,安裝和使用很方便。

為了能繼續(xù)后面的學習,你需要從MySQL官方網站下載并安裝MySQL Community Server 5.6,這個版本是免費的,其他高級版本是要收錢的(請放心,收錢的功能我們用不上)。

使用SQLite

SQLite是一種嵌入式數(shù)據(jù)庫,它的數(shù)據(jù)庫就是一個文件。由于SQLite本身是C寫的,而且體積很小,所以,經常被集成到各種應用程序中,甚至在iOS和Android的App中都可以集成。

Python就內置了SQLite3,所以,在Python中使用SQLite,不需要安裝任何東西,直接使用。

在使用SQLite前,我們先要搞清楚幾個概念:

表是數(shù)據(jù)庫中存放關系數(shù)據(jù)的集合,一個數(shù)據(jù)庫里面通常都包含多個表,比如學生的表,班級的表,學校的表,等等。表和表之間通過外鍵關聯(lián)。

要操作關系數(shù)據(jù)庫,首先需要連接到數(shù)據(jù)庫,一個數(shù)據(jù)庫連接稱為Connection;

連接到數(shù)據(jù)庫后,需要打開游標,稱之為Cursor,通過Cursor執(zhí)行SQL語句,然后,獲得執(zhí)行結果。

Python定義了一套操作數(shù)據(jù)庫的API接口,任何數(shù)據(jù)庫要連接到Python,只需要提供符合Python標準的數(shù)據(jù)庫驅動即可。

由于SQLite的驅動內置在Python標準庫中,所以我們可以直接來操作SQLite數(shù)據(jù)庫。

我們在Python交互式命令行實踐一下:

# 導入SQLite驅動:
>>> import sqlite3
# 連接到SQLite數(shù)據(jù)庫
# 數(shù)據(jù)庫文件是test.db
# 如果文件不存在,會自動在當前目錄創(chuàng)建:
>>> conn = sqlite3.connect('test.db')
# 創(chuàng)建一個Cursor:
>>> cursor = conn.cursor()
# 執(zhí)行一條SQL語句,創(chuàng)建user表:
>>> cursor.execute('create table user (id varchar(20) primary key, name varchar(20))')
<sqlite3.Cursor object at 0x10f8aa260>
# 繼續(xù)執(zhí)行一條SQL語句,插入一條記錄:
>>> cursor.execute('insert into user (id, name) values (\'1\', \'Michael\')')
<sqlite3.Cursor object at 0x10f8aa260>
# 通過rowcount獲得插入的行數(shù):
>>> cursor.rowcount
1
# 關閉Cursor:
>>> cursor.close()
# 提交事務:
>>> conn.commit()
# 關閉Connection:
>>> conn.close()

我們再試試查詢記錄:

>>> conn = sqlite3.connect('test.db')
>>> cursor = conn.cursor()
# 執(zhí)行查詢語句:
>>> cursor.execute('select * from user where id=?', '1')
<sqlite3.Cursor object at 0x10f8aa340>
# 獲得查詢結果集:
>>> values = cursor.fetchall()
>>> values
[('1', 'Michael')]
>>> cursor.close()
>>> conn.close()

使用Python的DB-API時,只要搞清楚ConnectionCursor對象,打開后一定記得關閉,就可以放心地使用。

使用Cursor對象執(zhí)行insertupdatedelete語句時,執(zhí)行結果由rowcount返回影響的行數(shù),就可以拿到執(zhí)行結果。

使用Cursor對象執(zhí)行select語句時,通過featchall()可以拿到結果集。結果集是一個list,每個元素都是一個tuple,對應一行記錄。

如果SQL語句帶有參數(shù),那么需要把參數(shù)按照位置傳遞給execute()方法,有幾個?占位符就必須對應幾個參數(shù),例如:

cursor.execute('select * from user where id=?', '1')

SQLite支持常見的標準SQL語句以及幾種常見的數(shù)據(jù)類型。具體文檔請參閱SQLite官方網站。

小結

在Python中操作數(shù)據(jù)庫時,要先導入數(shù)據(jù)庫對應的驅動,然后,通過Connection對象和Cursor對象操作數(shù)據(jù)。

要確保打開的Connection對象和Cursor對象都正確地被關閉,否則,資源就會泄露。

如何才能確保出錯的情況下也關閉掉Connection對象和Cursor對象呢?請回憶try:...except:...finally:...的用法。

練習

請編寫函數(shù),在Sqlite中根據(jù)分數(shù)段查找指定的名字:

# -*- coding: utf-8 -*-import os, sqlite3db_file = os.path.join(os.path.dirname(__file__), 'test.db')
if os.path.isfile(db_file):os.remove(db_file)# 初始數(shù)據(jù):
conn = sqlite3.connect(db_file)
cursor = conn.cursor()
cursor.execute('create table user(id varchar(20) primary key, name varchar(20), score int)')
cursor.execute(r"insert into user values ('A-001', 'Adam', 95)")
cursor.execute(r"insert into user values ('A-002', 'Bart', 62)")
cursor.execute(r"insert into user values ('A-003', 'Lisa', 78)")
cursor.close()
conn.commit()
conn.close()def get_score_in(low, high):' 返回指定分數(shù)區(qū)間的名字,按分數(shù)從低到高排序 '
----pass
----
# 測試:
assert get_score_in(80, 95) == ['Adam'], get_score_in(80, 95)
assert get_score_in(60, 80) == ['Bart', 'Lisa'], get_score_in(60, 80)
assert get_score_in(60, 100) == ['Bart', 'Lisa', 'Adam'], get_score_in(60, 100)print('Pass')
參考源碼

do_sqlite.py

使用MySQL

MySQL是Web世界中使用最廣泛的數(shù)據(jù)庫服務器。SQLite的特點是輕量級、可嵌入,但不能承受高并發(fā)訪問,適合桌面和移動應用。而MySQL是為服務器端設計的數(shù)據(jù)庫,能承受高并發(fā)訪問,同時占用的內存也遠遠大于SQLite。

此外,MySQL內部有多種數(shù)據(jù)庫引擎,最常用的引擎是支持數(shù)據(jù)庫事務的InnoDB。

安裝MySQL

可以直接從MySQL官方網站下載最新的Community Server 5.6.x版本。MySQL是跨平臺的,選擇對應的平臺下載安裝文件,安裝即可。

安裝時,MySQL會提示輸入root用戶的口令,請務必記清楚。如果怕記不住,就把口令設置為password。

在Windows上,安裝時請選擇UTF-8編碼,以便正確地處理中文。

在Mac或Linux上,需要編輯MySQL的配置文件,把數(shù)據(jù)庫默認的編碼全部改為UTF-8。MySQL的配置文件默認存放在/etc/my.cnf或者/etc/mysql/my.cnf

[client]
default-character-set = utf8[mysqld]
default-storage-engine = INNODB
character-set-server = utf8
collation-server = utf8_general_ci

重啟MySQL后,可以通過MySQL的客戶端命令行檢查編碼:

$ mysql -u root -p
Enter password: 
Welcome to the MySQL monitor...
...mysql> show variables like '%char%';
+--------------------------+--------------------------------------------------------+
| Variable_name            | Value                                                  |
+--------------------------+--------------------------------------------------------+
| character_set_client     | utf8                                                   |
| character_set_connection | utf8                                                   |
| character_set_database   | utf8                                                   |
| character_set_filesystem | binary                                                 |
| character_set_results    | utf8                                                   |
| character_set_server     | utf8                                                   |
| character_set_system     | utf8                                                   |
| character_sets_dir       | /usr/local/mysql-5.1.65-osx10.6-x86_64/share/charsets/ |
+--------------------------+--------------------------------------------------------+
8 rows in set (0.00 sec)

看到utf8字樣就表示編碼設置正確。

安裝MySQL驅動

由于MySQL服務器以獨立的進程運行,并通過網絡對外服務,所以,需要支持Python的MySQL驅動來連接到MySQL服務器。MySQL官方提供了mysql-connector-python驅動,但是安裝的時候需要給pip命令加上參數(shù)--allow-external

$ pip install mysql-connector-python --allow-external mysql-connector-python

我們演示如何連接到MySQL服務器的test數(shù)據(jù)庫:

# 導入MySQL驅動:
>>> import mysql.connector
# 注意把password設為你的root口令:
>>> conn = mysql.connector.connect(user='root', password='password', database='test')
>>> cursor = conn.cursor()
# 創(chuàng)建user表:
>>> cursor.execute('create table user (id varchar(20) primary key, name varchar(20))')
# 插入一行記錄,注意MySQL的占位符是%s:
>>> cursor.execute('insert into user (id, name) values (%s, %s)', ['1', 'Michael'])
>>> cursor.rowcount
1
# 提交事務:
>>> conn.commit()
>>> cursor.close()
# 運行查詢:
>>> cursor = conn.cursor()
>>> cursor.execute('select * from user where id = %s', ['1'])
>>> values = cursor.fetchall()
>>> values
[('1', 'Michael')]
# 關閉Cursor和Connection:
>>> cursor.close()
True
>>> conn.close()

由于Python的DB-API定義都是通用的,所以,操作MySQL的數(shù)據(jù)庫代碼和SQLite類似。

小結
  • 執(zhí)行INSERT等操作后要調用commit()提交事務;

  • MySQL的SQL占位符是%s。

參考源碼

do_mysql.py

使用SQLAlchemy

數(shù)據(jù)庫表是一個二維表,包含多行多列。把一個表的內容用Python的數(shù)據(jù)結構表示出來的話,可以用一個list表示多行,list的每一個元素是tuple,表示一行記錄,比如,包含idnameuser表:

[('1', 'Michael'),('2', 'Bob'),('3', 'Adam')
]

Python的DB-API返回的數(shù)據(jù)結構就是像上面這樣表示的。

但是用tuple表示一行很難看出表的結構。如果把一個tuple用class實例來表示,就可以更容易地看出表的結構來:

class User(object):def __init__(self, id, name):self.id = idself.name = name[User('1', 'Michael'),User('2', 'Bob'),User('3', 'Adam')
]

這就是傳說中的ORM技術:Object-Relational Mapping,把關系數(shù)據(jù)庫的表結構映射到對象上。是不是很簡單?

但是由誰來做這個轉換呢?所以ORM框架應運而生。

在Python中,最有名的ORM框架是SQLAlchemy。我們來看看SQLAlchemy的用法。

首先通過pip安裝SQLAlchemy:

$ pip install sqlalchemy

然后,利用上次我們在MySQL的test數(shù)據(jù)庫中創(chuàng)建的user表,用SQLAlchemy來試試:

第一步,導入SQLAlchemy,并初始化DBSession:

# 導入:
from sqlalchemy import Column, String, create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base# 創(chuàng)建對象的基類:
Base = declarative_base()# 定義User對象:
class User(Base):# 表的名字:__tablename__ = 'user'# 表的結構:id = Column(String(20), primary_key=True)name = Column(String(20))# 初始化數(shù)據(jù)庫連接:
engine = create_engine('mysql+mysqlconnector://root:password@localhost:3306/test')
# 創(chuàng)建DBSession類型:
DBSession = sessionmaker(bind=engine)

以上代碼完成SQLAlchemy的初始化和具體每個表的class定義。如果有多個表,就繼續(xù)定義其他class,例如School:

class School(Base):__tablename__ = 'school'id = ...name = ...

create_engine()用來初始化數(shù)據(jù)庫連接。SQLAlchemy用一個字符串表示連接信息:

'數(shù)據(jù)庫類型+數(shù)據(jù)庫驅動名稱://用戶名:口令@機器地址:端口號/數(shù)據(jù)庫名'

你只需要根據(jù)需要替換掉用戶名、口令等信息即可。

下面,我們看看如何向數(shù)據(jù)庫表中添加一行記錄。

由于有了ORM,我們向數(shù)據(jù)庫表中添加一行記錄,可以視為添加一個User對象:

# 創(chuàng)建session對象:
session = DBSession()
# 創(chuàng)建新User對象:
new_user = User(id='5', name='Bob')
# 添加到session:
session.add(new_user)
# 提交即保存到數(shù)據(jù)庫:
session.commit()
# 關閉session:
session.close()

可見,關鍵是獲取session,然后把對象添加到session,最后提交并關閉。DBSession對象可視為當前數(shù)據(jù)庫連接。

如何從數(shù)據(jù)庫表中查詢數(shù)據(jù)呢?有了ORM,查詢出來的可以不再是tuple,而是User對象。SQLAlchemy提供的查詢接口如下:

# 創(chuàng)建Session:
session = DBSession()
# 創(chuàng)建Query查詢,filter是where條件,最后調用one()返回唯一行,如果調用all()則返回所有行:
user = session.query(User).filter(User.id=='5').one()
# 打印類型和對象的name屬性:
print('type:', type(user))
print('name:', user.name)
# 關閉Session:
session.close()

運行結果如下:

type: <class '__main__.User'>
name: Bob

可見,ORM就是把數(shù)據(jù)庫表的行與相應的對象建立關聯(lián),互相轉換。

由于關系數(shù)據(jù)庫的多個表還可以用外鍵實現(xiàn)一對多、多對多等關聯(lián),相應地,ORM框架也可以提供兩個對象之間的一對多、多對多等功能。

例如,如果一個User擁有多個Book,就可以定義一對多關系如下:

class User(Base):__tablename__ = 'user'id = Column(String(20), primary_key=True)name = Column(String(20))# 一對多:books = relationship('Book')class Book(Base):__tablename__ = 'book'id = Column(String(20), primary_key=True)name = Column(String(20))# “多”的一方的book表是通過外鍵關聯(lián)到user表的:user_id = Column(String(20), ForeignKey('user.id'))

當我們查詢一個User對象時,該對象的books屬性將返回一個包含若干個Book對象的list。

小結

ORM框架的作用就是把數(shù)據(jù)庫表的一行記錄與一個對象互相做自動轉換。

正確使用ORM的前提是了解關系數(shù)據(jù)庫的原理。

參考源碼

do_sqlalchemy.py

Web開發(fā)

最早的軟件都是運行在大型機上的,軟件使用者通過“啞終端”登陸到大型機上去運行軟件。后來隨著PC機的興起,軟件開始主要運行在桌面上,而數(shù)據(jù)庫這樣的軟件運行在服務器端,這種Client/Server模式簡稱CS架構。

隨著互聯(lián)網的興起,人們發(fā)現(xiàn),CS架構不適合Web,最大的原因是Web應用程序的修改和升級非常迅速,而CS架構需要每個客戶端逐個升級桌面App,因此,Browser/Server模式開始流行,簡稱BS架構。

在BS架構下,客戶端只需要瀏覽器,應用程序的邏輯和數(shù)據(jù)都存儲在服務器端。瀏覽器只需要請求服務器,獲取Web頁面,并把Web頁面展示給用戶即可。

當然,Web頁面也具有極強的交互性。由于Web頁面是用HTML編寫的,而HTML具備超強的表現(xiàn)力,并且,服務器端升級后,客戶端無需任何部署就可以使用到新的版本,因此,BS架構迅速流行起來。

今天,除了重量級的軟件如Office,Photoshop等,大部分軟件都以Web形式提供。比如,新浪提供的新聞、博客、微博等服務,均是Web應用。

Web應用開發(fā)可以說是目前軟件開發(fā)中最重要的部分。Web開發(fā)也經歷了好幾個階段:

  1. 靜態(tài)Web頁面:由文本編輯器直接編輯并生成靜態(tài)的HTML頁面,如果要修改Web頁面的內容,就需要再次編輯HTML源文件,早期的互聯(lián)網Web頁面就是靜態(tài)的;

  2. CGI:由于靜態(tài)Web頁面無法與用戶交互,比如用戶填寫了一個注冊表單,靜態(tài)Web頁面就無法處理。要處理用戶發(fā)送的動態(tài)數(shù)據(jù),出現(xiàn)了Common Gateway Interface,簡稱CGI,用C/C++編寫。

  3. ASP/JSP/PHP:由于Web應用特點是修改頻繁,用C/C++這樣的低級語言非常不適合Web開發(fā),而腳本語言由于開發(fā)效率高,與HTML結合緊密,因此,迅速取代了CGI模式。ASP是微軟推出的用VBScript腳本編程的Web開發(fā)技術,而JSP用Java來編寫腳本,PHP本身則是開源的腳本語言。

  4. MVC:為了解決直接用腳本語言嵌入HTML導致的可維護性差的問題,Web應用也引入了Model-View-Controller的模式,來簡化Web開發(fā)。ASP發(fā)展為ASP.Net,JSP和PHP也有一大堆MVC框架。

目前,Web開發(fā)技術仍在快速發(fā)展中,異步開發(fā)、新的MVVM前端技術層出不窮。

Python的誕生歷史比Web還要早,由于Python是一種解釋型的腳本語言,開發(fā)效率高,所以非常適合用來做Web開發(fā)。

Python有上百種Web開發(fā)框架,有很多成熟的模板技術,選擇Python開發(fā)Web應用,不但開發(fā)效率高,而且運行速度快。

本章我們會詳細討論Python Web開發(fā)技術。

HTTP協(xié)議簡介

在Web應用中,服務器把網頁傳給瀏覽器,實際上就是把網頁的HTML代碼發(fā)送給瀏覽器,讓瀏覽器顯示出來。而瀏覽器和服務器之間的傳輸協(xié)議是HTTP,所以:

  • HTML是一種用來定義網頁的文本,會HTML,就可以編寫網頁;

  • HTTP是在網絡上傳輸HTML的協(xié)議,用于瀏覽器和服務器的通信。

在舉例子之前,我們需要安裝Google的Chrome瀏覽器。

為什么要使用Chrome瀏覽器而不是IE呢?因為IE實在是太慢了,并且,IE對于開發(fā)和調試Web應用程序完全是一點用也沒有。

我們需要在瀏覽器很方便地調試我們的Web應用,而Chrome提供了一套完整地調試工具,非常適合Web開發(fā)。

安裝好Chrome瀏覽器后,打開Chrome,在菜單中選擇“視圖”,“開發(fā)者”,“開發(fā)者工具”,就可以顯示開發(fā)者工具:

chrome-dev-tools

Elements顯示網頁的結構,Network顯示瀏覽器和服務器的通信。我們點Network,確保第一個小紅燈亮著,Chrome就會記錄所有瀏覽器和服務器之間的通信:

chrome-devtools-network

當我們在地址欄輸入www.sina.com.cn時,瀏覽器將顯示新浪的首頁。在這個過程中,瀏覽器都干了哪些事情呢?通過Network的記錄,我們就可以知道。在Network中,定位到第一條記錄,點擊,右側將顯示Request Headers,點擊右側的view source,我們就可以看到瀏覽器發(fā)給新浪服務器的請求:

sina-http-request

最主要的頭兩行分析如下,第一行:

GET / HTTP/1.1

GET表示一個讀取請求,將從服務器獲得網頁數(shù)據(jù),/表示URL的路徑,URL總是以/開頭,/就表示首頁,最后的HTTP/1.1指示采用的HTTP協(xié)議版本是1.1。目前HTTP協(xié)議的版本就是1.1,但是大部分服務器也支持1.0版本,主要區(qū)別在于1.1版本允許多個HTTP請求復用一個TCP連接,以加快傳輸速度。

從第二行開始,每一行都類似于Xxx: abcdefg

Host: www.sina.com.cn

表示請求的域名是www.sina.com.cn。如果一臺服務器有多個網站,服務器就需要通過Host來區(qū)分瀏覽器請求的是哪個網站。

繼續(xù)往下找到Response Headers,點擊view source,顯示服務器返回的原始響應數(shù)據(jù):

sina-http-response

HTTP響應分為Header和Body兩部分(Body是可選項),我們在Network中看到的Header最重要的幾行如下:

200 OK

200表示一個成功的響應,后面的OK是說明。失敗的響應有404 Not Found:網頁不存在,500 Internal Server Error:服務器內部出錯,等等。

Content-Type: text/html

Content-Type指示響應的內容,這里是text/html表示HTML網頁。請注意,瀏覽器就是依靠Content-Type來判斷響應的內容是網頁還是圖片,是視頻還是音樂。瀏覽器并不靠URL來判斷響應的內容,所以,即使URL是http://example.com/abc.jpg,它也不一定就是圖片。

HTTP響應的Body就是HTML源碼,我們在菜單欄選擇“視圖”,“開發(fā)者”,“查看網頁源碼”就可以在瀏覽器中直接查看HTML源碼:

sina-http-source

當瀏覽器讀取到新浪首頁的HTML源碼后,它會解析HTML,顯示頁面,然后,根據(jù)HTML里面的各種鏈接,再發(fā)送HTTP請求給新浪服務器,拿到相應的圖片、視頻、Flash、JavaScript腳本、CSS等各種資源,最終顯示出一個完整的頁面。所以我們在Network下面能看到很多額外的HTTP請求。

HTTP請求

跟蹤了新浪的首頁,我們來總結一下HTTP請求的流程:

步驟1:瀏覽器首先向服務器發(fā)送HTTP請求,請求包括:

方法:GET還是POST,GET僅請求資源,POST會附帶用戶數(shù)據(jù);

路徑:/full/url/path;

域名:由Host頭指定:Host: www.sina.com.cn

以及其他相關的Header;

如果是POST,那么請求還包括一個Body,包含用戶數(shù)據(jù)。

步驟2:服務器向瀏覽器返回HTTP響應,響應包括:

響應代碼:200表示成功,3xx表示重定向,4xx表示客戶端發(fā)送的請求有錯誤,5xx表示服務器端處理時發(fā)生了錯誤;

響應類型:由Content-Type指定;

以及其他相關的Header;

通常服務器的HTTP響應會攜帶內容,也就是有一個Body,包含響應的內容,網頁的HTML源碼就在Body中。

步驟3:如果瀏覽器還需要繼續(xù)向服務器請求其他資源,比如圖片,就再次發(fā)出HTTP請求,重復步驟1、2。

Web采用的HTTP協(xié)議采用了非常簡單的請求-響應模式,從而大大簡化了開發(fā)。當我們編寫一個頁面時,我們只需要在HTTP請求中把HTML發(fā)送出去,不需要考慮如何附帶圖片、視頻等,瀏覽器如果需要請求圖片和視頻,它會發(fā)送另一個HTTP請求,因此,一個HTTP請求只處理一個資源。

HTTP協(xié)議同時具備極強的擴展性,雖然瀏覽器請求的是http://www.sina.com.cn/的首頁,但是新浪在HTML中可以鏈入其他服務器的資源,比如<img src="http://i1.sinaimg.cn/home/2013/1008/U8455P30DT20131008135420.png">,從而將請求壓力分散到各個服務器上,并且,一個站點可以鏈接到其他站點,無數(shù)個站點互相鏈接起來,就形成了World Wide Web,簡稱WWW。

HTTP格式

每個HTTP請求和響應都遵循相同的格式,一個HTTP包含Header和Body兩部分,其中Body是可選的。

HTTP協(xié)議是一種文本協(xié)議,所以,它的格式也非常簡單。HTTP GET請求的格式:

GET /path HTTP/1.1
Header1: Value1
Header2: Value2
Header3: Value3

每個Header一行一個,換行符是\r\n

HTTP POST請求的格式:

POST /path HTTP/1.1
Header1: Value1
Header2: Value2
Header3: Value3body data goes here...

當遇到連續(xù)兩個\r\n時,Header部分結束,后面的數(shù)據(jù)全部是Body。

HTTP響應的格式:

200 OK
Header1: Value1
Header2: Value2
Header3: Value3body data goes here...

HTTP響應如果包含body,也是通過\r\n\r\n來分隔的。請再次注意,Body的數(shù)據(jù)類型由Content-Type頭來確定,如果是網頁,Body就是文本,如果是圖片,Body就是圖片的二進制數(shù)據(jù)。

當存在Content-Encoding時,Body數(shù)據(jù)是被壓縮的,最常見的壓縮方式是gzip,所以,看到Content-Encoding: gzip時,需要將Body數(shù)據(jù)先解壓縮,才能得到真正的數(shù)據(jù)。壓縮的目的在于減少Body的大小,加快網絡傳輸。

要詳細了解HTTP協(xié)議,推薦“HTTP: The Definitive Guide”一書,非常不錯,有中文譯本:

HTTP權威指南

HTML簡介

網頁就是HTML?這么理解大概沒錯。因為網頁中不但包含文字,還有圖片、視頻、Flash小游戲,有復雜的排版、動畫效果,所以,HTML定義了一套語法規(guī)則,來告訴瀏覽器如何把一個豐富多彩的頁面顯示出來。

HTML長什么樣?上次我們看了新浪首頁的HTML源碼,如果仔細數(shù)數(shù),竟然有6000多行!

所以,學HTML,就不要指望從新浪入手了。我們來看看最簡單的HTML長什么樣:

<html>
<head><title>Hello</title>
</head>
<body><h1>Hello, world!</h1>
</body>
</html>

可以用文本編輯器編寫HTML,然后保存為hello.html,雙擊或者把文件拖到瀏覽器中,就可以看到效果:

hello.html

HTML文檔就是一系列的Tag組成,最外層的Tag是<html>。規(guī)范的HTML也包含<head>...</head><body>...</body>(注意不要和HTTP的Header、Body搞混了),由于HTML是富文檔模型,所以,還有一系列的Tag用來表示鏈接、圖片、表格、表單等等。

CSS簡介

CSS是Cascading Style Sheets(層疊樣式表)的簡稱,CSS用來控制HTML里的所有元素如何展現(xiàn),比如,給標題元素<h1>加一個樣式,變成48號字體,灰色,帶陰影:

<html>
<head><title>Hello</title><style>h1 {color: #333333;font-size: 48px;text-shadow: 3px 3px 3px #666666;}</style>
</head>
<body><h1>Hello, world!</h1>
</body>
</html>

效果如下:

hello-css

JavaScript簡介

JavaScript雖然名稱有個Java,但它和Java真的一點關系沒有。JavaScript是為了讓HTML具有交互性而作為腳本語言添加的,JavaScript既可以內嵌到HTML中,也可以從外部鏈接到HTML中。如果我們希望當用戶點擊標題時把標題變成紅色,就必須通過JavaScript來實現(xiàn):

<html>
<head><title>Hello</title><style>h1 {color: #333333;font-size: 48px;text-shadow: 3px 3px 3px #666666;}</style><script>function change() {document.getElementsByTagName('h1')[0].style.color = '#ff0000';}</script>
</head>
<body><h1 onclick="change()">Hello, world!</h1>
</body>
</html>

點擊標題后效果如下:

hello-js-change-color

小結

如果要學習Web開發(fā),首先要對HTML、CSS和JavaScript作一定的了解。HTML定義了頁面的內容,CSS來控制頁面元素的樣式,而JavaScript負責頁面的交互邏輯。

講解HTML、CSS和JavaScript就可以寫3本書,對于優(yōu)秀的Web開發(fā)人員來說,精通HTML、CSS和JavaScript是必須的,這里推薦一個在線學習網站w3schools:

http://www.w3schools.com/

以及一個對應的中文版本:

http://www.w3school.com.cn/

當我們用Python或者其他語言開發(fā)Web應用時,我們就是要在服務器端動態(tài)創(chuàng)建出HTML,這樣,瀏覽器就會向不同的用戶顯示出不同的Web頁面。

WSGI接口

了解了HTTP協(xié)議和HTML文檔,我們其實就明白了一個Web應用的本質就是:

  1. 瀏覽器發(fā)送一個HTTP請求;

  2. 服務器收到請求,生成一個HTML文檔;

  3. 服務器把HTML文檔作為HTTP響應的Body發(fā)送給瀏覽器;

  4. 瀏覽器收到HTTP響應,從HTTP Body取出HTML文檔并顯示。

所以,最簡單的Web應用就是先把HTML用文件保存好,用一個現(xiàn)成的HTTP服務器軟件,接收用戶請求,從文件中讀取HTML,返回。Apache、Nginx、Lighttpd等這些常見的靜態(tài)服務器就是干這件事情的。

如果要動態(tài)生成HTML,就需要把上述步驟自己來實現(xiàn)。不過,接受HTTP請求、解析HTTP請求、發(fā)送HTTP響應都是苦力活,如果我們自己來寫這些底層代碼,還沒開始寫動態(tài)HTML呢,就得花個把月去讀HTTP規(guī)范。

正確的做法是底層代碼由專門的服務器軟件實現(xiàn),我們用Python專注于生成HTML文檔。因為我們不希望接觸到TCP連接、HTTP原始請求和響應格式,所以,需要一個統(tǒng)一的接口,讓我們專心用Python編寫Web業(yè)務。

這個接口就是WSGI:Web Server Gateway Interface。

WSGI接口定義非常簡單,它只要求Web開發(fā)者實現(xiàn)一個函數(shù),就可以響應HTTP請求。我們來看一個最簡單的Web版本的“Hello, web!”:

def application(environ, start_response):start_response('200 OK', [('Content-Type', 'text/html')])return [b'<h1>Hello, web!</h1>']

上面的application()函數(shù)就是符合WSGI標準的一個HTTP處理函數(shù),它接收兩個參數(shù):

  • environ:一個包含所有HTTP請求信息的dict對象;

  • start_response:一個發(fā)送HTTP響應的函數(shù)。

application()函數(shù)中,調用:

start_response('200 OK', [('Content-Type', 'text/html')])

就發(fā)送了HTTP響應的Header,注意Header只能發(fā)送一次,也就是只能調用一次start_response()函數(shù)。start_response()函數(shù)接收兩個參數(shù),一個是HTTP響應碼,一個是一組list表示的HTTP Header,每個Header用一個包含兩個strtuple表示。

通常情況下,都應該把Content-Type頭發(fā)送給瀏覽器。其他很多常用的HTTP Header也應該發(fā)送。

然后,函數(shù)的返回值b'<h1>Hello, web!</h1>'將作為HTTP響應的Body發(fā)送給瀏覽器。

有了WSGI,我們關心的就是如何從environ這個dict對象拿到HTTP請求信息,然后構造HTML,通過start_response()發(fā)送Header,最后返回Body。

整個application()函數(shù)本身沒有涉及到任何解析HTTP的部分,也就是說,底層代碼不需要我們自己編寫,我們只負責在更高層次上考慮如何響應請求就可以了。

不過,等等,這個application()函數(shù)怎么調用?如果我們自己調用,兩個參數(shù)environstart_response我們沒法提供,返回的bytes也沒法發(fā)給瀏覽器。

所以application()函數(shù)必須由WSGI服務器來調用。有很多符合WSGI規(guī)范的服務器,我們可以挑選一個來用。但是現(xiàn)在,我們只想盡快測試一下我們編寫的application()函數(shù)真的可以把HTML輸出到瀏覽器,所以,要趕緊找一個最簡單的WSGI服務器,把我們的Web應用程序跑起來。

好消息是Python內置了一個WSGI服務器,這個模塊叫wsgiref,它是用純Python編寫的WSGI服務器的參考實現(xiàn)。所謂“參考實現(xiàn)”是指該實現(xiàn)完全符合WSGI標準,但是不考慮任何運行效率,僅供開發(fā)和測試使用。

運行WSGI服務

我們先編寫hello.py,實現(xiàn)Web應用程序的WSGI處理函數(shù):

# hello.pydef application(environ, start_response):start_response('200 OK', [('Content-Type', 'text/html')])return [b'<h1>Hello, web!</h1>']

然后,再編寫一個server.py,負責啟動WSGI服務器,加載application()函數(shù):

# server.py
# 從wsgiref模塊導入:
from wsgiref.simple_server import make_server
# 導入我們自己編寫的application函數(shù):
from hello import application# 創(chuàng)建一個服務器,IP地址為空,端口是8000,處理函數(shù)是application:
httpd = make_server('', 8000, application)
print('Serving HTTP on port 8000...')
# 開始監(jiān)聽HTTP請求:
httpd.serve_forever()

確保以上兩個文件在同一個目錄下,然后在命令行輸入python server.py來啟動WSGI服務器:

wsgiref-start

注意:如果8000端口已被其他程序占用,啟動將失敗,請修改成其他端口。

啟動成功后,打開瀏覽器,輸入http://localhost:8000/,就可以看到結果了:

hello-web

在命令行可以看到wsgiref打印的log信息:

wsgiref-log

Ctrl+C終止服務器。

如果你覺得這個Web應用太簡單了,可以稍微改造一下,從environ里讀取PATH_INFO,這樣可以顯示更加動態(tài)的內容:

# hello.pydef application(environ, start_response):start_response('200 OK', [('Content-Type', 'text/html')])body = '<h1>Hello, %s!</h1>' % (environ['PATH_INFO'][1:] or 'web')return [body.encode('utf-8')]

你可以在地址欄輸入用戶名作為URL的一部分,將返回Hello, xxx!

hello-michael

是不是有點Web App的感覺了?

小結

無論多么復雜的Web應用程序,入口都是一個WSGI處理函數(shù)。HTTP請求的所有輸入信息都可以通過environ獲得,HTTP響應的輸出都可以通過start_response()加上函數(shù)返回值作為Body。

復雜的Web應用程序,光靠一個WSGI函數(shù)來處理還是太底層了,我們需要在WSGI之上再抽象出Web框架,進一步簡化Web開發(fā)。

參考源碼

hello.py

do_wsgi.py

使用Web框架

了解了WSGI框架,我們發(fā)現(xiàn):其實一個Web App,就是寫一個WSGI的處理函數(shù),針對每個HTTP請求進行響應。

但是如何處理HTTP請求不是問題,問題是如何處理100個不同的URL。

每一個URL可以對應GET和POST請求,當然還有PUT、DELETE等請求,但是我們通常只考慮最常見的GET和POST請求。

一個最簡單的想法是從environ變量里取出HTTP請求的信息,然后逐個判斷:

def application(environ, start_response):method = environ['REQUEST_METHOD']path = environ['PATH_INFO']if method=='GET' and path=='/':return handle_home(environ, start_response)if method=='POST' and path='/signin':return handle_signin(environ, start_response)...

只是這么寫下去代碼是肯定沒法維護了。

代碼這么寫沒法維護的原因是因為WSGI提供的接口雖然比HTTP接口高級了不少,但和Web App的處理邏輯比,還是比較低級,我們需要在WSGI接口之上能進一步抽象,讓我們專注于用一個函數(shù)處理一個URL,至于URL到函數(shù)的映射,就交給Web框架來做。

由于用Python開發(fā)一個Web框架十分容易,所以Python有上百個開源的Web框架。這里我們先不討論各種Web框架的優(yōu)缺點,直接選擇一個比較流行的Web框架——Flask來使用。

用Flask編寫Web App比WSGI接口簡單(這不是廢話么,要是比WSGI還復雜,用框架干嘛?),我們先用pip安裝Flask:

$ pip install flask

然后寫一個app.py,處理3個URL,分別是:

  • GET /:首頁,返回Home

  • GET /signin:登錄頁,顯示登錄表單;

  • POST /signin:處理登錄表單,顯示登錄結果。

注意噢,同一個URL/signin分別有GET和POST兩種請求,映射到兩個處理函數(shù)中。

Flask通過Python的裝飾器在內部自動地把URL和函數(shù)給關聯(lián)起來,所以,我們寫出來的代碼就像這樣:

from flask import Flask
from flask import requestapp = Flask(__name__)@app.route('/', methods=['GET', 'POST'])
def home():return '<h1>Home</h1>'@app.route('/signin', methods=['GET'])
def signin_form():return '''<form action="/signin" method="post"><p><input name="username"></p><p><input name="password" type="password"></p><p><button type="submit">Sign In</button></p></form>'''@app.route('/signin', methods=['POST'])
def signin():# 需要從request對象讀取表單內容:if request.form['username']=='admin' and request.form['password']=='password':return '<h3>Hello, admin!</h3>'return '<h3>Bad username or password.</h3>'if __name__ == '__main__':app.run()

運行python app.py,Flask自帶的Server在端口5000上監(jiān)聽:

$ python app.py * Running on http://127.0.0.1:5000/

打開瀏覽器,輸入首頁地址http://localhost:5000/

flask-home

首頁顯示正確!

再在瀏覽器地址欄輸入http://localhost:5000/signin,會顯示登錄表單:

flask-signin-form

輸入預設的用戶名admin和口令password,登錄成功:

flask-signin-ok

輸入其他錯誤的用戶名和口令,登錄失敗:

flask-signin-failed

實際的Web App應該拿到用戶名和口令后,去數(shù)據(jù)庫查詢再比對,來判斷用戶是否能登錄成功。

除了Flask,常見的Python Web框架還有:

  • Django:全能型Web框架;

  • web.py:一個小巧的Web框架;

  • Bottle:和Flask類似的Web框架;

  • Tornado:Facebook的開源異步Web框架。

當然了,因為開發(fā)Python的Web框架也不是什么難事,我們后面也會講到開發(fā)Web框架的內容。

小結

有了Web框架,我們在編寫Web應用時,注意力就從WSGI處理函數(shù)轉移到URL+對應的處理函數(shù),這樣,編寫Web App就更加簡單了。

在編寫URL處理函數(shù)時,除了配置URL外,從HTTP請求拿到用戶數(shù)據(jù)也是非常重要的。Web框架都提供了自己的API來實現(xiàn)這些功能。Flask通過request.form['name']來獲取表單的內容。

參考源碼

do_flask.py

使用模板

Web框架把我們從WSGI中拯救出來了?,F(xiàn)在,我們只需要不斷地編寫函數(shù),帶上URL,就可以繼續(xù)Web App的開發(fā)了。

但是,Web App不僅僅是處理邏輯,展示給用戶的頁面也非常重要。在函數(shù)中返回一個包含HTML的字符串,簡單的頁面還可以,但是,想想新浪首頁的6000多行的HTML,你確信能在Python的字符串中正確地寫出來么?反正我是做不到。

俗話說得好,不懂前端的Python工程師不是好的產品經理。有Web開發(fā)經驗的同學都明白,Web App最復雜的部分就在HTML頁面。HTML不僅要正確,還要通過CSS美化,再加上復雜的JavaScript腳本來實現(xiàn)各種交互和動畫效果??傊?#xff0c;生成HTML頁面的難度很大。

由于在Python代碼里拼字符串是不現(xiàn)實的,所以,模板技術出現(xiàn)了。

使用模板,我們需要預先準備一個HTML文檔,這個HTML文檔不是普通的HTML,而是嵌入了一些變量和指令,然后,根據(jù)我們傳入的數(shù)據(jù),替換后,得到最終的HTML,發(fā)送給用戶:

mvc-seq

這就是傳說中的MVC:Model-View-Controller,中文名“模型-視圖-控制器”。

Python處理URL的函數(shù)就是C:Controller,Controller負責業(yè)務邏輯,比如檢查用戶名是否存在,取出用戶信息等等;

包含變量{{ name }}的模板就是V:View,View負責顯示邏輯,通過簡單地替換一些變量,View最終輸出的就是用戶看到的HTML。

MVC中的Model在哪?Model是用來傳給View的,這樣View在替換變量的時候,就可以從Model中取出相應的數(shù)據(jù)。

上面的例子中,Model就是一個dict

{ 'name': 'Michael' }

只是因為Python支持關鍵字參數(shù),很多Web框架允許傳入關鍵字參數(shù),然后,在框架內部組裝出一個dict作為Model。

現(xiàn)在,我們把上次直接輸出字符串作為HTML的例子用高端大氣上檔次的MVC模式改寫一下:

from flask import Flask, request, render_templateapp = Flask(__name__)@app.route('/', methods=['GET', 'POST'])
def home():return render_template('home.html')@app.route('/signin', methods=['GET'])
def signin_form():return render_template('form.html')@app.route('/signin', methods=['POST'])
def signin():username = request.form['username']password = request.form['password']if username=='admin' and password=='password':return render_template('signin-ok.html', username=username)return render_template('form.html', message='Bad username or password', username=username)if __name__ == '__main__':app.run()

Flask通過render_template()函數(shù)來實現(xiàn)模板的渲染。和Web框架類似,Python的模板也有很多種。Flask默認支持的模板是jinja2,所以我們先直接安裝jinja2:

$ pip install jinja2

然后,開始編寫jinja2模板:

home.html

用來顯示首頁的模板:

<html>
<head><title>Home</title>
</head>
<body><h1 style="font-style:italic">Home</h1>
</body>
</html>
form.html

用來顯示登錄表單的模板:

<html>
<head><title>Please Sign In</title>
</head>
<body>{% if message %}<p style="color:red">{{ message }}</p>{% endif %}<form action="/signin" method="post"><legend>Please sign in:</legend><p><input name="username" placeholder="Username" value="{{ username }}"></p><p><input name="password" placeholder="Password" type="password"></p><p><button type="submit">Sign In</button></p></form>
</body>
</html>
signin-ok.html

登錄成功的模板:

<html>
<head><title>Welcome, {{ username }}</title>
</head>
<body><p>Welcome, {{ username }}!</p>
</body>
</html>

登錄失敗的模板呢?我們在form.html中加了一點條件判斷,把form.html重用為登錄失敗的模板。

最后,一定要把模板放到正確的templates目錄下,templatesapp.py在同級目錄下:

mvc-dir

啟動python app.py,看看使用模板的頁面效果:

mvc-form

通過MVC,我們在Python代碼中處理M:Model和C:Controller,而V:View是通過模板處理的,這樣,我們就成功地把Python代碼和HTML代碼最大限度地分離了。

使用模板的另一大好處是,模板改起來很方便,而且,改完保存后,刷新瀏覽器就能看到最新的效果,這對于調試HTML、CSS和JavaScript的前端工程師來說實在是太重要了。

在Jinja2模板中,我們用{{ name }}表示一個需要替換的變量。很多時候,還需要循環(huán)、條件判斷等指令語句,在Jinja2中,用{% ... %}表示指令。

比如循環(huán)輸出頁碼:

{% for i in page_list %}<a href="/page/{{ i }}">{{ i }}</a>
{% endfor %}

如果page_list是一個list:[1, 2, 3, 4, 5],上面的模板將輸出5個超鏈接。

除了Jinja2,常見的模板還有:

  • Mako:用<% ... %>${xxx}的一個模板;

  • Cheetah:也是用<% ... %>${xxx}的一個模板;

  • Django:Django是一站式框架,內置一個用{% ... %}{{ xxx }}的模板。

小結

有了MVC,我們就分離了Python代碼和HTML代碼。HTML代碼全部放到模板里,寫起來更有效率。

源碼參考

app.py

異步IO

在IO編程一節(jié)中,我們已經知道,CPU的速度遠遠快于磁盤、網絡等IO。在一個線程中,CPU執(zhí)行代碼的速度極快,然而,一旦遇到IO操作,如讀寫文件、發(fā)送網絡數(shù)據(jù)時,就需要等待IO操作完成,才能繼續(xù)進行下一步操作。這種情況稱為同步IO。

在IO操作的過程中,當前線程被掛起,而其他需要CPU執(zhí)行的代碼就無法被當前線程執(zhí)行了。

因為一個IO操作就阻塞了當前線程,導致其他代碼無法執(zhí)行,所以我們必須使用多線程或者多進程來并發(fā)執(zhí)行代碼,為多個用戶服務。每個用戶都會分配一個線程,如果遇到IO導致線程被掛起,其他用戶的線程不受影響。

多線程和多進程的模型雖然解決了并發(fā)問題,但是系統(tǒng)不能無上限地增加線程。由于系統(tǒng)切換線程的開銷也很大,所以,一旦線程數(shù)量過多,CPU的時間就花在線程切換上了,真正運行代碼的時間就少了,結果導致性能嚴重下降。

由于我們要解決的問題是CPU高速執(zhí)行能力和IO設備的龜速嚴重不匹配,多線程和多進程只是解決這一問題的一種方法。

另一種解決IO問題的方法是異步IO。當代碼需要執(zhí)行一個耗時的IO操作時,它只發(fā)出IO指令,并不等待IO結果,然后就去執(zhí)行其他代碼了。一段時間后,當IO返回結果時,再通知CPU進行處理。

可以想象如果按普通順序寫出的代碼實際上是沒法完成異步IO的:

do_some_code()
f = open('/path/to/file', 'r')
r = f.read() # <== 線程停在此處等待IO操作結果
# IO操作完成后線程才能繼續(xù)執(zhí)行:
do_some_code(r)

所以,同步IO模型的代碼是無法實現(xiàn)異步IO模型的。

異步IO模型需要一個消息循環(huán),在消息循環(huán)中,主線程不斷地重復“讀取消息-處理消息”這一過程:

loop = get_event_loop()
while True:event = loop.get_event()process_event(event)

消息模型其實早在應用在桌面應用程序中了。一個GUI程序的主線程就負責不停地讀取消息并處理消息。所有的鍵盤、鼠標等消息都被發(fā)送到GUI程序的消息隊列中,然后由GUI程序的主線程處理。

由于GUI線程處理鍵盤、鼠標等消息的速度非???#xff0c;所以用戶感覺不到延遲。某些時候,GUI線程在一個消息處理的過程中遇到問題導致一次消息處理時間過長,此時,用戶會感覺到整個GUI程序停止響應了,敲鍵盤、點鼠標都沒有反應。這種情況說明在消息模型中,處理一個消息必須非常迅速,否則,主線程將無法及時處理消息隊列中的其他消息,導致程序看上去停止響應。

消息模型是如何解決同步IO必須等待IO操作這一問題的呢?當遇到IO操作時,代碼只負責發(fā)出IO請求,不等待IO結果,然后直接結束本輪消息處理,進入下一輪消息處理過程。當IO操作完成后,將收到一條“IO完成”的消息,處理該消息時就可以直接獲取IO操作結果。

在“發(fā)出IO請求”到收到“IO完成”的這段時間里,同步IO模型下,主線程只能掛起,但異步IO模型下,主線程并沒有休息,而是在消息循環(huán)中繼續(xù)處理其他消息。這樣,在異步IO模型下,一個線程就可以同時處理多個IO請求,并且沒有切換線程的操作。對于大多數(shù)IO密集型的應用程序,使用異步IO將大大提升系統(tǒng)的多任務處理能力。

協(xié)程

在學習異步IO模型前,我們先來了解協(xié)程。

協(xié)程,又稱微線程,纖程。英文名Coroutine。

協(xié)程的概念很早就提出來了,但直到最近幾年才在某些語言(如Lua)中得到廣泛應用。

子程序,或者稱為函數(shù),在所有語言中都是層級調用,比如A調用B,B在執(zhí)行過程中又調用了C,C執(zhí)行完畢返回,B執(zhí)行完畢返回,最后是A執(zhí)行完畢。

所以子程序調用是通過棧實現(xiàn)的,一個線程就是執(zhí)行一個子程序。

子程序調用總是一個入口,一次返回,調用順序是明確的。而協(xié)程的調用和子程序不同。

協(xié)程看上去也是子程序,但執(zhí)行過程中,在子程序內部可中斷,然后轉而執(zhí)行別的子程序,在適當?shù)臅r候再返回來接著執(zhí)行。

注意,在一個子程序中中斷,去執(zhí)行其他子程序,不是函數(shù)調用,有點類似CPU的中斷。比如子程序A、B:

def A():print('1')print('2')print('3')def B():print('x')print('y')print('z')

假設由協(xié)程執(zhí)行,在執(zhí)行A的過程中,可以隨時中斷,去執(zhí)行B,B也可能在執(zhí)行過程中中斷再去執(zhí)行A,結果可能是:

1
2
x
y
3
z

但是在A中是沒有調用B的,所以協(xié)程的調用比函數(shù)調用理解起來要難一些。

看起來A、B的執(zhí)行有點像多線程,但協(xié)程的特點在于是一個線程執(zhí)行,那和多線程比,協(xié)程有何優(yōu)勢?

最大的優(yōu)勢就是協(xié)程極高的執(zhí)行效率。因為子程序切換不是線程切換,而是由程序自身控制,因此,沒有線程切換的開銷,和多線程比,線程數(shù)量越多,協(xié)程的性能優(yōu)勢就越明顯。

第二大優(yōu)勢就是不需要多線程的鎖機制,因為只有一個線程,也不存在同時寫變量沖突,在協(xié)程中控制共享資源不加鎖,只需要判斷狀態(tài)就好了,所以執(zhí)行效率比多線程高很多。

因為協(xié)程是一個線程執(zhí)行,那怎么利用多核CPU呢?最簡單的方法是多進程+協(xié)程,既充分利用多核,又充分發(fā)揮協(xié)程的高效率,可獲得極高的性能。

Python對協(xié)程的支持是通過generator實現(xiàn)的。

在generator中,我們不但可以通過for循環(huán)來迭代,還可以不斷調用next()函數(shù)獲取由yield語句返回的下一個值。

但是Python的yield不但可以返回一個值,它還可以接收調用者發(fā)出的參數(shù)。

來看例子:

傳統(tǒng)的生產者-消費者模型是一個線程寫消息,一個線程取消息,通過鎖機制控制隊列和等待,但一不小心就可能死鎖。

如果改用協(xié)程,生產者生產消息后,直接通過yield跳轉到消費者開始執(zhí)行,待消費者執(zhí)行完畢后,切換回生產者繼續(xù)生產,效率極高:

def consumer():r = ''while True:n = yield rif not n:returnprint('[CONSUMER] Consuming %s...' % n)r = '200 OK'def produce(c):c.send(None)n = 0while n < 5:n = n + 1print('[PRODUCER] Producing %s...' % n)r = c.send(n)print('[PRODUCER] Consumer return: %s' % r)c.close()c = consumer()
produce(c)

執(zhí)行結果:

[PRODUCER] Producing 1...
[CONSUMER] Consuming 1...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 2...
[CONSUMER] Consuming 2...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 3...
[CONSUMER] Consuming 3...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 4...
[CONSUMER] Consuming 4...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 5...
[CONSUMER] Consuming 5...
[PRODUCER] Consumer return: 200 OK

注意到consumer函數(shù)是一個generator,把一個consumer傳入produce后:

  1. 首先調用c.send(None)啟動生成器;

  2. 然后,一旦生產了東西,通過c.send(n)切換到consumer執(zhí)行;

  3. consumer通過yield拿到消息,處理,又通過yield把結果傳回;

  4. produce拿到consumer處理的結果,繼續(xù)生產下一條消息;

  5. produce決定不生產了,通過c.close()關閉consumer,整個過程結束。

整個流程無鎖,由一個線程執(zhí)行,produceconsumer協(xié)作完成任務,所以稱為“協(xié)程”,而非線程的搶占式多任務。

最后套用Donald Knuth的一句話總結協(xié)程的特點:

“子程序就是協(xié)程的一種特例。”

參考源碼

coroutine.py

asyncio

asyncio是Python 3.4版本引入的標準庫,直接內置了對異步IO的支持。

asyncio的編程模型就是一個消息循環(huán)。我們從asyncio模塊中直接獲取一個EventLoop的引用,然后把需要執(zhí)行的協(xié)程扔到EventLoop中執(zhí)行,就實現(xiàn)了異步IO。

asyncio實現(xiàn)Hello world代碼如下:

import asyncio@asyncio.coroutine
def hello():print("Hello world!")# 異步調用asyncio.sleep(1):r = yield from asyncio.sleep(1)print("Hello again!")# 獲取EventLoop:
loop = asyncio.get_event_loop()
# 執(zhí)行coroutine
loop.run_until_complete(hello())
loop.close()

@asyncio.coroutine把一個generator標記為coroutine類型,然后,我們就把這個coroutine扔到EventLoop中執(zhí)行。

hello()會首先打印出Hello world!,然后,yield from語法可以讓我們方便地調用另一個generator。由于asyncio.sleep()也是一個coroutine,所以線程不會等待asyncio.sleep(),而是直接中斷并執(zhí)行下一個消息循環(huán)。當asyncio.sleep()返回時,線程就可以從yield from拿到返回值(此處是None),然后接著執(zhí)行下一行語句。

asyncio.sleep(1)看成是一個耗時1秒的IO操作,在此期間,主線程并未等待,而是去執(zhí)行EventLoop中其他可以執(zhí)行的coroutine了,因此可以實現(xiàn)并發(fā)執(zhí)行。

我們用Task封裝兩個coroutine試試:

import threading
import asyncio@asyncio.coroutine
def hello():print('Hello world! (%s)' % threading.currentThread())yield from asyncio.sleep(1)print('Hello again! (%s)' % threading.currentThread())loop = asyncio.get_event_loop()
tasks = [hello(), hello()]
loop.run_until_complete(asyncio.wait(tasks))
loop.close()

觀察執(zhí)行過程:

Hello world! (<_MainThread(MainThread, started 140735195337472)>)
Hello world! (<_MainThread(MainThread, started 140735195337472)>)
(暫停約1秒)
Hello again! (<_MainThread(MainThread, started 140735195337472)>)
Hello again! (<_MainThread(MainThread, started 140735195337472)>)

由打印的當前線程名稱可以看出,兩個coroutine是由同一個線程并發(fā)執(zhí)行的。

如果把asyncio.sleep()換成真正的IO操作,則多個coroutine就可以由一個線程并發(fā)執(zhí)行。

我們用asyncio的異步網絡連接來獲取sina、sohu和163的網站首頁:

import asyncio@asyncio.coroutine
def wget(host):print('wget %s...' % host)connect = asyncio.open_connection(host, 80)reader, writer = yield from connectheader = 'GET / HTTP/1.0\r\nHost: %s\r\n\r\n' % hostwriter.write(header.encode('utf-8'))yield from writer.drain()while True:line = yield from reader.readline()if line == b'\r\n':breakprint('%s header > %s' % (host, line.decode('utf-8').rstrip()))# Ignore the body, close the socketwriter.close()loop = asyncio.get_event_loop()
tasks = [wget(host) for host in ['www.sina.com.cn', 'www.sohu.com', 'www.163.com']]
loop.run_until_complete(asyncio.wait(tasks))
loop.close()

執(zhí)行結果如下:

wget www.sohu.com...
wget www.sina.com.cn...
wget www.163.com...
(等待一段時間)
(打印出sohu的header)
www.sohu.com header > HTTP/1.1 200 OK
www.sohu.com header > Content-Type: text/html
...
(打印出sina的header)
www.sina.com.cn header > HTTP/1.1 200 OK
www.sina.com.cn header > Date: Wed, 20 May 2015 04:56:33 GMT
...
(打印出163的header)
www.163.com header > HTTP/1.0 302 Moved Temporarily
www.163.com header > Server: Cdn Cache Server V2.0
...

可見3個連接由一個線程通過coroutine并發(fā)完成。

小結

asyncio提供了完善的異步IO支持;

異步操作需要在coroutine中通過yield from完成;

多個coroutine可以封裝成一組Task然后并發(fā)執(zhí)行。

參考源碼

async_hello.py

async_wget.py

async/await

asyncio提供的@asyncio.coroutine可以把一個generator標記為coroutine類型,然后在coroutine內部用yield from調用另一個coroutine實現(xiàn)異步操作。

為了簡化并更好地標識異步IO,從Python 3.5開始引入了新的語法asyncawait,可以讓coroutine的代碼更簡潔易讀。

請注意,asyncawait是針對coroutine的新語法,要使用新的語法,只需要做兩步簡單的替換:

  1. @asyncio.coroutine替換為async
  2. yield from替換為await

讓我們對比一下上一節(jié)的代碼:

@asyncio.coroutine
def hello():print("Hello world!")r = yield from asyncio.sleep(1)print("Hello again!")

用新語法重新編寫如下:

async def hello():print("Hello world!")r = await asyncio.sleep(1)print("Hello again!")

剩下的代碼保持不變。

小結

Python從3.5版本開始為asyncio提供了asyncawait的新語法;

注意新語法只能用在Python 3.5以及后續(xù)版本,如果使用3.4版本,則仍需使用上一節(jié)的方案。

練習

將上一節(jié)的異步獲取sina、sohu和163的網站首頁源碼用新語法重寫并運行。

參考源碼

async_hello2.py

async_wget2.py

aiohttp

asyncio可以實現(xiàn)單線程并發(fā)IO操作。如果僅用在客戶端,發(fā)揮的威力不大。如果把asyncio用在服務器端,例如Web服務器,由于HTTP連接就是IO操作,因此可以用單線程+coroutine實現(xiàn)多用戶的高并發(fā)支持。

asyncio實現(xiàn)了TCP、UDP、SSL等協(xié)議,aiohttp則是基于asyncio實現(xiàn)的HTTP框架。

我們先安裝aiohttp

pip install aiohttp

然后編寫一個HTTP服務器,分別處理以下URL:

  • /?- 首頁返回b'<h1>Index</h1>'

  • /hello/{name}?- 根據(jù)URL參數(shù)返回文本hello, %s!。

代碼如下:

import asynciofrom aiohttp import webasync def index(request):await asyncio.sleep(0.5)return web.Response(body=b'<h1>Index</h1>')async def hello(request):await asyncio.sleep(0.5)text = '<h1>hello, %s!</h1>' % request.match_info['name']return web.Response(body=text.encode('utf-8'))async def init(loop):app = web.Application(loop=loop)app.router.add_route('GET', '/', index)app.router.add_route('GET', '/hello/{name}', hello)srv = await loop.create_server(app.make_handler(), '127.0.0.1', 8000)print('Server started at http://127.0.0.1:8000...')return srvloop = asyncio.get_event_loop()
loop.run_until_complete(init(loop))
loop.run_forever()

注意aiohttp的初始化函數(shù)init()也是一個coroutineloop.create_server()則利用asyncio創(chuàng)建TCP服務。

參考源碼

aio_web.py

實戰(zhàn)

看完了教程,是不是有這么一種感覺:看的時候覺得很簡單,照著教程敲代碼也沒啥大問題。

于是準備開始獨立寫代碼,就發(fā)現(xiàn)不知道從哪開始下手了。

這種情況是完全正常的。好比學寫作文,學的時候覺得簡單,寫的時候就無從下筆了。

雖然這個教程是面向小白的零基礎Python教程,但是我們的目標不是學到60分,而是學到90分。

所以,用Python寫一個真正的Web App吧!

目標

我們設定的實戰(zhàn)目標是一個Blog網站,包含日志、用戶和評論3大部分。

很多童鞋會想,這是不是太簡單了?

比如webpy.org上就提供了一個Blog的例子,目測也就100行代碼。

但是,這樣的頁面:

simple-hello-world

你拿得出手么?

我們要寫出用戶真正看得上眼的頁面,首頁長得像這樣:

awesomepy-home-blogs

評論區(qū):

awesomepy-comments

還有極其強大的后臺管理頁面:

awesomepy-manage-blogs

是不是一下子變得高端大氣上檔次了?

項目名稱

必須是高端大氣上檔次的名稱,命名為awesome-python3-webapp

項目計劃

項目計劃開發(fā)周期為16天。每天,你需要完成教程中的內容。如果你覺得編寫代碼難度實在太大,可以參考一下當天在GitHub上的代碼。

第N天的代碼在https://github.com/michaelliao/awesome-python3-webapp/tree/day-N上。比如第1天就是:

https://github.com/michaelliao/awesome-python3-webapp/tree/day-01

以此類推。

要預覽awesome-python3-webapp的最終頁面效果,請猛擊:

awesome.liaoxuefeng.com

Day 1 - 搭建開發(fā)環(huán)境

搭建開發(fā)環(huán)境

首先,確認系統(tǒng)安裝的Python版本是3.4.x:

$ python3 --version
Python 3.4.3

然后,用pip安裝開發(fā)Web App需要的第三方庫:

異步框架aiohttp:

$pip3 install aiohttp

前端模板引擎jinja2:

$ pip3 install jinja2

MySQL 5.x數(shù)據(jù)庫,從官方網站下載并安裝,安裝完畢后,請務必牢記root口令。為避免遺忘口令,建議直接把root口令設置為password

MySQL的Python異步驅動程序aiomysql:

$ pip3 install aiomysql
項目結構

選擇一個工作目錄,然后,我們建立如下的目錄結構:

awesome-python3-webapp/  <-- 根目錄
|
+- backup/               <-- 備份目錄
|
+- conf/                 <-- 配置文件
|
+- dist/                 <-- 打包目錄
|
+- www/                  <-- Web目錄,存放.py文件
|  |
|  +- static/            <-- 存放靜態(tài)文件
|  |
|  +- templates/         <-- 存放模板文件
|
+- ios/                  <-- 存放iOS App工程
|
+- LICENSE               <-- 代碼LICENSE

創(chuàng)建好項目的目錄結構后,建議同時建立git倉庫并同步至GitHub,保證代碼修改的安全。

要了解git和GitHub的用法,請移步Git教程。

開發(fā)工具

自備,推薦用Sublime Text,請參考使用文本編輯器。

參考源碼

day-01

Day 2 - 編寫Web App骨架

由于我們的Web App建立在asyncio的基礎上,因此用aiohttp寫一個基本的app.py

import logging; logging.basicConfig(level=logging.INFO)import asyncio, os, json, time
from datetime import datetimefrom aiohttp import webdef index(request):return web.Response(body=b'<h1>Awesome</h1>')@asyncio.coroutine
def init(loop):app = web.Application(loop=loop)app.router.add_route('GET', '/', index)srv = yield from loop.create_server(app.make_handler(), '127.0.0.1', 9000)logging.info('server started at http://127.0.0.1:9000...')return srvloop = asyncio.get_event_loop()
loop.run_until_complete(init(loop))
loop.run_forever()

運行python app.py,Web App將在9000端口監(jiān)聽HTTP請求,并且對首頁/進行響應:

$ python3 app.py
INFO:root:server started at http://127.0.0.1:9000...

這里我們簡單地返回一個Awesome字符串,在瀏覽器中可以看到效果:

awesome-home

這說明我們的Web App骨架已經搭好了,可以進一步往里面添加更多的東西。

參考源碼

day-02

Day 3 - 編寫ORM

在一個Web App中,所有數(shù)據(jù),包括用戶信息、發(fā)布的日志、評論等,都存儲在數(shù)據(jù)庫中。在awesome-python3-webapp中,我們選擇MySQL作為數(shù)據(jù)庫。

Web App里面有很多地方都要訪問數(shù)據(jù)庫。訪問數(shù)據(jù)庫需要創(chuàng)建數(shù)據(jù)庫連接、游標對象,然后執(zhí)行SQL語句,最后處理異常,清理資源。這些訪問數(shù)據(jù)庫的代碼如果分散到各個函數(shù)中,勢必無法維護,也不利于代碼復用。

所以,我們要首先把常用的SELECT、INSERT、UPDATE和DELETE操作用函數(shù)封裝起來。

由于Web框架使用了基于asyncio的aiohttp,這是基于協(xié)程的異步模型。在協(xié)程中,不能調用普通的同步IO操作,因為所有用戶都是由一個線程服務的,協(xié)程的執(zhí)行速度必須非???#xff0c;才能處理大量用戶的請求。而耗時的IO操作不能在協(xié)程中以同步的方式調用,否則,等待一個IO操作時,系統(tǒng)無法響應任何其他用戶。

這就是異步編程的一個原則:一旦決定使用異步,則系統(tǒng)每一層都必須是異步,“開弓沒有回頭箭”。

幸運的是aiomysql為MySQL數(shù)據(jù)庫提供了異步IO的驅動。

創(chuàng)建連接池

我們需要創(chuàng)建一個全局的連接池,每個HTTP請求都可以從連接池中直接獲取數(shù)據(jù)庫連接。使用連接池的好處是不必頻繁地打開和關閉數(shù)據(jù)庫連接,而是能復用就盡量復用。

連接池由全局變量__pool存儲,缺省情況下將編碼設置為utf8,自動提交事務:

@asyncio.coroutine
def create_pool(loop, **kw):logging.info('create database connection pool...')global __pool__pool = yield from aiomysql.create_pool(host=kw.get('host', 'localhost'),port=kw.get('port', 3306),user=kw['user'],password=kw['password'],db=kw['db'],charset=kw.get('charset', 'utf8'),autocommit=kw.get('autocommit', True),maxsize=kw.get('maxsize', 10),minsize=kw.get('minsize', 1),loop=loop)
Select

要執(zhí)行SELECT語句,我們用select函數(shù)執(zhí)行,需要傳入SQL語句和SQL參數(shù):

@asyncio.coroutine
def select(sql, args, size=None):log(sql, args)global __poolwith (yield from __pool) as conn:cur = yield from conn.cursor(aiomysql.DictCursor)yield from cur.execute(sql.replace('?', '%s'), args or ())if size:rs = yield from cur.fetchmany(size)else:rs = yield from cur.fetchall()yield from cur.close()logging.info('rows returned: %s' % len(rs))return rs

SQL語句的占位符是?,而MySQL的占位符是%sselect()函數(shù)在內部自動替換。注意要始終堅持使用帶參數(shù)的SQL,而不是自己拼接SQL字符串,這樣可以防止SQL注入攻擊。

注意到yield from將調用一個子協(xié)程(也就是在一個協(xié)程中調用另一個協(xié)程)并直接獲得子協(xié)程的返回結果。

如果傳入size參數(shù),就通過fetchmany()獲取最多指定數(shù)量的記錄,否則,通過fetchall()獲取所有記錄。

Insert, Update, Delete

要執(zhí)行INSERT、UPDATE、DELETE語句,可以定義一個通用的execute()函數(shù),因為這3種SQL的執(zhí)行都需要相同的參數(shù),以及返回一個整數(shù)表示影響的行數(shù):

@asyncio.coroutine
def execute(sql, args):log(sql)with (yield from __pool) as conn:try:cur = yield from conn.cursor()yield from cur.execute(sql.replace('?', '%s'), args)affected = cur.rowcountyield from cur.close()except BaseException as e:raisereturn affected

execute()函數(shù)和select()函數(shù)所不同的是,cursor對象不返回結果集,而是通過rowcount返回結果數(shù)。

ORM

有了基本的select()execute()函數(shù),我們就可以開始編寫一個簡單的ORM了。

設計ORM需要從上層調用者角度來設計。

我們先考慮如何定義一個User對象,然后把數(shù)據(jù)庫表users和它關聯(lián)起來。

from orm import Model, StringField, IntegerFieldclass User(Model):__table__ = 'users'id = IntegerField(primary_key=True)name = StringField()

注意到定義在User類中的__table__idname是類的屬性,不是實例的屬性。所以,在類級別上定義的屬性用來描述User對象和表的映射關系,而實例屬性必須通過__init__()方法去初始化,所以兩者互不干擾:

# 創(chuàng)建實例:
user = User(id=123, name='Michael')
# 存入數(shù)據(jù)庫:
user.insert()
# 查詢所有User對象:
users = User.findAll()
定義Model

首先要定義的是所有ORM映射的基類Model

class Model(dict, metaclass=ModelMetaclass):def __init__(self, **kw):super(Model, self).__init__(**kw)def __getattr__(self, key):try:return self[key]except KeyError:raise AttributeError(r"'Model' object has no attribute '%s'" % key)def __setattr__(self, key, value):self[key] = valuedef getValue(self, key):return getattr(self, key, None)def getValueOrDefault(self, key):value = getattr(self, key, None)if value is None:field = self.__mappings__[key]if field.default is not None:value = field.default() if callable(field.default) else field.defaultlogging.debug('using default value for %s: %s' % (key, str(value)))setattr(self, key, value)return value

Modeldict繼承,所以具備所有dict的功能,同時又實現(xiàn)了特殊方法__getattr__()__setattr__(),因此又可以像引用普通字段那樣寫:

>>> user['id']
123
>>> user.id
123

以及Field和各種Field子類:

class Field(object):def __init__(self, name, column_type, primary_key, default):self.name = nameself.column_type = column_typeself.primary_key = primary_keyself.default = defaultdef __str__(self):return '<%s, %s:%s>' % (self.__class__.__name__, self.column_type, self.name)

映射varcharStringField

class StringField(Field):def __init__(self, name=None, primary_key=False, default=None, ddl='varchar(100)'):super().__init__(name, ddl, primary_key, default)

注意到Model只是一個基類,如何將具體的子類如User的映射信息讀取出來呢?答案就是通過metaclass:ModelMetaclass

class ModelMetaclass(type):def __new__(cls, name, bases, attrs):# 排除Model類本身:if name=='Model':return type.__new__(cls, name, bases, attrs)# 獲取table名稱:tableName = attrs.get('__table__', None) or namelogging.info('found model: %s (table: %s)' % (name, tableName))# 獲取所有的Field和主鍵名:mappings = dict()fields = []primaryKey = Nonefor k, v in attrs.items():if isinstance(v, Field):logging.info('  found mapping: %s ==> %s' % (k, v))mappings[k] = vif v.primary_key:# 找到主鍵:if primaryKey:raise RuntimeError('Duplicate primary key for field: %s' % k)primaryKey = kelse:fields.append(k)if not primaryKey:raise RuntimeError('Primary key not found.')for k in mappings.keys():attrs.pop(k)escaped_fields = list(map(lambda f: '`%s`' % f, fields))attrs['__mappings__'] = mappings # 保存屬性和列的映射關系attrs['__table__'] = tableNameattrs['__primary_key__'] = primaryKey # 主鍵屬性名attrs['__fields__'] = fields # 除主鍵外的屬性名# 構造默認的SELECT, INSERT, UPDATE和DELETE語句:attrs['__select__'] = 'select `%s`, %s from `%s`' % (primaryKey, ', '.join(escaped_fields), tableName)attrs['__insert__'] = 'insert into `%s` (%s, `%s`) values (%s)' % (tableName, ', '.join(escaped_fields), primaryKey, create_args_string(len(escaped_fields) + 1))attrs['__update__'] = 'update `%s` set %s where `%s`=?' % (tableName, ', '.join(map(lambda f: '`%s`=?' % (mappings.get(f).name or f), fields)), primaryKey)attrs['__delete__'] = 'delete from `%s` where `%s`=?' % (tableName, primaryKey)return type.__new__(cls, name, bases, attrs)

這樣,任何繼承自Model的類(比如User),會自動通過ModelMetaclass掃描映射關系,并存儲到自身的類屬性如__table__、__mappings__中。

然后,我們往Model類添加class方法,就可以讓所有子類調用class方法:

class Model(dict):...@classmethod@asyncio.coroutinedef find(cls, pk):' find object by primary key. 'rs = yield from select('%s where `%s`=?' % (cls.__select__, cls.__primary_key__), [pk], 1)if len(rs) == 0:return Nonereturn cls(**rs[0])

User類現(xiàn)在就可以通過類方法實現(xiàn)主鍵查找:

user = yield from User.find('123')

往Model類添加實例方法,就可以讓所有子類調用實例方法:

class Model(dict):...@asyncio.coroutinedef save(self):args = list(map(self.getValueOrDefault, self.__fields__))args.append(self.getValueOrDefault(self.__primary_key__))rows = yield from execute(self.__insert__, args)if rows != 1:logging.warn('failed to insert record: affected rows: %s' % rows)

這樣,就可以把一個User實例存入數(shù)據(jù)庫:

user = User(id=123, name='Michael')
yield from user.save()

最后一步是完善ORM,對于查找,我們可以實現(xiàn)以下方法:

  • findAll() - 根據(jù)WHERE條件查找;

  • findNumber() - 根據(jù)WHERE條件查找,但返回的是整數(shù),適用于select count(*)類型的SQL。

以及update()remove()方法。

所有這些方法都必須用@asyncio.coroutine裝飾,變成一個協(xié)程。

調用時需要特別注意:

user.save()

沒有任何效果,因為調用save()僅僅是創(chuàng)建了一個協(xié)程,并沒有執(zhí)行它。一定要用:

yield from user.save()

才真正執(zhí)行了INSERT操作。

最后看看我們實現(xiàn)的ORM模塊一共多少行代碼?累計不到300多行。用Python寫一個ORM是不是很容易呢?

參考源碼

day-03

Day 4 - 編寫Model

有了ORM,我們就可以把Web App需要的3個表用Model表示出來:

import time, uuidfrom orm import Model, StringField, BooleanField, FloatField, TextFielddef next_id():return '%015d%s000' % (int(time.time() * 1000), uuid.uuid4().hex)class User(Model):__table__ = 'users'id = StringField(primary_key=True, default=next_id, ddl='varchar(50)')email = StringField(ddl='varchar(50)')passwd = StringField(ddl='varchar(50)')admin = BooleanField()name = StringField(ddl='varchar(50)')image = StringField(ddl='varchar(500)')created_at = FloatField(default=time.time)class Blog(Model):__table__ = 'blogs'id = StringField(primary_key=True, default=next_id, ddl='varchar(50)')user_id = StringField(ddl='varchar(50)')user_name = StringField(ddl='varchar(50)')user_image = StringField(ddl='varchar(500)')name = StringField(ddl='varchar(50)')summary = StringField(ddl='varchar(200)')content = TextField()created_at = FloatField(default=time.time)class Comment(Model):__table__ = 'comments'id = StringField(primary_key=True, default=next_id, ddl='varchar(50)')blog_id = StringField(ddl='varchar(50)')user_id = StringField(ddl='varchar(50)')user_name = StringField(ddl='varchar(50)')user_image = StringField(ddl='varchar(500)')content = TextField()created_at = FloatField(default=time.time)

在編寫ORM時,給一個Field增加一個default參數(shù)可以讓ORM自己填入缺省值,非常方便。并且,缺省值可以作為函數(shù)對象傳入,在調用save()時自動計算。

例如,主鍵id的缺省值是函數(shù)next_id,創(chuàng)建時間created_at的缺省值是函數(shù)time.time,可以自動設置當前日期和時間。

日期和時間用float類型存儲在數(shù)據(jù)庫中,而不是datetime類型,這么做的好處是不必關心數(shù)據(jù)庫的時區(qū)以及時區(qū)轉換問題,排序非常簡單,顯示的時候,只需要做一個floatstr的轉換,也非常容易。

初始化數(shù)據(jù)庫表

如果表的數(shù)量很少,可以手寫創(chuàng)建表的SQL腳本:

-- schema.sqldrop database if exists awesome;create database awesome;use awesome;grant select, insert, update, delete on awesome.* to 'www-data'@'localhost' identified by 'www-data';create table users (`id` varchar(50) not null,`email` varchar(50) not null,`passwd` varchar(50) not null,`admin` bool not null,`name` varchar(50) not null,`image` varchar(500) not null,`created_at` real not null,unique key `idx_email` (`email`),key `idx_created_at` (`created_at`),primary key (`id`)
) engine=innodb default charset=utf8;create table blogs (`id` varchar(50) not null,`user_id` varchar(50) not null,`user_name` varchar(50) not null,`user_image` varchar(500) not null,`name` varchar(50) not null,`summary` varchar(200) not null,`content` mediumtext not null,`created_at` real not null,key `idx_created_at` (`created_at`),primary key (`id`)
) engine=innodb default charset=utf8;create table comments (`id` varchar(50) not null,`blog_id` varchar(50) not null,`user_id` varchar(50) not null,`user_name` varchar(50) not null,`user_image` varchar(500) not null,`content` mediumtext not null,`created_at` real not null,key `idx_created_at` (`created_at`),primary key (`id`)
) engine=innodb default charset=utf8;

如果表的數(shù)量很多,可以從Model對象直接通過腳本自動生成SQL腳本,使用更簡單。

把SQL腳本放到MySQL命令行里執(zhí)行:

$ mysql -u root -p < schema.sql

我們就完成了數(shù)據(jù)庫表的初始化。

編寫數(shù)據(jù)訪問代碼

接下來,就可以真正開始編寫代碼操作對象了。比如,對于User對象,我們就可以做如下操作:

import orm
from models import User, Blog, Commentdef test():yield from orm.create_pool(user='www-data', password='www-data', database='awesome')u = User(name='Test', email='test@example.com', passwd='1234567890', image='about:blank')yield from u.save()for x in test():pass

可以在MySQL客戶端命令行查詢,看看數(shù)據(jù)是不是正常存儲到MySQL里面了。

參考源碼

day-04

Day 5 - 編寫Web框架

在正式開始Web開發(fā)前,我們需要編寫一個Web框架。

aiohttp已經是一個Web框架了,為什么我們還需要自己封裝一個?

原因是從使用者的角度來說,aiohttp相對比較底層,編寫一個URL的處理函數(shù)需要這么幾步:

第一步,編寫一個用@asyncio.coroutine裝飾的函數(shù):

@asyncio.coroutine
def handle_url_xxx(request):pass

第二步,傳入的參數(shù)需要自己從request中獲取:

url_param = request.match_info['key']
query_params = parse_qs(request.query_string)

最后,需要自己構造Response對象:

text = render('template', data)
return web.Response(text.encode('utf-8'))

這些重復的工作可以由框架完成。例如,處理帶參數(shù)的URL/blog/{id}可以這么寫:

@get('/blog/{id}')
def get_blog(id):pass

處理query_string參數(shù)可以通過關鍵字參數(shù)**kw或者命名關鍵字參數(shù)接收:

@get('/api/comments')
def api_comments(*, page='1'):pass

對于函數(shù)的返回值,不一定是web.Response對象,可以是str、bytesdict。

如果希望渲染模板,我們可以這么返回一個dict

return {'__template__': 'index.html','data': '...'
}

因此,Web框架的設計是完全從使用者出發(fā),目的是讓使用者編寫盡可能少的代碼。

編寫簡單的函數(shù)而非引入requestweb.Response還有一個額外的好處,就是可以單獨測試,否則,需要模擬一個request才能測試。

@get和@post

要把一個函數(shù)映射為一個URL處理函數(shù),我們先定義@get()

def get(path):'''Define decorator @get('/path')'''def decorator(func):@functools.wraps(func)def wrapper(*args, **kw):return func(*args, **kw)wrapper.__method__ = 'GET'wrapper.__route__ = pathreturn wrapperreturn decorator

這樣,一個函數(shù)通過@get()的裝飾就附帶了URL信息。

@post@get定義類似。

定義RequestHandler

URL處理函數(shù)不一定是一個coroutine,因此我們用RequestHandler()來封裝一個URL處理函數(shù)。

RequestHandler是一個類,由于定義了__call__()方法,因此可以將其實例視為函數(shù)。

RequestHandler目的就是從URL函數(shù)中分析其需要接收的參數(shù),從request中獲取必要的參數(shù),調用URL函數(shù),然后把結果轉換為web.Response對象,這樣,就完全符合aiohttp框架的要求:

class RequestHandler(object):def __init__(self, app, fn):self._app = appself._func = fn...@asyncio.coroutinedef __call__(self, request):kw = ... 獲取參數(shù)r = yield from self._func(**kw)return r

再編寫一個add_route函數(shù),用來注冊一個URL處理函數(shù):

def add_route(app, fn):method = getattr(fn, '__method__', None)path = getattr(fn, '__route__', None)if path is None or method is None:raise ValueError('@get or @post not defined in %s.' % str(fn))if not asyncio.iscoroutinefunction(fn) and not inspect.isgeneratorfunction(fn):fn = asyncio.coroutine(fn)logging.info('add route %s %s => %s(%s)' % (method, path, fn.__name__, ', '.join(inspect.signature(fn).parameters.keys())))app.router.add_route(method, path, RequestHandler(app, fn))

最后一步,把很多次add_route()注冊的調用:

add_route(app, handles.index)
add_route(app, handles.blog)
add_route(app, handles.create_comment)
...

變成自動掃描:

# 自動把handler模塊的所有符合條件的函數(shù)注冊了:
add_routes(app, 'handlers')

add_routes()定義如下:

def add_routes(app, module_name):n = module_name.rfind('.')if n == (-1):mod = __import__(module_name, globals(), locals())else:name = module_name[n+1:]mod = getattr(__import__(module_name[:n], globals(), locals(), [name]), name)for attr in dir(mod):if attr.startswith('_'):continuefn = getattr(mod, attr)if callable(fn):method = getattr(fn, '__method__', None)path = getattr(fn, '__route__', None)if method and path:add_route(app, fn)

最后,在app.py中加入middleware、jinja2模板和自注冊的支持:

app = web.Application(loop=loop, middlewares=[logger_factory, response_factory
])
init_jinja2(app, filters=dict(datetime=datetime_filter))
add_routes(app, 'handlers')
add_static(app)
middleware

middleware是一種攔截器,一個URL在被某個函數(shù)處理前,可以經過一系列的middleware的處理。

一個middleware可以改變URL的輸入、輸出,甚至可以決定不繼續(xù)處理而直接返回。middleware的用處就在于把通用的功能從每個URL處理函數(shù)中拿出來,集中放到一個地方。例如,一個記錄URL日志的logger可以簡單定義如下:

@asyncio.coroutine
def logger_factory(app, handler):@asyncio.coroutinedef logger(request):# 記錄日志:logging.info('Request: %s %s' % (request.method, request.path))# 繼續(xù)處理請求:return (yield from handler(request))return logger

response這個middleware把返回值轉換為web.Response對象再返回,以保證滿足aiohttp的要求:

@asyncio.coroutine
def response_factory(app, handler):@asyncio.coroutinedef response(request):# 結果:r = yield from handler(request)if isinstance(r, web.StreamResponse):return rif isinstance(r, bytes):resp = web.Response(body=r)resp.content_type = 'application/octet-stream'return respif isinstance(r, str):resp = web.Response(body=r.encode('utf-8'))resp.content_type = 'text/html;charset=utf-8'return respif isinstance(r, dict):...

有了這些基礎設施,我們就可以專注地往handlers模塊不斷添加URL處理函數(shù)了,可以極大地提高開發(fā)效率。

參考源碼

day-05

Day 6 - 編寫配置文件

有了Web框架和ORM框架,我們就可以開始裝配App了。

通常,一個Web App在運行時都需要讀取配置文件,比如數(shù)據(jù)庫的用戶名、口令等,在不同的環(huán)境中運行時,Web App可以通過讀取不同的配置文件來獲得正確的配置。

由于Python本身語法簡單,完全可以直接用Python源代碼來實現(xiàn)配置,而不需要再解析一個單獨的.properties或者.yaml等配置文件。

默認的配置文件應該完全符合本地開發(fā)環(huán)境,這樣,無需任何設置,就可以立刻啟動服務器。

我們把默認的配置文件命名為config_default.py

# config_default.pyconfigs = {'db': {'host': '127.0.0.1','port': 3306,'user': 'www-data','password': 'www-data','database': 'awesome'},'session': {'secret': 'AwEsOmE'}
}

上述配置文件簡單明了。但是,如果要部署到服務器時,通常需要修改數(shù)據(jù)庫的host等信息,直接修改config_default.py不是一個好辦法,更好的方法是編寫一個config_override.py,用來覆蓋某些默認設置:

# config_override.pyconfigs = {'db': {'host': '192.168.0.100'}
}

config_default.py作為開發(fā)環(huán)境的標準配置,把config_override.py作為生產環(huán)境的標準配置,我們就可以既方便地在本地開發(fā),又可以隨時把應用部署到服務器上。

應用程序讀取配置文件需要優(yōu)先從config_override.py讀取。為了簡化讀取配置文件,可以把所有配置讀取到統(tǒng)一的config.py中:

# config.py
configs = config_default.configstry:import config_overrideconfigs = merge(configs, config_override.configs)
except ImportError:pass

這樣,我們就完成了App的配置。

參考源碼

day-06

Day 7 - 編寫MVC

現(xiàn)在,ORM框架、Web框架和配置都已就緒,我們可以開始編寫一個最簡單的MVC,把它們全部啟動起來。

通過Web框架的@get和ORM框架的Model支持,可以很容易地編寫一個處理首頁URL的函數(shù):

@get('/')
def index(request):users = yield from User.findAll()return {'__template__': 'test.html','users': users}

'__template__'指定的模板文件是test.html,其他參數(shù)是傳遞給模板的數(shù)據(jù),所以我們在模板的根目錄templates下創(chuàng)建test.html

<!DOCTYPE html>
<html>
<head><meta charset="utf-8" /><title>Test users - Awesome Python Webapp</title>
</head>
<body><h1>All users</h1>{% for u in users %}<p>{{ u.name }} / {{ u.email }}</p>{% endfor %}
</body>
</html>

接下來,如果一切順利,可以用命令行啟動Web服務器:

$ python3 app.py

然后,在瀏覽器中訪問http://localhost:9000/。

如果數(shù)據(jù)庫的users表什么內容也沒有,你就無法在瀏覽器中看到循環(huán)輸出的內容??梢宰约涸贛ySQL的命令行里給users表添加幾條記錄,然后再訪問:

awesomepy-all-users

參考源碼

day-07

Day 8 - 構建前端

雖然我們跑通了一個最簡單的MVC,但是頁面效果肯定不會讓人滿意。

對于復雜的HTML前端頁面來說,我們需要一套基礎的CSS框架來完成頁面布局和基本樣式。另外,jQuery作為操作DOM的JavaScript庫也必不可少。

從零開始寫CSS不如直接從一個已有的功能完善的CSS框架開始。有很多CSS框架可供選擇。我們這次選擇uikit這個強大的CSS框架。它具備完善的響應式布局,漂亮的UI,以及豐富的HTML組件,讓我們能輕松設計出美觀而簡潔的頁面。

可以從uikit首頁下載打包的資源文件。

所有的靜態(tài)資源文件我們統(tǒng)一放到www/static目錄下,并按照類別歸類:

static/
+- css/
|  +- addons/
|  |  +- uikit.addons.min.css
|  |  +- uikit.almost-flat.addons.min.css
|  |  +- uikit.gradient.addons.min.css
|  +- awesome.css
|  +- uikit.almost-flat.addons.min.css
|  +- uikit.gradient.addons.min.css
|  +- uikit.min.css
+- fonts/
|  +- fontawesome-webfont.eot
|  +- fontawesome-webfont.ttf
|  +- fontawesome-webfont.woff
|  +- FontAwesome.otf
+- js/+- awesome.js+- html5.js+- jquery.min.js+- uikit.min.js

由于前端頁面肯定不止首頁一個頁面,每個頁面都有相同的頁眉和頁腳。如果每個頁面都是獨立的HTML模板,那么我們在修改頁眉和頁腳的時候,就需要把每個模板都改一遍,這顯然是沒有效率的。

常見的模板引擎已經考慮到了頁面上重復的HTML部分的復用問題。有的模板通過include把頁面拆成三部分:

<html><% include file="inc_header.html" %><% include file="index_body.html" %><% include file="inc_footer.html" %>
</html>

這樣,相同的部分inc_header.htmlinc_footer.html就可以共享。

但是include方法不利于頁面整體結構的維護。jinjia2的模板還有另一種“繼承”方式,實現(xiàn)模板的復用更簡單。

“繼承”模板的方式是通過編寫一個“父模板”,在父模板中定義一些可替換的block(塊)。然后,編寫多個“子模板”,每個子模板都可以只替換父模板定義的block。比如,定義一個最簡單的父模板:

<!-- base.html -->
<html><head><title>{% block title%} 這里定義了一個名為title的block {% endblock %}</title></head><body>{% block content %} 這里定義了一個名為content的block {% endblock %}</body>
</html>

對于子模板a.html,只需要把父模板的titlecontent替換掉:

{% extends 'base.html' %}{% block title %} A {% endblock %}{% block content %}<h1>Chapter A</h1><p>blablabla...</p>
{% endblock %}

對于子模板b.html,如法炮制:

{% extends 'base.html' %}{% block title %} B {% endblock %}{% block content %}<h1>Chapter B</h1><ul><li>list 1</li><li>list 2</li></ul>
{% endblock %}

這樣,一旦定義好父模板的整體布局和CSS樣式,編寫子模板就會非常容易。

讓我們通過uikit這個CSS框架來完成父模板__base__.html的編寫:

<!DOCTYPE html>
<html>
<head><meta charset="utf-8" />{% block meta %}<!-- block meta  -->{% endblock %}<title>{% block title %} ? {% endblock %} - Awesome Python Webapp</title><link rel="stylesheet" href="/static/css/uikit.min.css"><link rel="stylesheet" href="/static/css/uikit.gradient.min.css"><link rel="stylesheet" href="/static/css/awesome.css" /><script src="/static/js/jquery.min.js"></script><script src="/static/js/md5.js"></script><script src="/static/js/uikit.min.js"></script><script src="/static/js/awesome.js"></script>{% block beforehead %}<!-- before head  -->{% endblock %}
</head>
<body><nav class="uk-navbar uk-navbar-attached uk-margin-bottom"><div class="uk-container uk-container-center"><a href="/" class="uk-navbar-brand">Awesome</a><ul class="uk-navbar-nav"><li data-url="blogs"><a href="/"><i class="uk-icon-home"></i> 日志</a></li><li><a target="_blank" href="#"><i class="uk-icon-book"></i> 教程</a></li><li><a target="_blank" href="#"><i class="uk-icon-code"></i> 源碼</a></li></ul><div class="uk-navbar-flip"><ul class="uk-navbar-nav">{% if user %}<li class="uk-parent" data-uk-dropdown><a href="#0"><i class="uk-icon-user"></i> {{ user.name }}</a><div class="uk-dropdown uk-dropdown-navbar"><ul class="uk-nav uk-nav-navbar"><li><a href="/signout"><i class="uk-icon-sign-out"></i> 登出</a></li></ul></div></li>{% else %}<li><a href="/signin"><i class="uk-icon-sign-in"></i> 登陸</a></li><li><a href="/register"><i class="uk-icon-edit"></i> 注冊</a></li>{% endif %}</ul></div></div></nav><div class="uk-container uk-container-center"><div class="uk-grid"><!-- content -->{% block content %}{% endblock %}<!-- // content --></div></div><div class="uk-margin-large-top" style="background-color:#eee; border-top:1px solid #ccc;"><div class="uk-container uk-container-center uk-text-center"><div class="uk-panel uk-margin-top uk-margin-bottom"><p><a target="_blank" href="#" class="uk-icon-button uk-icon-weibo"></a><a target="_blank" href="#" class="uk-icon-button uk-icon-github"></a><a target="_blank" href="#" class="uk-icon-button uk-icon-linkedin-square"></a><a target="_blank" href="#" class="uk-icon-button uk-icon-twitter"></a></p><p>Powered by <a href="#">Awesome Python Webapp</a>. Copyright &copy; 2014. [<a href="/manage/" target="_blank">Manage</a>]</p><p><a href="http://www.liaoxuefeng.com/" target="_blank">www.liaoxuefeng.com</a>. All rights reserved.</p><a target="_blank" href="#"><i class="uk-icon-html5" style="font-size:64px; color: #444;"></i></a></div></div></div>
</body>
</html>

__base__.html定義的幾個block作用如下:

用于子頁面定義一些meta,例如rss feed:

{% block meta %} ... {% endblock %}

覆蓋頁面的標題:

{% block title %} ... {% endblock %}

子頁面可以在<head>標簽關閉前插入JavaScript代碼:

{% block beforehead %} ... {% endblock %}

子頁面的content布局和內容:

{% block content %}...
{% endblock %}

我們把首頁改造一下,從__base__.html繼承一個blogs.html

{% extends '__base__.html' %}{% block title %}日志{% endblock %}{% block content %}<div class="uk-width-medium-3-4">{% for blog in blogs %}<article class="uk-article"><h2><a href="/blog/{{ blog.id }}">{{ blog.name }}</a></h2><p class="uk-article-meta">發(fā)表于{{ blog.created_at}}</p><p>{{ blog.summary }}</p><p><a href="/blog/{{ blog.id }}">繼續(xù)閱讀 <i class="uk-icon-angle-double-right"></i></a></p></article><hr class="uk-article-divider">{% endfor %}</div><div class="uk-width-medium-1-4"><div class="uk-panel uk-panel-header"><h3 class="uk-panel-title">友情鏈接</h3><ul class="uk-list uk-list-line"><li><i class="uk-icon-thumbs-o-up"></i> <a target="_blank" href="#">編程</a></li><li><i class="uk-icon-thumbs-o-up"></i> <a target="_blank" href="#">讀書</a></li><li><i class="uk-icon-thumbs-o-up"></i> <a target="_blank" href="#">Python教程</a></li><li><i class="uk-icon-thumbs-o-up"></i> <a target="_blank" href="#">Git教程</a></li></ul></div></div>{% endblock %}

相應地,首頁URL的處理函數(shù)更新如下:

@get('/')
def index(request):summary = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'blogs = [Blog(id='1', name='Test Blog', summary=summary, created_at=time.time()-120),Blog(id='2', name='Something New', summary=summary, created_at=time.time()-3600),Blog(id='3', name='Learn Swift', summary=summary, created_at=time.time()-7200)]return {'__template__': 'blogs.html','blogs': blogs}

Blog的創(chuàng)建日期顯示的是一個浮點數(shù),因為它是由這段模板渲染出來的:

<p class="uk-article-meta">發(fā)表于{{ blog.created_at }}</p>

解決方法是通過jinja2的filter(過濾器),把一個浮點數(shù)轉換成日期字符串。我們來編寫一個datetime的filter,在模板里用法如下:

<p class="uk-article-meta">發(fā)表于{{ blog.created_at|datetime }}</p>

filter需要在初始化jinja2時設置。相關代碼如下:

def datetime_filter(t):delta = int(time.time() - t)if delta < 60:return '1分鐘前'if delta < 3600:return '%s分鐘前' % (delta // 60)if delta < 86400:return '%s小時前' % (delta // 3600)if delta < 604800:return '%s天前' % (delta // 86400)dt = datetime.fromtimestamp(t)return '%s年%s月%s日' % (dt.year, dt.month, dt.day)...
init_jinja2(app, filters=dict(datetime=datetime_filter))
...

現(xiàn)在,完善的首頁顯示如下:

home-with-uikit

參考源碼

day-08

Day 9 - 編寫API

自從Roy Fielding博士在2000年他的博士論文中提出REST(Representational State Transfer)風格的軟件架構模式后,REST就基本上迅速取代了復雜而笨重的SOAP,成為Web API的標準了。

什么是Web API呢?

如果我們想要獲取一篇Blog,輸入http://localhost:9000/blog/123,就可以看到id為123的Blog頁面,但這個結果是HTML頁面,它同時混合包含了Blog的數(shù)據(jù)和Blog的展示兩個部分。對于用戶來說,閱讀起來沒有問題,但是,如果機器讀取,就很難從HTML中解析出Blog的數(shù)據(jù)。

如果一個URL返回的不是HTML,而是機器能直接解析的數(shù)據(jù),這個URL就可以看成是一個Web API。比如,讀取http://localhost:9000/api/blogs/123,如果能直接返回Blog的數(shù)據(jù),那么機器就可以直接讀取。

REST就是一種設計API的模式。最常用的數(shù)據(jù)格式是JSON。由于JSON能直接被JavaScript讀取,所以,以JSON格式編寫的REST風格的API具有簡單、易讀、易用的特點。

編寫API有什么好處呢?由于API就是把Web App的功能全部封裝了,所以,通過API操作數(shù)據(jù),可以極大地把前端和后端的代碼隔離,使得后端代碼易于測試,前端代碼編寫更簡單。

一個API也是一個URL的處理函數(shù),我們希望能直接通過一個@api來把函數(shù)變成JSON格式的REST API,這樣,獲取注冊用戶可以用一個API實現(xiàn)如下:

@get('/api/users')
def api_get_users(*, page='1'):page_index = get_page_index(page)num = yield from User.findNumber('count(id)')p = Page(num, page_index)if num == 0:return dict(page=p, users=())users = yield from User.findAll(orderBy='created_at desc', limit=(p.offset, p.limit))for u in users:u.passwd = '******'return dict(page=p, users=users)

只要返回一個dict,后續(xù)的response這個middleware就可以把結果序列化為JSON并返回。

我們需要對Error進行處理,因此定義一個APIError,這種Error是指API調用時發(fā)生了邏輯錯誤(比如用戶不存在),其他的Error視為Bug,返回的錯誤代碼為internalerror

客戶端調用API時,必須通過錯誤代碼來區(qū)分API調用是否成功。錯誤代碼是用來告訴調用者出錯的原因。很多API用一個整數(shù)表示錯誤碼,這種方式很難維護錯誤碼,客戶端拿到錯誤碼還需要查表得知錯誤信息。更好的方式是用字符串表示錯誤代碼,不需要看文檔也能猜到錯誤原因。

可以在瀏覽器直接測試API,例如,輸入http://localhost:9000/api/users,就可以看到返回的JSON:

api-result

參考源碼

day-09

Day 10 - 用戶注冊和登錄

用戶管理是絕大部分Web網站都需要解決的問題。用戶管理涉及到用戶注冊和登錄。

用戶注冊相對簡單,我們可以先通過API把用戶注冊這個功能實現(xiàn)了:

_RE_EMAIL = re.compile(r'^[a-z0-9\.\-\_]+\@[a-z0-9\-\_]+(\.[a-z0-9\-\_]+){1,4}$')
_RE_SHA1 = re.compile(r'^[0-9a-f]{40}$')@post('/api/users')
def api_register_user(*, email, name, passwd):if not name or not name.strip():raise APIValueError('name')if not email or not _RE_EMAIL.match(email):raise APIValueError('email')if not passwd or not _RE_SHA1.match(passwd):raise APIValueError('passwd')users = yield from User.findAll('email=?', [email])if len(users) > 0:raise APIError('register:failed', 'email', 'Email is already in use.')uid = next_id()sha1_passwd = '%s:%s' % (uid, passwd)user = User(id=uid, name=name.strip(), email=email, passwd=hashlib.sha1(sha1_passwd.encode('utf-8')).hexdigest(), image='http://www.gravatar.com/avatar/%s?d=mm&s=120' % hashlib.md5(email.encode('utf-8')).hexdigest())yield from user.save()# make session cookie:r = web.Response()r.set_cookie(COOKIE_NAME, user2cookie(user, 86400), max_age=86400, httponly=True)user.passwd = '******'r.content_type = 'application/json'r.body = json.dumps(user, ensure_ascii=False).encode('utf-8')return r

注意用戶口令是客戶端傳遞的經過SHA1計算后的40位Hash字符串,所以服務器端并不知道用戶的原始口令。

接下來可以創(chuàng)建一個注冊頁面,讓用戶填寫注冊表單,然后,提交數(shù)據(jù)到注冊用戶的API:

{% extends '__base__.html' %}{% block title %}注冊{% endblock %}{% block beforehead %}<script>
function validateEmail(email) {var re = /^[a-z0-9\.\-\_]+\@[a-z0-9\-\_]+(\.[a-z0-9\-\_]+){1,4}$/;return re.test(email.toLowerCase());
}
$(function () {var vm = new Vue({el: '#vm',data: {name: '',email: '',password1: '',password2: ''},methods: {submit: function (event) {event.preventDefault();var $form = $('#vm');if (! this.name.trim()) {return $form.showFormError('請輸入名字');}if (! validateEmail(this.email.trim().toLowerCase())) {return $form.showFormError('請輸入正確的Email地址');}if (this.password1.length < 6) {return $form.showFormError('口令長度至少為6個字符');}if (this.password1 !== this.password2) {return $form.showFormError('兩次輸入的口令不一致');}var email = this.email.trim().toLowerCase();$form.postJSON('/api/users', {name: this.name.trim(),email: email,passwd: CryptoJS.SHA1(email + ':' + this.password1).toString()}, function (err, r) {if (err) {return $form.showFormError(err);}return location.assign('/');});}}});$('#vm').show();
});
</script>{% endblock %}{% block content %}<div class="uk-width-2-3"><h1>歡迎注冊!</h1><form id="vm" v-on="submit: submit" class="uk-form uk-form-stacked"><div class="uk-alert uk-alert-danger uk-hidden"></div><div class="uk-form-row"><label class="uk-form-label">名字:</label><div class="uk-form-controls"><input v-model="name" type="text" maxlength="50" placeholder="名字" class="uk-width-1-1"></div></div><div class="uk-form-row"><label class="uk-form-label">電子郵件:</label><div class="uk-form-controls"><input v-model="email" type="text" maxlength="50" placeholder="your-name@example.com" class="uk-width-1-1"></div></div><div class="uk-form-row"><label class="uk-form-label">輸入口令:</label><div class="uk-form-controls"><input v-model="password1" type="password" maxlength="50" placeholder="輸入口令" class="uk-width-1-1"></div></div><div class="uk-form-row"><label class="uk-form-label">重復口令:</label><div class="uk-form-controls"><input v-model="password2" type="password" maxlength="50" placeholder="重復口令" class="uk-width-1-1"></div></div><div class="uk-form-row"><button type="submit" class="uk-button uk-button-primary"><i class="uk-icon-user"></i> 注冊</button></div></form></div>{% endblock %}

這樣我們就把用戶注冊的功能完成了:

awesomepy-register

用戶登錄比用戶注冊復雜。由于HTTP協(xié)議是一種無狀態(tài)協(xié)議,而服務器要跟蹤用戶狀態(tài),就只能通過cookie實現(xiàn)。大多數(shù)Web框架提供了Session功能來封裝保存用戶狀態(tài)的cookie。

Session的優(yōu)點是簡單易用,可以直接從Session中取出用戶登錄信息。

Session的缺點是服務器需要在內存中維護一個映射表來存儲用戶登錄信息,如果有兩臺以上服務器,就需要對Session做集群,因此,使用Session的Web App很難擴展。

我們采用直接讀取cookie的方式來驗證用戶登錄,每次用戶訪問任意URL,都會對cookie進行驗證,這種方式的好處是保證服務器處理任意的URL都是無狀態(tài)的,可以擴展到多臺服務器。

由于登錄成功后是由服務器生成一個cookie發(fā)送給瀏覽器,所以,要保證這個cookie不會被客戶端偽造出來。

實現(xiàn)防偽造cookie的關鍵是通過一個單向算法(例如SHA1),舉例如下:

當用戶輸入了正確的口令登錄成功后,服務器可以從數(shù)據(jù)庫取到用戶的id,并按照如下方式計算出一個字符串:

"用戶id" + "過期時間" + SHA1("用戶id" + "用戶口令" + "過期時間" + "SecretKey")

當瀏覽器發(fā)送cookie到服務器端后,服務器可以拿到的信息包括:

  • 用戶id

  • 過期時間

  • SHA1值

如果未到過期時間,服務器就根據(jù)用戶id查找用戶口令,并計算:

SHA1("用戶id" + "用戶口令" + "過期時間" + "SecretKey")

并與瀏覽器cookie中的MD5進行比較,如果相等,則說明用戶已登錄,否則,cookie就是偽造的。

這個算法的關鍵在于SHA1是一種單向算法,即可以通過原始字符串計算出SHA1結果,但無法通過SHA1結果反推出原始字符串。

所以登錄API可以實現(xiàn)如下:

@post('/api/authenticate')
def authenticate(*, email, passwd):if not email:raise APIValueError('email', 'Invalid email.')if not passwd:raise APIValueError('passwd', 'Invalid password.')users = yield from User.findAll('email=?', [email])if len(users) == 0:raise APIValueError('email', 'Email not exist.')user = users[0]# check passwd:sha1 = hashlib.sha1()sha1.update(user.id.encode('utf-8'))sha1.update(b':')sha1.update(passwd.encode('utf-8'))if user.passwd != sha1.hexdigest():raise APIValueError('passwd', 'Invalid password.')# authenticate ok, set cookie:r = web.Response()r.set_cookie(COOKIE_NAME, user2cookie(user, 86400), max_age=86400, httponly=True)user.passwd = '******'r.content_type = 'application/json'r.body = json.dumps(user, ensure_ascii=False).encode('utf-8')return r# 計算加密cookie:
def user2cookie(user, max_age):# build cookie string by: id-expires-sha1expires = str(int(time.time() + max_age))s = '%s-%s-%s-%s' % (user.id, user.passwd, expires, _COOKIE_KEY)L = [user.id, expires, hashlib.sha1(s.encode('utf-8')).hexdigest()]return '-'.join(L)

對于每個URL處理函數(shù),如果我們都去寫解析cookie的代碼,那會導致代碼重復很多次。

利用middle在處理URL之前,把cookie解析出來,并將登錄用戶綁定到request對象上,這樣,后續(xù)的URL處理函數(shù)就可以直接拿到登錄用戶:

@asyncio.coroutine
def auth_factory(app, handler):@asyncio.coroutinedef auth(request):logging.info('check user: %s %s' % (request.method, request.path))request.__user__ = Nonecookie_str = request.cookies.get(COOKIE_NAME)if cookie_str:user = yield from cookie2user(cookie_str)if user:logging.info('set current user: %s' % user.email)request.__user__ = userreturn (yield from handler(request))return auth# 解密cookie:
@asyncio.coroutine
def cookie2user(cookie_str):'''Parse cookie and load user if cookie is valid.'''if not cookie_str:return Nonetry:L = cookie_str.split('-')if len(L) != 3:return Noneuid, expires, sha1 = Lif int(expires) < time.time():return Noneuser = yield from User.find(uid)if user is None:return Nones = '%s-%s-%s-%s' % (uid, user.passwd, expires, _COOKIE_KEY)if sha1 != hashlib.sha1(s.encode('utf-8')).hexdigest():logging.info('invalid sha1')return Noneuser.passwd = '******'return userexcept Exception as e:logging.exception(e)return None

這樣,我們就完成了用戶注冊和登錄的功能。

參考源碼

day-10

Day 11 - 編寫日志創(chuàng)建頁

在Web開發(fā)中,后端代碼寫起來其實是相當容易的。

例如,我們編寫一個REST API,用于創(chuàng)建一個Blog:

@post('/api/blogs')
def api_create_blog(request, *, name, summary, content):check_admin(request)if not name or not name.strip():raise APIValueError('name', 'name cannot be empty.')if not summary or not summary.strip():raise APIValueError('summary', 'summary cannot be empty.')if not content or not content.strip():raise APIValueError('content', 'content cannot be empty.')blog = Blog(user_id=request.__user__.id, user_name=request.__user__.name, user_image=request.__user__.image, name=name.strip(), summary=summary.strip(), content=content.strip())yield from blog.save()return blog

編寫后端Python代碼不但很簡單,而且非常容易測試,上面的API:api_create_blog()本身只是一個普通函數(shù)。

Web開發(fā)真正困難的地方在于編寫前端頁面。前端頁面需要混合HTML、CSS和JavaScript,如果對這三者沒有深入地掌握,編寫的前端頁面將很快難以維護。

更大的問題在于,前端頁面通常是動態(tài)頁面,也就是說,前端頁面往往是由后端代碼生成的。

生成前端頁面最早的方式是拼接字符串:

s = '<html><head><title>'+ title+ '</title></head><body>'+ body+ '</body></html>'

顯然這種方式完全不具備可維護性。所以有第二種模板方式:

<html>
<head><title>{{ title }}</title>
</head>
<body>{{ body }}
</body>
</html>

ASP、JSP、PHP等都是用這種模板方式生成前端頁面。

如果在頁面上大量使用JavaScript(事實上大部分頁面都會),模板方式仍然會導致JavaScript代碼與后端代碼綁得非常緊密,以至于難以維護。其根本原因在于負責顯示的HTML DOM模型與負責數(shù)據(jù)和交互的JavaScript代碼沒有分割清楚。

要編寫可維護的前端代碼絕非易事。和后端結合的MVC模式已經無法滿足復雜頁面邏輯的需要了,所以,新的MVVM:Model View ViewModel模式應運而生。

MVVM最早由微軟提出來,它借鑒了桌面應用程序的MVC思想,在前端頁面中,把Model用純JavaScript對象表示:

<script>var blog = {name: 'hello',summary: 'this is summary',content: 'this is content...'};
</script>

View是純HTML:

<form action="/api/blogs" method="post"><input name="name"><input name="summary"><textarea name="content"></textarea><button type="submit">OK</button>
</form>

由于Model表示數(shù)據(jù),View負責顯示,兩者做到了最大限度的分離。

把Model和View關聯(lián)起來的就是ViewModel。ViewModel負責把Model的數(shù)據(jù)同步到View顯示出來,還負責把View的修改同步回Model。

ViewModel如何編寫?需要用JavaScript編寫一個通用的ViewModel,這樣,就可以復用整個MVVM模型了。

好消息是已有許多成熟的MVVM框架,例如AngularJS,KnockoutJS等。我們選擇Vue這個簡單易用的MVVM框架來實現(xiàn)創(chuàng)建Blog的頁面templates/manage_blog_edit.html

{% extends '__base__.html' %}{% block title %}編輯日志{% endblock %}{% block beforehead %}<script>
varID = '{{ id }}',action = '{{ action }}';
function initVM(blog) {var vm = new Vue({el: '#vm',data: blog,methods: {submit: function (event) {event.preventDefault();var $form = $('#vm').find('form');$form.postJSON(action, this.$data, function (err, r) {if (err) {$form.showFormError(err);}else {return location.assign('/api/blogs/' + r.id);}});}}});$('#vm').show();
}
$(function () {if (ID) {getJSON('/api/blogs/' + ID, function (err, blog) {if (err) {return fatal(err);}$('#loading').hide();initVM(blog);});}else {$('#loading').hide();initVM({name: '',summary: '',content: ''});}
});
</script>{% endblock %}{% block content %}<div class="uk-width-1-1 uk-margin-bottom"><div class="uk-panel uk-panel-box"><ul class="uk-breadcrumb"><li><a href="/manage/comments">評論</a></li><li><a href="/manage/blogs">日志</a></li><li><a href="/manage/users">用戶</a></li></ul></div></div><div id="error" class="uk-width-1-1"></div><div id="loading" class="uk-width-1-1 uk-text-center"><span><i class="uk-icon-spinner uk-icon-medium uk-icon-spin"></i> 正在加載...</span></div><div id="vm" class="uk-width-2-3"><form v-on="submit: submit" class="uk-form uk-form-stacked"><div class="uk-alert uk-alert-danger uk-hidden"></div><div class="uk-form-row"><label class="uk-form-label">標題:</label><div class="uk-form-controls"><input v-model="name" name="name" type="text" placeholder="標題" class="uk-width-1-1"></div></div><div class="uk-form-row"><label class="uk-form-label">摘要:</label><div class="uk-form-controls"><textarea v-model="summary" rows="4" name="summary" placeholder="摘要" class="uk-width-1-1" style="resize:none;"></textarea></div></div><div class="uk-form-row"><label class="uk-form-label">內容:</label><div class="uk-form-controls"><textarea v-model="content" rows="16" name="content" placeholder="內容" class="uk-width-1-1" style="resize:none;"></textarea></div></div><div class="uk-form-row"><button type="submit" class="uk-button uk-button-primary"><i class="uk-icon-save"></i> 保存</button><a href="/manage/blogs" class="uk-button"><i class="uk-icon-times"></i> 取消</a></div></form></div>{% endblock %}

初始化Vue時,我們指定3個參數(shù):

el:根據(jù)選擇器查找綁定的View,這里是#vm,就是id為vm的DOM,對應的是一個<div>標簽;

data:JavaScript對象表示的Model,我們初始化為{ name: '', summary: '', content: ''}

methods:View可以觸發(fā)的JavaScript函數(shù),submit就是提交表單時觸發(fā)的函數(shù)。

接下來,我們在<form>標簽中,用幾個簡單的v-model,就可以讓Vue把Model和View關聯(lián)起來:

<!-- input的value和Model的name關聯(lián)起來了 -->
<input v-model="name" class="uk-width-1-1">

Form表單通過<form v-on="submit: submit">把提交表單的事件關聯(lián)到submit方法。

需要特別注意的是,在MVVM中,Model和View是雙向綁定的。如果我們在Form中修改了文本框的值,可以在Model中立刻拿到新的值。試試在表單中輸入文本,然后在Chrome瀏覽器中打開JavaScript控制臺,可以通過vm.name訪問單個屬性,或者通過vm.$data訪問整個Model:

mvvm-1

如果我們在JavaScript邏輯中修改了Model,這個修改會立刻反映到View上。試試在JavaScript控制臺輸入vm.name = 'MVVM簡介',可以看到文本框的內容自動被同步了:

mvvm-2

雙向綁定是MVVM框架最大的作用。借助于MVVM,我們把復雜的顯示邏輯交給框架完成。由于后端編寫了獨立的REST API,所以,前端用AJAX提交表單非常容易,前后端分離得非常徹底。

參考源碼

day-11

Day 12 - 編寫日志列表頁

MVVM模式不但可用于Form表單,在復雜的管理頁面中也能大顯身手。例如,分頁顯示Blog的功能,我們先把后端代碼寫出來:

apis.py中定義一個Page類用于存儲分頁信息:

class Page(object):def __init__(self, item_count, page_index=1, page_size=10):self.item_count = item_countself.page_size = page_sizeself.page_count = item_count // page_size + (1 if item_count % page_size > 0 else 0)if (item_count == 0) or (page_index > self.page_count):self.offset = 0self.limit = 0self.page_index = 1else:self.page_index = page_indexself.offset = self.page_size * (page_index - 1)self.limit = self.page_sizeself.has_next = self.page_index < self.page_countself.has_previous = self.page_index > 1def __str__(self):return 'item_count: %s, page_count: %s, page_index: %s, page_size: %s, offset: %s, limit: %s' % (self.item_count, self.page_count, self.page_index, self.page_size, self.offset, self.limit)__repr__ = __str__

handlers.py中實現(xiàn)API:

@get('/api/blogs')
def api_blogs(*, page='1'):page_index = get_page_index(page)num = yield from Blog.findNumber('count(id)')p = Page(num, page_index)if num == 0:return dict(page=p, blogs=())blogs = yield from Blog.findAll(orderBy='created_at desc', limit=(p.offset, p.limit))return dict(page=p, blogs=blogs)

管理頁面:

@get('/manage/blogs')
def manage_blogs(*, page='1'):return {'__template__': 'manage_blogs.html','page_index': get_page_index(page)}

模板頁面首先通過API:GET /api/blogs?page=?拿到Model:

{"page": {"has_next": true,"page_index": 1,"page_count": 2,"has_previous": false,"item_count": 12},"blogs": [...]
}

然后,通過Vue初始化MVVM:

<script>
function initVM(data) {var vm = new Vue({el: '#vm',data: {blogs: data.blogs,page: data.page},methods: {edit_blog: function (blog) {location.assign('/manage/blogs/edit?id=' + blog.id);},delete_blog: function (blog) {if (confirm('確認要刪除“' + blog.name + '”?刪除后不可恢復!')) {postJSON('/api/blogs/' + blog.id + '/delete', function (err, r) {if (err) {return alert(err.message || err.error || err);}refresh();});}}}});$('#vm').show();
}
$(function() {getJSON('/api/blogs', {page: {{ page_index }}}, function (err, results) {if (err) {return fatal(err);}$('#loading').hide();initVM(results);});
});
</script>

View的容器是#vm,包含一個table,我們用v-repeat可以把Model的數(shù)組blogs直接變成多行的<tr>

<div id="vm" class="uk-width-1-1"><a href="/manage/blogs/create" class="uk-button uk-button-primary"><i class="uk-icon-plus"></i> 新日志</a><table class="uk-table uk-table-hover"><thead><tr><th class="uk-width-5-10">標題 / 摘要</th><th class="uk-width-2-10">作者</th><th class="uk-width-2-10">創(chuàng)建時間</th><th class="uk-width-1-10">操作</th></tr></thead><tbody><tr v-repeat="blog: blogs" ><td><a target="_blank" v-attr="href: '/blog/'+blog.id" v-text="blog.name"></a></td><td><a target="_blank" v-attr="href: '/user/'+blog.user_id" v-text="blog.user_name"></a></td><td><span v-text="blog.created_at.toDateTime()"></span></td><td><a href="#0" v-on="click: edit_blog(blog)"><i class="uk-icon-edit"></i><a href="#0" v-on="click: delete_blog(blog)"><i class="uk-icon-trash-o"></i></td></tr></tbody></table><div v-component="pagination" v-with="page"></div>
</div>

往Model的blogs數(shù)組中增加一個Blog元素,table就神奇地增加了一行;把blogs數(shù)組的某個元素刪除,table就神奇地減少了一行。所有復雜的Model-View的映射邏輯全部由MVVM框架完成,我們只需要在HTML中寫上v-repeat指令,就什么都不用管了。

可以把v-repeat="blog: blogs"看成循環(huán)代碼,所以,可以在一個<tr>內部引用循環(huán)變量blog。v-textv-attr指令分別用于生成文本和DOM節(jié)點屬性。

完整的Blog列表頁如下:

awesomepy-manage-blogs

參考源碼

day-12

Day 13 - 提升開發(fā)效率

現(xiàn)在,我們已經把一個Web App的框架完全搭建好了,從后端的API到前端的MVVM,流程已經跑通了。

在繼續(xù)工作前,注意到每次修改Python代碼,都必須在命令行先Ctrl-C停止服務器,再重啟,改動才能生效。

在開發(fā)階段,每天都要修改、保存幾十次代碼,每次保存都手動來這么一下非常麻煩,嚴重地降低了我們的開發(fā)效率。有沒有辦法讓服務器檢測到代碼修改后自動重新加載呢?

Django的開發(fā)環(huán)境在Debug模式下就可以做到自動重新加載,如果我們編寫的服務器也能實現(xiàn)這個功能,就能大大提升開發(fā)效率。

可惜的是,Django沒把這個功能獨立出來,不用Django就享受不到,怎么辦?

其實Python本身提供了重新載入模塊的功能,但不是所有模塊都能被重新載入。另一種思路是檢測www目錄下的代碼改動,一旦有改動,就自動重啟服務器。

按照這個思路,我們可以編寫一個輔助程序pymonitor.py,讓它啟動wsgiapp.py,并時刻監(jiān)控www目錄下的代碼改動,有改動時,先把當前wsgiapp.py進程殺掉,再重啟,就完成了服務器進程的自動重啟。

要監(jiān)控目錄文件的變化,我們也無需自己手動定時掃描,Python的第三方庫watchdog可以利用操作系統(tǒng)的API來監(jiān)控目錄文件的變化,并發(fā)送通知。我們先用pip安裝:

$ pip3 install watchdog

利用watchdog接收文件變化的通知,如果是.py文件,就自動重啟wsgiapp.py進程。

利用Python自帶的subprocess實現(xiàn)進程的啟動和終止,并把輸入輸出重定向到當前進程的輸入輸出中:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-__author__ = 'Michael Liao'import os, sys, time, subprocessfrom watchdog.observers import Observer
from watchdog.events import FileSystemEventHandlerdef log(s):print('[Monitor] %s' % s)class MyFileSystemEventHander(FileSystemEventHandler):def __init__(self, fn):super(MyFileSystemEventHander, self).__init__()self.restart = fndef on_any_event(self, event):if event.src_path.endswith('.py'):log('Python source file changed: %s' % event.src_path)self.restart()command = ['echo', 'ok']
process = Nonedef kill_process():global processif process:log('Kill process [%s]...' % process.pid)process.kill()process.wait()log('Process ended with code %s.' % process.returncode)process = Nonedef start_process():global process, commandlog('Start process %s...' % ' '.join(command))process = subprocess.Popen(command, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr)def restart_process():kill_process()start_process()def start_watch(path, callback):observer = Observer()observer.schedule(MyFileSystemEventHander(restart_process), path, recursive=True)observer.start()log('Watching directory %s...' % path)start_process()try:while True:time.sleep(0.5)except KeyboardInterrupt:observer.stop()observer.join()if __name__ == '__main__':argv = sys.argv[1:]if not argv:print('Usage: ./pymonitor your-script.py')exit(0)if argv[0] != 'python3':argv.insert(0, 'python3')command = argvpath = os.path.abspath('.')start_watch(path, None)

一共70行左右的代碼,就實現(xiàn)了Debug模式的自動重新加載。用下面的命令啟動服務器:

$ python3 pymonitor.py wsgiapp.py

或者給pymonitor.py加上可執(zhí)行權限,啟動服務器:

$ ./pymonitor.py app.py

在編輯器中打開一個.py文件,修改后保存,看看命令行輸出,是不是自動重啟了服務器:

$ ./pymonitor.py app.py 
[Monitor] Watching directory /Users/michael/Github/awesome-python3-webapp/www...
[Monitor] Start process python app.py...
...
INFO:root:application (/Users/michael/Github/awesome-python3-webapp/www) will start at 0.0.0.0:9000...
[Monitor] Python source file changed: /Users/michael/Github/awesome-python-webapp/www/handlers.py
[Monitor] Kill process [2747]...
[Monitor] Process ended with code -9.
[Monitor] Start process python app.py...
...
INFO:root:application (/Users/michael/Github/awesome-python3-webapp/www) will start at 0.0.0.0:9000...

現(xiàn)在,只要一保存代碼,就可以刷新瀏覽器看到效果,大大提升了開發(fā)效率。

Day 14 - 完成Web App

在Web App框架和基本流程跑通后,剩下的工作全部是體力活了:在Debug開發(fā)模式下完成后端所有API、前端所有頁面。我們需要做的事情包括:

把當前用戶綁定到request上,并對URL/manage/進行攔截,檢查當前用戶是否是管理員身份:

@asyncio.coroutine
def auth_factory(app, handler):@asyncio.coroutinedef auth(request):logging.info('check user: %s %s' % (request.method, request.path))request.__user__ = Nonecookie_str = request.cookies.get(COOKIE_NAME)if cookie_str:user = yield from cookie2user(cookie_str)if user:logging.info('set current user: %s' % user.email)request.__user__ = userif request.path.startswith('/manage/') and (request.__user__ is None or not request.__user__.admin):return web.HTTPFound('/signin')return (yield from handler(request))return auth

后端API包括:

  • 獲取日志:GET /api/blogs

  • 創(chuàng)建日志:POST /api/blogs

  • 修改日志:POST /api/blogs/:blog_id

  • 刪除日志:POST /api/blogs/:blog_id/delete

  • 獲取評論:GET /api/comments

  • 創(chuàng)建評論:POST /api/blogs/:blog_id/comments

  • 刪除評論:POST /api/comments/:comment_id/delete

  • 創(chuàng)建新用戶:POST /api/users

  • 獲取用戶:GET /api/users

管理頁面包括:

  • 評論列表頁:GET /manage/comments

  • 日志列表頁:GET /manage/blogs

  • 創(chuàng)建日志頁:GET /manage/blogs/create

  • 修改日志頁:GET /manage/blogs/

  • 用戶列表頁:GET /manage/users

用戶瀏覽頁面包括:

  • 注冊頁:GET /register

  • 登錄頁:GET /signin

  • 注銷頁:GET /signout

  • 首頁:GET /

  • 日志詳情頁:GET /blog/:blog_id

把所有的功能實現(xiàn),我們第一個Web App就宣告完成!

參考源碼

day-14

Day 15 - 部署Web App

作為一個合格的開發(fā)者,在本地環(huán)境下完成開發(fā)還遠遠不夠,我們需要把Web App部署到遠程服務器上,這樣,廣大用戶才能訪問到網站。

很多做開發(fā)的同學把部署這件事情看成是運維同學的工作,這種看法是完全錯誤的。首先,最近流行DevOps理念,就是說,開發(fā)和運維要變成一個整體。其次,運維的難度,其實跟開發(fā)質量有很大的關系。代碼寫得垃圾,運維再好也架不住天天掛掉。最后,DevOps理念需要把運維、監(jiān)控等功能融入到開發(fā)中。你想服務器升級時不中斷用戶服務?那就得在開發(fā)時考慮到這一點。

下面,我們就來把awesome-python3-webapp部署到Linux服務器。

搭建Linux服務器

要部署到Linux,首先得有一臺Linux服務器。要在公網上體驗的同學,可以在Amazon的AWS申請一臺EC2虛擬機(免費使用1年),或者使用國內的一些云服務器,一般都提供Ubuntu Server的鏡像。想在本地部署的同學,請安裝虛擬機,推薦使用VirtualBox。

我們選擇的Linux服務器版本是Ubuntu Server 14.04 LTS,原因是apt太簡單了。如果你準備使用其他Linux版本,也沒有問題。

Linux安裝完成后,請確保ssh服務正在運行,否則,需要通過apt安裝:

$ sudo apt-get install openssh-server

有了ssh服務,就可以從本地連接到服務器上。建議把公鑰復制到服務器端用戶的.ssh/authorized_keys中,這樣,就可以通過證書實現(xiàn)無密碼連接。

部署方式

利用Python自帶的asyncio,我們已經編寫了一個異步高性能服務器。但是,我們還需要一個高性能的Web服務器,這里選擇Nginx,它可以處理靜態(tài)資源,同時作為反向代理把動態(tài)請求交給Python代碼處理。這個模型如下:

nginx-awesome-mysql

Nginx負責分發(fā)請求:

browser-nginx-awesome

在服務器端,我們需要定義好部署的目錄結構:

/
+- srv/+- awesome/       <-- Web App根目錄+- www/        <-- 存放Python源碼|  +- static/  <-- 存放靜態(tài)資源文件+- log/        <-- 存放log

在服務器上部署,要考慮到新版本如果運行不正常,需要回退到舊版本時怎么辦。每次用新的代碼覆蓋掉舊的文件是不行的,需要一個類似版本控制的機制。由于Linux系統(tǒng)提供了軟鏈接功能,所以,我們把www作為一個軟鏈接,它指向哪個目錄,哪個目錄就是當前運行的版本:

linux-www-symbol-link

而Nginx和gunicorn的配置文件只需要指向www目錄即可。

Nginx可以作為服務進程直接啟動,但gunicorn還不行,所以,Supervisor登場!Supervisor是一個管理進程的工具,可以隨系統(tǒng)啟動而啟動服務,它還時刻監(jiān)控服務進程,如果服務進程意外退出,Supervisor可以自動重啟服務。

總結一下我們需要用到的服務有:

  • Nginx:高性能Web服務器+負責反向代理;

  • Supervisor:監(jiān)控服務進程的工具;

  • MySQL:數(shù)據(jù)庫服務。

在Linux服務器上用apt可以直接安裝上述服務:

$ sudo apt-get install nginx supervisor python3 mysql-server

然后,再把我們自己的Web App用到的Python庫安裝了:

$ sudo pip3 install jinja2 aiomysql aiohttp

在服務器上創(chuàng)建目錄/srv/awesome/以及相應的子目錄。

在服務器上初始化MySQL數(shù)據(jù)庫,把數(shù)據(jù)庫初始化腳本schema.sql復制到服務器上執(zhí)行:

$ mysql -u root -p < schema.sql

服務器端準備就緒。

部署

用FTP還是SCP還是rsync復制文件?如果你需要手動復制,用一次兩次還行,一天如果部署50次不但慢、效率低,而且容易出錯。

正確的部署方式是使用工具配合腳本完成自動化部署。Fabric就是一個自動化部署工具。由于Fabric是用Python 2.x開發(fā)的,所以,部署腳本要用Python 2.7來編寫,本機還必須安裝Python 2.7版本。

要用Fabric部署,需要在本機(是開發(fā)機器,不是Linux服務器)安裝Fabric:

$ easy_install fabric

Linux服務器上不需要安裝Fabric,Fabric使用SSH直接登錄服務器并執(zhí)行部署命令。

下一步是編寫部署腳本。Fabric的部署腳本叫fabfile.py,我們把它放到awesome-python-webapp的目錄下,與www目錄平級:

awesome-python-webapp/
+- fabfile.py
+- www/
+- ...

Fabric的腳本編寫很簡單,首先導入Fabric的API,設置部署時的變量:

# fabfile.py
import os, re
from datetime import datetime# 導入Fabric API:
from fabric.api import *# 服務器登錄用戶名:
env.user = 'michael'
# sudo用戶為root:
env.sudo_user = 'root'
# 服務器地址,可以有多個,依次部署:
env.hosts = ['192.168.0.3']# 服務器MySQL用戶名和口令:
db_user = 'www-data'
db_password = 'www-data'

然后,每個Python函數(shù)都是一個任務。我們先編寫一個打包的任務:

_TAR_FILE = 'dist-awesome.tar.gz'def build():includes = ['static', 'templates', 'transwarp', 'favicon.ico', '*.py']excludes = ['test', '.*', '*.pyc', '*.pyo']local('rm -f dist/%s' % _TAR_FILE)with lcd(os.path.join(os.path.abspath('.'), 'www')):cmd = ['tar', '--dereference', '-czvf', '../dist/%s' % _TAR_FILE]cmd.extend(['--exclude=\'%s\'' % ex for ex in excludes])cmd.extend(includes)local(' '.join(cmd))

Fabric提供local('...')來運行本地命令,with lcd(path)可以把當前命令的目錄設定為lcd()指定的目錄,注意Fabric只能運行命令行命令,Windows下可能需要Cgywin環(huán)境。

awesome-python-webapp目錄下運行:

$ fab build

看看是否在dist目錄下創(chuàng)建了dist-awesome.tar.gz的文件。

打包后,我們就可以繼續(xù)編寫deploy任務,把打包文件上傳至服務器,解壓,重置www軟鏈接,重啟相關服務:

_REMOTE_TMP_TAR = '/tmp/%s' % _TAR_FILE
_REMOTE_BASE_DIR = '/srv/awesome'def deploy():newdir = 'www-%s' % datetime.now().strftime('%y-%m-%d_%H.%M.%S')# 刪除已有的tar文件:run('rm -f %s' % _REMOTE_TMP_TAR)# 上傳新的tar文件:put('dist/%s' % _TAR_FILE, _REMOTE_TMP_TAR)# 創(chuàng)建新目錄:with cd(_REMOTE_BASE_DIR):sudo('mkdir %s' % newdir)# 解壓到新目錄:with cd('%s/%s' % (_REMOTE_BASE_DIR, newdir)):sudo('tar -xzvf %s' % _REMOTE_TMP_TAR)# 重置軟鏈接:with cd(_REMOTE_BASE_DIR):sudo('rm -f www')sudo('ln -s %s www' % newdir)sudo('chown www-data:www-data www')sudo('chown -R www-data:www-data %s' % newdir)# 重啟Python服務和nginx服務器:with settings(warn_only=True):sudo('supervisorctl stop awesome')sudo('supervisorctl start awesome')sudo('/etc/init.d/nginx reload')

注意run()函數(shù)執(zhí)行的命令是在服務器上運行,with cd(path)with lcd(path)類似,把當前目錄在服務器端設置為cd()指定的目錄。如果一個命令需要sudo權限,就不能用run(),而是用sudo()來執(zhí)行。

配置Supervisor

上面讓Supervisor重啟awesome的命令會失敗,因為我們還沒有配置Supervisor呢。

編寫一個Supervisor的配置文件awesome.conf,存放到/etc/supervisor/conf.d/目錄下:

[program:awesome]command     = /srv/awesome/www/app.py
directory   = /srv/awesome/www
user        = www-data
startsecs   = 3redirect_stderr         = true
stdout_logfile_maxbytes = 50MB
stdout_logfile_backups  = 10
stdout_logfile          = /srv/awesome/log/app.log

配置文件通過[program:awesome]指定服務名為awesomecommand指定啟動app.py。

然后重啟Supervisor后,就可以隨時啟動和停止Supervisor管理的服務了:

$ sudo supervisorctl reload
$ sudo supervisorctl start awesome
$ sudo supervisorctl status
awesome                RUNNING    pid 1401, uptime 5:01:34
配置Nginx

Supervisor只負責運行gunicorn,我們還需要配置Nginx。把配置文件awesome放到/etc/nginx/sites-available/目錄下:

server {listen      80; # 監(jiān)聽80端口root       /srv/awesome/www;access_log /srv/awesome/log/access_log;error_log  /srv/awesome/log/error_log;# server_name awesome.liaoxuefeng.com; # 配置域名# 處理靜態(tài)文件/favicon.ico:location /favicon.ico {root /srv/awesome/www;}# 處理靜態(tài)資源:location ~ ^\/static\/.*$ {root /srv/awesome/www;}# 動態(tài)請求轉發(fā)到9000端口:location / {proxy_pass       http://127.0.0.1:9000;proxy_set_header X-Real-IP $remote_addr;proxy_set_header Host $host;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;}
}

然后在/etc/nginx/sites-enabled/目錄下創(chuàng)建軟鏈接:

$ pwd
/etc/nginx/sites-enabled
$ sudo ln -s /etc/nginx/sites-available/awesome .

讓Nginx重新加載配置文件,不出意外,我們的awesome-python3-webapp應該正常運行:

$ sudo /etc/init.d/nginx reload

如果有任何錯誤,都可以在/srv/awesome/log下查找Nginx和App本身的log。如果Supervisor啟動時報錯,可以在/var/log/supervisor下查看Supervisor的log。

如果一切順利,你可以在瀏覽器中訪問Linux服務器上的awesome-python3-webapp了:

awesome-run-on-server

如果在開發(fā)環(huán)境更新了代碼,只需要在命令行執(zhí)行:

$ fab build
$ fab deploy

自動部署完成!刷新瀏覽器就可以看到服務器代碼更新后的效果。

友情鏈接

嫌國外網速慢的童鞋請移步網易和搜狐的鏡像站點:

http://mirrors.163.com/

http://mirrors.sohu.com/

參考源碼

day-15

Day 16 - 編寫移動App

網站部署上線后,還缺點啥呢?

在移動互聯(lián)網浪潮席卷而來的今天,一個網站沒有上線移動App,出門根本不好意思跟人打招呼。

所以,awesome-python3-webapp必須得有一個移動App版本!

開發(fā)iPhone版本

我們首先來看看如何開發(fā)iPhone App。前置條件:一臺Mac電腦,安裝XCode和最新的iOS SDK。

在使用MVVM編寫前端頁面時,我們就能感受到,用REST API封裝網站后臺的功能,不但能清晰地分離前端頁面和后臺邏輯,現(xiàn)在這個好處更加明顯,移動App也可以通過REST API從后端拿到數(shù)據(jù)。

我們來設計一個簡化版的iPhone App,包含兩個屏幕:列出最新日志和閱讀日志的詳細內容:

awesomepy-iphone-app

只需要調用API:/api/blogs

在XCode中完成App編寫:

awesomepy-iphone-app-xcode

由于我們的教程是Python,關于如何開發(fā)iOS,請移步Develop Apps for iOS。

點擊下載iOS App源碼。

如何編寫Android App?這個當成作業(yè)了。

參考源碼

day-16

FAQ

常見問題

本節(jié)列出常見的一些問題。

如何獲取當前路徑

當前路徑可以用'.'表示,再用os.path.abspath()將其轉換為絕對路徑:

# -*- coding:utf-8 -*-
# test.pyimport osprint(os.path.abspath('.'))

運行結果:

$ python3 test.py 
/Users/michael/workspace/testing
如何獲取當前模塊的文件名

可以通過特殊變量__file__獲取:

# -*- coding:utf-8 -*-
# test.pyprint(__file__)

輸出:

$ python3 test.py
test.py
如何獲取命令行參數(shù)

可以通過sys模塊的argv獲取:

# -*- coding:utf-8 -*-
# test.pyimport sysprint(sys.argv)

輸出:

$ python3 test.py -a -s "Hello world"
['test.py', '-a', '-s', 'Hello world']

argv的第一個元素永遠是命令行執(zhí)行的.py文件名。

如何獲取當前Python命令的可執(zhí)行文件路徑

sys模塊的executable變量就是Python命令可執(zhí)行文件的路徑:

# -*- coding:utf-8 -*-
# test.pyimport sysprint(sys.executable)

在Mac下的結果:

$ python3 test.py 
/usr/local/opt/python3/bin/python3.4

期末總結

終于到了期末總結的時刻了!

經過一段時間的學習,相信你對Python已經初步掌握。一開始,可能覺得Python上手很容易,可是越往后學,會越困難,有的時候,發(fā)現(xiàn)理解不了代碼,這時,不妨停下來思考一下,先把概念搞清楚,代碼自然就明白了。

Python非常適合初學者用來進入計算機編程領域。Python屬于非常高級的語言,掌握了這門高級語言,就對計算機編程的核心思想——抽象有了初步理解。如果希望繼續(xù)深入學習計算機編程,可以學習C、JavaScript、Lisp等不同類型的語言,只有多掌握不同領域的語言,有比較才更有收獲。

Python3-新技能get


from:?http://www.liaoxuefeng.com/wiki/0014316089557264a6b348958f449949df42a6d3a2e542c000

http://www.risenshineclean.com/news/51769.html

相關文章:

  • seo排名技術教程seo排名軟件價格
  • 做網站好的網站建設公司排名青島百度推廣優(yōu)化怎么做的
  • 網站域名查詢地址做百度推廣銷售怎么找客戶
  • 中山市小欖新意網站設計有限公司太原網站建設制作
  • wordpress geogebraseo刷排名工具
  • 做本地網站需要的軟件網盤資源共享群吧
  • 淘寶上做網站排名的是真的嗎口碑營銷案例2022
  • 前程無憂怎么做網站百度識圖識別
  • 合肥高端網站建設公司哪家好seo優(yōu)化軟件大全
  • 企業(yè)網站建設相關書籍windows優(yōu)化大師卸載
  • 公司怎么申請免費做網站好用的搜索引擎
  • 龍元建設網站鄭州網絡營銷
  • 怎樣進入建設通網站怎樣做網站
  • 可以做展示頁面的網站seo推廣優(yōu)化公司哪家好
  • wordpress前臺打開慢手機端關鍵詞排名優(yōu)化軟件
  • 杭州手機軟件開發(fā)公司上海網站seo策劃
  • 網站如何做公安部備案整站優(yōu)化全網營銷
  • 瑞金網站建設互聯(lián)網營銷工具有哪些
  • 自己有服務器如何建設微網站上海疫情最新數(shù)據(jù)
  • 手機網站做指向谷歌搜索為什么用不了
  • 做團購網站需要多少錢seo優(yōu)化排名軟件
  • 招工 最新招聘信息怎么寫seo搜索引擎優(yōu)化教程
  • 看電影電視劇的好網站纖纖影院優(yōu)化網站服務
  • 網站備案難嗎批量查詢指數(shù)
  • 長沙網站建設公司排行榜百度信息流推廣技巧
  • 漂亮公司網站源碼打包下載seo自動刷外鏈工具
  • 專業(yè)濟南網站建設價格人民網 疫情
  • 合肥做網站多少錢哪里可以學企業(yè)管理培訓
  • 蚌埠網站制作哪家好沈陽seo
  • wordpress兩種語言主題鄭州seo顧問熱狗