17
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ユニークビジョン株式会社Advent Calendar 2020

Day 17

プログラミング言語におけるエラー処理の変容:値から多相まで

Last updated at Posted at 2020-12-16

言いたいこと

  • 例外処理の答えは,多値でもなく多態でもなく多相だった
  • 現状,エラー処理をするなら多相型と match 文を持った言語が良さげ
  • Rust はいいぞ!!
多値 多態 多相
エラー処理用の型 int, char tuple Exception Result, Either
エラー処理用の文 if, switch if,switch try catch match
パラダイム 手続 手続 オブジェクト指向 関数指向
代表言語 C 言語 Go C++, Python Rust, Haskell

背景

エラーが発生して頻繁に異常終了してしまうプログラムは,なるべく作りたくないものです.
しかし,作られたサービスの該当箇所でどのようなエラーが発生しうるのかを完全に把握するのは,
もはや人間の仕事ではなく,コンピュータの仕事のように思えます.

発生したエラーに対して,どのような対処をすべきかだけをプログラマーは取り扱いたいものです.

さて,人々はどのようにエラー処理を行なってきたのでしょうか?
今の技術で,漏れなくエラーをコンピュータに捉えさせることは可能なのでしょうか?

今日は,プログラミング言語のエラー処理の変容に対する私個人の認識をまとめてみました.
補足や修正,良い参考情報などがあったら教えてください.

エラーが発生したときにやること

最初に,この記事でのエラーとは発生しうる異常のことを想定しています.
発生し得ない異常の場合には assert や unreachable などを用いるべきです.
(後者は開発やテスト時にしか有効にならないので,リリース製品の性能を落とすこともありません)

また重要な点ですが,エラーを不必要に潰し,無視してはいけません
エラーは恐れるものではなく,不具合の原因に対するヒントを与えてくれる強力な助っ人です.
品質の高いソフトウェアを作りたいのなら,不具合対応の時間を減らしたいのなら,
物臭にならずにエラーと向き合いましょう.

それでは,発生したエラーに対して行うべき操作の種類から考えてみましょう.
行うべき操作は下記であると考えられます.

自分が呼んだ関数がエラーを伝えてきた場合

やるべきことは 3 種類です.

  1. エラーの内容を受け取り,対処をする(リクエストの再送信や,デフォルト値の代入など)
  2. 自分以外の人が解決してくれることを期待し,呼び出した側にエラーを伝える(raise など)
  3. 誰も解決できないと悟り,プログラムを終了する(exit,abort など)

自分がエラーを発生させた場合

やるべきことは 2 種類です.

先程と違い,1. がありません.
自分が発生させた異常に自分で対応できる場合,それはエラーには至らないからです.
(連想配列に要素が入っていなかった場合に,初期値を代入することなど)

使い分けの方針

ここでは,上で上げた 3 種類の対処方法をそれぞれ回復,例外,異常終了と呼びます.
まず,可能であれば常にエラーを処理し正常への回復を試みます.
それができない場合,例外と異常終了のどちらかを使い分ける必要がありますが,
これは何を作っているかによって変わります.

例えば,ライブラリ製作者の場合は常にエラーを例外として上流に伝えるべきです.
エラーが発生した時の振る舞いをどのようにしたいかはライブラリ製作者ではなく,
ライブラリ使用者が決めるべき事柄だからです.

一方,自作アプリの場合,プログラムを異常終了させても構いません.
なぜなら発生したエラーが誰も対応できないことはアプリの製作者である自分にはわかるためです.
呼び出し側に通知する必要がありません.

実際のコーティングでは異常終了と例外の境界線は曖昧になりがちになり,判断が難しいものです.
しかし,現状は例外に統一したほうが良いという考え方が強いようです.
これはエラーが発生した場所と,エラーに対処すべき場所を分離できる例外の方が,
異常終了よりもデバックやシステムの変更などに対応しやすいからでしょう.
(例えばシステムの仕様が変わり,エラーが正常回復可能になった時の修正は,
 例外の方が容易になります)

あまりにも明確なエラー(メモリの枯渇など)以外は,例外として処理すべきでしょう.

ここで問題となるのは例外の処理方法です.
他の 2 つと異なり,呼び出し側にエラーを伝える例外は,自分の関数の外にまで問題が波及します.
そのため,例外を処理するためのいくつかのアプローチが生まれました.

本記事では,この呼び出し側にエラーを伝える例外処理の方法に限って説明していきます.

値による解決(手続き型的なアプローチ)

原始的な解決は,C 言語のように関数の戻り値や参照引数にエラーを格納することです.
関数の実行後に値を確認し,エラーであればエラー処理を行います.

int get_some_error();

int err = get_some_error();
if (err) {
    /* エラー処理 */
}

問題点と対策

失敗を見落とす

値をチェックせず無視することが容易です.
エラーを確認していないことをコンパイラは検出して教えてくれません.

int get_some_error();

get_some_error(); // エラー情報を受け取り忘れた

/* エラー処理も忘れた */

そのため,うっかりエラー処理を忘れてしまうことが多いです.
さらに悪い場合,エラー値が返ってくることすら気づかないパターンがあります.
(c言語の printf が戻り値を返すことを知っている人は多くないでしょう)
エラーの存在に気づかないことが深刻なバグに繋がることは想像に固くありません.

これを解決するには,プログラマがエラー値を返す関数を完全に把握し,
確実にコードにエラー処理を反映することが必要です.
「確実に反映」なんて言葉,できないと言っているようなものです.

戻り値が生値

エラーを構造体で返してくれると嬉しいのですが,この手のものは整数であることが多いです.
エラー番号 298 とかが返ってきます.エラーの意味はどこかにある分厚いドキュメントを調べて,
人力でマッピングしなければわかりません.

値はエラー意味を直接表していないため,常に戻り値を意識しなければなりません.
例えば,成功なら 0 を返す関数があります(悲しいことに,そうじゃない関数もあります).
しかし,C 言語の if 文は 0 以外を true と認識するので,次の非直感的なコードが作成されます.

int get_some_error();

int err = get_some_error(); // 成功なら 0 を返す;
if (!err) {
    /* errを否定したが,驚くべきことに,ここはエラー処理のブロックなのだ */
}

直感的でないので,より明示的に書きましょう.

int get_some_error();
const int SUCCESS = 0;

int err = get_some_error();
if (err != SUCCESS) {
    /* エラー処理 */
}

判定値を変数名で包むことで,処理内容を明確にできます.
(変数名を err ではなく,result とするのも有りですが関数のドキュメントに反しないように注意です)

発生する例外に漏れなく対処できているか不明

本記事の主要な関心になりますが,関数から発生しうるエラーの種類をコンパイラは把握できません.

エラー処理分岐は, switch 文で行われますが,その際には必ず漏れがあった場合を考慮して,
最後に漏れた場合の対処を記述します.

int err = get_some_error();
switch (err) {
    case SUCCESS:
        /* 正常処理 */
        break;
    case SOME_ERROR:
        /* 例外処理 */
        break;
    case OTHER_ERROR:
        /* 他の例外処理 */
        break;
    // ...
    default:
        // 指定した例外に漏れがあった場合.
        // これを入れないと深刻なエラーに気づかない場合がある
        exit(1);
}

本当は,エラーの定義を確実に反映していれば default は不要ですが,コードが成長するにつれて,
「定義を追加したのにエラー処理側では対処方法を書き忘れていた」という状況が容易に発生します.

コンピュータは,処理漏れを教えてはくれません.人で抑える必要があります.

正常値と異常値を戻り値で返せない

昔のプログラミング言語は,引数を複数取れても,戻り値を複数取ることができませんでした.
しかし,例外は正常な結果とは異なる種類の情報のため,2 種類の値を返さなくてはなりません.

C 言語でよく行われる方法は,引数に可変ポインタとしてエラー情報などを含めることです.

int get_some_value(int *err);
const int SUCCESS = 0;

int err;
int result = get_some_value(&err);
if (err != SUCCESS) {
    /* エラー処理 */
}

もしくは,正常な結果とエラー情報を含む構造体を定義して返してもいいでしょう.

typedef struct {
    int value;
    int err;
} result_t;

result_t get_some_value();
const int SUCCESS = 0;

result_t result = get_some_value(&err);
if (result.err != SUCCESS) {
    /* エラー処理 */
}

構造体を返す方法は美しいですが,エラーを返しうる関数全てに独自の構造体を定義するのは,
開発コストに見合いません.上手く抽象化して共通の構造体を使い回すなどの工夫が必要です.

多値による解決(手続き的なアプローチ)

Go 言語は 正常な結果とエラーを構造体に包む方法を,C 言語より簡便な方法で提供しています.
タプルによって正常な結果とエラーのペアを返しているのです.

func get_some() (string, error) {
  return "Success", errors.New("Failure")
}

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

この方法は tuple という無名構造体を都度コンパイラが自動生成し,
型推論でユーザが意識しないように処理するという,現代的な Go 言語の方法と見ることができます.

エラー情報を文字列として扱ってる点も評価が高いです.

問題点と対策

ただのタプルか,エラー処理のためのタプルか区別しづらい

エラーが発生しても問題ない場合は,明示的にエラーを無視することができます.
先程の例では次のように書けば良いのです.

func get_some() (string, error) {
  return "", errors.New("Failure")
}

val, _ := get_some() // 意図的にエラーを無視

これの問題点は,関数が複数の値を戻し,最初の値だけが欲しかった場合と区別がつかないことです.

func get_some() (int, int) {
  return 1, 2
}

val, _ := get_some() // 意図的にエラーを無視したのか?正常な値を無視したのか?

関数の定義を見にいけば良いのですが,パッと見た時にエラー処理かの推測は難しいでしょう.
(ちなみに_ではなくerrと書いてエラー処理しなかった場合,
 未使用変数としてコンパイラから警告が出ます.素晴らしい)

if err != nilが大量発生する

例外を受け取ったものの自分では解決できない場合,さらに上流に例外を送ることが多いです.
これを例外の再送出と言います.
その場合,色々な場所で次のようなコードが散見されるようになります.

val, err := get_some()
if err != nil {
    return err
}

これは本当に見たい正常系の処理が見づらくなる危険性があります.
回避は難しいでしょう.

多態による解決(オブジェクト指向的なアプローチ)

オブジェクト指向言語で多く用いられる方法です.
例外が発生したら raise し,try 文で補足します.

def get_some():
    raise SomeError # 例外の送出

try: # 例外の補足
    value = get_some()
except SomeError:
    # エラー処理

これは関数の正常なやりとり(引数,戻り値)の経路に加え,
raise されてから try で補足されるまでの 2 つ目の処理経路を獲得したことになります.

この 2 つ目のフローのことを大域脱出といいます.
これによりエラー処理すべきところだけ処理を書けば良いため,
Go 言語で問題に上がったif err != nilの大量発生は防げます.

大域脱出.png

なぜ 2 つのフローを持つようになったのでしょうか?
個人的な見解としては,クラスの継承関係と例外処理の相性の悪さが原因だと考えています.

正常処理の戻りクラスと,異常処理の戻りクラスは別物なので,継承関係での表現が難しくなります.
そのため,一つのクラスを戻り値とする場合, Object 型のような抽象的すぎる型になります.
これでは void ポインタの再来です.クラスを用いる恩恵を得られません.
2 つの処理経路はこの問題に対する解決方法として編み出されたのではないでしょうか?

問題点と対策

関数が発生させる例外の種類がわからない

関数の定義を見ても,どのような例外が返ってくるのか知る術はありません.
(話の都合上,Pythonの型付で書いています)

def get_some() -> int:
    # エラーが発生する何らかの処理.

この対策として,関数が送出する例外の種類を関数定義部分で宣言し,
try 文で全例外を捉えているかコンパイラが検査する「検査例外」というアプローチがありました.
残念ながらコンパイラが検知してくれるとは言え,下記のような理由から受け入れられませんでした.

  • 変化点が発生した場合,人力で全ての関数の定義箇所を修正するのは変更コストが大きすぎた
  • Java などは後付けで導入したため,既存のエラーのクラス系統にマッチしなかった
  • エラーの抽象化への理解が進んでいない時期に導入してしまった

比較的新しい言語である swift でも 検査例外に対する取り組み が見られますが,
関数宣言時に型を示すことはしないようです.
swift を触ったことはないですが,検査例外に取り組んでいる言語として今後も頑張って欲しいです.

また C++は 最適化のため,逆に例外を投げないことを示す noexcept という形で残った ため,
失敗したという経験以上の成果はあったとみるべきでしょう

エラー処理に漏れがあった場合,暗黙的に再送出する

try 文は補足しなかった例外は全て自身を呼び出した側へ暗黙的に再送出します.

try:
    value = get_some()
except SomeError:
    # SomeErrorだった場合のエラー処理.OtherErrorかどうかよりも先に評価される
except OtherError:
    # OtherErrorだった場合のエラー処理
# 以下は暗黙的な処理.一致する例外がない場合,何も書かなくても例外は再送出される
# except Exception as e:
#     raise e

これは,記述量を減らせる便利な機能とも言えますが,
記述した例外処理で全ての要因を抑え込めているのかを理解しにくくする欠点も持ちます.

対策として,先程述べた検査例外がありますが, 上で述べたように上手くいかなかったようです.

大域脱出が例外処理に内包されている

try 文による大域脱出は非常に便利であったため,例外以外の処理に対しても使用されました.
下記の Python によるイテレータの記述を見てください.

# for文を使うと
for i in range(3):
    print(i)

# Iteratorを直接用いると
it = iter(range(3))
next(it) # 0
next(it) # 1
next(it) # 2
try:
    next(it) # なぜか例外が発生する
except StopIteration:
    pass # 悪いことはしてないので何もしない

通常の手続きにも関わらず,例外処理をしています.
これは直感に反する記述ですが,言語の選択であり,回避できません.

多相による解決(関数指向的なアプローチ)

最後に紹介するのは異なる 2 つの型のいずれかを返し,match 文で判断するアプローチです.

条件分岐の中で,どの型に対する処理かがわかるため,例外処理である事が非常に明確です.

fn get_some() -> Result<int, SomeError> {
    // do something
}

match get_some {
    Ok(value) => // some doing
    Err(err) => // error handling
}

try 文と構成が近いことに気づいたでしょうか.
try 文は例外処理の中に大域脱出が含まれていましたが,match 文は大域脱出の一部として例外処理が含まれています.

例えば,Rust におけるイテレータの処理は次の通りです.

iterator.rs
// for式を使うと
for i in 0..3 {
    println!("{}", i);
};

// Iteratorを直接用いると
let mut it = 0..3;
it.next() // Some(0)
it.next() // Some(1)
it.next() // Some(2)
it.next() // None. 例外じゃない素晴らしい!!

Rust では例外でない大域脱出は Option が用いられます.
大域脱出を例外より上位の機構とすることで,多態のアプローチを改善しています.

別の例として,try 文と match 文の類似性を確認するために,下記の 2 つを比べてみるといいかもしれません.

Python の try 文の場合:

a = list(range(3))
try:
    value = a[5]
except IndexError:
    pass
except Exception as e: # 明示していないもの以外.これは書かなくても暗黙的に実行される
    raise e

Rust の match 文の場合:

let a = [0;3];
let value = match a.get(5) {
    Some(v) => v,
    None => (),
    _ => () // 明示していないもの以外.コンパイラはSomeとNoneの2種類しかないと知っているため意味がない
};

重要なことですが,match 文はパターンが網羅されていない場合に警告されます
また, match 文の中で Err を扱うことが明示されるためエラー処理が明確になり,
エラー型の種類を見れば,再送出される例外の種類を調べることができます.

さらに,Rust に限りますが,例外の再送出は?オペレータを使えば行えます.
多値ようなif err != nilの大量発生も生じません.

fn get_value() -> Result<int, SomeError>{
    // 何らかの処理
}

fn notice_err() -> Result<_, SomeError>{
    let value = get_some()?; // ?で例外を呼び出し側に伝搬できる
}

問題点と対策

例外(の抽象性)を意識しなければならない

利点と相反しますが,送出する例外の型を意識する必要があります.
例えば,一つのフローの中に 2 種類の例外送出の可能性があった場合,
それらを包含したエラー型を定義しなければなりません.

enum AnyError {
    SomeError,
    OtherError
}

fn get_some() -> Result<int, SomeError> {
    // 何らかの処理
}

fn get_other() -> Result<String, OtherError> {
    // 何らかの処理
}

fn do_something() -> Result<_, AnyError> {
    let some = get_some()?;
    let other = get_other()?;
    // 何らかの処理
}

これは,値による解決で構造体の戻り値による解決を図った場合,
構造体を都度定義しなければならなかった問題と似ています.

特にエラーの型が(エラーの中に他のエラー型が定義されているような)複雑な場合,
どのようにエラーを扱うかについては明確な回答が出ていないように感じます.

タプルで見られたような,発生するエラー型をコンパイラが自動推論するアプローチが必要なのです.

この問題は,例外というものが発生と処理の段階では具体的な型であって欲しいが,
その途中経路においては抽象的であって欲しいという我が儘な要求から来ていると思われます.

Rust は未発達の言語であり.公式のエラー周りの処理は今も改善が進んでいます.
(今人気のanyhowは途中経過の抽象化を試みていますが,
 代わりに match 文を使えなくしてしまっているため,好きではありません.
 抽象化はあくまで静的な問題のままに行われるべきです)

今後の改善に期待されます.

まとめ

多値でもなく多態でもなく多相だった

ここまでの,異なるプログラミング言語における複数のアプローチをまとめると下記になります.

  • 値(多値)による方法
    • 多値型(Tuple)と if 文(もしくは switch 文)による解決.
    • 手続き言語的なアプローチ(C 言語,Go 言語, ...)
  • 多態による方法
    • 継承型(Base & Drived)と try 文による解決.
    • オブジェクト指向言語的なアプローチ(C++, Python, ...)
  • 多相による方法
    • 多相型(Result や Either)と match 文による解決.
    • 関数言語的なアプローチ(Rust, Haskell, ...)

そもそも正常なフローと例外フローは別物であり継承関係をもちません.
正常フローと例外フローを含んだ戻り値のいずれかを型で表現するなら,多相が正しかったのです.

例外処理と文との密接な関係

上のまとめを見れば,例外処理は文と密接な関係にあることがわかります.
現状,多相の例外処理は例外の漏れを自動検出するのに最も優れた方法であるように思えますが,
文と密接に関連しているが故に,過去の言語は移行が困難であるでしょう.

Result のような型を作ることは C++ や Python でも可能です.
問題はエラー処理として,既存の try 文がすでに言語に埋め込まれている点です.
もし,Python が match 文を持っていれば,次のようなプログラミングができるでしょう.

class Ok(Generic[T]):
    value: T

Result = Union[Ok[T], E]

def get_value() -> Result[int, Exception]:
    a = list(range(3))
    match a[5]:
        case Ok as ok:
            return ok.value
        case IndexError:
            return 0
        case Exception as e:
            return e

しかし,Python には既に try 文による例外機構があります,
2 つの例外機構を抱えるデメリットは大きく,match 文への部分的な移行はベストとは言えません.

新しい言語が必要なのです.最近新しいプログラミング言語の開発が活発な気がしますが,
それは関数型プログラミング言語のアイデアを踏まえた,
既存の仕組みに依存しない言語が必要と考える設計者が増えたからかもしれません.
これは静的言語であるか動的言語であるかという問題ではありません.文法の問題です.

今主流の言語は,多相の例外処理機構を持つ新しい言語に敗れてしまうかもしれないし,
上手く多相を取り込むか,多態を維持した別の優れた回答を用意するかもしれません.

楽しい時代に生まれたものです.

参考文献

17
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?