🐟

例外を使わないエラー送出パターン

2024/11/14に公開

C++ には例外処理があるものの, その機能が貧弱なため一般的にはあまり使われていません. そこで例外処理を使わずにエラーを扱うためのパターンを挙げます.

そのエラーは回復可能か

『Effective Java 第3版』[1]には「項目 70 回復可能な状態にはチェックされる例外を、プログラミングエラーには実行時例外を使う」という記述があります.

また『Good Code, Bad Code ~持続可能な開発のためのソフトウェアエンジニア的思考』[2]では以下のように述べられています.

エラーについて考える際、ソフトウェアの一部が回復したいエラーと適切な回復方法がないものを区別することは多くの場合に役に立ちます。

つまりエラー処理を考えるとき, 関数の呼び出し元が回復可能かどうかを基準としてエラー処理を考えることが重要です. そのためにはAPIがどのように使われるかという観点からエラー処理を考える必要があります.

上述の書籍でもエラー処理に関して詳しく書かれていますが, Java に関する書籍のため例外を使ったエラー処理について書かれています. ここでは C++ で利用できるように例外を使わないエラー処理について具体例を挙げていきます.

エラーが無視できる場合

そもそも関数の実行結果によってその後の処理が変わらないような場合があります. このような場合も関数はエラー処理を提供しますが, 簡易的なものにとどめます. 一般的に intbool などで返すことが多いです.

例えば以下の処理は現在時刻を表示しますが, Display 関数がエラーであっても問題ないでしょう.

#include <ctime>

int main() {
  while (true) {
    SetTime(std::time(nullptr));
    Display();

    Delay(1000);
  }
}

回復可能なエラーを返す場合

関数が副作用だけを持つ場合に関数の呼び出し元に回復可能なエラーを返す場合, より明示的にエラーを返すことが望ましいです. エラーの有無によってのちの処理が変わることになるため, std::optional と列挙型を使ってエラーを返すことを明示的に示すとよいでしょう.

以下ではHTTP POSTリクエストを行っています. リクエストがタイムアウトした場合のみ再実行し, その他のエラーでは即座に終了するようにしています.

#include <iostream>
#include <optional>
#include <memory>
#include <string>

enum class RequestError {
  kNone,
  kTimeout,
  kServerError,
  kUnknown,
};

class Client {
  public:
    virtual std::optional<RequestError> PostGreeter(const std::string_view name) = 0;
}

constexpr int32_t kRetryCount = 5;

int main() {
  auto client = std::make_unique<ClientImpl>();

  std::optional<RequestError> error;
  for (int i = 0; i < kRetryCount; i++) {
    error = client->PostGreeter("Alice");
    if (error.has_value()) {
      switch (error.value()) {
        case RequestError::kTimeout:
          break;
        case RequestError::kServerError:
          std::cerr << "Server error" << std::endl;
          return 1;
        case RequestError::kUnknown:
          error = client->PostGreeter("Alice");
          return 1;
        default:
          break;
      }
    } else {
      return 0;
    }
  }
  std::cerr << "Retry count exceeded" << std::endl;
}

値を返す関数の場合

関数が値を返す場合, エラーによって値が取得できない場合があります. この場合は素直に std::optional を使うことができます.

以下では key-value ストアから文字列を取得しています. key の存在可能性を std::optional で表現しています.

#include <iostream>
#include <optional>
#include <memory>
#include <string>

class Storage {
  public:
    virtual std::optional<std::string> Read(const std::string_view key) = 0;
}

int main() {
  auto storage = std::make_unique<StorageImpl>();

  std::string key = "foo"
  std::optional<std::string> value = storage->Read(key);
  if (value.has_value()) {
    std::cout << "Key: " << key << " found. value: " << value.value() << std::endl;
  } else {
    std::cerr << "Key: " << key << " is not found." << std::endl;
    return 1;
  }
  return 0;
}

値を返し回復可能なエラーも返す場合

いままで挙げてきた例の組み合わせとして, 関数の値によってもエラーによっても後続の処理に影響を及ぼす場合があります. 最近では Rust やその他の関数型言語で Result(Either) を使ってエラー処理を行うことが好まれています. C++ にはこのような機能が標準ライブラリにはないため, 自前で実装する必要があります.

またこれ以外では Go のように tuple を使って値とエラーをそのまま返す方法があります. C++ にも std::pair があるため, これを使えばSTLの機能だけでエラー処理を行うことができます.

以下ではHTTP POSTリクエストを行い, サーバーからの戻り値を取得しています. サーバーからのエラーは複数あり, タイムアウトした場合のみ再度リクエストを行います.

#include <iostream>
#include <memory>
#include <string>
#include <utility>

enum class RequestError {
  kNone,
  kTimeout,
  kServerError,
  kUnknown,
};

class Client {
  public:
    virtual std::pair<std::string, RequestError> PostGreeter(const std::string_view name) = 0;
};

constexpr int32_t kRetryCount = 5;

int main() {
  auto client = std::make_unique<ClientImpl>();

  std::pair<std::string, RequestError> result;
  for (int i = 0; i < kRetryCount; i++) {
    result = client->PostGreeter("Alice");
    if (result.second != RequestError::kNone) {
      switch (result.second) {
        case RequestError::kTimeout:
          result = client->PostGreeter("Alice");
          break;
        case RequestError::kServerError:
          std::cerr << "Server error" << std::endl;
          return 1;
        case RequestError::kUnknown:
          return 1;
        default:
          break;
      }
    } else {
      std::cout << "Greeter: " << result.first << std::endl;
      return 0;
    }
  }
  std::cerr << "Retry count exceeded" << std::endl;
}

エラーの有無に関する判別は RequestError が担っており, これは Go のエラー処理でよく見かけるエラー処理に似た見た目になっています.

result, err := func1()
if err != nil {
    // エラー処理
}

コードは『Software Design 2024年9月号』[3]から引用.

非同期処理

マルチスレッドが使える場合は std::future か Observer パターンを使うことができます(割愛).

ファームウェアの場合は, モジュールによる非同期処理になるため State パターンを使うほうが望ましい場合があります. この場合はメソッドの実行結果をインスタンスが保持し, その結果を取得することでエラー処理をすることができます.

例としてUARTの送信処理を挙げます. UART送信処理は Init, ReadySending の3つの状態を持ち, 送信が完了すると last_result を参照することができます.

#include <iostream>
#include <memory>
#include <vector>

enum class State {
  kInit,
  kReady,
  kSending,
};

enum class Result {
  kNotInitialized,
  kSuccess,
  kSendFailed,
  kNotRespond,
  kUnknown,
};

class Uart {
  public:
    int Send(const std::vector<uint8_t> data) {
      switch (state_) {
        case State::kInit:
          last_result_ = Result::kNotInitialized;
          return 1;
        case State::kReady:
          state_ = State::kSending;
          last_result_ = Result::kUnknown;
          break;
        case State::kSending:
          return 1;
      }
    }

    State GetState() const {
      return state_;
    }

    Result GetLastResult() const {
      return last_result_;
    }

  State state_ = State::kInit;
  Result last_result_;
};

int main() {
  auto uart = std::make_unique<UartImpl>();

  uart->Send({0x01, 0x02, 0x03});

  while (uart->GetState() == State::kSending) {
    Delay(100);
  }
  if (uart->GetLastResult() == Result::kSuccess) {
    std::cout << "Send success" << std::endl;
  } else {
    std::cerr << "Send failed" << std::endl;
    return 1;
  }
  return 0;
}

またはステートそのものにエラーを持たせることもできます. 例として Wi-Fi の接続処理を考えます. このとき状態は Init, Connecting, Connected, Disconnected の4つをとることとします. Connected から Disconnected への遷移は Disconnect メソッドの実行によって行われますが, これ以外にも Wi-Fi の接続が切れるなどして自動的に Disconnected へ遷移します. このような場合はステートそのものによってエラー処理を行うことができると考えられるでしょう.

#include <memory>

enum class State {
  kInit,
  kConnecting,
  kConnected,
  kDisconnected,
};

class Wifi {
  public:
    virtual int Connect() = 0;

    State GetState() const {
      return state_;
    }

    Result GetLastResult() const {
      return last_result_;
    }

  State state_ = State::kInit;
};

int main() {
  auto wifi = std::make_unique<WifiImpl>();

  wifi->Connect();

  while (wifi->GetState() == State::kConnecting) {
    Delay(100);
  }

  if (wifi->GetState() == State::kDisconnected) {
    wifi->Connect();

    while (wifi->GetState() == State::kConnecting) {
      Delay(100);
    }
  }

  auto client = std::make_unique<ClientImpl>();
  std::optional<RequestError> error = client->PostGreeter("Alice");
  if (error.has_value()) {
    std::cerr << "PostGreeter failed" << std::endl;
    return 1;
  }

  return 0;
}

まとめ

このように実行する処理によってどのようなエラー処理を行うかが変わります. 例外機能の有無にかかわらず, 関数の呼び出し元が回復可能かをもとに考えることで, 適切なエラー処理を実装することができます.

脚注
  1. Effective Java 第3版, Joshua Bloch, 柴田 芳樹, 丸善出版, 2018 https://www.maruzen-publishing.co.jp/item/b303054.html ↩︎

  2. Good Code, Bad Code ~持続可能な開発のためのソフトウェアエンジニア的思考, Tom Long, 秋 勇紀, 高田 新山, 山本 大祐, 秀和システム, 2023 https://www.shuwasystem.co.jp/book/9784798068169.html ↩︎

  3. 第2特集 Goのエラーハンドリングと向き合う ベストな設計戦略を徹底解剖, 第1章:Goのエラー処理を理解する 「early return」が推奨される理由とその効果, mattn, Software Design 2024年9月号, 技術評論社 https://gihyo.jp/magazine/SD/archive/2024/202409 ↩︎

  4. State Pattern (Localize state-specific behavior) ↩︎

Discussion