Go
golang
ネタ
例外
disり芸
Go3Day 8

「例外」がないからGo言語はイケてないとかって言ってるヤツが本当にイケてない件

 この記事は、Go3 Advent Calendar 2018 の8日目の記事です。
 7日目は @codehex さんによる「Go でアプリケーションとクライアントのミドルウェアを作成する方法知ってますか?」でした。

 本日はネタ全開でお送りいたします。

Disclaimer(免責事項)

 はじめに言い訳というか、これを書いた経緯というか。

 というツイートをいたしまして、言った手前自分でやるか、と思い立った次第です。
 なので、ネタとしてお楽しみください。

 なお、炎上した場合にも、それすらもネタとして楽しむ所存ですのでアシカラズ。

 それでは、いってみましょう。

Go言語がイケてない…だ…と……?

 Go言語はイケてない言語としてよくdisられているが、その中でも2大disられポイントがこれだ↓

  • Genericsがないm9(^Д^)プギャー
  • 「例外」がないm9( ゚,_ゝ゚)ブブッ

 前者をdisってるヤツらについてはすでに 別の人がdisっているので 、今回は後者、「例外」1 がないことをdisってるヤツらがいかにイケてないかdisることにする。

 なお、これ以降のコード例では、ライブラリのインポートや冗長なクラス宣言などは省略することにする。

事実:達人たちは「例外」を使わない

 おまいら、Googleの「Google C++ Style Guide」を読んだことはあるか?ここに日本語訳があるので、まずは読んでからdisりに来い。
 ここにあるように、GoogleではC++のコーディングで throw の使用を禁じている。それによる恩恵よりもデメリットのほうが上回ると考えているからだ。
 これはなにもGoogleに限った話ではない。LLVM Coding Standard も同様に例外機構の使用を禁じている

 V8やLLVMといった、世界で最も利用されているであろう言語処理系を開発している達人たちは、「例外」を使っていないのだ。彼らの開発した処理系が例外機構を実現しているのというのに、なんとも皮肉な話である。

いやいや、それはC++の話でしょ。C++はtry-catchの存在が考えられていなかった時代のコードとか、メモリ管理の煩雑さとかあるから、問題多いのはわかるよ。
でも、Goはそういうしがらみないじゃん。新しい言語を設計するなら、Javaみたいに「例外」をいれるのがモダンな言語としては常識なんだよ。そんなことも知らないの?

 オーケー、わかった。アンタの言っている「例外」ってのはJavaのtry-throw-catchのことなわけね?で、アンタはJavaの「例外」をどれだけ理解しているわけ?

そもそも「例外」ってなんなん?

 んじゃ一度、アンタらにとってのバイブル的な存在であるはずの『Java Language Specification』に立ち返って、「例外」とはなんだったのか考えてみようじゃまいか。

呼び出し元に異なる型の値を返せる手段としての例外

 「Chapter 11. Exceptions」では次のように書かれている:

Explicit use of throw statements provides an alternative to the old-fashioned style of handling error conditions by returning funny values, such as the integer value -1 where a negative value would not normally be expected.

※強調は筆者による

 奇妙な値(funny values)っていうのは、C言語でよく見られるこういうやつね↓

C
pid_t funny = fork();
if ( funny < 0 ) {
    // pidがマイナス値だとエラー
    fprintf(stderr, "Can't fork");
    exit(1)
} else if ( funny == 0 ) {
    // 子プロセスでの処理
} else {
    // 親プロセスでの処理
}

 これはたしかに、いろいろと問題だ。
 具体的になにがエラー原因だったのかは error 変数を参照しなければいけなかったりするし、なにより、正常時に返す値の型でエラーが表現できない場合に、どうやって呼び出し元にエラーを通知したらいいものか?

 たとえば、次のようにint配列の平均を求める関数があったとしよう:

C
int average(int* a, int n) {
    int s = 0;
    for ( size_t i=0; i < n; i++ ) s += a[i];
    return s / n;
}

 これを、次のように空配列に対して呼び出すと、

C
int main (){
    int a[] = {};
    int r = average(a, 0);
    printf("%d\n", r);
}

 コアダンプしてしまう:

[1]    939 floating point exception (core dumped)

 n == 0 の場合にはエラーを返すようにしたいが、呼び出し元にintしか返せないのに、どうやって呼び出し元に例外を知らせたらいいというのか?2 この関数は、正常な戻り値として 0 はもちろん負数だって返しうる。異常時のために使える"スキマ"はすでに int の中にはないのだ3

 こんなとき、Javaだったらこう書ける:

Java
static int average ( int[] a ) {
    if ( a.length == 0 ) {
        // 配列が空なら ArithmeticException を投げる
        throw new ArithmeticException("division by zero");
    }
    int s = 0;
    for ( int i=0; i < a.length; i++ ) s += a[i];
    return s / a.length;
}

 この関数は配列が空だったとき、

Java
public static void main(string[] args) {
    int a[] = new int{};
    try{
        System.out.println(average(a));
    } catch ( ArithmeticException e ) {
        System.err.println(e)
    }
}

 例外によってエラーを教えてくれる。しかも、その原因は ArithmeticException という人間にもわかりやすい型で教えてくれるのだ。

Every exception is represented by an instance of the class Throwable or one of its subclasses (§11.1). Such an object can be used to carry information from the point at which an exception occurs to the handler that catches it.

 メソッドの処理結果として(正常時の結果の型とは別に) Throwable を継承するオブジェクトを好きなように投げることができるので、このオブジェクトの中にエラーに関する情報などを入れれば、呼び出し元に例外についての詳細な情報も通知することができる。

 つまり、正常な場合と異常な場合とで異なる型の値を呼び出し元に返せるようにすることが、「例外」の役割の1つなわけだ。

大域脱出の手段としての例外

 また、「Chapter 11. Exceptions」では次のようにも書かれている:

During the process of throwing an exception, the Java Virtual Machine abruptly completes, one by one, any expressions, statements, method and constructor invocations, initializers, and field initialization expressions that have begun but not completed execution in the current thread. This process continues until a handler is found that indicates that it handles that particular exception by naming the class of the exception or a superclass of the class of the exception (§11.2). If no such handler is found, then the exception may be handled by one of a hierarchy of uncaught exception handlers (§11.3) - thus every effort is made to avoid letting an exception go unhandled.

※強調は筆者による

 例外が発生したその場で処理されなかったとしても、呼び出し元を遡っていって、処理してくれるハンドラ(=型が合致するcatch節)を探すことで、なんとかして例外が処理されるように努力を尽くすというようなことを言っている。

 つまり、こういうことだ:

Java
static float variance ( int[] a ) {
    int s = 0;
    int av = average(a);
    for ( int v : a ) {
        v -= av;
        s += v*v;
    }
    return s / a.length;
}

public static void main(string[] args) {
    int a[] = new int{};
    try{
        System.out.println(variance(a));
    } catch ( ArithmeticException e ) {
        System.err.println(e)
    }
}

 average を呼び出している variance では例外を処理していないが、それを呼び出している main で見事に例外が補足されて処理されている。
 ここで、average 自身の実行はもちろん、それを呼び出している variance も一気に飛び越えて、main に実行が戻ってきている。こうした入れ子になった関数呼び出しを一気に巻き戻すことを「大域脱出」と呼ぶ。

 古式ゆかしいエラーを示す値を返す方法では、経験上、戻り値が無視されることが多かったと上で書いてあった。そのため、たとえその場では無視されてしまったとしても、呼び出し元の誰かが処理してくれることを期待して、その誰かの元へと一気に脱出することが、「例外」のもうひとつの役割だと言えよう。

 実際のところ、JavaScriptのように変数の型がない動的型付け言語では、return文で任意の型の値が返せてしまう:

JavaScript
function average ( arr ) {
    if ( arr.length == 0 ) return new Error("division by zero");
    return arr.reduce((x, y) => x+y) / arr.length;
}

let r = average([]);
if ( r instanceof Error ) {
    console.log(`Something wrong!: ${r}`);
} else {
    console.log(`OK: ${r}`);
}

 そのため、動的型付け言語においては、事実上この大域脱出のみが例外機構の役割と言ってもいいだろう。
 つまり、return文で普通に返ってきたのではなく、throw文で大域脱出してきた場合には「なんか普通と違うことがあったな」と捉えるという不文律の上に、JavaScriptの「例外」は例外機構として成立している。

function average ( arr ) {
    if ( arr.length == 0 ) throw new Error("division by zero");
    return arr.reduce((x, y) => x+y) / arr.length;
}

try {
    let r = average([]);
    console.log(`OK: ${r}`);
} catch ( e ) {  // if文よりもこう書いたほうが、「なんか普段と違う」感がある
    console.log(`Something wrong!: ${e}`);
}

 もちろん、これ自体は良い約束事だ。4

 なるほど、よく考えられてるわー。

ほれみろ、やっぱり例外は叡智の結晶であってイケてる機能なのだ。そして、それがない言語はイケてないんだ!

 ちょっ!待てって。そんな結論あせんなし。

分けて議論しようじゃないか

 いわゆる「例外」が議論されるとき、上の2つが無意識にごっちゃになって議論されていることが話が混乱する原因じゃねーのかな?でも、この2つが不可分であると誰が決めたわけ?
 別々に提供されていても、例外機構としては問題ないんじゃないか?むしろ、別々になっているほうが良いこともあるんじゃね?

は?別々?そんなの例外機構じゃねーよ

 だから決めつけんなし!まずは考えてみるべ。

複数の値を返せる多値返却

 Go言語の場合、throw に頼らずとも、異なる型の値を返すことができる:

Go
func average ( a []int ) (int, error) {  // 値を2つ返す関数
    if len(a) == 0 {
        return 0, errors.New("division by zero")
    }
    s := 0
    for _, i := range a {
        s += i
    }
    return s / len(a), nil
}

func main () {
    a := []int{}
    r, err := average(a)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("OK: %d\n", r)
}

 このように、正常時の結果とエラー時の原因を表す型の値(普通は error インターフェースを実装した値)の2つを関数から返すようにして、エラーの値をチェックするように書くのがGo言語の例外処理の流儀である。
 なので、try-throw-catchなんてものがなくても、例外処理はちゃんとできるのだ。

複数の値が返せるとか、キモっw
そんな変な言語なんて誰が使ってんだよ?ww

 は?まじで言っちゃってんの?
 多値を返せる言語なんていくらでもあんべ。

 90年台にはCGIのデファクトスタンダードだったPerlだって↓

Perl5
sub average {
    my $a = shift;
    return (0, "division by zero") if @$a == 0;
    my $s = 0;
    $s += $_ foreach @$a;
    return $s / @$a, undef;
}

my ($r, $e) = average([]);
if ( defined $e ) {
    die $e;
}
print "OK: $r\n";

 機械学習の流行で今をときめくPythonだって5

Python
def average(a):
    if len(a) == 0:
        return 0, Exception("division by zero")
    s = 0
    for i in a:
        s += i
    return s / len(a), None

av, err = average([])
if err is not None:
    print err
    exit(1)
print av

 超美しい型システムで有名なHaskellさんだって↓6

Haskell
average :: [Int] -> (Int, String)
average as = if (length as) == 0
                 then (0, "division by zero")
                 else ((foldl (+) 0 as) `div` (length as), "")

main = do (r, e) <- return (average [])
          if e /= "" then putStrLn e
                     else putStrLn $ "OK: " ++ show r

 みんな多値返却できるわ。
 むしろ、引数が複数とれるのに返り値は複数かえせないことのほうがキモいんじゃ!

 多値返却がないから、「例外」とかいう大仰そうなもの持ち出してきて自分の無力さを偉そうに自慢するなんて、まじイケてねーわーw むしろ痛えわーww

で、でも、それ大域脱出のほうができてねーじゃん!

 お?じゃあ、次いっちゃう?

大域脱出がないと誰が言った?

 たしかにGo言語にはthrowもcatchもないけど、誰も大域脱出ができないなんて言ってない。
 panicdeferrecoverという機能があるんだぜ:

Go
func average ( a []int ) int {
    if len(a) == 0 {
        panic(errors.New("division by zero"))  // ここから↓のdeferまで一気に脱出する
    }
    s := 0
    for _, i := range a {
        s += i
    }
    return s / len(a)
}

func variance ( a []int ) float64 {
    s := 0.0
    av := average(a)
    for _, i := range a {
        t := (float64)(i - av)
        s += t * t
    }
    return s / (float64)(len(a))
}

func main () {
    defer func(){
        if e := recover(); e != nil {
            log.Fatal("Caught error: ", e)
        }
    }()
    a := []int{}
    r := variance(a)
    fmt.Printf("OK: %f\n", r)
}

 このように、panic を呼ぶとその呼び出し元の defer があるところにまで遡っていって、defer が見つかったらそれが実行される7。そして、defer の中では recover を使って panic が投げた値を捕捉(catch)できるというわけ。
 ね?ちゃんと大域脱出できてんしょ?
 どこの panic でどんな値が投げられてくるのかは型チェックされないから、実質的には上で書いたJavaScriptの throw と同じことが実現できてるっつーわけ。

defer とか recover とか、予約語のセンスが意味わかんねーんだけど・・・

 それ言ったら、Javaやってないヤツからしたら、throw とか catch とか意味わかんねーんですけど?
 日本語しかしゃべれねーヤツが英語わかんねーとかブーたれてんのと一緒だべ?まじイケてねーこと言ってねーで、他言語も勉強しろや。
 それにな、慣れるとこっちの書き方のほうがいいなって思える点もいろいろあんだよ!ブツクサいってねーで、慣れろ!!

そんなにtry-catch風に書きたいなら

 こういうこともできる↓

Go
package main

import (
    . "github.com/mattn/go-try/try"
    "errors"
    "fmt"
)

func average (a []int) int {
    if len(a) == 0 {
        panic(errors.New("division by zero"))  // throwの代わりにpanicで例外を投げる
    }
    s := 0
    for _, i := range a {
        s += i
    }
    return s / len(a)
}

func main() {
    Try(func() {
        av := average([]int{})  // 空配列を渡しているので、
        fmt.Println(av)         // この行は実行されない
    }).Catch(func(e error) {
        fmt.Println(e)          // averageからここに飛んでくる
    })
}

 Go言語なら、その気になればtry-catch風の書き方をライブラリとして実装できるというわけだ。
 ただし、これは推奨された書き方ではない8

なぜ Go は「大域脱出」を例外処理として採用しなかったのか?

そんなんあるなら、じゃあなんで標準ライブラリで if f, err := os.Open(...); err != nil { ... } とかやってんだよ?全部 panic 使ってJavaっぽくすりゃ良かったじゃん?

 あー、それな。
 それについては、Dave Cheneyさんが歴史をおって解説してくれている
 詳しくは全文を読んでほしいけど、一部を引用しておこう:

Java exceptions ceased to be exceptional at all, they became commonplace. They are used from everything from the benign to the catastrophic, differentiating between the severity of exceptions falls to the caller of the function.

 いろんなことに例外を使った結果として、「Javaの例外はまったく例外的なものではなくなってしまった」と。そして、どの「例外」がどれくらい深刻なものなのか(つまり、正常な状態に復帰させるべき例外はどれで、すぐさまシステムを停止させるべき例外はどれか)を見分けるのは(例外を起こした側ではなくて)関数の呼び出し側に丸投げされることになってしまった。

 極めつけは、

because every exception Java extends java.Exception any piece of code can catch it, even if it makes little sense to do so, leading to patterns like

catch (e Exception) { // ignore } 9

 こういうパターンがまかり通るようになってしまった。

 このパターンがどんなに理に適っていないとしても、また、わかったつもりで使っているのだとしても、利用しているメソッドがどんなExceptionを投げてくるのか完全にはわからない以上、知らないうちに重要な例外を握りつぶしてしまうということが起こる。
 自分でも気づかないうちに例外を握りつぶしてしまうことは、システムを予測不可能な状態にしてしまうし、もちろんデバッグも困難にさせる。

 Java Language Specification の「Chapter 11. Exceptions」には次のようにも書かれている:

Experience shows that too often such funny values are ignored or not checked for by callers, leading to programs that are not robust, exhibit undesirable behavior, or both.

 例外が無視されて誰にも処理されずに終わってしまうことのないよう、なるべく誰かに catch されるようにした結果、ある意味で catch しすぎてしまったとも言える。その結果、robustなプログラムを書くための弊害となってしまったのであれば、なんとも皮肉な話である。

そ、そんな Exception を catch するだけして無視しちゃうような乱暴なコード、誰も書かないし・・・

 そうな、アンタはそうなのかもな。
 でも、もしウソだと思うんなら、GitHubを見てから 語り合おうや。

 一方でGoでは、例外は呼び出し元が責任をもって処理をするべきという考えを持っている。言い換えれば、システムが "例外的な状態" におちいったときには呼び出し元が "正常な状態" へと戻すことが期待されている。そして、"正常な状態" へと戻すことが期待されないような状態におちいったときには panic を使うべき という約束の上に、Goの例外処理は成り立っている。
 本来あってはならないような状態に陥ってしまった場合には、その場を取り繕うようなことはせず、さっさとプログラムを停止して、コードを修正すべきだという考えに立脚しているのだ。
 プログラムを早く修正できるようにするため、なるべく早期にプログラムを停止し、異常な状態におちいった箇所のなるべく近くで状態を調べ上げレポートするべきという信条にもとづいているのである。(これは Fail-fast の考え方である)

 それから、並行処理を書くにはthrow-try-catchは適さないって話 もある。
 もともとGoの設計ゴールのひとつは、 並行処理を安全に効率よく書けるプログラミング言語 となることだった。そのため、並行処理と相性の悪い try-catch 方式は、Goの推奨されたやり方にはなれなかったのだ。まぁ、詳しくは読んどいてくれや。

それ、本当に型安全だと思ってんの?

ちょぉっと待っっったーーー!!

 む、新手か!?

さっきから黙って聞いていれば、メソッドがどんな Exception を投げてくるかわからないだと?どうやらキサマは検査例外のことも知らんようだな?
Java言語仕様には 検査例外(Checked exceptions) という極めて洗練された静的型チェックの仕組みがあるということを!!!

たとえば、一見正しそうなこのJavaプログラム、

Java
import java.io.*;

class Main {
    public static void main(String[] args) {
        new File("tempfile").createNewFile();
    }
}

これをコンパイルしようとすると、ほれ、このとおりエラーとなる:

5: error: unreported exception IOException; must be caught or declared to be thrown
        new File("tempfile").createNewFile();
                                          ^
1 error

エラーメッセージが示しているように、createNewFileIOException を投げるメソッドであり、呼び出し元がそれを catch していないことをコンパイラが検知してくれるのだ。
なぜJavaではこのようなことが可能なのかというと、Javaでは各メソッドが自らが投げる可能性のある例外を型情報として持っているのだ。たとえば、createNewFile のシグネチャはこのとおり

public boolean createNewFile()
                      throws IOException

自ら IOException を投げると宣言している。
こういった例外を投げるメソッドを使っているプログラムのコンパイルを通すには、このように例外をちゃんと catch するか、

Java
public static void main(String[ ] args) {
    try{
        new File("tempfile").createNewFile();
    } catch (IOException e) {
        System.err.println(e);
    }
}

もしくは、自分の呼び出し元が正しく catch して処理できるように、自らもまた IOException を投げることを宣言しなくてはならない:

Java
public static void main(String[ ] args) throws IOException {
    new File("tempfile").createNewFile();
}

従って、どんな例外が投げられるのかわかっているため、"知らずに重要な例外を握りつぶす" などという妄挙は起こり得ぬのだ!
見たか、Java言語の型システムの素晴らしさをぉぉぉ!!

(ちっ、めんどくさいヤツ来たな…)

 もちろん、俺だって検査例外のことを知らないわけじゃない。
 Dave Cheneyさんも書いているように、検査例外はC++の「例外」の問題点をうまく克服しているいいアイデアだって、最初はみんな思ってた。新しいものが発明されるときってのは、いつだってそうさ。
 でも、歴史がいつだってそう証明してくれているように、現実はそんなにうまくはいかなかった。

 例として、入力としてユーザー名とパスワードを受け取って認証結果のtrue/falseを返すメソッドについて考えてみよう。
 パスワードを照合する実装としては、/etc/passwd のようなファイルを使う場合や、LDAPのようなデータベースに問い合わせる場合などいろいろ考えられるから、実装が切り替えられるように抽象化しておきたい。
 これは Authenticator という次のようなインターフェースで表せるだろう:

Java
interface Authenticator {
    boolean authenticate(String name, String password);
}

 そして、これを実装する FileAuthenticator クラスはこう書けそうだ:

Java
class FileAuthenticator implements Authenticator {
    @Override
    public boolean authenticate(String name, String password) throws FileNotFoundException {
        File f = new File("/etc/passwd");
        FileReader r = new FileReader(f);
        ...()
    }
}

 でもこれ、コンパイル通らないんだぜ:

error: authenticate(String,String) in FileAuthenticator cannot implement authenticate(String,String) in Authenticator
        public boolean authenticate(String name, String password) throws FileNotFoundException {
                       ^
  overridden method does not throw FileNotFoundException
1 error

 なんで怒られてるかっつーと、「オーバーライド元のメソッド(インターフェースの宣言)が FileNotFoundException を投げるって宣言してないのに勝手に投げるな」って言われてるわけ。

それはそうだろう。呼び出し元は Authenticator を使うとしか思っていないのだから、Authenticator が投げないと言っている例外を勝手に投げられては、検査例外のコンパイル時チェックが働かなくなってしまうではないか。仕様にそった正しい動作である。

 じゃあ、呼び出し元にファイルが開けなかったってどうやって知らせるわけ?

例外を投げる必要があるのであれば、インターフェースでそれを明示するべきである。つまり、こう書くべきである:

Java
interface Authenticator {
    boolean authenticate(String name, String password) throws FileNotFoundException;
}

 だよね。そうなるよね。
 じゃあ、これに加えてLDAPを使った実装を作ろうとすると LdapException が投げられる可能性もあるんだけど、どうすんの?

こ、こう書くのである…

Java
interface Authenticator {
    boolean authenticate(String name, String password) throws FileNotFoundException, LdapException;
}

 あとさ、MySQLに問い合わせる実装も追加するかもしんないんだけど・・・・それ、毎回インターフェース宣言書き換えなくちゃいけないの?
 っつーか、実装を抽象化してるはずのインターフェースが、FileNotFoundException だったり LdapException だったりって実装固有の情報を持っちゃってるって、抽象化としてどうなの?

ぐぬぬ・・・・・・

 というように、検査例外は実装の詳細を不用意にさらけ出しているという批判は昔からある問題だ。

 実装元のインターフェースが書き換え可能な場合はまだマシ10で、実装しなくちゃいけないインターフェースがサードパーティのフレームワークのものだったりすると、書き換えて対応することすらできない。

 こういうことはよくあるわけで、実際にはどうすりゃいいの?っていうと、こういうワークアラウンドがよく知られている:

Java
class FileAuthenticator implements Authenticator {
    @Override
    public boolean authenticate(String name, String password) {
        try {
            File f = new File("/etc/passwd");
            FileReader r = new FileReader(f);
            ...()
        } catch (FileNotFoundException e) {
            throw new RuntimeException(e);
        }
    }
}

 例外を RuntimeException でラップして投げ直してしまえばいい。

 この RuntimeException は非検査例外って呼ばれてるものの一種で、コイツとコイツのサブクラスはコンパイル時のチェックを受けない11。なので、authenticate メソッドのシグネチャでも宣言しなくていい。
 もし RuntimeException で例外を隠してしまわないようにしたいというのなら、プログラムを大きく書き換えなくてはいけない。多くの人はそんなことしたくないから、結果として、こういうワークアラウンドが蔓延することになるわけだ。

 というわけで、さっきの「メソッドがどんな例外を投げるのかはわかっている」というのはウソで、メソッドシグネチャを見ただけでは、どんな RuntimeException が飛んでくるのかはわからない。
 しかもタチの悪いことに、みんな大好きヌルポ(NullPointerException) がこの RuntimeException のサブクラスだったりする12

 なのでさっきの例を使って、

Java
// ボクは FileNotFoundException と LdapException を投げることがあるから気をつけてね!
interface Authenticator {
    boolean authenticate(String name, String password) throws FileNotFoundException, LdapException;
}

public class Main {
    // 実はバグってて、ヌルポを投げることがある実装↓
    private static Authenticator auth = new MyAuthenticator();

    public static void main(String[] args) {
        try{
            boolean ok = auth.authenticate(args[0)], args[1]);
            if ( ok ) {
                System.out.println("OK");
                System.exit(0);
            }
        } catch (Exception e) {
            // FileNotFoundException か LdapException が投げられるかもしれない
            // けど、どちらの場合も失敗として無視すればいいや
        }
        System.out.println("NG");
        System.exit(1);
    }
}

 とかやって、NullPointerException も握りつぶしてしまって、なかなかバグに気づけないということが起こるわけだ。

 検査例外が完全に間違ったアイデアだったとまでは言わない13。でも、検査例外はひとつの問題を解決すると同時に、別の問題を作り出してしまったんだ。つまり、トレードオフがある。しかし、そのトレードオフに表面上は気づきにくいがゆえに、罪が深い。
 Javaよりもあとに設計された言語である C# や、Javaの後継とすら言われることのある Scala が検査例外を採用しなかった14のには、それなりの理由があるってわけだ。

 Goも同じだ。Java後の歴史をよく反省した上で、抜本的な解決策として大域脱出を「例外」として使わない道を選んだんだ15
 Goに try-throw-catch がないことで、まるで Java よりもすごい退化した言語のようにdisるヤツらがいるが、そういうおまいらこそが、Java後の世界から何も学ばず進化できてないんだってこと、そろそろ自覚しねーと、まじでやべぇぞ。

ある関数言語話者の感想

ずーっと横で聞いてたんだけどさ、

 うおっ!?また新手かよ!

キミたち、大域脱出くらいでよくもまぁ、そんなに熱くなれるよね。
大域脱出なんて、言語で直接サポートしなくても、ライブラリとして提供すればよくない?こんなかんじで↓

Scheme
(require 'macro) ; Required to use this with SCM. Other Scheme implementations
                 ; may not require this, or it may even be an error.
(define *cont-stack* '())

(define (push-catch cont)
  (set! *cont-stack* (cons cont *cont-stack*)))

(define (pop-catch)
  (let ((retval (car *cont-stack*)))
    (set! *cont-stack* (cdr *cont-stack*))
    retval))

(define (throw exn)
  (if (null? *cont-stack*)
      (error "Can't use throw outside of a try form")
      ((car *cont-stack*) exn)))

(define-syntax try
  (syntax-rules (catch)
    ((try (body ...)
      catch exception (catch-body ...))
     (call-with-current-continuation
      (lambda (escape)
    (dynamic-wind
        (lambda ()
          (let ((exception
             (call-with-current-continuation
              (lambda (cc)
            (push-catch cc)
            #f))))
        (if exception
            (begin catch-body ... (escape)))))
        (lambda ()
          (begin body ...))
        (lambda () (pop-catch))))))))
> (try ((display "one\n")
        (throw "some-error\n")
        (display "two\n"))
   catch err ((display err)))
one
some-error

(※SCMで実行してください)

あれ?もしかしてキミたちの言語、継続が第一級の計算対象になってないの?
それやばくない?例外がどうとか言ってる前に、そっちのほうがまじでイケてなくない?

 ・・・・・・・。
 おあとがよろしいようで。

References


  1. 文中で「例外」とあえてカッコ書きにしているのは、暗にtry-throw-catchとその類型のことを指しています。本来的には例外とは処理の結果が通常どおりにはいかなかったことを指すので、それをreturnで表そうがthrowで表そうが構わないはずです。しかし、try-throw-catchがないことを指して「例外がない」と揶揄されることが多いので、「例外」とカッコ書きで使うことにしました。 

  2. 「そんなときのためにC言語には longjump という機能があってじゃな…」というC言語賢者のあなたは、下の大域脱出の話まで読み飛ばしてください。 

  3. intにはスキマがなくても、floatだったら NAN (not a number) というスキマがある。割り算は小数が発生しうるのに、ここでの例をfloatではなくあえてintにしているのはこのため。まあ、説明のためであって、実用プログラムではないのでご理解ください。 

  4. 裏を返せば、JavaScriptのtry-throw-catchのようなものを例外機構と認めている人にとっては「例外機構=大域脱出」でしかないということを示しています。勝手な印象ですが、「例外」がないことをdisっている人の9割は大域脱出の部分しか意識していないように思います。 

  5. これは多値というより、タプルという1つの値なのでは?という意見もありましょうが、細かいことは置いといてください。 

  6. これも多値というよりタプルなんですが、細かいことは(ry また、Haskellならこういう場合はMaybe なり Either なりを使うべきってご意見もあるでしょうが、モナドの説明をするにはこの余白はあまりにも狭す(ry 

  7. 正確にいうと、defer が実行されるのは panic が呼ばれたときだけではありません。defer が書かれた関数から抜けるときに必ず実行されます。try-finally を知っている人にとっては、finally節と同じだと思えばいいでしょう。Goでは finally のなかで catch に相当する処理を行うわけですね。 

  8. 念のため、mattnさんのライブラリが良くないというわけではなく、Go言語の流儀ではないということです。 

  9. このコード片は引用元の記事からそのまま転載しています。懸命なJavarista16諸氏はお気づきでしょうが、これは正しいJavaコードではありません。正しくはこう書くべきでしょう: catch (Exception e) { /* ignore */ } 

  10. これをマシと言ってしまっていいのかは、意見がわかれるところでしょうね。 

  11. 補足すると、Error も非検査例外の一種であり、Javaの型階層の中ではこの Error あるいは RuntimeException とそれらのサブクラスのみが非検査例外であり、それ以外のすべての Throwable が検査例外と決められています。 

  12. あるいは NullPointerExceptionArrayIndexOutOfBoundsException といった如何にもコーディングのミスで起きてしまいそうな例外が RuntimeException ではなく Error のサブクラスだったとしたら、Java の例外処理はもう少し平和な世界になっていたのかもしれない・・・・・・と起こり得なかった世界線について思いを馳せることもあります。 

  13. 実際、近年設計された言語であるSwiftはJavaの検査例外に似た機能を持っています。ただし、投げられる値の型は書かないという大きな違いがあります。これは「失敗する可能性のある計算とそれ以外 だけ は区別できるようにしよう」という試みで、実質的にはHaskellのMaybeと同じコンセプトと考えられます。投げられる値の型を1つだけ指定できるようにしようという議論もされているらしいですが、その場合にもやはりHaskellのEitherに相当するものと言えます。Javaの失敗をよく踏まえて設計されていると感じます。 

  14. なお、JavaからScalaのメソッドを呼び出したときの互換性を確保するために、@throws アノテーションを使って例外を投げることをJavaの呼び出し元に知らせることはできる。でも、アノテーションをつけた場合でも、やっぱりScalaの世界ではコンパイル時チェックされません。 

  15. この選択そのものに対する意義はあってもいいと思っています。が、意義をとなえるなら、それに代わるベターな対案を提示するべきでしょう。その意味では、Haskell や Scala の Either を使う例外処理のようなコンポーザブルな手法と比較するのは有意義でしょう。 

  16. http://www.nilab.info/z3/20120708_04.html