[python] [VI coding] 第十四章 檔案處理 - 教學區

[python] [VI coding] 第十四章 檔案處理

文章瀏覽次數 3125

特種兵

特種兵圖像(預設)

2021-09-11 09:46:29

From:211.23.21.202

第十四章 檔案處理

在之前的章節中,我們已經學習過基本的輸出與輸入。

輸出就是把資料列印在畫面上,輸入就是讓使用者透過鍵盤輸入文字。

在這一章中,學習針對檔案的輸出與輸入,甚至是一些資料庫的基本概念,也可以在這裡看到。

現在我們的程式有幾十行的規模,在每章的除錯提到的錯誤處理方法,也應該研究一下。

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 錯誤。

這裡有一篇講 檔案讀寫參數 的文章可以參考。以下列出檔案物件常用的方法:

  1. tell: 回傳檔案指標所在的位置(數字),檔首的位置是 0
  2. 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.existsos.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}')

重構過的程式跟之前還沒重構的比起來,至少有以下優點:

  1. 將每個功能函數化
  2. 加入錯誤判斷與處理
  3. 儘量讓所有的參數都可以由調用者決定
  4. 加上詳細註解
  5. 讓每個函數具有再利用與擴充的可能性

或許你會對於一個函數應該有哪些功能,如何將功能細分感到疑惑,以下提供幾個原則供參:

  • 一個函數只執行一個功能
  • 可以很簡短快速地用一句話當作註解,讓使用者知道這個函數的功能
  • 不要把主程式流程放在函數當中

最後,補上主程式:

# 輸入預存檔之檔名
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 資料夾下,檔案或資料夾名稱可以允許中文。

複製過去的路徑必須保持完整,以下情況必須有錯誤提示:

  1. backup_list.txt 檔案不存在
  2. backup_list.txt 裡沒有內容,或列出的檔案、資料夾不存在
  3. 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 元

參考資料

python 的檔案處理函數

判斷字串是否為字母、數字等

python seek

總結讀寫檔

如何提升 python 程式效能

影片

第十四章 檔案處理 part one

第十四章 檔案處理 part two

最後更新:2021-10-08 21:23:57

From: 111.249.165.250

By: 特種兵