さかなソフトブログ

プログラミングやソフトウェア開発に関する情報

プログラミング

例外について複数の言語の特徴を見ながら今一度考察してみる

更新日:

Swiftに以下の様な感じで例外を扱うコードを見かけました:

enum ApplicationError : Error {
    case fail(String)
}

func test() throws {
    throw ApplicationError.fail("something")
}

え?Swiftってthrows定義書くの!?後発の言語じゃ見かけなくてJavaの悪しき風習だと思ってたのに・・・

ということで、Swiftでは使わない訳にもいかないのでどう付き合って行くかも考えながら業務経験のある言語を中心に例外の仕組みを比較しながら考察していきたいと思います。

目次

エラーの種類

ソフトウェアで云うエラーとは、システムが要求を満たすことが出来ない事を指します。エラーが発生する要因にはいくつかの種類があります。この記事の便宜上それぞれにエラー呼称もつけておきます:

  1. ユーザー操作などで起こりうるシステム動作前に想定可能なエラー (業務エラー)
  2. 本来起こってはいけないシステム内のロジックミスによるエラー (ロジックエラー)
  3. ハード異常など、システム動作前では対処不可能なエラー (異常系エラー)

3つのエラーをそれぞれ具体的に見ていきます。

ユーザー操作などで起こりうるシステム動作前に想定可能なエラー

主にユーザー操作起因で発生する以下の様なエラーです:

  • ユーザーが正しい入力を行わなかった
  • ファイルが無いパスを指定して開こうとした

システムが要求を満たす為の事前条件を満たしていない為に起こるエラーで、システムとしてはユーザーにメッセージを表示するなどのエラーハンドリングを行う必要があります。

本来起こってはいけないシステム内のロジックミスによるエラー

システムの役割として想定していない動作をしたり異常状態になる、バグに依るエラーのことを云います。以下の様なものです:

  • 0で割った
  • Nullを呼び出した・無いメソッドを呼んだ
  • ファイルが無いとメッセージを出すべきなのに表示しようとして変な表示になった

ソフトウェア起因のエラーとなり、ユーザーが同一条件で操作を行うと同じ現象を引き起こします。システムとしてはバグが解消するようにプログラムを修正する必要があります。

ハード異常など、システム動作前では対処不可能なエラー

システムが動作するのに必要な条件を満たさない環境下で動作させた場合に発生する以下の様なエラーのことです:

  • システムが推奨するメモリ容量が用意されていなく、オーバーフローした
  • ファイルがストレージ異常で書き込めなかった

ソフトウェアでは解消出来ないので、故障やスペック不足ならハード交換するなどシステムを正常に動作させる条件を満たす環境に改善する必要があります。

例外とは

プログラムが何らかのエラーを検知したときに処理を呼び出し元に差し戻す仕組みのことを指し、ExceptionやErrorを投げる(throw)/上げる(raise)などと構文として表現することが多いです。

今回の主題は、この例外の使われ方が言語によってかなりばらつきがあり、それが元凶になって正しい使われ方がされていなかったり、時にはその間違った使い方によってバグを引き起こす原因となったりしているので、各言語の例外の仕組みを調査・比較してみます。

検査例外と非検査例外

例外には構文的に明示的に記述する必要があるかによって検査例外非検査例外に分かれます。

  • 検査例外
    • システムが事前に想定出来る業務エラーを例外として扱う
    • throwsのようにエラーハンドリングが必要な旨を構文で記述できる
    • 例外が発生するメソッド呼び出し側でcatchしてエラーハンドリングを記述しないとコンパイルエラーとなる
    • Java、Swiftで採用 (他にあったら教えてください)
  • 非検査例外
    • システムが事前に想定出来ないエラーを例外として扱う
    • 構文として記述しなくてもコンパイル可能
    • Java、Swift以外で採用。Ruby / C++ / C# / JavaScript / Kotlin 等

Go言語は例外を採用していませんpanic構文が用意されていて検査例外を絶対に使わせないという気持ちが表れているように感じます。

以降、各言語の例外の構文を見ながら考察していきます。

各言語の例外構文調査

検査例外採用言語

Java

Javaにはthrowできるものに以下の継承ツリーがあります:

  • Throwable
    • Error
    • Exception
      • RuntimeException

ErrorはOutOfMemoryErrorやStackOverflowError等異常系エラーで非検査例外RuntimeExceptionはNullPointerExceptionやArrayIndexOutOfBoundsException等ロジックエラーで非検査例外、IOExceptionやSQLException等、その他のException業務エラーで検査例外として利用します。

BufferedReader bufferedReader = new BufferedReader( new InputStreamReader(System.in) );

try {
    String string = bufferedReader.readLine();
} catch (IOException exception) {
    System.out.println("IOException");
}

このコードではbufferedReader.readLine()呼び出し時にIOExceptionが発生する可能性がある為、try-catch節で囲まないと検査例外なのでコンパイルエラーとなります。このように検査例外はエラーハンドリングを強制します。

Javaの場合、エラーハンドリングを呼び出しメソッド内でハンドリングしきれない例外は再度throwsする必要があります:

void method() throws FileNotFoundException {
    FileReader fileReader = new FileReader("file.txt");
}

一番やってはいけないのは例外を握り潰すことで、例外が分かっていない、若しくは複雑な場合、検査例外だけどエラーに興味が無い場合に使われてしまわれることがあります:

try {
    String string = bufferedReader.readLine();
} catch (IOException exception) {
    // 何もしない
}

エラーに興味無い場合の対処法は難しいですが、文脈上その例外が発生することはあり得ないと断言できる場合はRuntimeExceptionを再throwするか、せめてログを出しておく等の対応が必要です。

Swift

冒頭のコードサンプルの通り、検査例外があってしまいます:

enum ApplicationError : Error {
    case fail(String)
}

func test() throws {
    throw ApplicationError.fail("Application Error")
}

do {
    try test()
} catch ApplicationError.fail(let message) {
    print(message)
}

try? test() // 実行されて継続。戻り値はnil
try! test() // Fatal errorでクラッシュする

流石に後発なので、Javaとは違い、検査例外の煩わしさを軽減する仕組みが用意されています。まず、throwsで型は明記は出来ません。また、try / try? / try! が特徴的で、tryは例外が発生するメソッドがどれかを明確にし、try?は例外発生時はnilが返り、エラー詳細など細かいことは良いからとにかくエラーとなったことだけハンドリングしたい場合に、try!はロジック上起こりえず、例外が発生するのはバグだと判断出来る場合に防御的プログラミングとしてAssertionのように扱う事が出来ます。

いずれにせよ、プログラミングを開始した初期段階から検査例外が発生する場合はどのようにハンドリングするかを設計する必要があります。

検査例外非採用言語

Ruby

動的言語なので非検査例外。Rubyは更にSwiftで云うところのtry?にあたる、例外を発生させるよりもnil or 0を戻り値に返すことが多いです:

> nil.test
NoMethodError: private method 'test' called for nil:NilClass
> nil.to_i # nilを整数化
=> 0
> [1,2][2]
=> nil

C++

非検査例外のみサポートしていますが、C++はそもそもGCが無いのでthrowするとメモリリークを起こしやすいことやfinallyが無いといった仕組みなのであまり積極的に例外を使用しようという方向性にはなりにくいようです。

C#

非検査例外のみのJava構文の様でtry 〜 catch 〜 finally節もあり特に使いづらい箇所も無く実用的です。実際のプロジェクトでは.NET Frameworkで提供されている標準の例外一覧よりとりあえず実装してないときはNotImplementedExceptionを、I/Fは予約で定義しておきたい場合にはNotSupportedExceptionをthrowして実装せずに気づかずにリリースしちゃったり、まだサポートする予定じゃ無いのに呼び出してしまったりしてしまわないようにして活用しています:

public void MethodA() {
     throw new NotImplementedException();
}

public void MethodB() {
     throw new NotSupportedException();
}

JavaScript

JavaScriptの例外については過去の記事「async / await / Promiseにおけるエラー(例外とreject)の扱いについて考察」があるので詳しくはそちらを参考にしてください。

nullアクセスや無い関数を呼ぶ等は例外が出ますが、数値での通常あり得ない操作や文字列からの数値変換では例外は殆ど出ない様です:

> 1/0.0
Infinity
> 0/0
NaN
> parseInt("a")
NaN
> NaN.test
undefined
> null.test
VM376:1 Uncaught TypeError: Cannot read property 'test' of null

Kotlin

非検査例外のみ。例外の種類はJavaとよく似ているが、try - catchは評価値を返すことが出来るので余りストレスを感じずに使用出来るようになっています:

val a: Int? = try {
    Integer.parseInt(intString)
} catch (e: NumberFormatException) {
    null
}

Golang

Exceptionという言葉を捨ててpanicにして通常のエラーハンドリングには使わないんですよというのを言語的に表現してて例外まわりの仕組みではGoが個人的には一番好み。エラーは複数戻り値にErrorインターフェースで返すよう公式で使い方を推奨しています。以下はHttpclientをurl指定でbodyをすぐに取り出せるようにした簡易ラッパーの例です:

client.go

package http

import (
	"io/ioutil"
	"net/http"
	"time"
)

type Client struct {
	httpClient http.Client
	Response   *http.Response
}

func NewClient() (client *Client, error error) {
	client = new(Client)
	timeout := time.Duration(5 * time.Second)
	client.httpClient = http.Client{
		Timeout: timeout,
	}
	return
}

func (client *Client) Get(url string) (body string, error error) {
	client.Response, error = client.httpClient.Get(url)
	if error != nil {
		return
	}
	bodyBytes, error := ioutil.ReadAll(client.Response.Body)
	defer client.Response.Body.Close()
	if error == nil {
		body = string(bodyBytes)
	}
	return
}

呼び出し側でのエラーは複数戻り値で受け取ります:

get.go

package main

import (
	"fmt"
	"github.com/toshi3221/http"
)

func main() {
	client, _ := http.NewClient()
	body, error := client.Get("https://example.com/")

	if error != nil {
		fmt.Printf("not found.")
		return
	}

	fmt.Printf("body:\n\n%s\n", body)
}

サンプルコード内でも使っていますが、第2戻り値でエラーを返すのがGolangの風習となっていて、呼び出し側では興味の無いエラーは_で受け取りをスキップすることも可能です。

例外の扱い方に対する提案

以上、複数の言語の例外の特徴を見てきました。以下は例外の扱い方に対する個人的な提案です:

  • 業務エラーハンドリングの為に言語に仕組みがあろうが無かろうが検査例外を使うのをやめた方が良い
    • 検査例外を使うと後でコードを参照した場合に、エラーハンドリングすべきなのかコードを修正すべきなのか判断するのは至難の業です
    • ライブラリなど呼び出し側で検査例外を強制してくる場合、無責任にthrowsを使わず必ずcatch(Swiftならtry/try?/try!)してハンドリングしましょう
      • ロジック上発生があり得ないならRuntimeExceptionをthrow
      • 業務エラーかつエラー内容にあまり興味ないならnullを返すなどしてハンドリング
  • 例外がシステムで発生することはバグがあるか、動作環境に異常があるように設計する
    • 非検査例外をcatchしない
      • 但し、アプリケーションをUncaughtExceptionでクラッシュさせる事は本意では無いので予期せぬエラーが発生したメッセージ表示やクラッシュレポートなどでハンドリングしておく必要がある
    • 業務エラーでの例外の発生を排除出来た後、非検査例外をバグ検知・異常検知のために活用する
      • ロジック上あり合えない条件にはAssertion(RuntimeException)を使う
      • 未実装部分にはNotImplementedException
      • 未サポート部分にはNotSupportedException

上手に例外を活用しましょう

以上が個人的な見解による例外の活用の仕方の提案ですが、既にコードがある一定のルールの元に例外が使用されている場合に無理に適用を薦めるものでは無いです。重要なのはエラーハンドリングの方法、例外の活用方法が明確になっていて統一感があることです。

例外の概念は複雑になりがちなエラーまわりのコードを改善する為の仕組みだと思うので、仕組みを理解して上手に活用出来るようになれると良いですね。自分もそうなれるように学んでいきたいと思います。

正方形336

正方形336

-プログラミング
-, , , , , , ,

Copyright© さかなソフトブログ , 2024 All Rights Reserved.