第十四章 檔案處理
在之前的章節中,我們已經學習過基本的輸出與輸入。
輸出就是把資料列印在畫面上,輸入就是讓使用者透過鍵盤輸入文字。
在這一章中,學習針對檔案的輸出與輸入,甚至是一些資料庫的基本概念,也可以在這裡看到。
現在我們的程式有幾十行的規模,在每章的除錯提到的錯誤處理方法,也應該研究一下。
14-1 不消失的資料
想要長期保存資料就需要把它寫入檔案中,如此資料就能保存在儲存媒體,像是硬碟、隨身碟等。
之前的程式,執行時,資料都是存在記憶體中,當程式結束後,資料也跟著消失,再執行時又是從零開始。
但有時需求不是這樣,執行程式會在檔案中寫入資料,程式結束後仍能繼續保留,下次執行該程式時,可以先取得上次保存下來的資料,通常我們玩的線上遊戲都是這樣保存進度。
有些系統的設計也是如此,像是網頁中的編輯區,隨時等待使用者輸入資訊。或指令列模式,隨時讓使用者輸入命令等,讓這些資訊可以被記錄下來。
事實上,在之前的章節中,大家已經學會了讀取檔案,應該還記得那些單字列表吧?
在這章中,要學習寫入檔案,並且介紹簡單資料庫的資料輸出、入方法。
14-2 讀與寫
在儲存媒體中,文字檔的內容是以字元序列來儲存的,在第9章,已經學過如何開啟並閱讀檔案內容的方法。
若想寫入一個檔案,必須將 open 的第二個參數設定成字元 w ,表示寫入,如下:
>>> fout = open('output.txt', 'w')
如果檔案存在,那麼 python 會把原本檔案的內容清空,從頭開始寫入資料。
這部分需要特別小心,若檔案不存在,則會新建一個檔案來準備寫入資料。
open() 這個函數會回傳一個檔案物件,檔案物件可以使用檔案的方法來處理檔案,
作用在這個檔案物件上的變數,其實可以想像成就是直接作用在 open 指定的檔案中。像這樣:
>>> line1 = '我是第一行,\n'
>>> fout.write(line1)
7
fout 會回傳寫入檔案的字元數,檔案物件會保持在剛剛寫入的位置上,可以再繼續寫入資料。
注意一下,換行字元也是要自己放到字串當中,才會被寫入檔案內,否則就不會有換行效果。
>>> line2 = ' 我是第二行。\n'
>>> fout.write(line2)
9
當結束寫入檔案後,記得關閉該檔案:
>>> fout.close()
雖然程式在結束時會關閉該檔案,但最好還是養成自己開、關檔案的好習慣。
這裡列出常用的 open() 函數開檔模式供參:
- r: 唯讀。這是預設值,當檔案不存在時會觸發 FileNotFoundError 錯誤。
- r+: 讀寫。
- a: 添加。python 能在檔尾添加資料,但不會更動原本檔案的內容。檔案不存在時,會新增一個新的檔案。
- w: 寫入。檔案存在時會清空所有內容,檔案不存在則新增檔案。
- x: 新增。檔案不存在則新增,存在則觸發 FileExistsError 錯誤。
這裡有一篇講 檔案讀寫參數 的文章可以參考。以下列出檔案物件常用的方法:
- tell: 回傳檔案指標所在的位置(數字),檔首的位置是 0
- seek: 移動檔案指標的位置,有兩個參數,分別是 offset 與 whence,offset 是指標的偏移量,whence (可選)有三個值:
- 0 檔首(預設)
- 1 目前指標位置
- 2 檔尾
因此 offset 是相對於 whence 指定的指標位置偏移量。
來看一下這個範例,先列出檔案的內容:
test.txt:
<html>abc
<head>
<meta charset="utf-8">
下面的程式連續讀入兩行,接著把檔案指標移回第二行開頭,最後再讀一行,那是重複的第二行:
f = open('test.txt')
line = f.readline()
print(line)
line = f.readline()
print(line)
f.seek(11, 0)
line = f.readline()
print(line)
f.close()
14-3 格式運算子
在寫入檔案時,內容一定要是字串型態,如果想要寫入其他值,一定要先將其轉成字串才行。
還記得我們使用 input 讓使用者輸入資料,它也是把輸入進來的值全當成字串型態回傳的。
比較簡單的方法是使用 str 函數:
>>> x = 52
>>> fout.write(str(x))
另一個方式是使用格式運算子 %
來轉換,注意,這個符號在這裡不是取餘數喔!
在運算元是數值時才是取餘數,如果是字串型態,就是轉換的格式運算子。
格式字串中,可以包含數個轉換格式碼,它們最終都會被轉換成字串。
例如,%d
格式碼代表整數,我們可以這樣轉換成字串:
>>> camels = 42
>>> '%d' % camels
'42'
轉換的結果是字串 42 而不是整數。利用格式碼可以把各種類型的值轉換成字串,比較長的序列也沒問題:
>>> '我有 %d 隻駱駝' % camels
我有 42 隻駱駝
多格式碼就需要使用數組 (tuple) 來對應,以下補充常用的格式碼:
- %d 是整數
- %g 是浮點數
- %s 是字串
>>> '我花了 %d 年賺了 %g 美金買了很多%s' % (2, 10.2, '駱駝')
我花了 2 年賺了 10.2 美金買了很多駱駝
當格式碼與數組內的元素數量不同,或者型態不正確時,都會產生錯誤:
>>> '%d %d %d' % (1, 2)
TypeError: not enough arguments for format string
# 型態錯誤,格式化字串的參數不足
>>> '%d' % 'dollars'
TypeError: %d format: a number is required, not str
# 型態錯誤,%d 格式只允許數字,不能是字串
第一個例子是兩邊數量不等,第二個例子是兩邊型態不符。
這裡有更多關於 格式字串 的說明,還有一些 格式字串的方法參考。
14-4 檔名與路徑
檔案通常包含在資料夾內,當前執行的程式資料夾稱為當前資料夾。
對多數系統而言,它也是預設資料夾。例如,不指定路徑而開啟一個檔案時,python 通常會在當前資料夾裡進行查找。
os 模組可以提供讓我們處理檔案與資料夾的相關函數,像是 os.getcwd
會回傳當前資料夾的路徑。
>>> import os
>>> cwd = os.getcwd()
>>> cwd
'C:\\Users\\forblind'
當前資料夾又稱為工作目錄,在不同的作業系統,會有不同的目錄架構與符號。
在 windows 是以反斜線 \
來當作路徑的分隔符號,但我們知道對 python 字串來說,反斜線是用來跳脫字元,或有特殊字元用途。
在作業系統中,路徑是長這樣 c:\nvda
,此時 \n
被 python 認為是換行符號,因此這個路徑會有錯誤或被截斷的狀況。
為了避免這種問題,我們以 \\
來代表反斜線,或者直接將所有反斜線取代成斜線,如上例,python的顯示也是使用兩個反斜線來代表反斜線。
那如果要顯示兩個反斜線時,該給程式幾個反斜線呢?答案是四個,因為一個跳脫字元只針對下一個字元,第一、三個反斜線是跳脫字元。
還有一種字串的表示法,那就是 repr (下面除錯單元會介紹),簡寫為 r。我們之前學過了 f-string
,現在這個可以稱為 r-string
。
像這樣 r'C:\nvda'
反斜線不會被一般字串解釋為跳脫字元,完全按照原生字串來顯示,就不需要考慮跳脫字元的問題。
而在 linux 的路徑分隔符號使用斜線,就比較沒有這類狀況。舉例來說,通常自己的家目錄會在 /home 下面,跟 windows 的目錄架構不太一樣。
像 'C:\\Users\\forblind'
這樣的字串,可能包含檔案與資料夾,我們稱為路徑。
而一個簡單的檔名如 memo.txt
,它其實也包含路徑,但那稱為相對路徑,只是省略了當前資料夾的完整路徑。
一個完整的路徑像 C:\Users\forblind\memo.txt
裡面包含路徑與檔名,這稱為絕對路徑,如果是在 linux 系統,它會是以 /
開頭。
我們可以使用 os.path.abspath
來得到檔案的絕對路徑,os.path.join
也行,但 join 還可以接受給定兩個字串參數,它會將第一個參數路徑與第二個參數檔名結合成完整的絕對路徑。
>>> os.path.abspath('memo.txt')
'C:\\Users\\forblind\\memo.txt'
>>> os.path.join(os.getcwd(), 'readme.txt')
'C:\\Users\\forblind\\readme.txt'
os.path 對於資料夾與檔案還支援其他的函數,像是 os.path.exists 可以確認檔案或資料夾是否存在:
>>> os.path.exists('memo.txt')
True
os.path.isdir 可以確認是不是資料夾:
>>> os.path.isdir('memo.txt')
False
>>> os.path.isdir('C:\\Users\\forblind')
True
os.path.isfile 則用來確認是否為檔案:
>>> os.path.isfile('memo.txt')
True
os.listdir 接受一個資料夾路徑,會回傳該路徑下所有資料夾與檔案的字串列表(僅第一層):
>>> cwd = os.getcwd()
>>> os.listdir(cwd)
['music', 'xyz', 'memo.txt']
下面範例中的函數,它的功能是列出該資料夾下(包含所有子資料夾)之所有檔案檔名:
def walk(dirname):
for name in os.listdir(dirname):
path = os.path.join(dirname, name)
if os.path.isfile(path):
print(path)
else:
walk(path)
其實在 os 模組下也有 walk 函數,它的功能更加強大,有興趣的伙伴可以 看這篇 walk 函數介紹。
簡單解說一下,os 提供的 walk 函數回傳一個 tuple,裡面包含三個元素,分別是路徑字串、該層下的資料夾列表與該層下的檔案列表。
import os
for dirPath, dirNames, fileNames in os.walk(r'd:\python\demo'):
print(dirPath)
for f in fileNames:
print(os.path.join(dirPath, f))
補充的連結中解釋得很清楚,試著拿一個自己熟悉的資料夾來測驗結果。
上例 for 和 in 之間的變數,一樣是可以隨意取名,因為一次想迭代三個元素,所以就給它三個變數。在本例中,只顯示檔案的部分,因此第二個參數 dirNames 用不到。
其實連判斷權限的函數都有,像是 os.access:
import os
os.chdir('d:\\')
if os.access('t.py', os.R_OK):
print('有讀取權限')
if os.access('t.py', os.W_OK):
print('有寫入權限')
if os.access('t.py', os.X_OK):
print('有執行權限')
if os.access('t.py', os.R_OK | os.W_OK | os.X_OK):
print('三個權限都有')
其他還有 os.path.dirname 列出某個檔案之絕對路徑(不含檔名),os.chdir 切換當前資料夾等。
順帶一提,在使用 notepad++ 執行 python 程式時,有些伙伴遇到開檔找不到檔案的問題,這邊做個方法整理,看了之後就會應用了:
import os
print(os.getcwd()) # 顯示當前工作資料夾路徑
dir = os.path.dirname(__file__) # 截取當前 python 程式所在的路徑
os.chdir(dir) # 切換工作資料夾到當前 python 程式所在的路徑
print(os.getcwd()) # 印出切換後的當前工作資料夾路徑確認
14-5 捕捉異常的錯誤處理
14-5-1 開檔錯誤的類型
在讀寫檔案的過程中,很容易發生錯誤。例如開啟一個不存在的檔案來讀取時,會得到 IOError 的錯誤:
>>> fin = open('bad_file')
IOError: [Errno 2] No such file or directory: 'bad_file'
# 讀寫錯誤:沒找到 bad_file 這個資料夾或檔案。
如果確定路徑和檔名都正確,那可能是沒權限讀取該檔案,下面是沒有權限寫入檔案而發生的錯誤:
>>> fout = open('/etc/passwd', 'w')
PermissionError: [Errno 13] Permission denied: '/etc/passwd'
# 權限錯誤: /etc/passwd 權限錯誤遭到拒絕。
如果給定讀取的不是檔案,而是資料夾,也會發生錯誤:
>>> fin = open('/home')
IsADirectoryError: [Errno 21] Is a directory: '/home'
# 是一個資料夾錯誤: /home 是一個資料夾。
上面的 Errno 是錯誤的編號,暫時可以先忽略它。
14-5-2 捕捉
想要避免這些錯誤的發生而中斷程式的執行,雖然可以使用上述提到的 os.path.exists
或 os.path.isfile
等函數事先判斷是資料夾還是檔案,
但這會花較多時間在撰寫所有的測試條件上,而且也會讓整個程式的架構相對複雜,如果無法專心撰寫主要功能,就容易出錯。
比較好的做法會是使用 try, exception 來處理程式可能發生的異常,語法架構像 if-else:
try:
fin = open('bad_file')
except:
print('發生錯誤')
當 python 在執行上例程式時,如果一切順利,就不會執行 except 區塊。
發生錯誤時,程式會跳離 try 區塊而執行 except 區塊的程式碼,所以通常會將可能發生錯誤的程式碼放在 try 區塊內。
使用 try, exception 來處理異常,稱為「捕獲異常」。在上例中,發生錯誤時會印出一行錯誤訊息。錯誤訊息的內容很重要,儘量避免籠統的寫法。
比較好的做法是提供一個明確的錯誤提示,或者錯誤發生時,可以直接處理或排除錯誤,至少能讓使用者順利結束程式,並明白發生了什麼狀況。
之前曾經使用 raise 來引發錯誤,在 系統預設有很多錯誤類型,因此 except 也可以指定發生錯誤的類型,如此就能依不同的錯誤來對應正確的處理方式。
while True:
try:
s = int(input('請輸入數字'))
print('我有', s, '元')
break
except ValueError:
print('只能輸入整數')
except:
print('發生未知的錯誤')
其實 try, exception 還有更完整的結構,我們先簡單了解到這裡即可。
14-5-3 引發
如果你認為 raise 只能引發 python 定義好的錯誤類型,那就太小看它了。
這邊會有點超進度,講到類別,目前先有個概念就好。我們可以自訂一個錯誤類別,只要讓它繼承 BaseException 類別即可,使用自行定義的錯誤類別,可撰寫使用者能看懂的錯誤訊息,對於錯誤也可以有更適當的處理方式。
因為 raise 所引發的錯誤類別都是繼承這個父類別而來的,像這樣:
class MyDefError(BaseException):
pass
a = 10
if a == 10:
raise MyDefError('我自創的錯誤類別')
錯誤處理除了用 try, exception 來捕捉錯誤,也可以使用 raise 來引發錯誤,這兩者有什麼不同呢?
最大的不同是 raise 引發錯誤後,程式就停止了,但 try, exception 可以讓程式有處理或彌補錯誤的機會,之後在除錯章節會再深入研究。
14-5-4 錯誤處理
另外,還有個狀況,就是 python 不認為它是錯誤,但對程式而言是我們不想要的結果,當這種狀況出現時,需要立即處理。
最明顯的例子是讓使用者輸入,輸入後 input 一定是回傳字串,通常程式會對輸入的字串有一些要求:
s = input('請輸入小寫字母:').lower()
print(s)
直接將輸入的文字全部轉成小寫,連判斷式都省了。幸好無論輸入什麼,使用 lower 都不會有錯誤訊息。
相對地,需要是整數時,直接使用 int() 包住 input() 又沒有 try, exception 顯然不是好主意。當輸入的字串無法轉成整數時,就會報錯,整個程式將停止運作。
因此在強制轉換前,應該先判斷這個字串的內容,可以這樣做:
s = input('請輸入整數:')
if s.isdigit():
int_s = int(s)
print(type(int_s))
上例的做法是先判斷輸入的字串是否為 0-9 的數值,如果是,則進行轉整數動作。
14-5-5 判斷資料型態
最後,補充一個函數 isinstance() ,可以用來判斷各種資料型態:
s = '123'
if isinstance(s, str):
print('字串')
else:
print('非字串')
它支援的資料型態有: bool, int, float, complex, str, list, tuple, set, dict 等。
14-6 資料庫
資料庫就是將有組織的資料整理後儲存在一起的結構,像是 python 的字典那樣,有 key-value 鍵值的對應關係。
但兩者最大的不同,資料庫是儲存在硬碟或其他儲存媒體上,當程式結束時,資料並不會跟著消失。
dbm 模組可以提供新增及更新資料庫的介面,舉個例子,現在想新增一個包含圖片檔及標題的資料庫,而開啟資料庫其實就跟開啟檔案的方式差不多,也是以檔案的形式來儲存資料:
>>> import dbm
>>> db = dbm.open('captions', 'c')
採用模式 c
,若資料庫不存在,就會自動建立一個資料庫。它會回傳一個資料庫物件,使用起來有點像字典。
當建立新項目後,我們更新資料庫的內容:
>>> db['python.png'] = '這是一張 python 的圖片'
同樣地,可以讀取這筆資料:
>>> db['python.png']
b'\xe9\x80\x99\xe6\x98\xaf\xe4\xb8\x80\xe5\xbc\xb5 python \xe7\x9a\x84\xe5\x9c\x96\xe7\x89\x87'
結果是一個 bytes 物件,它是以 b 開頭。bytes 很多性質類似字串,中文字的部分會經過 utf-8 編碼。
其實這兩者是可以互相轉換的:
import dbm
db = dbm.open('dbtest', 'c')
str1 = '上海自來水來自海上'
db['test'] = str1
print(db['test'])
# b'\xe4\xb8\x8a\xe6\xb5\xb7\xe8\x87\xaa\xe4\xbe\x86\xe6\xb0\xb4\xe4\xbe\x86\xe8\x87\xaa\xe6\xb5\xb7\xe4\xb8\x8a'
str2 = db['test'].decode('utf-8')
print(str2)
# 上海自來水來自海上
str3 = str1.encode('utf-8')
print(str3)
# b'\xe4\xb8\x8a\xe6\xb5\xb7\xe8\x87\xaa\xe4\xbe\x86\xe6\xb0\xb4\xe4\xbe\x86\xe8\x87\xaa\xe6\xb5\xb7\xe4\xb8\x8a'
encode 是字串方法,而 decode 是 bytes 方法,裡面的參數都是字串編碼,使用 bytes() 也可以轉換,方法差不多就不舉例了。
bytes 是另外一種資料型態,先不深究它,有這些概念即可。
有些檔案是屬於二進位檔案編碼,也就是非文字檔,像圖檔、壓縮檔等,想開啟這些檔案,就要以 bytes 的方式開啟。
這次使用 with 語句 來開檔,以下除錯單元會比較不同的開檔方式差異。在 with 整個區塊結束後,python 會將已開啟的檔案資源關閉。
with open('happy_pickle.jpg', 'rb') as f:
filecontents = f.read()
print(filecontents)
如果開啟的是文字檔,卻發生類似底下的錯誤:
with open('d:/20191211.txt', 'r') as f:
f.read()
print(f)
# UnicodeDecodeError: 'cp950' codec can't decode byte 0xe3 in position 0: illegal multibyte sequence
# unicode 編碼錯誤:cp950 編碼無法順利解碼,二進位字元 0xe3 不合法。
open 函數在不指定的情況下,是以 ansi 編碼來開啟檔案,但在作業系統中的編輯器會以 utf-8 編碼來儲存文字檔,已經是主流。
簡言之,對中文而言 utf-8 編碼就是可以顯示更多正確的文字,關於 utf-8 在網路上已經有很多資訊可以閱讀,只要記得檔案內容是中文的,現在大多也建議都是存成 utf-8 編碼的檔案就好。
解法就是指定 open 以 utf-8 編碼的方式開檔即可,那是 open 的第三個參數:
with open('d:/20210901.txt', 'r', encoding = 'utf-8') as f:
content = f.read()
print(content)
在開檔時還可以指定第四個參數,newline = ''
這樣在讀檔時就會忽略換行符號了。
再回到資料庫的討論,如果為同一個鍵指派不同的值,就像字典那樣,python 會以新的值取代舊的:
>>> db['python.png'] = 'python 小圖'
>>> db['python.png']
b'python \xe5\xb0\x8f\xe5\x9c\x96'
有一些字典的方法在資料庫物件無法使用,像是 keys, items 都不行,但可以直接用 for 迴圈迭代:
for key in db:
print(key, db[key])
最後,跟開檔一樣,當結束資料庫時,也必須關閉它:
>>> db.close()
14-7 轉換
資料庫的局限性在於只能使用鍵值對,並且是 bytes 物件或者字串的型態,如果存入其他型態,則會發生錯誤:
pickle 模組可以把所有資料型態轉成字串並存入資料庫中。更重要的是還可以轉換回原本的資料型態。
pickle.dumps 就是將其他的物件轉成字串的函數:
>>> import pickle
>>> t = [1, 2, 3]
>>> pickle.dumps(t)
b'\x80\x03]q\x00(K\x01K\x02K\x03e.'
這種格式很難理解,主要是給資料庫儲存使用的,利用 pickle.loads 就能把資料轉換回原本的物件了:
>>> t1 = [1, 2, 3]
>>> s = pickle.dumps(t1)
>>> s
b'\x80\x04\x95\x0b\x00\x00\x00\x00\x00\x00\x00]\x94(K\x01K\x02K\x03e.'
>>> t2 = pickle.loads(s)
>>> t2
[1, 2, 3]
注意一下,雖然 t1 與 t2 的值一樣,但它們其實是不同的物件:
>>> t1 == t2
True
>>> t1 is t2
False
這樣就可以不受字串的限制,使用各種資料型態來儲存資料了。
在實務上這樣的做法很常見,有高手把這些方式直接封裝成 shelve,它用起來比 dbm 更方便直覺且強大。
14-8 管線
多數的作業系統支援命令列操作,一開始還沒有把執行 python 程式結合到 notepad++ 時,也是以下指令方式來執行程式。
通常稱這種介面為 shell,可查看檔案架構、瀏覽檔案與執行程式等。
例如,在 shell 使用 cd 來切換資料夾,使用 dir 來查看資料夾中包含哪些子資料夾與檔案,甚至使用 firefox 瀏覽器。
想在命令列執行程式時, python 允許使用 pipe 物件,像這樣:
>>> cmd = 'dir'
>>> fp = os.popen(cmd)
參數是一個 shell 命令字串,回傳物件後,可以用 read 方法讀取所有執行後的輸出內容,也可以用 readline 方法來讀取一行輸出:
>>> res = fp.read()
請自行使用 print 看一下 res 輸出結果,確認是否與直接在命令提示列執行 dir 的結果相同。
最後結束時,一樣要使用 close() 方法來關閉 pipe 物件:
>>> stat = fp.close()
>>> print(stat)
None
回傳 None 表示沒有錯誤訊息,當然也可以捨棄接收回傳值。
14-9 撰寫模組
任何具有 python 程式碼的檔案都能被 python 當成模組來引用。因此我們不只可自行撰寫函數,也可以撰寫自己的模組。
例如有一個名為 wc.py 的檔案,程式碼內容如下:
# 檔名:wc.py
def lineCount(filename):
count = 0
for line in open(filename):
count += 1
return count
print(lineCount('wc.py'))
如果執行這個程式,會在畫面得到 7 這個檔案的行數,可以把它當成模組來使用:
>>> import wc
7
現在有一個叫做 wc 的模組:
>>> wc
<module 'wc' from 'wc.py'>
這個模組包含了一個 lineCount 函數:
>>> wc.lineCount('wc.py')
7
到此,已經知道怎麼撰寫及使用模組了。
不過,發現了一個美中不足之處,就是程式被當成模組時,我們只希望定義出新的函數或類別等,但不希望它直接自動執行主程式,應該是由z呼叫者決定何時執行,因此將會作為模組的程式,有個慣用寫法:
if __name__ == '__main__':
print(lineCount('wc.py'))
習慣上會再包成函數,寫成這樣:
def _main():
print(lineCount('wc.py'))
if __name__ == '__main__':
_main()
使用底線開頭可以防止 import * 的引入,也就是可以避免覆寫(會被排除)。
模組被當作 script 程式在執行時,系統變數 __name__
的值會是字串 '__main__'
,此時判斷條件成立,就會去執行下面的 print 語句。反之,如果被當成模組 import 引用時,就會忽略 print 語句。
更正確地說,應該是 if 條件為 False,所以不會被執行。自己可以練習看看,程式被當成模組時, __name__
這個變數的值是什麼?
如果更改被引入成模組的原始碼,已經引入的模組並不會立即生效,要使用 reload 函數。但最保險的做法,應該是重新 import 一次。
14-10 json 格式
json 格式是一種源自 javascript 語言的資料格式,但因其方便性已被很多語言採用,所以在 python 也有支援,它可以讓我們快速地處理數據。
看個例子,將一串數字列表以 json 格式的方式寫入檔案中:
import json
numbers = [2, 4, 6, 8, 10]
filename = 'data.json'
with open(filename, 'w') as f:
json.dump(numbers, f)
可以開啟 data.json 檔案來查看它長什麼樣子:
[2, 4, 6, 8, 10]
檔案的內容與原本數字的列表完全相同,接著將數據從檔案讀出並存入列表:
import json
filename = 'data.json'
with open(filename) as f:
numbers = json.load(f)
print(numbers)
# [2, 4, 6, 8, 10]
有了 json 格式,可以方便快速地處理數據,不需要一個個或一行行讀出分析再寫入檔案。
上述介紹的寫入檔案方法 write(),寫入檔案時只能接受字串,但實際應用上可能有不同的資料結構,像是列表、字典等等。
如果每次都需要想辦法轉換成字串,那就太不方便了,更何況到時讀出還得轉回原本的資料型態,才能順利處理後續數據。這些轉換的過程,無法讓開發者專心且快速直覺地處理數據。
在 python 3 中,對於中文的支援也沒問題,將一個字串以 json 格式寫入檔案中保存時,它會以 utf-8 的編碼寫入。
雖然檔案內容像是亂碼,但以 json 格式讀出後,就會轉換成正常的中文字,非常方便,解決了編碼的問題。
接下來,將字典列表以 json 格式寫入檔案並取出:
import json
filename = 'data.json'
students = [
{'姓名' : '劉大毛', '數學' : 60, '自然' : 80},
{'姓名' : '張小貓', '數學' : 80, '自然' : 90}
]
with open(filename, 'w') as f:
json.dump(students, f)
with open(filename) as f:
new_students = json.load(f)
for num, stu_dic in enumerate(new_students, 1):
print(f' {num}號')
for i, j in stu_dic.items():
print(f'{i}:{j}')
可以開啟 data.json 看一下是不是和原本 python 資料型態的格式一樣,json 格式相當方便,幾乎支援所有 python 的資料型態,這樣就不必為型態轉換而煩惱了。
14-11 重構
理論上,我們現在已經可以依照需求寫出符合的程式,接著要開始考慮寫出來的程式架構是不是良好。
以上一個章節的最後一個範例,這樣的程式碼只能當範例或測試使用,實際應該包裝成結構較良好的程式才行,這個動作稱為程式碼的重構。
重構上面的程式碼,並稍微改寫一下,讓學生的資料是可以輸入的活資料,並且有機制可以結束輸入。當然程式可以無限擴展,先確認好目標再重構程式。
基本上,需要將各個功能包裝成函數,在還沒學習類別前,先這樣做就好:
import os
import json
def checkFile():
''' 確認輸入的檔名,其檔案是否存在'''
filename = input('請輸入要儲存資料的檔名:')
if os.path.exists(filename):
return False
else:
return filename
def inputStudentData():
''' 讓使用者輸入學生資料並回傳資料列表 '''
number = 1 # 學號
stu_dic = {} # 學生資料字典
stu_list = [] # 所有學生資料列表
# 不輸入 q 就一直讓使用者輸入學生資料
while True:
print(f'學號:{number}')
name = input('姓名(輸入 q 結束):')
if name == 'q': break
math = input('數學分數(輸入 q 結束):')
if math == 'q': break
natural = input('自然分數(輸入 q 結束):')
if natural == 'q': break
# 儲存學生資料為字典,並加入列表
stu_dic['姓名'] = name
stu_dic['數學'] = math
stu_dic['自然'] = natural
stu_list.append(stu_dic)
stu_dic = {}
number += 1
return stu_list
def saveJsonData(fn, data):
''' 以 json 格式將資料存入檔案 '''
try:
with open(fn, 'w') as f:
json.dump(data, f)
except:
print('儲存資料失敗')
else:
print('儲存資料成功')
def getJsonData(fn):
''' 以 json 格式從檔案取出資料並回傳 '''
try:
with open(fn) as f:
data = json.load(f)
except:
return False
else:
return data
def showStudentData(data):
''' 顯示學生資料 '''
for num, stu_dic in enumerate(data, 1):
print(f'\t{num}號')
for k, v in stu_dic.items():
print(f'{k}:{v}')
重構過的程式跟之前還沒重構的比起來,至少有以下優點:
- 將每個功能函數化
- 加入錯誤判斷與處理
- 儘量讓所有的參數都可以由調用者決定
- 加上詳細註解
- 讓每個函數具有再利用與擴充的可能性
或許你會對於一個函數應該有哪些功能,如何將功能細分感到疑惑,以下提供幾個原則供參:
- 一個函數只執行一個功能
- 可以很簡短快速地用一句話當作註解,讓使用者知道這個函數的功能
- 不要把主程式流程放在函數當中
最後,補上主程式:
# 輸入預存檔之檔名
filename = checkFile()
if filename:
student_data = inputStudentData() # 輸入學生資料
saveJsonData(filename, student_data) # 將學生資料以 json 格式存入檔案
new_student_data = getJsonData(filename) # 從檔案以 join 格式取出資料
if new_student_data:
showStudentData(new_student_data) # 顯示學生資料
else:
print('資料讀取失敗')
else:
print('檔案已存在')
除錯
當我們在讀寫檔案時,會遇到空格、換行或 tab 等特殊符號的問題,就算顯示在電腦畫面上,也不容易發現:
>>> s = '1 2\t 3\n 4'
>>> print(s)
1 2 3
4
有個內建函數 repr 可以幫助我們,它會將字串以原始的符號回傳:
>>> print(repr(s))
'1 2\t 3\n 4'
另外,跨平臺的部分,在不同系統裡的檔案行尾換行符號是不同的,有的是 \n
、\r
也有 \r\n
的。
作業系統有提供轉換工具,或者自己寫程式來轉換也不難,基本上就是字串的取代而已。
動動腦
1.比較各種開檔的方式有什麼差異:
- 傳統開檔需要手動關檔
- with 開檔,只要該區塊結束,則會自動關檔
- 直接用
for line in open():
一行行迭代,也是在區塊結束後,會自動關檔
2.比較讀取檔案內容的方法有什麼差異:
- read(): 可指定數字參數讀取部分檔案內容,不輸入參數則一次全部讀取。在讀取文字檔時,參數代表字元數;在讀取二進制檔時,參數代表 bytes 數。read()會回傳所讀取之字串。
- readline(): 一次讀取一行內容,接著調用就會繼續讀下一行。讀取一行的標準就是讀到換行符號為止,預設換行符號也會讀入,readline()會回傳所讀取之字串。
- readlines(): 以行為單位,一次讀入全部的檔案內容,包括換行字元。readlines()會回傳所讀取之字串,一行是一個元素。
註1. 後兩個方法僅用在文字檔,因使用者輸入才有換行字元問題。
註2. 而 read 與 readlines 方法都是一次讀取全部的檔案內容,差別在於 read 是整塊讀入,readlines 是將一行行放到列表當中後再回傳。檔案比較大時,這兩個方法都不好,因為效率不彰。
這邊示範當檔案很大時,較佳的讀取方式:
with open('d:/utf-8.txt', 'r', encoding= 'utf-8') as f:
one_line = f.readline()
while one_line:
print(one_line)
one_line = f.readline()
最後,當二進位檔很大時,該怎麼處理?來看一個將二進位檔的內容複製到另一個檔案的範例:
file_to_copy = 'python.jpg'
new_file = 'python1.jpg'
with open(file_to_copy, 'rb') as original_file:
with open(new_file, 'wb') as copy_to:
chunk = original_file.read(4096) # 一次讀取 4096kb 就是 4mb
while len(chunk) > 0: # 檔案結束,chunk 長度會是 0
copy_to.write(chunk)
chunk = original_file.read(4096) Files
習題
練習 1 檔名: 14-1.py
利用比對編碼可以確認兩個字串是不是相等,例如想確認使用者登入的密碼是否正確,而又不知使用者輸入的密碼為何時,就很實用。
擴大應用面就是比對兩個檔案內容是否相等,請閱讀 python 與 md5 等編碼 ,並進行以下練習。
現在有一個檔名為 user.db 的檔案,裡面存放著使用者的帳號、密碼、鎖定狀態與錯誤次數,我們把這個檔案想像成資料庫。
格式是每個帳號一行,密碼是用 sha256 加密的字串存放的,目前裡面只有兩筆使用者資料。
檔案內容與格式如下:
user 6ca13d52ca70c883e0f0bb101e425a89e8624de51db2d2392593af6a84118090 0 0
python cd897e1386ba10d51bfea041af28357b04f7e3c508d524e6f6efd186413065e8 0 0
請自行建立該檔案,然後寫一個登入程式,讓使用者輸入帳號與密碼,若帳密正確,就顯示成功登入訊息;若輸入錯誤,則顯示錯誤訊息,並讓使用者重新登入。
輸入三次錯誤,就必須鎖住帳號,此時雖然可以繼續輸入,但就算輸入正確,也不能登入系統。
因此,如果想解鎖,就要把檔案第三個欄位的 1 改為 0。
輸入錯誤的次數需要累積,若測試時想歸零,現階段先直接開檔案修改第四個欄位的值即可。
輸入密碼時,不能顯示在畫面上,建議搜尋關鍵字 python getpass
會有所收穫。
比對密碼時,只能使用加密後比對的方式,不可直接比對輸入的明碼字串。
小彩蛋出現,實際上正確的密碼可能是下面其中兩組,請自己試看看,並且注意整個程式的運作合理性:
- python8899
- logo is logo
- abc123
- I love python
- yaya1234
- python is good
- yes888
- 22668899
- ooqq9988
練習 2 檔名: 14-2.py
寫一個簡單的備份程式。程式執行後會去讀取現行資料夾下的 backup_list.txt 檔案,該檔案的內容格式為:
d:\01
d:\203\abcd.brl
d:\book\application
d:\程式需求.7z
總之,裡面一行一個資料夾或檔案,程式會將該檔所列之檔案或資料夾,實際複製一份到程式指定的備份資料夾內。例如統統複製到 d 槽的 backup 資料夾下,檔案或資料夾名稱可以允許中文。
複製過去的路徑必須保持完整,以下情況必須有錯誤提示:
- backup_list.txt 檔案不存在
- backup_list.txt 裡沒有內容,或列出的檔案、資料夾不存在
- backup_list.txt 內的格式不正確,例如用了反斜線當分隔符號,直接將它轉成相同符號也可以,方法自己思考。
成功備份完成必須顯示訊息,並包括備份了幾個檔案、幾個資料夾與備份的年月日時分秒。
當目的已經有相同的資料夾或檔案時,直接蓋掉原本的資料。
這個練習看起來很簡單,但實際去做後,會發現有很多細節必須處理與考慮。如果沒有用對方法,程式會變得冗長,會出現許多不必要的判斷語法。
成功訊息類似這樣:
建立 d:/backup/
複製資料夾 d:/01 到 d:/backup/01
複製檔案 d:/203/abcd.brl 到 d:/backup/203/abcd.brl
複製資料夾 d:/book/application 到 d:/backup/book/application
複製檔案 d:/程式需求.7z 到 d:/backup/程式需求.7z
備份時間: 2020-02-09 22:10:13.334786
備份資料夾: 2
備份檔案數: 2
需要自行依據 backup_list.txt 的內容放置相應檔案或資料夾,才有辦法實際讓備份程式運作。
練習 3 檔名: 14-3.py
我們想要從日記中整理出收支資訊以利統計之用,在 日記
資料夾中的所有檔案裡,只要該行有數字的都要抽出來。
抽出來的一整行,要按照日期順序,放到日記下的 收支資訊.txt
這個檔案中,並且標上年月日。
結果類似這樣:
2019-12-01
我花了 10000 元買了一臺筆電
今天很幸運,老闆賞我 1000 元獎金
2019-12-11
今天小明跟我借了 500 元去買睡衣
我在路上看到有人在賣滷味,就花了 100 塊買了一份
2019-12-30
5000 元買了一個 23 寸的螢幕
中午訂外賣花了 200
2020-01-03
夢裡可感到跟你相擁的氣溫,我今天受傷花了 50 塊醫藥費。
願你可保佑 沉睡中的情人,今天發薪水了,我的薪水是 30000 元
怕睡了失去你 如果累了 請留下 狂風暴雨 想起我嗎,今天撿到手機賣掉得到 3600 元
參考資料
影片
最後更新:2021-10-08 21:23:57
From: 111.249.165.250
By: 特種兵