String.fromCodePoint が遅かった

発端

ある日, 私は文字列の先頭を切り出すために次のような関数を書いていました.

https://github.com/susisu/loquat-core/blob/dcd37f885b5f5b1b0dfdc0e15680375a16239318/lib/utils.js#L97-L113

function unconsString(str, unicode) {
    if (unicode) {
        const cp = str.codePointAt(0);
        if (cp === undefined) {
            return { empty: true };
        }
        else {
            const char = String.fromCodePoint(cp);
            return { empty: false, head: char, tail: str.substr(char.length) };
        }
    }
    else {
        return str === ""
            ? { empty: true }
            : { empty: false, head: str[0], tail: str.substr(1) };
    }
}

unicode = false のときは別に特に変わったことはしていないのですが, unicode = true のときは String.prototype.codePointAt を使って code point 単位で文字コードを取り出し, それを String.fromCodePoint を使って文字列に変換しています.

要するに unicode = true のときは, サロゲートペア (絵文字とか) をペアのまま取り出してくれるというやつです.

const str = "🍣🍤"; // === "\uD83C\uDF63\uD83C\uDF64";
console.log(unconsString(str, false));
// -> { empty: false, head: "\uD83C", tail: "\uDF63🍤" }
console.log(unconsString(str, true));
// -> { empty: false, head: "🍣", tail: "🍤" }

ところで unconsString 関数のパフォーマンスを調べてみましょう (Node.js v7.3.0, 雑に2回実行して平均とってます).

ASCII, unicode = false

const N = 10000000;

console.time("uncons");
let str = "abcd";
for (let i = 0; i < N; i++) {
    unconsString(str, false);
}
console.timeEnd("uncons");

400 ms.

ASCII, unicode = true

const N = 10000000;

console.time("uncons");
let str = "abcd";
for (let i = 0; i < N; i++) {
    unconsString(str, true);
}
console.timeEnd("uncons");

1620 ms. 既に雲行きが怪しいとかそういうレベルではない.

サロゲートペア, unicode = false

const N = 10000000;

console.time("uncons");
let str = "🍣🍤";
for (let i = 0; i < N; i++) {
    unconsString(str, false);
}
console.timeEnd("uncons");

790ms. ASCII と比べて遅いのは内部表現の都合だろうか?

サロゲートペア, unicode = true

const N = 10000000;

console.time("uncons");
let str = "🍣🍤";
for (let i = 0; i < N; i++) {
    unconsString(str, true);
}
console.timeEnd("uncons");

4300 ms. もうダメだ.

何が遅いのか

unconsString 関数内では特に複雑なこともしていないので, 遅いことが疑われるのは String.prototype.codePointAtString.fromCodePoint のどちらかしかないわけで, 結局タイトルの通り, String.fromCodePoint が犯人だったというわけです.

書き直す

そんなこんなで以下のように書き直しました.

https://github.com/susisu/loquat-core/blob/6fbcffecb21b415547d20e728b1ef259f5975571/lib/utils.js#L97-L123

function unconsString(str, unicode) {
    const len = str.length;
    if (unicode) {
        if (len === 0) {
            return { empty: true };
        }
        else if (len === 1) {
            return { empty: false, head: str[0], tail: str.substr(1) };
        }
        else {
            const first = str.charCodeAt(0);
            if (first < 0xD800 || 0xDBFF < first) {
                return { empty: false, head: str[0], tail: str.substr(1) };
            }
            const second = str.charCodeAt(1);
            if (second < 0xDC00 || 0xDFFF < second) {
                return { empty: false, head: str[0], tail: str.substr(1) };
            }
            return { empty: false, head: String.fromCharCode(first, second), tail: str.substr(2) };
        }
    }
    else {
        return len === 0
            ? { empty: true }
            : { empty: false, head: str[0], tail: str.substr(1) };
    }
}

String.fromCodePoint の代わりに String.fromCharCode を使っています. その過程で code point を code unit に分割する処理が必要だったりして, 結局 String.prototype.codePointAt の中身も仕様書を見ながら再実装したりしています (色々省いてはいますが).

実際これでパフォーマンスが改善したのか, 再度計測して比べてみましょう.

ASCII, unicode = true

const N = 10000000;

console.time("uncons");
let str = "abcd";
for (let i = 0; i < N; i++) {
    unconsString(str, true);
}
console.timeEnd("uncons");

1620 ms → 460 ms. unicode = false の場合とほぼ変わらない速度が出るようになりました.

サロゲートペア, unicode = true

const N = 10000000;

console.time("uncons");
let str = "🍣🍤";
for (let i = 0; i < N; i++) {
    unconsString(str, true);
}
console.timeEnd("uncons");

4300 ms → 680 ms. むしろ unicode = false より速くなった.

まとめ

というわけで無事 String.fromCodePoint を避けることで高速化に成功しました. パフォーマンスが気になる場合は代わりに旧来通りの String.fromCharCode を使いましょう. あまりちゃんと調べていませんが, Firefox でも効果があるっぽいです.

(追記: なぜ遅いのかと V8 の中身をざっと見た感じでは String.fromCharCode がかなり最適化されたような実装になっているのに対して, String.fromCodePoint はほぼ仕様書見たままのナイーブな実装になっている雰囲気だった. 雰囲気しかわからないので正しくないかもしれない.)

unconsString 関数はパーサライブラリの中で使っていて, 文字列分割は頻繁に行われるため, 全体のパフォーマンスにそこそこ影響を及ぼします. 適当に JSON パーサで調べたら unicode = true の場合に 20 〜 30 % くらい高速化しました. Cookie Cliker なら 2000 時間放置後の ascension くらいの威力ですね (人に依るのでは?).

Node.js でコンソールアプリを作る

この記事は OUCC Advent Calendar 2016 の 21 日目の記事です.

www.adventar.org

こんにちは, @susisu2413 です.

みなさんは JavaScript でコンソールアプリを作りたくありませんか? 私は作りたいです.

この記事では Node.js (+ NPM) を使って JavaScript でコンソールアプリを作成する方法を紹介します.

コンソールアプリケーションはあんまり見栄えしませんね。

そんなことないよ! f:id:susisu:20151224004353g:plain

用意するもの

公式サイトからダウンロードしてくる以外の方法もありますが, ここでは割愛.

NPM は Node.js をインストールするとたぶん勝手についてきます.

作ってみる

まずはディレクトリを作りましょう. 適当に名前は myconsoleapp とかで.

$ mkdir myconsoleapp
$ cd myconsoleapp

次に Node.js (NPM) の初期設定をします.

$ npm init

適当にエンターキーを連打していると, 以下のような内容の package.json が作成されます.

{
  "name": "myconsoleapp",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Susisu <susisu2413@yahoo.co.jp> (https://github.com/susisu)",
  "license": "MIT"
}

次に実行ファイルを配置するディレクトリを作成します. 後で作成する実体は JavaScript ですが, bin という名前にしておきます.

$ mkdir bin

続いて package.json に以下のように directories という項目を追加します. こうすることで NPM が実行ファイルのあるディレクトリを認識してくれるというわけですね.

{
  "name": "myconsoleapp",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Susisu <susisu2413@yahoo.co.jp> (https://github.com/susisu)",
  "license": "MIT",
  "directories": {
    "bin": "bin"
  }
}

さて, ようやくですが実行ファイル本体を作成しましょう. とりあえず bin/myconsoleapp に以下のような何の面白みもない "Hello, world!" を表示するだけのプログラムを作成します.

#!/usr/bin/env node

// 呪い
"use strict";

process.stdout.write("Hello, world!\n");

ShebangWindows では動かないんじゃないかとか chmod +x しないといけないんじゃないかといった心配は必要ありません. 全て NPM がいい感じにやってくれます. 便利ですね.

実際にインストールして動かしてみましょう.

$ npm link
$ myconsoleapp
Hello, world!

めでたい.

もし command not fount などと言われた場合は NPM のグローバルインストールのディレクトリにパスが通っているか確認しましょう.

さらに変更を加える場合はそのまま bin/myconsoleapp を編集すれば, 再度 npm link をしなくても変更が反映されます (リンク (ショートカット) なので).

ライブラリを使う

NPM は名前から察しがつく通りパッケージマネージャなので, ライブラリの管理を行うこともできます.

ここでは Commander.js というコマンドラインパーサのライブラリを依存パッケージに加えてみましょう.

まずはパッケージを今作っているコンソールアプリの環境にインストールします. package.json に依存関係を保存するため, --save というオプションをつけています.

npm install --save commander

次に bin/myconsoleapp を以下のように変更します. ここでは --name <name> というオプションを追加しています.

#!/usr/bin/env node

"use strict";

var program = require("commander");
// -n <name> あるいは --name <name> というオプションを作成
program.option("-n, --name <name>", "name");
// 引数を読み取る
program.parse(process.argv);

if (typeof program.name === "string") {
  // name が指定されていればその人に挨拶
  process.stdout.write("Hello, " + program.name + "!\n");
}
else {
  process.stdout.write("Hello, world!\n");
}

実行してみましょう. 既に上の手順で npm link してあればそのまま実行できます.

$ myconsoleapp
Hello, world!
$ myconsoleapp --name Shinobu
Hello, Shinobu!

よかったですね.

Commander.js の詳しい使い方は API documentation などを参考にしてください. なんとなく不安なところはあるけど良いライブラリです.

この他にも NPM には大量に便利なライブラリがあるので, 何かがほしい時は探せば大抵何か見つかると思います. 個人的にコンソールアプリを作るときに便利だと思うものを紹介すると,

  • chalk: 簡単に出力する文字に色をつけられたりするやつ
  • blessed: curses 的な

等々. 実際にこれらを使っている例はこんな感じ.

配布する

良い感じのコンソールアプリができたら配布したくなることでしょう.

作成したコンソールアプリを配布するには例えば以下のような方法があります.

  1. GitHubリポジトリを配置する
  2. NPM にパブリッシュする

GitHubリポジトリを配置する

最も簡単な方法として, GitHubリポジトリを配置する方法があります.

ここでは git や GitHub の使い方は説明しませんので, わからなければググりましょう.

例えば私 (susisu) が myconsoleapp という名前のリポジトリを作成した場合,

$ npm install --global susisu/myconsoleapp

で他の環境にもインストールできるようになります.

例として一つ置いておきます: https://github.com/susisu/kinmosa-gen

NPM にパブリッシュする

GitHubリポジトリを配置した場合の欠点として, バージョン管理がしにくいということがあります.

きちんとバージョン管理をする必要が出るくらいまともなものを作った時は NPM にパブリッシュするようにしましょう.

注意: 以下のコマンドを実行すると, NPM のレジストリ上の名前を専有し, ついでに削除することも基本的にはできませんので, 実行する際は慎重に. 名前を専有するほどでもない場合は, 以下の scoped package についてを参照してください.

予め NPM のアカウントを作った状態で,

$ npm publish

を実行すると, 他の環境にも

$ npm install --global myconsoleapp

でインストールできるようになります (このときの名前は package.json 内の name が使われます).

scoped package

上でも書きましたが, 普通に NPM にパブリッシュすると名前を専有してしまい, 他の人が同じ名前のものをパブリッシュできなくなってしまいます.

名前を専有するほどでもない場合や, 他の人が既に作成したものと名前が被っているときは, scope という仕組みがあるのでそれを使うようにしましょう.

まずは package.json 内の name を以下のように変更します (私の場合ユーザー名が susisu なので, 前に @susisu をつける).

{
  "name": "@susisu/myconsoleapp",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Susisu <susisu2413@yahoo.co.jp> (https://github.com/susisu)",
  "license": "MIT",
  "directories": {
    "bin": "bin"
  }
}

初回にパブリッシュする時は,

$ npm publish --access public

のように --access public というフラグを付けます (元は private なパッケージのための仕組みだったため?).

こうすると, 他の環境へのインストールは,

$ npm install --global @susisu/myconsoleapp

のようにできるようになります. ユーザー名以下のスコープを使うのでなんとなく気が楽で良い感じ.

まとめ

というわけで Node.js でコンソールアプリを作成する方法でした.

Node.js でコンソールアプリを作成する利点は, 作成も配布も簡単に行えて, Node.js がインストールされていればどこでも動くといったところでしょうか. とにかく何でも JavaScript で書きたい人にとってはこの上ない環境だと思います.

OUCCM

OUCC ではとにかく何でも JavaScript で書きたい部員を募集しております.

oucc.org