讓 AI 幫忙寫程式,已經變成理所當然了。
可是,你有沒有過這種悶悶不安的感覺?
程式生成得越快,負責確認的人負擔就越大。
所以我正在做一個語言 Tauto,讓 AI 在寫程式的同時也寫出「這段程式一定滿足這個性質」的證明,而人類則負責審查那些性質(命題)。
安裝方法與執行方式可以參考儲存庫的 README 裡的 Quickstart(本文中的範例,已在文章公開時同步釋出的版本上確認可運作)。
本文會介紹:
並透過具體例子來說明。
假設部落格服務有一條規則:「只有文章作者可以刪除該篇文章」。
但是程式一大起來之後,就可能出現:
這種 前後不一致的狀態。
結果就是,可以偷偷刪掉別人的文章。
這叫做「存取控制不足」,也是 OWASP Top 10 中名列前茅、Web 應用常見的弱點。
在 Tauto 裡,我們會把這條規則寫成 命題,並且證明它對任何輸入都成立。
// 操作的人與文章之間的關係
pub(open) inductive enum Relation {
Author
Stranger
}
// 能不能刪除文章
pub fn can_delete(r: Relation): Bool {
match r {
Author => true
Stranger => false
}
}
// 命題:「如果能刪除,那麼那個人就是作者」
pub theorem delete_only_author(r: Relation, h: can_delete(r) = true): r = Author {
match r: r = Author {
Author => Refl
Stranger => False::elim(false_ne_true(h))
}
}
如果有人(或是 AI)把 Stranger => true 改掉,這個證明就無法通過,會直接編譯錯誤。
$ tauto check --lang ja post.taut
FAILED delete_only_author: post.taut:19:17: 型別不一致:預期 `false = true`,但實際上是 `can_delete(Stranger) = true`
差異部分:預期 `false`,但實際上是 `true`
2 ok, 1 failed, 0 incomplete; 0 skipped, 0 from imports; axioms: none
人類不必把證明的每個細節都追完,而是可以把重點放在審查 delete_only_author 的結論與前提是否符合規格。
這是個入門用的小例子(如果只有兩種情況,靠測試也能涵蓋)。證明真正派上用場的時候,是輸入與狀態的組合變多,讓完整測試變得很困難的時候。
另外,這裡證明的是針對 Relation 的判定規則。在實際應用中,伺服器端會去比對已登入使用者與文章作者,進而求出 Relation。接著,把刪除流程集中到一個函式上,並要求它以 can_delete(r) = true 的證明作為參數,那麼型別檢查就能阻止它在沒有證明的情況下被呼叫。至於從外部程式直接操作資料庫之類的路徑,則不在這個證明的範圍內。
證明不是測試的替代品。 兩者擅長的事情不同。
測試證明可確認的範圍你寫到的輸入 針對所有輸入 擅長的事情實際執行並確認「真的會這樣」保證「絕對不會發生」適合的對象外部 API 整合、效能、UI、執行環境中的行為權限、狀態轉移、計算規則舉例來說,像下面這些事,光靠證明還不夠,在實際環境中做測試或量測 仍然很重要。
反過來說,像「別人的文章 絕對不能 刪除」這種規則,一旦輸入或狀態組合變得非常多,光靠測試要完整涵蓋就很困難。這就是證明登場的地方。
用測試確認「會照預期運作」,用證明確認「指定的性質一定成立」。
Tauto 本身也是這樣做的:語言實作用數千筆測試案例確認,而型別檢查核心的部分性質則用 Lean 證明。
在 Tauto 裡,我們用以下方式表達正確性:
另外,透過 依賴型別 的機制,可以比一般靜態型別系統更進一步,用型別對更多事情加以約束。
只要編譯通過,就代表機器已經確認該命題成立。
因此,人類不必逐行讀完證明本文,而是可以把重點放在「命題怎麼立才對」。
但也有一些要注意的地方:
tauto check 也會列出用到的公理(上面的例子是 axioms: none)。機器能檢查,不代表證明就可以不讀、或是不用寫得能讀。
閱讀證明時,有時也能發現命題或規格本身有問題。
因此在 Tauto 裡,只要使用重寫之類的步驟,就要求明確寫出該步驟前後的 goal。例如,「乘法交換律」的證明片段可以像這樣:
Succ(k) => by rewrite!(mul_succ_l(k, n)): nat!(`$n * $k + $n = ${Succ(k)} * $n`) {
⊢ nat!(`$n * $k + $n = $n + $k * $n`) from by rewrite!(mul_comm(n, k)) {
⊢ nat!(`$k * $n + $n = $n + $k * $n`) from add_comm(k * n, n)
}
}
⊢ 後面的內容,就是「現在這一步要證明什麼」。
因為每次重寫都會把 goal 寫出來,所以證明可以從上往下順著讀。
有證明的程式,如果不能在正式環境執行,就沒有意義。
想執行的地方編譯目標瀏覽器・Node.jsJavaScript伺服器・原生應用程式RustWebAssembly透過 Rust 轉成 wasm32也可以呼叫既有的 JavaScript 或 Rust 函式庫(但可對應的型別範圍有一定限制)。
執行速度也很重要。輸出成 Rust 時,會透過所有權與參照計數,在沒有 GC 的情況下管理記憶體。
不過,生成後程式的效能仍會受到處理內容與執行時開銷影響,因此在效能重要的部分,還是要靠基準測試確認。
不需要把所有程式都用 Tauto 寫。最推薦的是那些「壞掉會出大事」的部分。
要請 AI 幫忙時,可以這樣指示:
「請實作
can_delete。同時也證明『若能刪除,那麼就是作者』這個定理」
而在審查時,則確認:
h: …) 是否太強,強到變成什麼也沒說的命題證明的本文會由編譯器幫你檢查。
未來是 AI 寫程式的時代。人類工程師會不會變得不需要了呢?
我不這麼認為。
就算 AI 寫程式的能力再怎麼提升,也不可能完全沒有錯誤。
還是需要有人確認,產出是不是人類真正想要的東西。
我認為,未來人類工程師的重要能力會是:
當然,並不是所有工作都會收斂成這兩件事。要做什麼、易用性、可維護性、測試等,仍有很多不是靠證明就能解決的課題。
但即便如此,把規格準確地表達成命題的能力,以及理解並驗證 AI 所建立命題的能力,我認為會越來越重要。
先從你手邊的程式開始,挑出一個「絕對不能變成這樣」的性質,把它寫成命題看看吧。
我們將舉辦一場可以實際動手學習如何建立命題、閱讀證明的讀書會!
報名請到 connpass。
儲存庫在這裡。