題幹與適用場景
訂單服務依次執行輸入驗證、庫存預留和扣款。庫存不足、餘額不足和外部依賴逾時都是可預期失敗;程式設計錯誤、違反類別不變量和無法繼續的資源故障需要單獨處理。面試官要求你使用 C++23 std::expected 設計返回類型、組合呼叫和錯誤記錄。
這道題考察錯誤契約和可組合程式碼。std::expected<T, E> 表示物件要麼持有值 T,要麼持有錯誤 E;它不會自動替你重試、記錄或回滾。回答應先定義錯誤域,再說明呼叫方如何檢查結果,最後交代例外邊界和資源補償。
面試官在評估什麼
- 能否區分業務失敗、外部失敗和程式不變量破壞。
- 能否正確使用
has_value、value、error、unexpected和[[nodiscard]]。 - 能否用
andthen、transform、orelse組合不拋例外的失敗路徑。 - 能否避免把
std::expected當成全域例外替代品或裸字串錯誤。 - 能否處理錯誤映射、日誌去重、冪等和回滾邊界。
回答前需要釐清的問題
- 程式碼庫是否已啟用 C++23、對應標準庫是否實作目標 API?先確認編譯器和函式庫版本。
- 錯誤是給同一層呼叫方處理,還是要跨服務邊界序列化?跨邊界需要穩定錯誤碼。
- 函式是否擁有鎖、檔案控制代碼或預留庫存等資源?錯誤返回前必須說明清理與補償。
- 訂單步驟是否冪等?重試扣款和重複釋放庫存的風險不同。
- 需要保留哪些診斷欄位?使用者可見訊息和內部日誌不應混用。
30 秒回答框架
「我先把可預期業務失敗建模為 std::expected<T, OrderError>,讓呼叫方顯式處理;用 [[nodiscard]] 防止忽略結果,再用 andthen 串起驗證、預留和扣款,用 orelse 統一錯誤映射和指標。例外保留給違反不變量、初始化失敗或無法在目前邊界安全恢復的情況。每一步都要有冪等鍵、資源補償和不洩露敏感資訊的日誌。」
深度回答步驟
先劃分錯誤域
OrderError 應表達呼叫方能採取行動的類別,例如 invalidrequest、outofstock、paymentdeclined 和 dependency_timeout,並攜帶穩定的內部上下文。不要把使用者文案、堆疊和供應商原始錯誤直接塞進一個字串。程式設計錯誤和類別不變量破壞不應被靜默轉換為普通業務失敗。
選擇值類型與錯誤類型
驗證函式可以返回 std::expected<ValidatedOrder, OrderError>,預留函式返回 std::expected<Reservation, OrderError>,扣款函式返回 std::expected<Receipt, OrderError>。讓錯誤類型可複製、可移動且足夠小;若需要大診斷物件,用受控引用或共享所有權,但要明確生命週期。結果函式應標註 [[nodiscard]],避免呼叫方忘記檢查。
用顯式檢查保證清晰邊界
當步驟少或需要對不同錯誤採取不同動作時,顯式檢查很清楚:
[[nodiscard]] auto reserve(const ValidatedOrder& order)
-> std::expected<Reservation, OrderError>;
auto create_order(Input input) -> std::expected<Receipt, OrderError> {
auto valid = validate(std::move(input));
if (!valid) return std::unexpected(valid.error());
auto held = reserve(*valid);
if (!held) return std::unexpected(held.error());
return charge(*valid, *held);
}operator* 和 value() 只應在已確認有值時使用;錯誤路徑必須保留原始類別,不能把庫存不足偽裝成通用失敗。
在同構鏈路中使用 monadic 操作
當每一步都返回 std::expected 時,andthen 可以在有值時繼續下一步,遇到錯誤時短路;transform 用於把成功值映射成另一種值;orelse 用於記錄或轉換錯誤。C++23 標準庫還提供 transform_error,可在邊界統一錯誤類型。組合操作應保持副作用順序可見,不能把扣款、重試和補償隱藏在難以稽核的鏈中。
定義例外邊界
網路逾時、庫存不足和支付拒絕通常是可預期結果,返回 expected 讓業務層決定重試、提示或人工處理。違反類別不變量、記憶體配置失敗或初始化階段無法建立服務時,例外仍可表達「目前呼叫方無法安全恢復」。邊界必須一致:不要讓同一個函式有時返回業務錯誤、有時把同一類錯誤拋例外。
處理副作用、冪等與補償
expected 只傳遞結果,不會撤銷已完成的副作用。庫存預留成功、扣款失敗時,需要釋放預留或進入可重試的補償狀態;扣款重試必須帶冪等鍵。錯誤物件應攜帶訂單號、步驟和重試建議等非敏感診斷欄位,日誌系統再關聯 trace,而不是把支付憑據寫入錯誤。
設計可測試的錯誤契約
測試每個錯誤分支、組合短路、錯誤映射和補償順序;還要測試意外的例外是否穿過邊界並被統一捕獲。編譯器版本、標準庫特性宏和建置選項要在 CI 固定。跨服務時把 OrderError 映射到穩定協議碼,避免把 C++ 類型名當作 API 合約。
高品質示範回答
「我會定義 OrderError,包含穩定的類別和內部診斷欄位;驗證、預留和扣款分別返回帶 [[nodiscard]] 的 std::expected。業務失敗如庫存不足、支付拒絕和逾時由呼叫方顯式處理,不能把它們全部變成例外。
步驟少時我用 if (!result) 傳播 std::unexpected(result.error()),保證每個副作用邊界可稽核;同構的純轉換可以用 andthen 和 transform,用 orelse 統一記錄和錯誤映射。value() 只在已確認成功時存取。
預留成功後扣款失敗不會被 expected 自動回滾,所以我會用冪等鍵記錄狀態,並執行釋放預留或補償任務。例外保留給不變量破壞、初始化失敗等目前邊界無法安全恢復的情況。CI 固定 C++23 工具鏈,測試所有錯誤分支、短路和補償,並在服務邊界把錯誤映射為穩定協議碼。」
常見錯誤
- 把
std::expected當自動重試器:返回類型不提供重試語義 → 在業務層按錯誤類別和冪等策略決定。 - 用
std::optional丟掉錯誤原因:呼叫方無法區分庫存不足與逾時 → 使用穩定錯誤類型。 - 忽略
[[nodiscard]]:呼叫方可能直接丟棄失敗 → 為結果函式加屬性並在 CI 檢查警告。 - 未檢查就呼叫
value():失敗狀態會觸發badexpectedaccess→ 先判斷或使用明確分支。 - 把所有例外改成錯誤碼:不變量破壞可能被靜默吞掉 → 保留清晰的例外邊界。
- 錯誤物件寫入敏感資訊:日誌或序列化洩露憑據 → 分離使用者訊息、穩定碼和內部診斷。
- 忽略已完成副作用:扣款失敗後庫存預留仍存在 → 設計冪等狀態和補償路徑。
- 跨服務直接傳 C++ 類型:版本升級破壞協議 → 映射到穩定協議碼和版本化欄位。
追問與回答
追問 1:std::expected 和例外怎麼選?
可預期、呼叫方能處理的業務結果適合 expected;違反不變量、初始化失敗或目前邊界無法安全恢復的情況可以用例外。關鍵是同一錯誤類別在一個邊界內保持一致,不要讓呼叫方猜測控制流。
追問 2:為什麼不用 std::optional<T>?
optional 只能表示有值或無值,不能攜帶失敗原因。訂單流程需要根據庫存、支付和依賴逾時採取不同動作,因此需要 expected<T, E>。
追問 3:and_then 裡可以執行扣款嗎?
可以,但要讓副作用邊界清晰,並保證每一步可重試或可補償。若鏈路過長、錯誤策略不同,顯式 if 往往更易稽核;不要為了函式式外觀隱藏狀態變更。
追問 4:如何統一多個下游的錯誤類型?
在服務邊界定義穩定的領域錯誤,底層供應商錯誤映射到有限類別並保留內部原因。用 transformerror 或 orelse 做映射,同時記錄供應商碼和 trace,不把供應商文案直接暴露給使用者。
追問 5:錯誤返回會不會比例外更慢?
應測量實際路徑。expected 讓常見失敗成為顯式返回,但可能複製錯誤物件或增加分支;例外通常把成本集中在拋出路徑。選擇應根據延遲目標、編譯器和錯誤頻率基準,而不是絕對宣稱一種更快。
追問 6:如何保證呼叫方不會忘記檢查?
給返回類型和關鍵函式加 [[nodiscard]],把編譯警告提升為 CI 失敗,並在程式碼審查中檢查 value() 使用點。對跨語言 API,再用協議層狀態和契約測試補充編譯器無法覆蓋的保證。