Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

檔案與例外處理

Python 檔案與例外處理進階:落盤保證、編碼真相與例外繼承樹

當 write 之後資料還在半路上——深入緩衝、原子寫入、位元組與字元的邊界,以及例外型別背後的繼承結構與 traceback 機制

當「寫進去了」其實沒寫進去:一個讓資料庫工程師失眠的問題

你的程式跑完最後一行 f.write("已存檔"),視窗印出「完成」,你安心關機。隔天打開檔案——資料只有一半,最後幾筆憑空消失。程式沒崩潰、沒拋例外、with 也乖乖關了檔,那資料到底去哪了?

如果你讀過入門篇,已經知道用 with 開檔、用 try / except 接住意外。但那只是「能用」的層次。真正讓資料在斷電、當機、多程序競爭下都不出錯的,是底下一整層你看不見的機制:作業系統的緩衝(buffering)位元組與字元的邊界例外型別的繼承樹,以及錯誤發生當下那份 traceback 物件裡藏著什麼。這篇進階篇,我們把這層蓋子掀開。

檔案與例外處理進階概念示意圖

緩衝與持久化:write 之後,資料還在半路上

開頭那個謎題的答案是:f.write() 不保證資料真的落到磁碟上

當你呼叫 write(),資料先進到 Python 自己的緩衝區,再交給作業系統的頁快取(page cache),最後才由作業系統在它認為合適的時機寫入實體儲存裝置。這條路上有好幾層暫存,目的是把許多次小寫入合併成一次大寫入,換取效能。代價是:在資料真正落盤之前,只要程式被 kill -9、機器斷電,那些還在快取裡的位元組就一起蒸發。

f.close()(包含 with 自動關檔)會把 Python 緩衝區flush 給作業系統——所以入門篇教的 with 確實避免了「Python 層級」的資料遺失。但它不會強迫作業系統把頁快取寫進磁碟。要做到那一步,得明確呼叫 os.fsync()

import os

with open("critical.txt", "w", encoding="utf-8") as f:
    f.write("這筆資料不能丟\n")
    f.flush()            # 把 Python 緩衝區交給作業系統
    os.fsync(f.fileno()) # 命令作業系統真正寫入實體磁碟
# 走到這裡,資料才有「斷電也還在」的保證

flush()fsync() 是兩個不同層級的動作,缺一不可:flush() 把資料從「Python 緩衝區」推到「作業系統頁快取」,fsync() 才把它從「頁快取」逼到「磁碟」。資料庫(MySQL、PostgreSQL)在每次 commit 時都會做 fsync,這也是為什麼 commit 比單純的 write 慢得多——它在用效能換取持久性(durability)

你可以用 open()buffering 參數調整這個行為。buffering=0 關閉緩衝(僅限二進位模式),每次 write 直接送往作業系統;buffering=1 是行緩衝(line buffering),每遇換行就 flush,適合即時的日誌輸出。

原子寫入:要嘛全有,要嘛全無

知道資料可能寫到一半,下一個問題就浮現了:如果程式在覆寫一份舊有檔案的中途崩潰,會發生最糟的情況——舊資料已經被清掉、新資料又沒寫完,你同時失去了新舊兩份。

專業的解法叫原子寫入(atomic write):先把完整內容寫到一個暫存檔,確認落盤後,再用 os.replace() 一口氣換掉目標檔。

import os, tempfile

def atomic_write(path, text):
    """寫入過程崩潰時,原檔案保持不變;成功時才整份替換。"""
    directory = os.path.dirname(os.path.abspath(path))
    # 暫存檔放在同一個目錄,確保 replace 是同一個檔案系統內的操作
    fd, tmp = tempfile.mkstemp(dir=directory, suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            f.write(text)
            f.flush()
            os.fsync(f.fileno())   # 確保暫存檔內容真正落盤
        os.replace(tmp, path)      # 原子替換:這一步在 POSIX 上保證不可分割
    except BaseException:
        os.unlink(tmp)             # 出錯就清掉暫存檔,原檔毫髮無傷
        raise

關鍵在 os.replace():在同一個檔案系統內,rename / replace 是作業系統保證的原子操作——任何時刻去讀目標檔,看到的不是完整的舊版,就是完整的新版,永遠不會是「寫到一半」的殘缺狀態。注意暫存檔必須和目標檔在同一個目錄(同一個檔案系統),否則 os.replace() 會退化成「複製+刪除」,原子性就破功了。這個模式是所有講究資料完整性的程式(編輯器存檔、設定檔更新、套件管理器)的標準做法。

位元組與字元:編碼是一道翻譯,不是一個開關

入門篇叮嚀你「永遠寫 encoding="utf-8"」。進階篇要解釋這行字背後到底在做什麼,以及不寫會出什麼事。

磁碟上的檔案本質是一串位元組(bytes),0 到 255 的數字序列。但你在 Python 裡操作的是字串(str),一串 Unicode 字元。從位元組到字元之間,需要一張翻譯表——這就是編碼(encoding)。用文字模式 "r" / "w" 開檔時,Python 在你和磁碟之間插了一層 TextIOWrapper,自動幫你做這道翻譯;用二進位模式 "rb" / "wb" 開檔,則完全不翻譯,你直接面對裸位元組。

text = "café 中文"

# 同一段文字,不同編碼產生完全不同的位元組
print(text.encode("utf-8"))   # b'caf\xc3\xa9 \xe4\xb8\xad\xe6\x96\x87'
print(text.encode("utf-16"))  # 每字元至少 2 bytes,且帶 BOM 位元組順序標記

# 用錯編碼解讀,就是亂碼的根源
raw = text.encode("utf-8")
print(raw.decode("big5", errors="replace"))  # 用 big5 硬解 utf-8 → 一堆問號方塊

「中文亂碼」的真相是:寫入時用 A 編碼、讀取時用 B 編碼。檔案本身沒壞,只是讀的人拿錯了翻譯表。utf-8 之所以是預設首選,是因為它對 ASCII 完全相容、能表示所有 Unicode 字元,且沒有位元組順序的歧義。

當編碼真的對不上、又無法重來時,errors 參數決定遇到無法翻譯的位元組怎麼辦:

data = b"good\xff\xfedata"  # \xff\xfe 在 utf-8 裡是非法序列

data.decode("utf-8", errors="strict")    # 預設:直接拋 UnicodeDecodeError
data.decode("utf-8", errors="ignore")    # 'gooddata',壞位元組被丟棄
data.decode("utf-8", errors="replace")   # 'good��data',換成替代字元 �

處理來路不明的外部資料(爬蟲抓的網頁、老舊系統匯出的檔案)時,errors="replace" 能讓程式不因一個壞位元組就整批失敗——但你要清楚這是在「犧牲正確性換取存活」,被替換的字元已經永久遺失。

看一個例子:偵測並修復編碼錯置的檔案

假設你收到一份檔案,內容明明是中文卻顯示成亂碼。常見原因是「資料原本是 utf-8,卻被某個工具用 cp950(Big5) 存了一遍」。我們可以用「先用錯的編碼還原成位元組、再用對的編碼重新解讀」這招把它救回來:

def fix_mojibake(broken_text):
    """修復『utf-8 被誤當 cp950 讀取』造成的亂碼。"""
    try:
        # 步驟一:把亂碼字元還原回它們當初被誤讀的位元組
        raw = broken_text.encode("cp950")
        # 步驟二:用正確的 utf-8 重新解讀這串位元組
        return raw.decode("utf-8")
    except (UnicodeEncodeError, UnicodeDecodeError):
        # 還原失敗,代表猜錯了來源編碼,原樣退回
        return broken_text

# 模擬一份被錯誤處理過的檔案
original = "資料科學"
mojibake = original.encode("utf-8").decode("cp950")  # 製造亂碼
print("亂碼:", mojibake)              # 看起來是一串無意義方塊
print("修復:", fix_mojibake(mojibake)) # 資料科學

這段程式碼之所以能運作,正是因為你理解了「字串 ↔ 位元組」是可逆的翻譯,而亂碼只是「翻譯方向接錯」。能在腦中清楚分離這兩個世界,是從「會讀寫檔案」進階到「能 debug 編碼問題」的分水嶺。

例外的繼承樹:抓對層級,不多不少

入門篇教你抓 FileNotFoundErrorValueError。進階篇要你看見:這些例外不是平行的清單,而是一棵繼承樹。理解這棵樹,你才知道一個 except 到底會接住哪些東西。

BaseException
 ├── SystemExit          ← sys.exit() 觸發
 ├── KeyboardInterrupt   ← 使用者按 Ctrl+C
 └── Exception           ← 「一般」錯誤的共同祖先
      ├── OSError
      │    ├── FileNotFoundError
      │    ├── PermissionError
      │    └── IsADirectoryError
      ├── ValueError
      │    └── UnicodeDecodeError
      └── ...

except 的比對規則是:抓父類,就會連同所有子類一起接住。所以 except OSError 會同時接住 FileNotFoundErrorPermissionError;而 except Exception 幾乎接住一切「程式錯誤」——但故意漏掉 SystemExitKeyboardInterrupt

這就解釋了入門篇警告的「裸 except」為什麼危險:except: 等同 except BaseException:,連 Ctrl+Csys.exit() 都吞掉,使用者按了中斷鍵程式卻不停。正確的「我要接住所有合理錯誤」寫法是 except Exception,它把 BaseException 那兩個「控制流程用」的特殊例外放過。

抓的層級要恰到好處:太細(只抓 FileNotFoundError)會漏掉「沒權限」這種同樣需要處理的情況;太粗(抓 Exception)會把真正的程式 bug 也一併掩蓋。實務上常見的折衷是抓 OSError,因為所有檔案系統相關的失敗都歸在它底下:

try:
    with open("/etc/secret", "r") as f:
        data = f.read()
except OSError as e:
    # 一網打盡:不存在、沒權限、是目錄、磁碟壞軌……
    print(f"檔案操作失敗:{e.__class__.__name__} — {e}")

設計自己的例外型別:讓錯誤可被精準捕捉

當你的程式變大,內建例外不夠用了。把「設定檔格式錯誤」硬塞成 ValueError,呼叫者就無法把它和「使用者打錯數字」區分開。專業做法是定義自己的例外類別,從 Exception 繼承:

class ConfigError(Exception):
    """設定相關錯誤的共同基底,呼叫者可以一次抓住所有設定問題。"""
    pass

class MissingKeyError(ConfigError):
    def __init__(self, key):
        self.key = key
        super().__init__(f"缺少必要設定項:{key}")

class InvalidValueError(ConfigError):
    def __init__(self, key, value):
        self.key, self.value = key, value
        super().__init__(f"設定項 {key} 的值無效:{value!r}")

def load_port(config):
    if "port" not in config:
        raise MissingKeyError("port")
    port = config["port"]
    if not (1 <= port <= 65535):
        raise InvalidValueError("port", port)
    return port

# 呼叫者可以選擇精準處理,或用基底類別一網打盡
try:
    load_port({"port": 99999})
except MissingKeyError as e:
    print(f"設定不完整,請補上 {e.key}")
except ConfigError as e:        # 攔截所有設定類錯誤(含 InvalidValueError)
    print(f"設定有問題:{e}")

這裡有個關鍵設計原則:建立一個共同的基底例外(這裡是 ConfigError),讓所有相關的子例外都繼承它。如此一來,呼叫者既能精準抓某個特定錯誤,也能用基底類別一次處理整個「家族」。Python 標準函式庫到處是這個模式——OSError 就是檔案錯誤家族的基底。把例外當成「可被分類捕捉的型別」來設計,而不只是「印一段訊息」,是進階例外處理的核心思維。

traceback 物件:錯誤現場的黑盒子

當例外往外傳,它隨身攜帶一個 traceback 物件,記錄了「從哪個函式呼叫到哪個函式、最後在哪一行爆炸」的完整路徑。平常你看到終端機印出的那串紅字,就是它被格式化後的樣子。但 traceback 不只能印,還能被當成資料來檢視與記錄

import traceback

def risky():
    return 1 / 0

try:
    risky()
except ZeroDivisionError as e:
    # 取得結構化的呼叫堆疊,而不只是一段字串
    tb_lines = traceback.format_exc()
    print("=== 完整錯誤現場 ===")
    print(tb_lines)

    # 也能逐層走訪,取出每一格 frame 的檔名與行號
    tb = e.__traceback__
    while tb is not None:
        frame = tb.tb_frame
        print(f"檔案 {frame.f_code.co_filename},第 {tb.tb_lineno} 行,"
              f"函式 {frame.f_code.co_name}")
        tb = tb.tb_next

為什麼要在意這個?因為在真實服務裡,你不能把錯誤訊息原樣丟給使用者(會洩漏內部路徑、變數,是資安風險),但你必須把完整 traceback 寫進日誌,否則出事時無從追查。logging 模組的 logger.exception() 就是專門做這件事的——它在 except 區塊內自動抓取當前 traceback 並寫入日誌:

import logging
logging.basicConfig(filename="app.log", encoding="utf-8")
logger = logging.getLogger(__name__)

try:
    risky()
except Exception:
    logger.exception("處理請求時發生未預期錯誤")  # 完整 traceback 進日誌
    # 對使用者只回一句無害的話
    print("系統忙碌中,請稍後再試")

「對外給乾淨訊息、對內留完整現場」這條原則,正是入門篇 app.py 範例裡 logger.exception(...)return jsonify({'error': str(e)}) 的設計用意。理解 traceback 是一個可程式化操作的物件,你才能寫出既安全又可維運的錯誤處理。

重點回顧

  • write 不等於落盤:資料先經 Python 緩衝、再經作業系統頁快取才到磁碟。要斷電也不丟,必須 flush() 後再 os.fsync()with 只保證前者。
  • 覆寫舊檔要用原子寫入:先寫暫存檔、fsyncos.replace(),避免崩潰時新舊兩失。暫存檔必須與目標檔同一檔案系統。
  • 亂碼是翻譯方向接錯:檔案是位元組、程式裡是字元,編碼是兩者間的翻譯表。寫入與讀取用同一套編碼,問題就消失。
  • 例外是一棵繼承樹:抓父類連同子類一起接;用 except Exception(不是裸 except)才能放過 Ctrl+Csys.exit()
  • 自訂例外要有共同基底:讓呼叫者既能精準抓特定錯誤,也能用基底類別一次攔截整個家族;對外給乾淨訊息,對內用 logger.exception() 留完整 traceback。

深入探討(研究所視角)

為什麼 fsync 仍不是完整的保證:write barrier 與儲存裝置的謊言

os.fsync() 把資料逼出作業系統頁快取,但故事還沒結束。現代 SSD 與硬碟自己也有一層裝置內快取(on-disk cache),有些消費級裝置為了跑分,會在收到 flush 命令後「謊報」已寫入,實際資料還在裝置 DRAM 裡。要真正穿透到非揮發性媒介,作業系統與檔案系統需支援 write barrier 並正確下達 FLUSH CACHE 指令;資料庫工程師為此會特別測試 fsync 的真實語意(著名的 diskchecker.pl、PostgreSQL 的 fsync 爭議即源於此)。更嚴謹的論述會引入 crash consistency 模型:一個寫入序列在任意時間點被中斷後,系統能回到的合法狀態集合,是檔案系統(ext4 的 journaling、ZFS 的 copy-on-write)與應用層(WAL,預寫式日誌)共同設計的結果。換言之,「資料安全」不是單一 API 呼叫,而是一條從應用、作業系統、檔案系統到硬體韌體的完整信任鏈,任一環撒謊都會破功。

例外處理的計算成本與零成本模型

入門篇提過 EAFP 在「正常路徑」幾乎零開銷。從編譯器實作角度,這對應到例外處理的兩種策略。CPython 採用的是動態查找:直譯器維護一個 block stack,進入 try 時推入處理器位址,這有微小但非零的設定成本。相對地,C++ 與 Rust 採用零成本例外(zero-cost exception)模型:正常執行路徑完全不插入任何檢查指令,編譯器改在二進位檔的唯讀區段建立一張例外處理表(exception handling table,如 DWARF .gcc_except_table);只有當例外真正拋出時,執行期才去查這張表做堆疊展開(stack unwinding)。代價是:不出錯時零開銷,一旦拋出則昂貴(要解析表、逐格展開、執行解構子)。這從根本上印證了一條設計準則——例外只該用於「例外」情況,絕不該拿來當迴圈控制流程。一個會在熱路徑(hot path)裡頻繁拋接例外的程式,效能會被堆疊展開拖垮。

上下文管理器的數學本質:資源生命週期即括號匹配

入門篇示範了 __enter__ / __exit__。把它推到一般化,with 解決的是一個結構化資源管理問題:每個被取得的資源都必須被釋放,且釋放順序與取得順序相反——這在形式上等價於括號匹配(balanced parentheses),是一個 context-free 語言。contextlib.ExitStack 把這個抽象做到極致,讓你在執行期動態決定要管理幾個資源,仍保證全部正確釋放:

from contextlib import ExitStack

def merge_files(paths, output):
    """同時開啟未知數量的檔案,任一步出錯都保證全部關閉。"""
    with ExitStack() as stack:
        files = [stack.enter_context(open(p, encoding="utf-8")) for p in paths]
        with open(output, "w", encoding="utf-8") as out:
            for f in files:
                out.write(f.read())
    # 離開時,所有 enter_context 進來的檔案以「後進先出」順序關閉

ExitStack 的釋放順序是嚴格的 LIFO(後進先出),這正是堆疊的語意,也對應 RAII(Resource Acquisition Is Initialization) 在 C++ 中由解構子依宣告逆序自動觸發的機制。把「資源管理」看成「具有良好巢狀結構的代數問題」,你會發現 Python 的 with、C++ 的 RAII、Rust 的 Drop、乃至函數式語言的 bracket 組合子,全是同一個數學結構的不同語法外衣。理解這層共通性,你帶走的就不只是 Python 語法,而是一套跨語言通用的資源安全思維——下一步,不妨試著用 ExitStack 改寫你手上任何需要同時管理多個檔案或連線的程式,親手感受這個抽象的威力。

AI 共讀助教正在陪你讀:Python 檔案與例外處理進階:落盤保證、編碼真相與例外繼承樹
嗨!我是這篇文章的共讀助教,只根據〈Python 檔案與例外處理進階:落盤保證、編碼真相與例外繼承樹〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。