銀の弾丸

プログラミングに関して、いろいろ書き残していければと思っております。

JavaScriptで構文解析:npm Lex-BNF で任意の言語を定義する

f:id:takamints:20210228215020j:plain
photo credit: Morton1905 Austria. Wien. Pieter Breugel d. Ä. Oil on oak panel, 114 x 155 cm. via photopin (license)

JavaScriptで、テキストの構文を定義して、その文法に従った解析、評価を支援する npm モジュール lex-bnf を正式にリリースしましたのでご紹介。 ユーザー入力のコマンドや計算式の解釈など、ちょっとしたテキストの解析器を実装するために使えますよ。

特徴

構文定義方法

以降で構文定義の方法を説明してみますが、詳細はかなり長くなりそうなので、雰囲気だけ掴んでみてください(徐々に加筆します)

基本

言語の構文はlex-bnf がエクスポートする Language クラスのインスタンスとして定義します。 このクラスのコンストラクタで、以下3つのstaticプロパティを使用して、BNFのようなデータ構造を記述します。

  1. syntax - 文法を定義するための関数。戻り値は構文の「項(term)」を表します。第一引数に項の名称(文字列)、第二引数では、項の名称(=非終端記号)か終端記号の二次元配列によって構文のルールを定義します。第三引数には規則の条件によっては省略可能な評価関数(後述)を指定します。終端記号は次の2つのプロパティで定義します。項の名称は別の文法を参照し、構文定義内に含まれていなくてはなりません。
  2. literal - 文字リテラル(定数)の定義するための関数です。第一引数にアルファベットとアンダースコアだけを含む任意の長さの文字列を指定します。数字や区切り文字を含ませてはいけません(そういえば関数内でチェックしてませんわ汗)。
  3. numlit - 数字リテラルの終端記号を表す定数。数字リテラルは数字だけを含む任意の長さの文字列です。

※ 簡潔に記述するために、これらプロパティは独立した定数としてスプレッド構文で取り出しておいたほうが便利です。

calc.js - 四則演算式の文法定義(冒頭部分を抜粋;全体は下の方に掲載)

"use strict";
const Language = require("lex-bnf");
const {syntax, literal: lit, numlit} = Language;

// Defines syntax of language by BNF-like definition and evaluator.
const calc = new Language([
    syntax("calc", [["expression"]]),

    // The evaluator is omittable when the rules contains only one
    // term such as  `additive-expression` below.
    syntax("expression", [["additive-expression"]]),

    // (略)

繰り返し記号

規則に現れる項の名前の末尾に*をつけると、その項が繰り返されることを意味します。

項の繰り返しは、項を再帰的に参照することで定義できますが、規則の左端で再帰を行う再帰では、構文解析時に無限再帰に陥りますので使えません(概念としては左再帰で定義可能)。 規則の右端で再起する再帰では式の評価順序が「右から左」となってしまいますので、式によっては正しい結果が得られません。

このため、再帰が必要である場合は、繰り返し記号を使用して以下のように定義しなくてはなりません。

以下のサンプルでは左再帰を回避するために繰り返し記号を使っています。

    // 省略
    syntax("additive-expression",
        [
            ["multiplicative-expression", "additive-expression-rest*" /* ← */ ],
        ],
        (term) => { /*評価関数(中略)*/}),
    syntax("additive-expression-rest",
        [
            [lit("+"), "multiplicative-expression"],
            [lit("-"), "multiplicative-expression"],
        ],
        (term) => term.contents()),
    syntax("multiplicative-expression",
        [
            ["unary-expression", "multiplicative-expression-rest*"],
        ],
    // 省略

以下は左再帰の例。解析時に無限再帰するので避けるべきです。

    // 省略
    syntax("additive-expression",
        [
            ["additive-expression", "multiplicative-expression"],
            ["multiplicative-expression"],
        ],
        (term) => { /*評価関数(中略)*/}),
    syntax("multiplicative-expression",
        [
            ["multiplicative-expression", "unary-expression"],
            ["unary-expression"],
        ],
    // 省略

以下は右再帰の例。 左から右へ評価される計算式のような構文では、式によっては正しい計算結果が得られない場合があります。 あまりないと思いますが、右から左へ評価される文法では問題なし。

    // 省略
    syntax("additive-expression",
        [
            ["multiplicative-expression", "additive-expression"],
            ["multiplicative-expression"],
        ],
        (term) => { /*評価関数(中略)*/}),
    syntax("multiplicative-expression",
        [
            ["multiplicative-expression", "unary-expression"],
            ["unary-expression"],
        ],
    // 省略

参考

サンプル:四則演算計算機

シンプルな計算機を実装したサンプルプログラムです。 コマンドラインから指定した計算式の結果を表示します。

以下2つのソースファイルで構成しています。 どちらのファイルもリポジトリsample ディレクトリにあります。

ここではこれらを使って、構文定義の方法を説明しています。

  • calc.js - 四則演算の構文定義。
  • eval-expr.js - 四則演算計算機

実行するには、git リポジトリをclone してから node sample/eval-expr.js '<式>' とします。 式はシングルコーテーションでくくったほうが良いです。Bash* や括弧が特別な意味を持っていますので。

実行例)

~ $ cd Git
~/Git $ git clone https://github.com/takamin/lex-bnf.git
~/Git $ cd lex-bnf
~/lex-bnf $ node sample/eval-expr.js '(1 + 2) * (3 - 4) / 5'
-0.6

calc.js - 四則演算の構文定義

このファイルで、+-*/ と括弧が使える四則演算式の構文定義を行っています。

"use strict";
const Language = require("lex-bnf");
const {syntax, literal: lit, numlit} = Language;

// Defines syntax of language by BNF-like definition and evaluator.
const calc = new Language([
    syntax("calc", [["expression"]]),

    // The evaluator is omittable when the rules contains only one
    // term such as  `additive-expression` below.
    syntax("expression", [["additive-expression"]]),

    syntax("additive-expression",
        [
            // The trailing `*` is a repetition specifier.
            // It can avoid an infinite recursion leading from
            // left-recursion.
            ["multiplicative-expression", "additive-expression-rest*"],
        ],
        /**
         * evaluate 'additive-expression'
         * @param {Term} term result of parsing 'additive-expression'
         * @return {number} A result of the calculation.
         */
        (term) => {
            // get no whitespace tokens
            const terms = [].concat(...term.contents());
            let acc = terms[0];
            for(let i = 1; i < terms.length; i += 2) {
                const ope = terms[i];
                const value = terms[i + 1];
                switch(ope) {
                case "+": acc += value; break;
                case "-": acc -= value; break;
                }
            }
            return acc;
        }),

    syntax("additive-expression-rest",
        [
            [lit("+"), "multiplicative-expression"],
            [lit("-"), "multiplicative-expression"],
        ],
        (term) => {
            return term.contents();
        }),

    syntax("multiplicative-expression",
        [
            ["unary-expression", "multiplicative-expression-rest*"],
        ],
        (term) => {
            const terms = [].concat(...term.contents());
            let acc = terms[0];
            for(let i = 1; i < terms.length; i += 2) {
                const ope = terms[i];
                const value = terms[i + 1];
                switch(ope) {
                case "*": acc *= value; break;
                case "/": acc /= value; break;
                }
            }
            return acc;
        }),

    syntax("multiplicative-expression-rest",
        [
            [lit("*"), "unary-expression"],
            [lit("/"), "unary-expression"],
        ],
        (term) => {
            return term.contents();
        }),

    syntax("unary-expression", [["postfix-expression"]]),

    syntax("postfix-expression", [["primary-expression"]]),

    syntax("primary-expression", [
        ["literal"],
        [lit("("), "expression", lit(")")],
    ],
    (term) => {
        const terms = term.contents();
        return (terms[0] !== "(" ? terms[0] : terms[1]);
    }),

    syntax("literal",
        [
            ["floating-constant"],
            ["integer-constant"],
        ]),

    syntax("floating-constant",
        [
            ["floating-real-part", lit("e"), "integer-constant"],
            ["floating-real-part"],
        ],
        (term) => {
            // Get a token representing this element
            const s = term.str();
            // With the BNF rule above, the string could include white spaces.
            // If the string includes white spaces, this method throws an error.
            // The error will be returned from `Language#evaluate()`.
            if(/\s/.test(s)) {
                throw new Error("invalid text for floating-constant");
            }
            return parseFloat(s);
        }),
    syntax("floating-real-part",
        [
            // The optional term is not offerd, so all patterns should be declared.
            [numlit, lit("."), numlit],
            [numlit, lit(".")],
            [lit("."), numlit],
            ["sign", numlit, lit("."), numlit],
            ["sign", numlit, lit(".")],
            ["sign", lit("."), numlit],
        ],
        (term) => term.str()),
    syntax("integer-constant",
        [
            [numlit],
            ["sign", numlit],
        ],
        (term) => {
            return parseInt(term.str());
        }),
    syntax("sign",
        [
            [lit("+")],
            [lit("-")],
        ],
        (term) => term.str()),
]);

module.exports = calc;

eval-expr.js - 四則演算計算機

"use strict";
const calc = require("./calc.js");
const expr = process.argv[2];
const result = calc.parse(expr);
if(result.error) {
    console.error(
        `Syntax error: stopped at ${JSON.stringify(result.errorToken)}`);
} else {
    try {
        const value = calc.evaluate(result);
        console.log(`${value}`);
    } catch(err) {
        console.error(`Evaluation error: ${err.message}`);
    }
}

リンク

www.npmjs.com

github.com

意外な結果:MapとObjectの速度を比較

f:id:takamints:20201130212710j:plain
photo credit: hehaden D is for dry via photopin (license)

ObjectとMapの速度を比較をしてみたんですが、予想外の結果を得ましたのでご報告します。

JavaScript で Key-Valueマップ(=ディクショナリ)を操作する場合、今まで何の疑いもなく object でやっていました。 しかし、ES6 で Mapクラスというのが追加されていて、Key-Valueマップとしては、こちらを使うのがオススメだそうなんです。 MDNのリファレンスでは、「(MapとObjectを比較すると)いくつかの場面で Map の方が勝るような重要な違いがあります」と書かれています。 ほぼ同じことがObjectで実現できるのに、なにが違っているのだろう? そして、パフォーマンス的にはどっちが有利なんだろう?といった疑問が出てきたので、検証用のプログラムを書いて確認してみました。

※ 以降で紹介している検証は Node.js v14.15.1 (lts/fermium) で行いました。自宅の貧弱PCでの検証です。別の環境だと違う結果になるかも知れませんのでご留意を。

目次

B083NFRKKG
[ゲーム&モダンJavaScript文法で2倍楽しい]グラフィックスプログラミング入門——リアルタイムに動く画面を描く。プログラマー直伝の基本 WEB+DB PRESS plus
杉本 雅広(著)

新品 ¥2,905 5つ星のうち4.4 18個の評価
Amazon.co.jpで詳細を見る

MapとObjectの比較検証

ObjectとMapのインスタンスに対して、以下の内容の操作を行い、その処理速度を比較します。

  1. Key-Valueの挿入(Insert) - 新たなキーを複数回挿入する。
  2. Valueの上書き(Update) - 既存のキーの値を更新する。
  3. Valueの取得(Get) - 特定のキーを指定して値を取り出す。
  4. Keyの列挙(Keys) - 内包するキーをすべて列挙する。
  5. Valueの列挙(Values) - 内包する値をすべて列挙する。
  6. Key-Valueの削除(Delete) - 既存のキーを削除する

また、キーの型によって優劣に差が出るかも知れないと思い、とりあえず「文字列をキーにした場合」と、「数値をキーにした場合」の両方で検証しました。

検証用のプログラム

ちょっと長いですが、上記の内容をゴリゴリっと書いた結果がコレ。 もう少し短くできると思ったのですが、途中で諦めた。

"use strict";
const ListIt = require("list-it");
class TestRun {
    elapse;
    constructor(x, keys, handler) {
        const t0 = Date.now();
        handler(x, keys);
        const t1 = Date.now();
        this.elapse = t1 - t0;
    }
}
const TEST_AMOUNT = 2000000;
const STRING_KEYS = Array(TEST_AMOUNT).fill(null).map((_, i) => `STR-${i}`);
const NUMBER_KEYS = Array(TEST_AMOUNT).fill(null).map((_, i) => i);

const tests = [
    {
        name: "insert",
        operation: {
            obj: (obj, keys) => keys.forEach((key, i) => obj[key] = i),
            map: (map, keys) => keys.forEach((key, i) => map.set(key, i)),
        },
    },
    {
        name: "update",
        operation: {
            obj: (obj, keys) => keys.forEach((key, i) => obj[key] = i * 2),
            map: (map, keys) => keys.forEach((key, i) => map.set(key, i * 2)),
        },
    },
    {
        name: "get",
        operation: {
            obj: (obj, keys) => keys.forEach((key, i) => obj[key] = obj[key] * 2),
            map: (map, keys) => keys.forEach((key, i) => map.set(key, map.get(key))),
        },
    },
    {
        name: "keys",
        operation: {
            obj: (obj, keys) => Object.keys(obj),
            map: (map, keys) => map.keys(),
        },
    },
    {
        name: "values",
        operation: {
            obj: (obj, keys) => Object.values(obj),
            map: (map, keys) => map.values(),
        },
    },
    {
        name: "delete",
        operation: {
            obj: (obj, keys) => keys.forEach((key, i) => delete obj[key]),
            map: (map, keys) => keys.forEach((key, i) => map.delete(key)),
        },
    },
];
const targetNames = tests.map(test => test.name);
const transpose = (arr) => {
    const rows = Math.max(...arr.map(arr1 => arr1.length));
    const tArr = Array(rows).fill(null).map(()=>(Array(arr.length).fill(0)));
    arr.forEach((arr1, row) => {
        arr1.forEach((data, col) => {
            tArr[col][row] = data;
        });
    });
    return tArr;
};
const obj1 = {};
const map1 = new Map();
const testObjStr = tests.map(test => (new TestRun(obj1, STRING_KEYS, test.operation.obj)));
const testMapStr = tests.map(test => (new TestRun(map1, STRING_KEYS, test.operation.map)));
const resultS = transpose([
    ["operation", ...targetNames],
    ["object", ...testObjStr.map(result => result.elapse)],
    ["Map", ...testMapStr.map(result => result.elapse)],
]);
const listitS = new ListIt({headerBold: true, headerColor: "green", headerUnderline: true});
console.log("Key=string");
console.log(listitS.setHeaderRow(resultS.shift()).d(resultS).toString());
console.log("");

const obj2 = {};
const map2 = new Map();
const testObjNum = tests.map(test => (new TestRun(obj2, NUMBER_KEYS, test.operation.obj)));
const testMapNum = tests.map(test => (new TestRun(map2, NUMBER_KEYS, test.operation.map)));
const resultN = transpose([
    ["operation", ...targetNames],
    ["object", ...testObjNum.map(result => result.elapse)],
    ["Map", ...testMapNum.map(result => result.elapse)],
]);
const listitN = new ListIt({headerBold: true, headerColor: "green", headerUnderline: true});
console.log("Key=number");
console.log(listitN.setHeaderRow(resultN.shift()).d(resultN).toString());
console.log("");

結果発表

上記プログラムを実行すると「キーを文字列にした場合」と「キーを整数にした場合」の2つの表がコンソールに出力されます。 表には、それぞれの操作を行ったときのミリ秒単位の時間を表示しています。 さて、それぞれについて見ていきましょう。

※ キーや値の列挙操作以外は、二千万回の操作をしている時間ですが、ループのオーバーヘッドがあるので純粋な処理時間ではありません。 なので以下の内容で「差がある」と書いていても、それほど大きな違いではありません。

キーを文字列にした場合

コンソール出力:

Key=string
operation object Map
--------- ------ ----
insert      3888 1558
update       388  846
get          398 1086
keys        2452    0
values      4381    0
delete       936 1127

パッと見感想:不思議な結果ですが、何度やり直してもこれぐらいの数値になりますので、そういうことなんでしょう。

個別の操作について:

  • 挿入 - Mapの勝ち。Objectへのキーの挿入はMapの倍以上の時間がかかる。
  • 更新 - Objectの勝ち。Mapより3倍近く速い。
  • 取得 - Objectの勝ち。更新操作の時間も含まれているので、1行上の時間を減じると object: 10ms, Map: 240ms となり、実質的に24倍!!!
  • Key列挙 - Mapの勝ち。Objectめちゃ遅い。Mapはおそらくイテレータを包有しているのでしょう。
  • Value列挙 - Mapの勝ち。Objectめちゃめちゃ遅い。Mapはおそらく(同上)
  • 削除 - Objectの勝ち。接戦だけど優位な差がある。

キーを整数にした場合

コンソール出力:

Key=number
operation object Map
--------- ------ ----
insert       244 1161
update        83  501
get          105  515
keys         530    0
values        54    0
delete       426  694

パッと見感想:一部を除いてObjectが爆速。内部的に配列として実装されているのではないか?

個別の操作について:

  • 挿入 - Objectの勝ち。Mapの4倍以上の速度。
  • 更新 - Objectの勝ち。Mapの6倍以上の速度。
  • 取得 - Mapの勝ち。実質的に object: 22ms, Map: 14ms となりますので、ほとんど差はありませんがMapの逆転勝ち。
  • Key列挙 - Mapの勝ち。Objectがやはり遅いが、文字列をキーとした場合より4倍以上は頑張っている。
  • Value列挙 - Mapの勝ち。Objectが遅いけど、文字列をキーとした場合よりは極端に速い。
  • 削除 - Objectの勝ち。接戦だけど優位な差がある。

結果まとめ

ObjectとMapのどちらを使うかと迷ったら、処理内容に応じて、なんとなく以下の順番で検討すれば良いのかなと思いました。

  1. キーや値を列挙する操作が多い場合 → Mapが優位。
  2. キーが整数値 → Objectが優位。
  3. あらかじめキーと値を設定しておき更新や参照をする場合には、Objectが優位。
  4. 削除の操作は大差なし。

MDNの「キー付きコレクション」というページには以下の文面があります。

Map と Object のどちらを使用すべきかを決めるには下記の 3 つのヒントが役立つでしょう :

  • 実行時までキーが不明なとき、またはすべてのキーが同じ型、すべての値が同じ型のときは Object よりも Map を使用しましょう。
  • プリミティブ値をキーとして保存する必要がある場合に Map を使用しましょう。Object はキーが数値、真偽値、もしくはいずれのプリミティブ値であるかどうかに関わらず、それぞれのキーを文字列として扱います。
  • 個々の要素を操作するロジックがある場合は、Object を使用しましょう。

「実行時までキーが不明なときはMap」というのはObjectへのInsertが遅いためですね。これはわかります。 しかし、「プリミティブ値をキーとして保存するならMap」というのが検証結果とは多少ニュアンスが違っていますが、キーを整数にしたときのObjectの速さは魅力ですね。 まんべんなくいろんな操作をしているのであれば、勝敗表的には引き分けですから、MDNの推奨通りMapを使えばよいのではないでしょうか。

ただし、これは、あくまでも 現在の Node.js v14.15.3 で、自宅PCで実行した結果に基づくものです。 別のプラットフォーム(ブラウザとか)だと別の結果になるかも知れませんからご注意ください。

参考ページ

JavaScriptのヤヤコシイとこ

f:id:takamints:20201031232344j:plain

会社ではJavaScriptおじさん的になっています。主にNode.jsでバックエンドですが、軽めのフロントエンドもたまにやります。

そんなJavaScriptで、このおじさん。最近ちょくちょくやらかしています。 若い人に間違いを指摘されたりしてお恥ずかしい。 少なからず迷惑もかけております。

そんなこんなで、誠心誠意、懺悔と言い訳&最終的にはJavaScriptへの愚痴になるけど、「JavaScriptのこういうところがヤヤコシイですー」ってところを書いておきます。 JavaScriptは大好きですけどヤヤコシイのだ。

目次


空文字列は0である?

先日、以下のようなコードを書いて、思った通りに動いて無くて困りました。

const a = "";
if(a == 0) {
    console.log("値はゼロ");
} else {
    console.log("値は空文字列");
}

変数 a を 空文字列で初期化して「aは0である」という条件判定をしています。 これ、直感的には「偽」ですが、 JavaScriptでは「真」になります。 つまり上のプログラムを実行すると「値はゼロ」と表示されます。

これを正しく判定するには=を3つ書いて a === 0 としなくてはなりません。

const a = 0;
if(a === 0) {
    console.log("値はゼロ");
} else {
    console.log("値は空文字列");
}

やってみましょう。以下は Node.js のREPLでの実行結果です。 コンソール(Bashコマンドプロンプト、パワーシェルなど)を開いて node と打てば対話型のインタプリタ(REPL)が起動します。 行頭が > となっている行の右の文字列を入力して[Enter]で一行ずつ実行できます。

$ node
Welcome to Node.js v12.18.2.
Type ".help" for more information.
> let a = 0
undefined
> a
0
> a == ""
true
> a === ""
false
>

これはStrict モードでも同じです。REPLをStrictモードで開くには node --user_strict で起動します。

型に寛容なJavaScriptの世界ではかなり有名で、自分もしっかり意識しているつもりでした。 が、ついうっかり「==」と書いてしまったんですね。 こういった「ついうっかり」を防ぐためにもユニットテストを書くべきですが、それはまた別の話として。

NaN(Not A Number)は数値なの?

NaN は Not a Number の略です。「数値ではない」という意味です。判定するにはグローバルコンテキストの isNaN という関数が使えます。

しかし NaN の型名は number ですし、コンストラクタは Number なのです。 何かスッキリしないものが残ります。

> a = NaN
NaN
> isNaN(a)
true
> typeof a
'number'
> a.constructor.name
'Number'
>

まあ、あまり多用する言語要素でないし、使うときはきっちり調べてコーディングするので、非効率ですが問題になりにくいのが救いかも。

Array#reverse は配列自体を変化させる

Array#reverse は配列を逆順にするためのメソッドです。 しかし自分は「Array#reverseは配列自体を変化させずに、新しい配列を返す」と思いこんでいたのですね。 実際、このメソッドの戻り値は逆順になった配列なのですが、まさか配列自体が逆順になって return this; されているとは思っていなかった。 「ホントかよ!?」みたいな。 困りものです。

REPLで確認してみると、明らかに配列自体が逆順になっていました。

$ node
Welcome to Node.js v12.18.3.
Type ".help" for more information.
> a = [1,2,3,4]
[ 1, 2, 3, 4 ]
> a.reverse()
[ 4, 3, 2, 1 ]
> a
[ 4, 3, 2, 1 ]
>

ややこしいのは、reverse だけでないのです。reverseのように配列自体を変化させてしまうメソッドや、配列自体は変化させず、別の配列を返してくるメソッドが、Arrayクラスには混在してます。 しかし、メソッド名を見ても、その挙動がわからない。

いちいちリファレンスを参照しないと不安ですよね。 全部覚えちゃうわけにも行きませんし。 こういうところで生産性が下がるのね。

まあ、よく使うものは覚えていますが、体調が悪いときや睡眠不足のときなどにはわけがわからなくなってしまったり。 割と大きな問題かもなと思います。

まとめ

それぞれJavaScriptの歴史的な事情が関わっていると思います。1990年代から2005年ぐらいまではブラウザの添え物的で、プラットフォーム依存がひどかった。

その後、ブラウザ間の実装の違いを吸収するライブラリが開発され、ECMAScriptとしての統一仕様の策定、Ajaxの勃興、Node.jsの登場、HTML5の策定など重要なイベントがたくさん有って、しかも、ほとんど下位互換性を保ちながら進化してきたのです。

こういった歴史を知っているなら「仕方がないね」と思えますけど、今から使おうとする人には敷居が高すぎるんじゃないかなと。 こういった論理的でない挙動が参入障壁になっていたり、JavaScriptのイメージが悪化につながっているのではないだろうかと、要らぬ心配をしてしまう。 特にビギナーに説明するには、それなりの時間を使いますし、心理的に「なんやようわからん」となりがちだろうなと。

プログラミング言語の人気投票的な結果ではJavaScriptは割と上位に来ますし、多く使われているのも事実だとは思います。 なんか良い方法ないですかね。

全部約束 Promise.all - 非同期処理を効率よく並列実行するために

f:id:takamints:20200830201734j:plain photo credit: EpicTop10.com Promise via photopin (license)

互いに独立した複数の非同期処理は、 Promise.all で待ちましょうね!ってことを書いています。

例えば、複数のREST APIの結果を得たい場合、以下のように書きがちです。 一見問題なく見えますが、処理速度的にちょっと無駄なんですね。

実際ワタシも細かいことを気にせずにザクザクとコードを書いている時はこのように書いていて、あとから見直すことが多々あります。

async function sample() {
    const response1 = await fetch("http://path.to/rest/api1");
    const response2 = await fetch("http://path.to/rest/api2");
}

上のコードでは、 最初のAPIの結果を得てから次のリクエストを行います。つまり順次実行されるのですが、これってちょっと無駄なんです。

APIの数が多くなればなるほど処理速度はどんどん伸びます。しかし、そのほとんどが結果を待っているだけの時間なので無駄なんです。 (他の非同期処理が行われるため一概に無駄とは言い切れませんが)

無駄なく実行するコード

複数の独立した非同期処理を素早く実行するには、以下のように書きます。

async function sample() {
    const [response1, response2] = await Promise.all([
        fetch("http://path.to/rest/api1"),
        fetch("http://path.to/rest/api2"),
    ]);
}

仮にAPIの数が10個になると、最初の例では約10倍の時間がかかります。でも Promise.all を使った場合は、それほど時間は伸びません。 理論的には、ひとつのAPIの結果を得るまでの時間と同程度のはず(実際にはRESTサーバー側の処理能力に依存しますが)。

Promise.all について

Promise.all は、複数のPromiseオブジェクトがすべて完了するのを待ちます。 引数はPromiseオブジェクトの配列で、ひとつのPromiseオブジェクトを返します。 Promise.all が返すPromiseオブジェクトは、引数で与えられたすべてのPromiseが解決した場合に解決し、 その結果は、引数で与えたPromise配列と同じ数の配列であり、各要素が解決した値となります。 (ここではすべてのPromiseの解決が成功する前提で書いています)

上のコードの例では、個々のAPI呼び出しが返すPromiseオブジェクトを、個別に awaitせず、配列に格納して Promise.all に与えています。 これによって、すべてのAPIリクエストを一気に行って、結果が出るまで待つことになります。

複数非同期処理の並列実行で気をつけること

ここまでに書いたコードは、以下のように Array.map を使って置き換え可能です。 これが直感的であるかどうかは Promiseやasync/awaitに慣れているかどうかに依存すると思いますが、ここでワタシがよくやる間違いをひとつ書いておきます。

async function sample() {
    const apis = [
        "http://path.to/rest/api1",
        "http://path.to/rest/api2",
    ];
    const responses = await Promise.all(apis.map(api => fetch(api));
}

例えば、当初は非同期処理でなかったので Array.forEach で書いていたのだが、途中で非同期処理に変更されたような時に、Array.forEachArray.map に置き換えることを忘れていたり、 Promise.allawait するのを忘れていたり。これが実行時にエラーにならないんですよねえ。

できれば Array.forEach に AsyncFunction を与えた場合や、 Array.map の戻り値に対してなにも行っていない場合は警告を出してほしい。

AsciiDocのテーブルを入れ子にするにはビックリマーク

f:id:takamints:20200714090741j:plain
photo credit: Trey Ratcliff Boston Library via photopin (license)


AsciiDocのテーブル(表)のセル内に、これまたAsciiDocのテーブルを記述する方法です。

ごくたま~に、これをやりたくなるのですが、いつも書き方を忘れておりまして、 検索しても、なぜか上位のサイトで書き方が説明されていません。

「どこかの英文サイトの深~いところで確か見つけた記憶があるんだけどなあ~」と時間をかけて、やっと見つけて「あぁこれこれよ」という毎度の始末。

ということで、AsciiDocでネストしたテーブルを書く方法を、未来の自分のために書いておきます。時間がもったいないですからね。書いて覚えるスタイルで。


B07JHZMH9C
技術者のためのテクニカルライティング入門講座
髙橋 慈子(著)

新品 ¥2,178 5つ星のうち4.2 27個の評価
Amazon.co.jpで詳細を見る


目次

以降、このページ内の AsciiDocの変換は、npm miceroux を使っています。 使い方などは以下のページで説明しています。 takamints.hatenablog.jp


素のテーブル内にAsciiDocは書けません

そもそも AsciiDocの素のテーブル内に、AsciiDocを書いても、AsciiDocとして解釈されません。
以下のように、書いた内容がそのまま表示されてしまいます。

[cols="2,2,5"]
|===
|Firefox
|ブラウザ
|FirefoxはオープンソースのWEBブラウザです。

下記のような特徴があります。:

* 標準仕様準拠
* 高パフォーマンス
* 高い可搬性

http://getfirefox.com[Firefoxをダウンロードする]!
|===

ちょっとわかりにくいですが、箇条書きが箇条書きになっていませんね。
リンクが正しく解釈されているのはよくわかりません。

テーブル内にAsciiDocを記述する

セルの中のAsciiDocが正しく表示されるようにするには、セルの属性に a を設定する必要があります。おそらく asciidoc の意味ですよね。よく知りませんが。

AsciiDocのテーブルは(当たり前ですが)AsciiDocの文書ですから、この設定をしておかないとテーブルは書けません。

このことは Asciidoctor 文法クイックリファレンス(日本語訳) にしっかり書かれておりまして、文書も丸パクリしています。ちなみにワタシのメインブラウザはChromeです。

以下の例では、AsciiDocを記述する列全体に a 属性を設定しています。

[cols="2,2,5a"]
|===
|Firefox
|ブラウザ
|FirefoxはオープンソースのWEBブラウザです。

下記のような特徴があります。:

* 標準仕様準拠
* 高パフォーマンス
* 高い可搬性

http://getfirefox.com[Firefoxをダウンロードする]!
|===

以下のように、列単位ではなくセル単体に a 属性を設定することも可能です。

a|FirefoxはオープンソースのWEBブラウザです。

セルの中では「| (パイプ or 縦棒)」は使えない

AsciiDocのテーブルは | (パイプ or 縦棒) を使用して記述します。 テーブル自体が |=== の行で始まって |=== の行で終わります。 また、その中のセルの区切りにも | を使用します。

しかしセルの中にネストしたテーブルを書くために、これらの記号は使えません。 |=== が出てきたところで、外側のテーブルが終了してしまって意図通りの表示にはなりません。

ネストされたテーブルでは「!」を使う

じゃあ、どうするか?

ネストしたテーブルでは、| の代わりに ! (エクスクラメーション or ビックリマーク)を使います」ってことでした。

[cols="2,2,5a"]
|===
|Firefox
|ブラウザ
|FirefoxはオープンソースのWEBブラウザです。

下記のような特徴があります。:

* 標準仕様準拠
* 高パフォーマンス
* 高い可搬性

http://getfirefox.com[Firefoxをダウンロードする]!

[cols="2,2,5a"]
!===
!Firefox
!ブラウザ
!FirefoxはオープンソースのWEBブラウザです。
下記のような特徴があります。:

* 標準仕様準拠
* 高パフォーマンス
* 高い可搬性

http://getfirefox.com[Firefoxをダウンロードする]
!===

|===

それなら3段重ねはどうするの?

「じゃあ、3段階のネストはどうする?」って言うと、、、ワタシそれは知りません(あいすみません)。 できるのかどうかもわからないです。

ま、「そんなテーブルは見にくいだろうし、書かないでしょ?」ってことかもしれませんね。