具代表性的面試主題

C++ 面試:如何用 std::expected 設計可組合的錯誤契約?

程式題中等
Offer.cc 編輯團隊發佈 更新

題幹

一個訂單服務的驗證、庫存預留和扣款函式都可能失敗。請用 C++23 std::expected 設計錯誤傳播鏈,並說明什麼時候仍應使用例外。

題幹與適用場景

訂單服務依次執行輸入驗證、庫存預留和扣款。庫存不足、餘額不足和外部依賴逾時都是可預期失敗;程式設計錯誤、違反類別不變量和無法繼續的資源故障需要單獨處理。面試官要求你使用 C++23 std::expected 設計返回類型、組合呼叫和錯誤記錄。

這道題考察錯誤契約和可組合程式碼。std::expected<T, E> 表示物件要麼持有值 T,要麼持有錯誤 E;它不會自動替你重試、記錄或回滾。回答應先定義錯誤域,再說明呼叫方如何檢查結果,最後交代例外邊界和資源補償。

面試官在評估什麼

  • 能否區分業務失敗、外部失敗和程式不變量破壞。
  • 能否正確使用 has_valuevalueerrorunexpected[[nodiscard]]
  • 能否用 and_thentransformor_else 組合不拋例外的失敗路徑。
  • 能否避免把 std::expected 當成全域例外替代品或裸字串錯誤。
  • 能否處理錯誤映射、日誌去重、冪等和回滾邊界。

回答前需要釐清的問題

  • 程式碼庫是否已啟用 C++23、對應標準庫是否實作目標 API?先確認編譯器和函式庫版本。
  • 錯誤是給同一層呼叫方處理,還是要跨服務邊界序列化?跨邊界需要穩定錯誤碼。
  • 函式是否擁有鎖、檔案控制代碼或預留庫存等資源?錯誤返回前必須說明清理與補償。
  • 訂單步驟是否冪等?重試扣款和重複釋放庫存的風險不同。
  • 需要保留哪些診斷欄位?使用者可見訊息和內部日誌不應混用。

30 秒回答框架

「我先把可預期業務失敗建模為 std::expected<T, OrderError>,讓呼叫方顯式處理;用 [[nodiscard]] 防止忽略結果,再用 and_then 串起驗證、預留和扣款,用 or_else 統一錯誤映射和指標。例外保留給違反不變量、初始化失敗或無法在目前邊界安全恢復的情況。每一步都要有冪等鍵、資源補償和不洩露敏感資訊的日誌。」

深度回答步驟

先劃分錯誤域

OrderError 應表達呼叫方能採取行動的類別,例如 invalidrequest、outofstock、paymentdeclined 和 dependency_timeout,並攜帶穩定的內部上下文。不要把使用者文案、堆疊和供應商原始錯誤直接塞進一個字串。程式設計錯誤和類別不變量破壞不應被靜默轉換為普通業務失敗。

選擇值類型與錯誤類型

驗證函式可以返回 std::expected<ValidatedOrder, OrderError>,預留函式返回 std::expected<Reservation, OrderError>,扣款函式返回 std::expected<Receipt, OrderError>。讓錯誤類型可複製、可移動且足夠小;若需要大診斷物件,用受控引用或共享所有權,但要明確生命週期。結果函式應標註 [[nodiscard]],避免呼叫方忘記檢查。

用顯式檢查保證清晰邊界

當步驟少或需要對不同錯誤採取不同動作時,顯式檢查很清楚:

cpp
[[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 時,and_then 可以在有值時繼續下一步,遇到錯誤時短路;transform 用於把成功值映射成另一種值;or_else 用於記錄或轉換錯誤。C++23 標準庫還提供 transform_error,可在邊界統一錯誤類型。組合操作應保持副作用順序可見,不能把扣款、重試和補償隱藏在難以稽核的鏈中。

定義例外邊界

網路逾時、庫存不足和支付拒絕通常是可預期結果,返回 expected 讓業務層決定重試、提示或人工處理。違反類別不變量、記憶體配置失敗或初始化階段無法建立服務時,例外仍可表達「目前呼叫方無法安全恢復」。邊界必須一致:不要讓同一個函式有時返回業務錯誤、有時把同一類錯誤拋例外。

處理副作用、冪等與補償

expected 只傳遞結果,不會撤銷已完成的副作用。庫存預留成功、扣款失敗時,需要釋放預留或進入可重試的補償狀態;扣款重試必須帶冪等鍵。錯誤物件應攜帶訂單號、步驟和重試建議等非敏感診斷欄位,日誌系統再關聯 trace,而不是把支付憑據寫入錯誤。

設計可測試的錯誤契約

測試每個錯誤分支、組合短路、錯誤映射和補償順序;還要測試意外的例外是否穿過邊界並被統一捕獲。編譯器版本、標準庫特性宏和建置選項要在 CI 固定。跨服務時把 OrderError 映射到穩定協議碼,避免把 C++ 類型名當作 API 合約。

高品質示範回答

「我會定義 OrderError,包含穩定的類別和內部診斷欄位;驗證、預留和扣款分別返回帶 [[nodiscard]]std::expected。業務失敗如庫存不足、支付拒絕和逾時由呼叫方顯式處理,不能把它們全部變成例外。

步驟少時我用 if (!result) 傳播 std::unexpected(result.error()),保證每個副作用邊界可稽核;同構的純轉換可以用 and_thentransform,用 or_else 統一記錄和錯誤映射。value() 只在已確認成功時存取。

預留成功後扣款失敗不會被 expected 自動回滾,所以我會用冪等鍵記錄狀態,並執行釋放預留或補償任務。例外保留給不變量破壞、初始化失敗等目前邊界無法安全恢復的情況。CI 固定 C++23 工具鏈,測試所有錯誤分支、短路和補償,並在服務邊界把錯誤映射為穩定協議碼。」

常見錯誤

  • std::expected 當自動重試器:返回類型不提供重試語義 → 在業務層按錯誤類別和冪等策略決定。
  • std::optional 丟掉錯誤原因:呼叫方無法區分庫存不足與逾時 → 使用穩定錯誤類型。
  • 忽略 [[nodiscard]]呼叫方可能直接丟棄失敗 → 為結果函式加屬性並在 CI 檢查警告。
  • 未檢查就呼叫 value()失敗狀態會觸發 bad_expected_access → 先判斷或使用明確分支。
  • 把所有例外改成錯誤碼:不變量破壞可能被靜默吞掉 → 保留清晰的例外邊界。
  • 錯誤物件寫入敏感資訊:日誌或序列化洩露憑據 → 分離使用者訊息、穩定碼和內部診斷。
  • 忽略已完成副作用:扣款失敗後庫存預留仍存在 → 設計冪等狀態和補償路徑。
  • 跨服務直接傳 C++ 類型:版本升級破壞協議 → 映射到穩定協議碼和版本化欄位。

追問與回答

追問 1:std::expected 和例外怎麼選?

可預期、呼叫方能處理的業務結果適合 expected;違反不變量、初始化失敗或目前邊界無法安全恢復的情況可以用例外。關鍵是同一錯誤類別在一個邊界內保持一致,不要讓呼叫方猜測控制流。

追問 2:為什麼不用 std::optional<T>

optional 只能表示有值或無值,不能攜帶失敗原因。訂單流程需要根據庫存、支付和依賴逾時採取不同動作,因此需要 expected<T, E>

追問 3:and_then 裡可以執行扣款嗎?

可以,但要讓副作用邊界清晰,並保證每一步可重試或可補償。若鏈路過長、錯誤策略不同,顯式 if 往往更易稽核;不要為了函式式外觀隱藏狀態變更。

追問 4:如何統一多個下游的錯誤類型?

在服務邊界定義穩定的領域錯誤,底層供應商錯誤映射到有限類別並保留內部原因。用 transform_erroror_else 做映射,同時記錄供應商碼和 trace,不把供應商文案直接暴露給使用者。

追問 5:錯誤返回會不會比例外更慢?

應測量實際路徑。expected 讓常見失敗成為顯式返回,但可能複製錯誤物件或增加分支;例外通常把成本集中在拋出路徑。選擇應根據延遲目標、編譯器和錯誤頻率基準,而不是絕對宣稱一種更快。

追問 6:如何保證呼叫方不會忘記檢查?

給返回類型和關鍵函式加 [[nodiscard]],把編譯警告提升為 CI 失敗,並在程式碼審查中檢查 value() 使用點。對跨語言 API,再用協議層狀態和契約測試補充編譯器無法覆蓋的保證。

公開來源

同類題目

相關面試工具

用 Screenshot 處理演算法題

截圖題目後,依序看約束、解法、程式碼、邊界條件和複雜度。

查看工具