Bugs and Errors

Eloquent JavaScript Chapter 8 の読書ノート。

バグは思考の混乱によるものと、思考をコードに変換する際のミスによるものとに分類できる。一般的に、前者は後者に比べて診断や修正が難しいとされている。

Language

  • JavaScript のゆるさがプログラムに対する間違い探しの邪魔になる。

  • 構文エラーの類はエラー報告が得られる。

  • 無意味な計算をすると、単に NaNundefined を生成するものの、プログラムの実行は続くことになる。

  • プログラムの出力がおかしいことを見て初めて問題があることに気がつく。こういう場合に問題の原因を突き止めるのは難しい。

  • プログラムの中のミス・バグを発見するプロセスをデバッグと呼ぶ。

Strict mode

JavaScript は厳格モードを有効にすることで、少しだけ厳しくできる。厳格モードをオンにするには、ファイルや関数の先頭に文字列で use strict と記述する。

function canYouSpotTheProblem() {
    "use strict";
    for (counter = 0; counter < 10; counter++) {
        console.log("Happy happy");
    }
}
canYouSpotTheProblem(); // ReferenceError: counter is not defined
  • 非厳密モードならば、JavaScript は暗黙的に counter を大域スコープに作成してそれを使う。これが厳密モードならば ReferenceError を報告する。これがとても役に立つ。

  • ただし、すでに変数 counter が大域スコープに存在する場合には、適格なコードであるから厳密モードは機能しない。

  • 厳密モードには、メソッドとして呼び出されない関数では変数 thisundefined になるという変更点もある。

    • 厳密モード以外でのそのような呼び出しは、this は大域スコープのオブジェクトを参照する。そのオブジェクトとは、プロパティーが大域変数であるようなものだ。

    • 結果的に、厳密モードで誤ってメソッドを呼び出すと、this から何かを読むとすぐにエラーとなる。大域スコープに何かを作ることはない。

次の二つの違いをよく憶えておくこと:

function Person(name) { this.name = name; }.
let ferdinand = Person("Ferdinand"); // oops
console.log(name); // "Ferdinand"
"use strict";
function Person(name) { this.name = name; }.
let ferdinand = Person("Ferdinand"); // TypeError: Cannot set property 'name' of undefined
  • もっとも、クラス記法で作成されたコンストラクターは new を伴わずに呼び出された場合には必ず文句を言うので非厳密モードでも問題は少なくなる。

  • 厳密モードでは関数に同じ名前のパラメータを複数与えることは禁止される。

  • with 文などの問題のある言語機能を完全に削除する。

Types

  • JavaScript はプログラムを実際に実行するときしか型を考慮しない。あまつさえ値を期待される型に暗黙的に変換しようとする。それゆえ JavaScript では型はほとんど役に立たないと思ったほうがいい。

  • 型がわかっていれば、計算機がプログラマーに代わって実行前に間違いをチェックすることができる。

  • JavaScript には型をチェックする機能を備えた方言がある。有力なのは TypeScript という言語だ。型付け言語が好みならば試すといい。

本書では、この後も型付けされていない JavaScript コードを使用していく。

Testing

  • プログラマーが手作業で何度もプログラムを実行して動作確認をすることは煩わしいだけでなく、効果がないことが多い。変更を加えるたびにすべてを徹底的にテストするには時間がかかりすぎる。

  • 自動テストとは、他のプログラムをテストするプログラムを書くことだ

    • テストを書くことは、手動でテストするよりも少し手間がかかりるが、一度やってしまえば数秒で対象のプログラムがすべての状況で適切に動作するかどうかを確認できる。

    • プログラムを変更したときに何かを壊したとしても、後で不具合が偶発的に起こる前に気づくことができる。

  • テストは、コードの特定の性質を検証する小さなラベル付きのプログラムの形式をとる。

  • 本書のテストコードは他の言語で見かけるものとはかなり異なっているように見える。

  • JavaScript でもテストスイートの構築と実行を支援するソフトウェアがある。

  • テストしやすいコードとそうでないコードがある。

    • 一般的に、コードが外部のオブジェクトとやりとりすればするほど、テストするためのコンテキストを設定するのが難しくなる。

    • 前章で示したような自己完結型のプログラムはテストしやすい。

Debugging

プログラムがおかしな挙動をしたり、エラーが発生したりすることで何かおかしいと気付いたら、次にやることはその問題が何であるかを知ることだ。

  • エラーメッセージが表示されるようなものは特定の行が示されるので問題が明らかでになることが多い。

  • 誤動作しているプログラムのコードを偶発的に変更して修正されているかどうかを確認するのはダメだ。何が起こっているかを分析し、それがなぜ起こるのかを理論に基づいて考えるのだ。そのような理論がまだないならば、理論に至るために観察を追加する。

  • 誤動作の原因を突き止める方法は色々ある。

    • ループの急所に console.log 呼び出しを一時的に埋め込む。

    • ブラウザーのデバッガー機能を用いる。ウォッチ式やブレイクポイントを併用するなど。

デバッガーの使い方は真剣に習得したほうがいい。Chrome DevTools のそれはよく出来ている。

Error propagation

関数が処理に失敗したときにエラーを表す何かを返す方法には欠点がある。

  • 関数があらゆる種類の値を返せるようなものである場合、成功と失敗を区別できるように、結果をオブジェクトでラップするようなことをしなければならなくなる。これは使いにくい。

  • そもそも、返り値をチェックしなければならないことが厄介なのだ。

Exceptions

JavaScript にも他の高級言語のような例外処理の機構が備わっている。

  • 例外を発生させることは、関数からの超強力なリターンのようなものだ。現在の関数だけでなく、現在の関数を開始した最初の呼び出しに至るまで、その呼び出し元から飛び出す。これを「スタックの巻き戻し」という。

  • もし例外が常にスタックの一番下まで飛び出すのならば、あまり意味がない。

  • 例外の威力は、スタックに沿って「障害物」を設定する (catch) ことができるという事実にある。例外を捕捉したら、その例外を使って問題を解決した後、プログラムを続行することができる。

  • キーワード throw は例外を発生させるために使用する。

  • 例外を捕捉するには、コードの一部を try ブロックで囲み、その後に catch ブロックを記述する。

    • try ブロック内のコードで例外が発生すると、catch ブロックが評価され、括弧内の名前と例外の値が結び付けられて評価される。その後 catch ブロックが終了するか、あるいは try ブロックが問題なく終了した場合はプログラムはこれらのブロックの次に進む。

  • Error は JavaScript の標準的な例外コンストラクターで、プロパティー message を持つオブジェクトを作成する。ほとんどの JavaScript 環境では、このコンストラクターのインスタンスは、例外が作成されたときに存在していたコールスタックに関する情報、いわゆるスタックトレースも収集する。プロパティー stack に格納される。問題が発生した関数と、失敗した呼び出しを行った関数がわかる。

Cleaning up after exceptions

例外の送出は、通常では実行されるはずだった文をそうでなくするという性質がある。例外が送出されてもされなくとも実行するべき文がある場合には finally ブロックを設けることでこれを遂行する。

  • 構文だけは Java の例外機構と同じようだ。

  • finally ブロックでは、獲得しておいた資源の解放をするのが定石だ。

Selective catching

  • プログラムが処理しない例外は環境が処理する。

    • ブラウザーでは JavaScript コンソールにエラーの内容が出力される。

    • Node.js ではさらにプロセス全体を中止する。

  • プログラマーのミスによる例外の場合、エラーをそのままにしておくことが最善の方法であることが多い。プログラムが壊れていることを知らせる合理的な方法だ。

  • JavaScript は、例外を選択的に捕捉するための直接的なサポートを提供していない。

    • 他の言語のように例外クラスが階層的にできないことが理由と思われる。

    • catch ブロックで受け取った例外オブジェクトをよく見ないと何であるかが不明のままだ。

  • 一般的なルールとして、例外をどこかに「ルーティング」する目的でない限り、例外を包括的に捕捉してはならない。

  • 特定の種類の例外を捕捉するには、catch ブロックで受け取った例外が目的のものかどうかをチェックして、そうでない場合は投げ直す。

教科書のコードは次のものだが、どうも演算子 instanceof に頼るような方法しかないようだ。

class InputError extends Error {}

function promptDirection(question) {
    let result = prompt(question);
    if (result.toLowerCase() == "left") return "L";
    if (result.toLowerCase() == "right") return "R";
    throw new InputError("Invalid direction: " + result);
}

for (;;) {
    try {
        let dir = promptDirection("Where?");
        console.log("You chose ", dir);
        break;
    } catch (e) {
        if (e instanceof InputError) {
            console.log("Not a valid direction. Try again.");
        } else {
            throw e;
        }
    }
}

Assertions

  • アサーションとは、プログラム内のチェックであって、何かが想定されている通りであることを検証するものだ。

  • アサーションは、通常の操作で起こりうる状況を処理するためではなく、プログラマーのミスを見つけるために使用される。

function firstElement(array) {
    if (array.length == 0) {
        throw new Error("firstElement called with []");
    }
    return array[0];
}
  • ありとあらゆる種類の悪い入力に対してアサーションを書こうとすることは勧められない。それは大変な作業であり、非常にノイズの多いコードになるだろう。

Chrome DevTools には console.assert というものがあるので、この環境ではそれを利用する。

Summary

  • プログラミングの重要な部分の一つに、バグを発見して、診断し、それを修正することがある。

  • 自動化されたテストスイートがあったり、プログラムにアサーションを追加したりすると、問題に気付きやすくなる。

  • 例外を送出すると、すぐ外側の try/catch ブロックまたはスタックの最下部まで呼び出しスタックが巻き戻される。

  • catch ブロックでは実際に期待される種類の例外であることが確認できたらその例外に対して適切な処理をする必要がある。

  • 例外によって引き起こされる予測不可能な制御フローに対処するために、finally ブロックを使用して、ブロックが終了したときにが常に実行されるようなコードを指定することができる。

Exercises

Retry

問題 20% の確率で二つの数の積を返し、80% の確率で MultiplicatorUnitFailure 型の例外を発生させる関数 primitiveMultiply があるとする。この不便な関数をラップして、呼び出しが成功するまで試行を続け、その後結果を返す関数を書け。処理したい例外しか例外処理しないこと。

解答 せっかくなので関数 primitiveMultiply をも実装する:

class MultiplicatorUnitFailure extends Error{}

function primitiveMultiply(lhs, rhs){
    if(Math.random() < 0.8){
        throw new MultiplicatorUnitFailure;
    }

    return lhs * rhs;
}

function multiply(lhs, rhs){
    for(;;){
        try{
            return primitiveMultiply(lhs, rhs);
        }
        catch(e){
            if(e instanceof MultiplicatorUnitFailure){
                console.log("Try again");
            }
            else{
                throw e;
            }
        }
    }
}

The locked box

問題 次のようなかなりわざとらしいオブジェクトを考える:

const box = {
    locked: true,
    unlock() { this.locked = false; },
    lock() { this.locked = true; },
    _content: [],
    get content() {
        if (this.locked) throw new Error("Locked!");
        return this._content;
    }
};

鍵のかかった箱だ。箱の中には配列が入っているが、それを手に入れるには箱の鍵を開けなければならない。プライベートなプロパティー _content に直接アクセスすることは禁じられている。

関数 withBoxUnlocked を書け。この関数は、関数を引数にとり、箱の鍵を開け、その関数を実行し、引数の関数が正常に戻ったか例外が発生したかにかかわらず、ボックスが再びロックされたことを確認してから戻る。

さらに、箱がすでに解錠されているときに withBoxUnlocked を呼び出すと、箱の鍵はまだ開けられているままになることを確認しておくと得点が高い。

解答 題意だと思われるコードを書く:

function withBoxUnlocked(f){
    const alreadyLocked = box.locked();
    if(alreadyLocked){
        box.unlock();
    }

    try{
        f(box.content());
    }
    catch(e){
        console.log('Handle e...');
    }
    finally{
        if(alreadyLocked){
            box.lock();
        }
    }
}

テストコード:

function f(content){ console.log(content); }
function g(content){ throw new Error; }

box.lock();
withBoxUnlocked(f);
console.assert(box.locked);
box.unlock();
withBoxUnlocked(f);
console.assert(box.!locked);

box.lock();
withBoxUnlocked(g);
console.assert(box.locked);
box.unlock();
withBoxUnlocked(g);
console.assert(box.!locked);

以上