網路基礎-因數篇
-
3-8a 錯誤處理(Error Handling)
「錯誤處理」是程式設計必備的技能之一,由於非同步程式常用到外部資源,難免遇到意外狀況,必然需要錯誤處理,所以若想要進一步善用 async/await,就必須先熟悉 Swift 錯誤處理的語法。
錯誤處理和除錯(debug)意義不同,除錯(debug)是要排除程式的語法錯誤或邏輯錯誤,例如標點符號用錯、條件句的true/false子句用顛倒、邊界條件沒有檢查...等等,修正程式無法執行或執行結果與預期不符的問題。
錯誤處理則是對所有已知的「例外狀況」進行處置,以本單元網路程式為例,必須連到外部網站,才能抓取圖片或JSON資料,「正常狀況」下都能順利執行,但如果是網路斷線,或甚至過若干時日,外部網站已關閉,程式要如何正常執行?
好的App一定是強健的程式,能應付各種狀況,要寫出強健的程式,就必須做好錯誤處理。
我們用「質因數分解」來寫個範例。根據數學上的定義,「質數」必然是大於1的正整數,「因數」則正負整數均可,但不可為零(任何數都不可除以零),零也不能因數分解。
為了簡單起見,以下範例程式只做「正整數」的因數分解,所以如果遇到零或負整數,則當作已知的錯誤來處理。
操作步驟如下:
- 選取畫面左方的「+」號新增電子書頁面。
- 將新增的電子書面頁命名為「(23)網路程式基礎-7因數篇」。
- 在「Main」模組中撰寫程式:
// 3-8b 非同步+錯誤處理 -- 非同步因數分解 // Created by Philip, Heman, Jean 2022/01/11 // Revised by Jean 2024/12/28 import Foundation enum 因數分解錯誤: Error { case 負整數 case 等於零 } func 因數分解(_ n: Int) async throws -> [Int] { if n < 0 { // 排除零與負數 throw 因數分解錯誤.負整數 } else if n == 0 { throw 因數分解錯誤.等於零 } else if n == 1 { return [1] } var 因數: [Int] = [1] // 1與n是基本的因數 for i in 2...(n-1) { if n % i == 0 { 因數 = 因數 + [i] } } 因數 = 因數 + [n] return 因數 } let 某數 = [-20220109, 0, 20220109, 20220911] var 時間差: Double = 0.0 let 計時開始 = Date() print("因數分解 -- 開始時間:\(計時開始)") for i in 某數 { Task { do { let 因數 = try await 因數分解(i) 時間差 = Date().timeIntervalSince(計時開始) print("\(i): \(因數) 時間差 \(時間差)") } catch 因數分解錯誤.負整數, 因數分解錯誤.等於零 { print("錯誤:僅限正整數的因數分解(\(i))") } catch { print("錯誤:其他原因(\(i))") } } } 時間差 = Date().timeIntervalSince(計時開始) print("[因數分解]共花費時間(秒):\(時間差)") print("程式結束:\(Date())") import PlaygroundSupport PlaygroundPage.current.needsIndefiniteExecution = true
- 程式執行結果,如下圖。
從以上實際的程式碼可以看出來,錯誤處理就是對可能發生的例外狀況,加以偵測判斷並撰寫應對的程式碼。
在語法上,「錯誤處理」分為「偵測」與「處理」兩個部分,「偵測」通常以函式為主體,由函式偵測並回報已知的錯誤或例外狀況,「處理」則是根據回報的狀況加以應對。
負責偵測錯誤的函式,在宣告時要在參數後面加上 throws 指令(注意加上 's',是第三人稱單數用的動詞),然後在偵測到的錯誤狀況下,用 throw (祈使句,不加 's')丟出錯誤狀況的代碼:
func 因數分解(_ n: Int) throws -> [Int] { if n < 0 { // 排除零與負數 throw 因數分解錯誤.負整數 } else if n == 0 { throw 因數分解錯誤.等於零 } else if n == 1 { return [1] } ... }
錯誤狀況的代碼可以自行定義,只要符合 Error 規範的類型即可,這個 Error 規範是個空規範(empty protocol),沒有任何強制要求,用法和 View 規範類似,用 struct 或 enum 來定義都可以。
enum 因數分解錯誤: Error { case 負整數 case 等於零 }
我們在第2單元提過,規範(protocol, 又稱協議、協定)在 Swift 語言裡面,是在一般資料類型更上一層的分類,所以上面我們定義了一個符合 Error 規範的列舉(enum)類型,稱為「因數分解錯誤」,只有兩個值,代表兩種例外狀況。前面提過,要取用列舉值時,只要用句號 . 即可,如「因數分解錯誤.等於零」。
然後在函式「因數分解」的宣告中,加入 throws 指令,代表這個函式有可能發生例外狀況,函式裡面分別偵測各種已知的例外狀況,再用 throw 丟出相對應的錯誤代碼。
當程式偵測到例外狀況,執行到 throw 時,函式會直接返回,不會在函式內繼續往下執行。
錯誤狀況的應對處理,是呼叫函式者(caller)的責任,語法上通常用 do-try-catch 的句型:
do { let 因數 = try 因數分解(i) 時間差 = Date().timeIntervalSince(計時開始) print("\(i): \(因數) 時間差 \(時間差)") } catch 因數分解錯誤.負整數 { print("錯誤:暫不支援負整數的因數分解(\(i))") } catch 因數分解錯誤.等於零 { print("錯誤:零無法因數分解(\(i))") } catch { print("錯誤:其他原因(\(i))") }
try 其實是與 throw 成對的,就像 async/await 成對一樣,凡是宣告時加上 throws 的函式,就必須在呼叫該函式前加上 try:
func 因數分解(_ n: Int) throws -> [Int] { ... } let 因數 = try 因數分解(i)
所以我們前面用過的 JSONDecoder() 或是 URLSession.shared.data(),現在終於知道為什麼要加上 try 了,顯然他們都是會丟出(throw)錯誤狀況的。另一方面,也知道了他們並不保證能成功取得返回值。
當函式沒有正常的返回值,而是回報錯誤時,就必須根據錯誤代碼加以處置,這就是 do-catch 的用法,若捕獲(catch)到某個或某些狀況,則如何如何...(應對內容沒有特別限制,程式設計師可自由發揮),程式碼就寫在catch後續的 { } 段落中。
do { let 因數 = try 因數分解(i) 時間差 = Date().timeIntervalSince(計時開始) print("\(i): \(因數) 時間差 \(時間差)") } catch 因數分解錯誤.負整數 { ... }
若上面 try 因數分解(i) 真的回報錯誤,則下面緊接的兩行程式碼(時間差=、print())就不再繼續執行,而是跳到相對應的 catch 子句,然後結束 do-catch 句型。所以 do-catch 和 switch-case 類似,程式碼會選擇性的執行。
注意最後一個 catch 沒有接任何錯誤代碼,表示所有其他錯誤狀況都在這裡處置。當我們寫:
do { 段落A } catch { 段落B }
就表示任何錯誤狀況均由段落B處理。
以下是執行結果,注意前兩個錯誤狀況並不會列印出時間差:
因數分解 -- 開始時間:2022-01-09 06:56:06 +0000 錯誤:暫不支援負整數的因數分解(-20220109) 錯誤:零無法因數分解(0) 20220109: [1, 7, 13, 91, 222199, 1555393, 2888587, 20220109] 時間差 14.630668997764587 20220911: [1, 97, 208463, 20220911] 時間差 29.198646068572998 [因數分解]共花費時間(秒):29.198896050453186 程式結束:2022-01-09 06:56:35 +0000
【註解】
1. 所謂「錯誤處理(error handling)」,並不是要「解決問題」或「修正錯誤」,而是根據App的需要,對每個設想的意外狀況加以應對。例如,遇到外部網站當掉或檔案被移除的情況,程式不可能解決這類問題,但可以提示使用者可能原因,及時提示可增進使用者體驗。
2. 這點與除錯(debug)不同,除錯是必須找出問題(bug)的根源(root cause),並加以解決或設法避開(workaround)。
3. 所以對軟體而言,error 與 bug 的意義不同,雖然中文都可能翻譯為「錯誤」。3-8b 非同步 + 錯誤處理
在非同步程式中,通常也須處理可能發生的錯誤狀況,這時就會全部用到上面7個關鍵字: Task, async/await, throw/try, do-catch,看起來好像很複雜,但實際寫起來,其實邏輯非常直觀。
下面範例程式,我們將上一節的「因數分解」函式改為非同步模式,只需增加三個關鍵字:async, await 以及 Task,比起其他程式語言,Swift 語法顯得特別乾淨俐落,很接近自然語言,這也是Swift易學易用的原因之一。
- 在「Main」模組中撰寫程式:
// 3-8b 錯誤處理(Error Handling) -- 非同步因數分解 // Created by Philip, Heman, Jean 2022/01/11 // Revised by Jean 2024/12/28 import Foundation enum 因數分解錯誤: Error { case 負整數 case 等於零 } func 因數分解(_ n: Int) async throws -> [Int] { if n < 0 { // 排除零與負數 throw 因數分解錯誤.負整數 } else if n == 0 { throw 因數分解錯誤.等於零 } else if n == 1 { return [1] } var 因數: [Int] = [1] // 1與n是基本的因數 for i in 2...(n-1) { if n % i == 0 { 因數 = 因數 + [i] } } 因數 = 因數 + [n] return 因數 } let 某數 = [-20220109, 0, 20220109, 20220911] var 時間差: Double = 0.0 let 計時開始 = Date() print("因數分解 -- 開始時間:\(計時開始)") for i in 某數 { Task { do { let 因數 = try await 因數分解(i) 時間差 = Date().timeIntervalSince(計時開始) print("\(i): \(因數) 時間差 \(時間差)") } catch 因數分解錯誤.負整數, 因數分解錯誤.等於零 { print("錯誤:僅限正整數的因數分解(\(i))") } catch { print("錯誤:其他原因(\(i))") } } } 時間差 = Date().timeIntervalSince(計時開始) print("[因數分解]共花費時間(秒):\(時間差)") print("程式結束:\(Date())") import PlaygroundSupport PlaygroundPage.current.needsIndefiniteExecution = true
- 程式執行結果,如下圖。
【注意】
程式最後兩行,用來控制 Swift Playgrounds 主控台不要自動結束,改為手動停止,這樣才能顯示非同步的運算結果,而不必像之前,故意在後面加額外的運算拖延時間。如下圖所示。
執行結果如下,兩個8位正整數的因數分解都在16.8秒左右結束,比上一節29.2秒才全部結束,快了將近一倍:
因數分解 -- 開始時間:2022-01-11 08:34:56 +0000 錯誤:僅限正整數的因數分解(-20220109) 錯誤:僅限正整數的因數分解(0) [因數分解]共花費時間(秒):0.0076819658279418945 程式結束:2022-01-11 08:34:56 +0000 20220911: [1, 97, 208463, 20220911] 時間差 16.82110297679901 20220109: [1, 7, 13, 91, 222199, 1555393, 2888587, 20220109] 時間差 16.859354972839355
3-8c 快速演算法因數分解
在我們前面的範例程式,若仔細看「因數分解」程式碼,用的是最直觀方法,當n大於2時,已知1與n必然是因數,所以用for迴圈從2一路找到n-1,看能否整除n,如果能整除,就加入「因數」陣列中:
for i in 2...(n-1) { if n % i == 0 { 因數 = 因數 + [i] } }
這樣算法雖然正確,但實在太慢,分解8位數就需花十幾秒,那分解100位數(密碼學應用需100位數以上)豈不是算到宇宙滅亡!有沒有更快的方法呢?
當然有,而且還不只一種。如果反覆觀察,我們可以發現「因數分解」有以下幾個特徵,可用來「加速」程式:
因數都是成對出現的,如果 n / i 能整除的話(也就是 n % i == 0),除了除數 i 是因數,(n / i)的商也是因數。唯一的例外就是如果 n 是平方數,那 n 的平方根就是唯一不成對的因數。不需要從2找到(n-1)整個範圍,只要找2到n的平方根即可。
舉例來說,觀察100的因數分解,100平方根是10,所以只要找 2...10能夠整除100的,再加上配對的商數即可,也就是:
(2, 50), (4, 25), (5, 20), (10)
經過重新排序,再加上 1, 100 也是因數,最後因數分解的結果就是:
[1, 2, 4, 5, 10, 20, 25, 50, 100]
這樣一來,迴圈計算的次數從原本98次(2...99)大幅降低到9次(2...10)。對於8位數20220109來說,迴圈次數會從2千萬次降低到只需4千多次,相當於將8位數縮短成4位數的計算時間!
以下我們就根據這樣的運算方法(或稱為「演算法」),重新改寫3-8b因數分解程式。
- 在「Main」模組中撰寫程式:
// 3-8c 加速因數分解 // Created by Philip, Heman, Jean 2022/01/11 // Revised by Jean 2024/12/28 import Foundation enum 因數分解錯誤: Error { case 負整數 case 等於零 } func 因數分解(_ n: Int) async throws -> [Int] { if n < 0 { // 排除零與負數 throw 因數分解錯誤.負整數 } else if n == 0 { throw 因數分解錯誤.等於零 } else if n == 1 { return [1] } var 因數: [Int] = [1] // 1與n是基本的因數 let 近平方根 = Int(sqrt(Double(n))) for i in 2...近平方根 { if n % i == 0 { 因數 = 因數 + [i] let 商數 = Int(n / i) if 商數 != 近平方根 { 因數 = 因數 + [商數] } } } 因數 = 因數 + [n] return 因數.sorted() } let 某數 = [-20220109, 0, 20220109, 20220911, 123456789012345, 135791357913579] var 時間差: Double = 0.0 let 計時開始 = Date() print("因數分解 -- 開始時間:\(計時開始)") for i in 某數 { Task { do { let 因數 = try await 因數分解(i) 時間差 = Date().timeIntervalSince(計時開始) print("\(i): \(因數) 時間差 \(時間差)") } catch 因數分解錯誤.負整數, 因數分解錯誤.等於零 { print("錯誤:僅限正整數的因數分解(\(i))") } catch { print("錯誤:其他原因(\(i))") } } } 時間差 = Date().timeIntervalSince(計時開始) print("[因數分解]共花費時間(秒):\(時間差)") print("程式結束:\(Date())") import PlaygroundSupport PlaygroundPage.current.needsIndefiniteExecution = true
- 程式執行結果,如下圖。
在程式中,我們用了一個新的函式,sqrt() 用來求平方根,參數必須是實數,所以用Double(n)將整數n轉換為實數,算出平方根(實數)之後,再用 Int() 轉回整數:
let 近平方根 = Int(sqrt(Double(n)))
所以「近平方根」就是最接近(小於或等於)n平方根的整數。
接下來 for 迴圈只要從2算到「近平方根」,將可整除n的除數與商數加入到「因數」陣列中:
for i in 2...近平方根 { if n % i == 0 { 因數 = 因數 + [i] let 商數 = Int(n / i) if 商數 != 近平方根 { 因數 = 因數 + [商數] } } }
最後將 n 本身也加入「因數」陣列中,並且將整個陣列排序後回傳。排序用的是物件方法 .sorted(),可對陣列內容加以排序後,回傳「排序後的陣列」(所以用被動語態sorted):
因數 = 因數 + [n] return 因數.sorted()
最後執行結果,對3個8位數20220109, 20220911, 20223009竟然只要0.02秒多,比起上一節3-8b用16秒足足快了約800倍,比最初版本3-8a用29.2秒近1500倍!
為了檢驗新的算法,我們還特地增加一個平方數 20223009(4497的平方),以及兩個15位數的整數,全部算出來也不過花8.9秒多而已。
因數分解 -- 開始時間:2022-01-13 00:05:16 +0000 錯誤:僅限正整數的因數分解(-20220109) 錯誤:僅限正整數的因數分解(0) [因數分解]共花費時間(秒):0.007884979248046875 程式結束:2022-01-13 00:05:16 +0000 20223009: [1, 3, 9, 1499, 4497, 13491, 2247001, 6741003, 20223009] 時間差 0.021432995796203613 20220109: [1, 7, 13, 91, 222199, 1555393, 2888587, 20220109] 時間差 0.021718978881835938 20220911: [1, 97, 208463, 20220911] 時間差 0.0246199369430542 123456789012345: [1, 3, 5, 15, 283, 849, 1415, 3851, 4245, 11553, 19255, 57765, 1089833, 3269499, 5449165, 7552031, 16347495, 22656093, 37760155, 113280465, 2137224773, 6411674319, 10686123865, 29082871381, 32058371595, 87248614143, 145414356905, 436243070715, 8230452600823, 24691357802469, 41152263004115, 123456789012345] 時間差 8.547168970108032 135791357913579: [1, 3, 31, 37, 93, 111, 367, 1101, 1147, 1369, 3441, 4107, 11377, 13579, 34131, 40737, 42439, 127317, 420949, 502423, 1262847, 1507269, 2906161, 8718483, 15575113, 46725339, 90090991, 107527957, 270272973, 322583871, 1066561087, 3199683261, 3333366667, 3978534409, 10000100001, 11935603227, 33063393697, 39462760219, 99190181091, 118388280657, 123334566679, 370003700037, 1223345566789, 1460122128103, 3670036700367, 4380366384309, 45263785971193, 135791357913579] 時間差 8.970889925956726
只是改變一個計算方式,程式碼也沒幾行,就能加快這麼多,可見演算法的威力!
【註解】
1. 在電腦科學(computer science)中,研究解決問題的方法與程序稱為「演算法(algorithm)」,是資訊科系必修課程,除了研究更快更好的方法之外,也研究計算困難的數學難題。
2. 質因數分解與第一單元提過的費波那契數,都只用到「整數」,數學上研究整數特性的理論稱為「整數論」(或簡稱「數論」),聽起來簡單,小學生都能懂,讓人誤以為用電腦一定可以解決所有數論問題,沒什麼好研究的,其實不然。
3. 2018年上海有位名叫「談方琳」的14歲小女孩,解開了一個數論的千年難題,就是研究「費波那契數」和「貝祖數(與最大公因數有關)」的關係,成為公認的數學天才,堪稱當代的高斯,就是最好例子。
4. 本節的因數分解其實還不夠快,尚有很多改善空間,值得深入研究。不過演算法並非本單元課程範圍,重點還是放在「非同步模式」與「錯誤處理」。