![]()
去年有個(gè)尼日利亞賣家在后臺(tái)問客服:為什么我只收到?350,而不是?54,000?技術(shù)團(tuán)隊(duì)查了三周,發(fā)現(xiàn)問題出在一行代碼的參數(shù)順序上。客戶ID和店鋪ID都是字符串,毛收入和凈收入都是整數(shù)——編譯器全程沉默,測(cè)試全部通過。
這個(gè)案例來自一位Rust開發(fā)者的真實(shí)踩坑經(jīng)歷。他在X上吐槽時(shí)寫道:「編譯器檢查了數(shù)據(jù)的形狀,卻沒檢查數(shù)據(jù)的意義。」這句話戳中了很多人的痛處。
強(qiáng)類型≠真安全
來看這個(gè)函數(shù)簽名。JavaScript版本接收7個(gè)參數(shù):3個(gè)ID字符串,4個(gè)金額整數(shù)。Go和Rust版本也一樣,只是語法更嚴(yán)格。
「Typed」對(duì)吧?每個(gè)參數(shù)都有明確類型。但問題在于:shop_id和customer_id都是string,amount和net_amount都是i64(64位整數(shù))。類型系統(tǒng)把它們視為完全等價(jià)。
于是調(diào)用時(shí)可以這樣寫:
process_order_payout(customer_id, shop_id, order_id, net_amount, tx_fee, platform_fee, amount);
客戶ID進(jìn)了店鋪ID的位置,凈收入替換了毛收入,手續(xù)費(fèi)順序全亂。編譯器不報(bào)錯(cuò),因?yàn)槊總€(gè)位置的數(shù)據(jù)類型都"正確"。
這種bug的可怕之處在于隱蔽性。單元測(cè)試通常用模擬數(shù)據(jù),不會(huì)觸發(fā)真實(shí)世界的金額校驗(yàn)。集成測(cè)試可能覆蓋不到這個(gè)調(diào)用點(diǎn)。直到真金白銀轉(zhuǎn)錯(cuò)賬戶,才有人發(fā)現(xiàn)。
結(jié)構(gòu)體是第一步,但不夠
自然的改進(jìn)是把參數(shù)打包成結(jié)構(gòu)體。命名字段消除了位置混淆——你沒法在Rust里把params.customer_id填進(jìn)shop_id的位置,編譯器會(huì)攔住。
但這解決的是"調(diào)用時(shí)"的問題。數(shù)據(jù)從哪來?
假設(shè)你從HTTP請(qǐng)求體解析出這個(gè)結(jié)構(gòu)體。JSON里的字段名拼錯(cuò)一個(gè)字母,或者前端傳了字符串"123"而后端期望數(shù)字,反序列化庫會(huì)默默做類型轉(zhuǎn)換。ShopID和CustomerID在數(shù)據(jù)庫查詢時(shí)仍然只是兩個(gè)字符串,SQL沒法區(qū)分哪個(gè)該關(guān)聯(lián)sellers表、哪個(gè)該關(guān)聯(lián)users表。
結(jié)構(gòu)體封裝了形狀,沒封裝語義。
新類型模式:給類型加"標(biāo)簽"
Rust社區(qū)有個(gè)慣用法叫Newtype Pattern。簡單說,就是給基礎(chǔ)類型包一層薄薄的結(jié)構(gòu)體:
struct ShopId(String); struct CustomerId(String); struct OrderId(String);
struct Amount(i64); struct NetAmount(i64);
現(xiàn)在函數(shù)簽名變成:
fn process_order_payout( shop_id: ShopId, customer_id: CustomerId, order_id: OrderId, amount: Amount, net_amount: NetAmount, ) { ... }
編譯錯(cuò)誤立刻變得具體:「expected struct ShopId, found struct CustomerId」。不是"類型不匹配"這種模糊提示,是直接告訴你拿錯(cuò)了ID類型。
這個(gè)模式在Go里需要多寫點(diǎn)樣板代碼,但效果一樣。JavaScript/TypeScript可以用 branded types 模擬:type ShopId = string & { __brand: 'ShopId' }。
成本是什么?每次從字符串轉(zhuǎn)換時(shí)需要顯式包裝:ShopId(raw_id)。但這恰恰是價(jià)值所在——轉(zhuǎn)換點(diǎn)成了審計(jì)點(diǎn),你可以在這里加驗(yàn)證邏輯,比如檢查ID格式是否符合ULID規(guī)范。
從"能跑"到"不會(huì)錯(cuò)"的距離
那位開發(fā)者在clippy(Rust的lint工具)提了個(gè)issue,建議檢測(cè)這種"同類型參數(shù)相鄰"的函數(shù)簽名。維護(hù)者的回復(fù)很典型:這屬于語義分析范疇,超出靜態(tài)檢查的能力邊界。
換句話說,編譯器能告訴你"這里要個(gè)整數(shù)",但沒法知道"這個(gè)整數(shù)代表尼日利亞奈拉,且必須大于零、小于平臺(tái)單日限額"。
新類型模式把部分語義壓進(jìn)了類型系統(tǒng)。更進(jìn)一步的做法是用狀態(tài)機(jī)類型:UnvalidatedOrder → ValidatedOrder → PaidOrder,每個(gè)狀態(tài)只有特定操作合法。Rust的類型系統(tǒng)足夠表達(dá)這種流轉(zhuǎn),代價(jià)是代碼量增加30%-50%。
很多團(tuán)隊(duì)停在"能跑"階段。畢竟,寫struct ShopId(String)比寫string麻煩,測(cè)試能覆蓋的路徑有限,而業(yè)務(wù)需求永遠(yuǎn)在排隊(duì)。
但那個(gè)?350的賠付工單,最終花了工程師兩周排查、財(cái)務(wù)團(tuán)隊(duì)手動(dòng)沖正、客服三次道歉。隱性成本算過嗎?
類型驅(qū)動(dòng)設(shè)計(jì)的邊界
不是每個(gè)項(xiàng)目都需要走到極致。內(nèi)部工具、原型驗(yàn)證、生命周期短于六個(gè)月的腳本——scalar types夠用,快速交付比防錯(cuò)更重要。
但如果你在處理資金流轉(zhuǎn)、醫(yī)療數(shù)據(jù)、身份認(rèn)證,"假安全"就是真風(fēng)險(xiǎn)。類型系統(tǒng)的設(shè)計(jì)選擇,本質(zhì)是"把哪些約束前置到編譯期"的決策。
那位開發(fā)者最后把代碼改成了新類型模式。他在X的后續(xù)帖子說,重構(gòu)后發(fā)現(xiàn)了另外兩處潛在參數(shù)錯(cuò)位,都是之前沒觸發(fā)的代碼路徑。
編譯器不會(huì)替你思考業(yè)務(wù)邏輯,但好的類型設(shè)計(jì)能讓"不可能的狀態(tài)"真的無法表示。這不是銀彈,是降低認(rèn)知負(fù)荷的工具——當(dāng)你看到ShopId和CustomerId是兩個(gè)類型時(shí),不需要翻文檔就知道它們不該互換。
那個(gè)尼日利亞賣家后來收到了補(bǔ)款。技術(shù)團(tuán)隊(duì)的復(fù)盤文檔里多了一條:「所有ID類型必須包裝,禁止裸string傳入核心業(yè)務(wù)流程。」
這條規(guī)則現(xiàn)在攔住了多少潛在bug?沒人知道。這正是類型安全的悖論——你統(tǒng)計(jì)的是"沒發(fā)生的事"。
特別聲明:以上內(nèi)容(如有圖片或視頻亦包括在內(nèi))為自媒體平臺(tái)“網(wǎng)易號(hào)”用戶上傳并發(fā)布,本平臺(tái)僅提供信息存儲(chǔ)服務(wù)。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.