Learning Objectives
- 分辨 Warning 與例外:Warning 印出訊息後繼續執行,沒有被接住的例外會讓程式停止
- 用 try、catch、finally 處理例外,說明 finally 在 return 時也會執行
- 用 throw 丟出適當的例外,看懂 Exception 與 Error 兩個家族與 Throwable 的關係
- 用多重 catch 與 `catch (A | B $e)` 分別處理不同的例外,並注意 catch 的順序
- 寫出繼承 Exception 的自訂例外,用 previous 串起例外鏈,並設定全域的錯誤與例外處理函式
事情不會總是照計畫進行
檔案可能不存在、使用者可能輸入「abc」當數量、資料庫可能連不上、餘額可能不夠付款。好的程式不是「不會出錯」, 而是出錯的時候知道該怎麼辦:給使用者看得懂的訊息、把細節寫進紀錄檔、確保資料不會只改了一半。
PHP 有兩種回報問題的方式:
| 錯誤訊息(Warning、Notice、Deprecated) | 例外(exception) | |
|---|---|---|
| 長相 | Warning: Undefined array key "Ken" in … on line 4 |
一個物件,例如 InvalidArgumentException |
| 發生後 | 印出訊息,繼續執行下一行 | 立刻跳離目前的程式,往外找能處理它的 catch |
| 沒人處理 | 只是一行訊息 | Fatal error: Uncaught …,整個程式停止 |
| 能不能用 try/catch 接 | 預設不行 | 可以 |
例外的好處是不會被忽略:函式遇到它處理不了的狀況時丟出(throw)例外,呼叫它的程式必須處理,否則程式就停下來, 不會帶著錯誤的資料繼續跑。
Warning 與沒有被接住的例外
第 4 行讀取不存在的鍵,只是 Warning,第 5 行照樣執行。第 16 行傳入空陣列,average 用 throw 丟出例外,沒有任何 catch 接住,程式就在這裡停止,第 17 行不會執行。看看最後印出的 Stack trace(呼叫堆疊):它列出例外發生時,程式是經過哪些函式呼叫走到這裡的。
看懂未捕捉例外的訊息
Fatal error: Uncaught InvalidArgumentException: 至少要有一個分數 in /var/www/html/index.php:10
Stack trace:
#0 /var/www/html/index.php(16): average(Array)
#1 {main}
thrown in /var/www/html/index.php on line 10
| 部分 | 意思 |
|---|---|
Uncaught InvalidArgumentException |
沒有被接住的例外,和它的類別 |
至少要有一個分數 |
例外的訊息(throw 時給的字串) |
index.php:10 |
例外在第 10 行被建立 |
#0 index.php(16): average(Array) |
第 16 行呼叫了 average,傳入一個陣列 |
#1 {main} |
最外層:主程式 |
閱讀時從上往下:最上面是出事的地方,往下是「誰呼叫了它」。正式網站不能把這段訊息顯示給使用者(會洩漏路徑與程式結構),
本課後面的 set_exception_handler 可以接手處理。
try、catch、finally
try {
// 可能丟出例外的程式
} catch (InvalidArgumentException $e) {
// try 裡丟出 InvalidArgumentException(或它的子類別)時執行;$e 是那個例外物件
} finally {
// 不論有沒有例外、有沒有被接住,最後都會執行
}
try裡某一行丟出例外,後面的行就不會執行,直接跳到符合的catch- 沒有丟出例外時,
catch整段跳過 finally用來做「一定要做」的收尾:關閉檔案、釋放鎖定、記錄結束時間。即使try裡有return,finally也會在函式真正回傳之前執行catch和finally至少要有一個;catch可以有好幾個
例外物件常用的方法:
| 方法 | 回傳 |
|---|---|
getMessage() |
訊息字串 |
getCode() |
錯誤代碼(整數,建立時沒給就是 0) |
getLine()、getFile() |
例外建立的行號與檔案 |
getTraceAsString() |
呼叫堆疊的文字 |
getPrevious() |
前一個例外(例外鏈,後面介紹) |
try/catch/finally 的執行順序
迴圈跑兩次:第一次 10 / 4 正常,第二次 5 / 0 在 divide 裡丟出例外。注意第二次的第 14 行沒有執行,直接從 divide 跳到第 15 行的 catch;兩次都會執行第 18 行的 finally。
想一想:執行順序
下面的程式會印出什麼?
<?php
function f() {
try {
echo 'A';
return 'B';
} finally {
echo 'C';
}
}
echo f();
下面的程式會印出什麼?
<?php
try {
echo '1';
throw new RuntimeException('x');
echo '2';
} catch (RuntimeException $e) {
echo '3';
} finally {
echo '4';
}
echo '5';
例外的家族:Exception 與 Error
所有能被 throw 的東西都實作了 Throwable 介面(PHP 7 加入),底下分成兩大家族:
Throwable(介面,所有能被 throw 的東西)
├── Exception 程式預期可能發生、應該處理的狀況
│ ├── ErrorException 由 Warning 等錯誤轉成的例外
│ ├── LogicException 程式寫錯了(呼叫的方式不對)
│ │ ├── InvalidArgumentException 引數不合理
│ │ ├── DomainException
│ │ ├── LengthException
│ │ └── OutOfRangeException
│ ├── RuntimeException 執行時才發現的問題(外部狀況)
│ │ ├── OutOfBoundsException
│ │ ├── RangeException
│ │ ├── OverflowException
│ │ └── UnexpectedValueException
│ └── JsonException 等其他內建例外
└── Error PHP 引擎發現的錯誤,多半是程式寫錯
├── TypeError 型別不對
│ └── ArgumentCountError 引數個數不對
├── ValueError 型別對但值不合理
├── ArithmeticError
│ └── DivisionByZeroError 除以 0、對 0 取餘數
├── CompileError
│ └── ParseError 語法錯誤
└── UnhandledMatchError match 沒有對應的分支
- Exception:你的程式主動丟出、預期呼叫者會處理的狀況。自己丟例外時,從這一支挑一個意思最接近的類別(或自訂,後面介紹)
- Error:PHP 引擎在執行時發現的錯誤,例如型別不對、除以 0、呼叫不存在的函式。PHP 8.0 起,內建函式收到型別不對的引數會丟出
TypeError, PHP 8.0 也新增了ValueError(型別對但值不合理,例如str_repeat('ab', -1));在這之前,很多情況只是 Warning 加上回傳 null
catch (Exception $e) 接不到 Error 家族,兩者是平行的兩支。真的要「全部接住」時寫 catch (Throwable $e),
但通常只在最外層(記錄錯誤、顯示「系統忙碌中」)這樣做;在一般的程式裡接住 Error,常常只是把程式寫錯的地方藏起來。
catch 比對的是「是不是這個類別或它的子類別」:catch (LogicException $e) 也會接住 InvalidArgumentException。
多重 catch 的寫法:
try {
…
} catch (TypeError | ValueError $e) { // PHP 7.1 起:一個 catch 接住多種例外,用 | 分隔
…
} catch (DivisionByZeroError $e) {
…
} catch (Throwable $e) { // 範圍大的放最後
…
}
PHP 由上往下找第一個符合的 catch,所以子類別要寫在前面。如果把 catch (Exception $e) 寫在 catch (RuntimeException $e) 前面,
後者永遠不會被執行到。
內建的 Error 與多重 catch
三個輸入 '10'、'abc'、'0' 分別走到不同的地方:正常、TypeError、DivisionByZeroError。最後一段把 catch (Exception $e) 寫在 catch (RuntimeException $e) 前面,看看哪一個接到例外。
自訂例外與例外鏈
內建的例外類別只說得出「哪一類問題」。業務上的狀況(餘額不足、庫存不夠、帳號被停用)可以自訂例外類別, 讓呼叫者用 catch 精準地分辨,還能附帶額外的資料。
class InsufficientFundsException extends Exception
{
public int $shortage; // 自己加的屬性:還差多少錢
public function __construct(int $shortage, ?Throwable $previous = null)
{
$this->shortage = $shortage;
parent::__construct("餘額不足,還差 {$shortage} 元", 1001, $previous);
}
}
這是本課程第一次寫 class,只要看懂三件事,其他的(屬性、方法、$this、建構子、繼承)第 14 課會完整介紹:
class 名稱 extends Exception:新的類別繼承 Exception,所以它也是一種 Exception,getMessage()這些方法都能用,catch (Exception $e)也接得到__construct是建立物件時(new InsufficientFundsException(300))自動執行的函式parent::__construct(訊息, 代碼, 前一個例外)把訊息交給 Exception 原本的建構子保存
例外鏈:低層的程式丟出例外,高層的程式接住後,換成一個對自己的呼叫者更有意義的例外再丟出,同時把原本的例外當成第三個參數 previous 傳進去。
這樣上層看到的是「結帳失敗」,需要追查時再用 getPrevious() 找到真正的原因「餘額不足」,資訊不會遺失。
自訂例外與例外鏈
第一次結帳 300 元成功;第二次 800 元,withdraw 丟出自訂的 InsufficientFundsException,checkout 接住後包成 RuntimeException 再丟出去。看右邊的物件:最後 $e 和 $prev 是兩個例外物件。
全域的錯誤與例外處理
把 Warning 變成例外:Warning 不能用 try/catch 接住。set_error_handler() 可以指定一個函式,每次出現 Warning、Notice 時改由它處理;
在這個函式裡丟出 ErrorException,所有的 Warning 就都變成可以 catch 的例外,不會被默默忽略。很多框架預設就是這樣設定的。
set_error_handler(function (int $no, string $msg, string $file, int $line) {
throw new ErrorException($msg, 0, $no, $file, $line);
});
最後一道防線:set_exception_handler() 指定一個函式,處理所有沒有被任何 catch 接住的例外。
它執行完之後程式就結束了(不會回到丟出例外的地方),適合用來把細節寫進紀錄檔,再顯示一個友善的錯誤頁面。
正式網站的設定:display_errors 關閉(不把錯誤訊息顯示給使用者)、log_errors 開啟(寫進紀錄檔);開發時則打開 display_errors 方便除錯。
錯誤處理函式與例外處理函式
第 8 行讀取不存在的檔案,原本只會印出 Warning;設定了錯誤處理函式之後,Warning 變成 ErrorException,被第 10 行的 catch 接住。第 20 行丟出的例外沒有 catch,交給第 14 行設定的例外處理函式。
想一想:例外的家族與鏈
下面的程式執行的結果是什麼?
<?php
try {
echo intdiv(10, 0);
} catch (Exception $e) {
echo '接住了';
}
下面的程式會印出什麼?
<?php
class AppException extends Exception {}
try {
try {
throw new ValueError('壞值', 7);
} catch (ValueError $e) {
throw new AppException('包起來', 1, $e);
}
} catch (AppException $e) {
echo $e->getCode(), ' ', $e->getPrevious()->getMessage();
}
程式讀取不存在的陣列鍵 $a['x'],PHP 印出 Warning: Undefined array key "x"。用 try { … } catch (Throwable $e) { … } 包住這一行,會怎麼樣?
動手做:檢查訂購數量
完成 parseQty:輸入不是由數字組成時丟出 InvalidArgumentException('不是整數');數字不在 1 到 10 之間時丟出 RangeException('數量要在 1 到 10 之間')。主程式逐一處理五個輸入:成功印「輸入:OK」並加進合計,失敗印「輸入:錯誤訊息」,最後印出合計。
重點回顧
- Warning 印出訊息後繼續執行;例外沒有被接住時印出
Fatal error: Uncaught …與 Stack trace,程式停止 try裡丟出例外後,剩下的行不執行,跳到第一個符合的catch;finally一定會執行,return時也會在回傳前執行throw new 類別(訊息, 代碼, previous)丟出例外;常用方法getMessage、getCode、getLine、getTraceAsString、getPreviousThrowable底下兩支:Exception(程式預期的狀況,InvalidArgumentException、RuntimeException…)與 Error(引擎發現的錯誤,TypeError、ValueError、DivisionByZeroError…);catch (Exception $e)接不到 Errorcatch (A | B $e)一次接多種;catch 由上往下比對,子類別寫前面- 自訂例外:
class X extends Exception,在建構子裡呼叫parent::__construct(…);用 previous 串起例外鏈,上層用getPrevious()追查原因 set_error_handler把 Warning 轉成ErrorException;set_exception_handler處理沒被接住的例外,執行完程式就結束