2020年を振り返って

今年はコロナ禍でリモートワークになり、生活リズムが大きく変わりました。年末も帰省を控えているため、人生で初めて実家で過ごさない正月となります。年末年始を京都で過ごすのは初めてで少しわくわくしています。仕事は担当サービスのコンテナ化やAWS化などを手伝いながら、インターン講師をやったり、プロジェクトのタスクマネジメント的なことをやってました。むずかしいですね。

OSS活動としてはmmvを作ったりHomebrewのインストーラーをBashに書き直したりgojqのパーサーを書き直したりtimefmt-gorassemble-goを作ったりしていました。gojqJSONを出力する処理を自前で書くことでjqコマンドの3倍以上のパフォーマンスを出せるようになりました。他には、jqのリポジトリwatchしてissueの打ち返しをしたり、Vim 9 scriptの仕様に関するフィードバックを行ったりしていました。

秋頃に競技プログラミングにハマり、AtCoderをRustで解いていました。昔やっていた頃よりもツールやライブラリが揃っていて参加しやすくなったと感じました。しかしgojqでやることが増えたら自然と競技プログラミングをやる時間も減ってしまいました。なかなか続かないですね… Rustをもっと書きたいんですがどうしても時間がかかってしまってGoを選んでしまいます。

9月に家族で天橋立に行きました。200kmほど運転を担当し、天気が良くて気持ちよかったです。しかし連休に行ったのもありとても混雑していて大変でした。12月には京都水族館に行きました。クラゲが可愛かったです。

将棋が強くなりたくて対局を観たりアプリをやったりしていたのですが、最近は時間がなくてやれてません。将棋鑑賞、なかなか大変な趣味です。夏頃には筋トレグッズを買ってしばらくやっていましたが、これも続きませんでした。来年こそは続けるんだ。YouTubeをPremiumにしてから結構見るようになりました。しみさん夫婦るかちゃんねるはるまきちゃんねるあたりが好きです。

今年の良かったアニメは『アサルトリリィ BOUQUET』『安達としまむら』『かぐや様は告らせたい?~天才たちの恋愛頭脳戦~』『魔女の旅々』『ストライクウィッチーズ ROAD to BERLIN』あたりですね。アサルトリリィはOP・EDもWEBラジオも赤尾ひかるさんも良かったです。舞台をやっているからなのか、声優さんたちが仲がいいですね。『魔女の旅々』の本渡楓さんもよかったです。ドラマもかなり見ていて『危険なビーナス』『七人の秘書』『テセウスの船』『監察医 朝顔』『アリバイ崩し承ります』あたりがよかったです。映画は『TENET』が最高でした。クリストファー・ノーランは常に最高を届けてくれます。

来年はいい一年になりますように。

伊東閑「責任感と罪悪感はきちんと分けないと、身を滅ぼすわよ」

アサルトリリィ BOUQUET 第11話

Vimの古いバージョンをコンパイルし高速にbisectする (macOS編)

Vimプラグインを作っていると、特定の機能がどのバージョンで実装されたか調べるのが難しいことがあります。 ヘルプ (:h vim7, :h vim8) や git log のコミットメッセージから探せば多くのケースでは見つかるのですが、うまく検索できなくて見つからないこともあります。 また、急にテストが古いVimで落ちるようになったときに、どの機能が原因で落ちているのかつかめないこともあります。

確実な方法は git bisect すれば良いのですが、これには2つの問題があります。

後は純粋な興味もあって、古いバージョンのビルド済みバイナリーを作っておこうと思い立ちました。 しかし1パッチごとにビルドするのはあまりに大変です。 そもそも当時のコンパイルでもビルドできず、その後のパッチで直ったりするバージョンがあるので、どうしてもビルドできないバージョンは存在します。 後はディスク容量の問題もあります。

まずは以下のようなスクリプトで100パッチごとにビルドしてみることにしました。 macOS・clang 12.0.0 (clang-1200.0.32.27) で v7.3.000 から v8.2.2000 までの103バージョンをビルドすることができました。

vim-backcompile.sh

#!/bin/bash
set -euxo pipefail

if ! command -v gsed >/dev/null; then
  echo 'gsed required' >&2
  exit 1
fi

if [[ ! -d /tmp/vim ]]; then
  git clone https://github.com/vim/vim.git /tmp/vim
fi
cd /tmp/vim

backup_dir=/tmp/vim-backup
mkdir -p "$backup_dir"

read -r -d '' -a tags < <(git tag --sort=-committerdate |
  grep -E '^v\d[.]\d(?:[.]\d(?:\d*00)?)?$' && printf '\0')

for tag in "${tags[@]}"; do
  git restore .
  git switch --detach "$tag"
  gsed -i '/sizeof(uint32_t) != 4/,+1s/exit/return/' src/auto/configure # v8.2.1119
  gsed -i '/if (\(p\|res\|e1.*\) [!=]= NUL)/s/NUL/NULL/g' src/*.c # v8.0.0046
  gsed -i '/#define VV_NAME/s/, {0}$//' src/eval.c # v7.4.1648
  gsed -i '/static struct vimvar/,+4s/dictitem_T/dictitem16_T/;/char\s*vv_filler.*/d' src/eval.c # v7.4.1648
  if ! git grep -q 'struct dictitem16_S' src/structs.h; then
    gsed -i '/typedef struct dictitem_S dictitem_T;/a struct dictitem16_S {\n  typval_T di_tv;\n  char_u di_flags;\n  char_u di_key[17];\n};\ntypedef struct dictitem16_S dictitem16_T;' src/structs.h # v7.4.1648
  fi
  gsed -i 's/__ARGS(\(.*\))/\1/' src/proto/os_mac_conv.pro # v7.4.1202
  gsed -i 's/!builtin_first == i/(!builtin_first) == i/' src/term.c # v7.4.914
  gsed -i '/extern int sigaltstack/s/^/\/\//' src/os_unix.c # v8.0.1236
  gsed -i '/+multi_byte/,+6s/FEAT_BIG/FEAT_NORMAL/' src/feature.h # v7.3.968
  if ! ./configure; then
    make distclean || :
    rm -f auto/config.cache
    ./configure
  fi
  make -j8
  src/vim --version
  major=$(./src/vim --version | head -n 1 | awk '{ print $5 }')
  minor=$(./src/vim --version | grep patch | awk '{ print $3 }' | sed -e 's/^[0-9]*-//g' || :)
  cp src/vim "$backup_dir/$(printf "vim-%s.%04d" "$major" "$minor")"
done
  • ビルドが通っても起動できない事があったので、 src/vim --version で起動チェックを行う
  • 変わっていくソースコードにパッチファイルは使いにくいので、今のコンパイラコンパイルできない箇所は sed で雑にパッチを当てる。

実行ファイルのサイズが少しずつ増える様子が分かりました。

今は上のスクリプトを少し変えて、25パッチごとにビルドして実行ファイルを保存しています。 ビルドできなかったのは 8.0.0750 (8.0.0751で修正), 7.4.1625 (7.4.1626で修正) のみでした。

ビルド済み実行ファイルが揃ったので、次はこれを使ってbisectします。

vim-bisect.sh

#!/bin/bash
set -euo pipefail

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"

read -r -d '' -a VERSIONS < <(cd "$script_dir" && ls vim-[0-9]* |
  sort --version-sort --field-separator=. && printf '\0')

find_index() {
  for i in "${!VERSIONS[@]}"; do
    if [[ "${VERSIONS[$i]}" = "$1" ]]; then
      echo "$i"
    fi
  done
}

exec_command="${1:?specify bisecting command}"
low_version="${2:-${VERSIONS[0]}}"
high_version="${3:-${VERSIONS[${#VERSIONS[@]}-1]}}"
if [[ ! "$low_version" =~ ^vim- ]]; then
  low_version="vim-$low_version"
fi
if [[ ! "$high_version" =~ ^vim- ]]; then
  high_version="vim-$high_version"
fi
low_index="$(find_index "$low_version")"
high_index="$(find_index "$high_version")"

test_version() {
  env VIM="$script_dir/$1" bash -c "$exec_command"
}

if test_version "$high_version"; then
  echo "$high_version is good"
  exit 1
fi
if ! test_version "$low_version"; then
  echo "$low_version is bad"
  exit 1
fi

bad_version="$high_version"
while [[ "$low_index" -lt "$high_index" ]]; do
  mid_index="$(((low_index + high_index) / 2))"
  version="${VERSIONS[$mid_index]}"
  if test_version "$version"; then
    echo "$version: good"
    low_index="$((mid_index + 1))"
  else
    echo "$version: bad"
    high_index="$((mid_index))"
    bad_version="$version"
  fi
done

echo "$bad_version is the bad version"

$VIM という環境変数に実行ファイルのパスを入れるので、 vim-bisect.sh '! env THEMIS_VIM=$VIM /tmp/vim-themis/bin/themis --reporter spec >/dev/null' のようなコマンドで vim-themis を使ったテストを回すことができます。

すべてのパッチバージョンに対する実行ファイルを用意できるわけではないので、git bisect みたいにこのパッチがbad commitというところまではいけませんが、50パッチや25パッチの範囲くらいならコミットログに全部目を通すことはできます。 ビルド済みbisectはめちゃくちゃ便利なので、ぜひ試してみてください。

おしまい

Vimのパッチ存在確認処理を速くした

昨日Apple Eventを待機しながらVimのコードを眺めていたら、なんだか香ばしい匂いのするコードを見つけてしまいました。

/*
 * Return TRUE if patch "n" has been included.
 */
    int
has_patch(int n)
{
    int        i;

    for (i = 0; included_patches[i] != 0; ++i)
        if (included_patches[i] == n)
            return TRUE;
    return FALSE;
}

ペロッ… こ、これは、線形探索!

Vimは、メインのブランチのすべてのコミットでパッチバージョンが上がっていく方式をとっています。 プラグインが新しい機能を使いたい時に、ユーザーが使っているVimに特定のパッチが入っているかをチェックする必要があります (関数やイベントなど機能が入っているかを直接チェックすることができる場合もありますが、機能が入ってもバグっていることがありますし、直接チェックできない機能もあります。その時はパッチバージョンでチェックする必要があります)。 こういうプラグイン実装者のために、 has('patch-8.2.1200') のように関数 has() でパッチバージョンが含まれているかをチェックすることができます (詳しくは :h has-patch)。

含まれているパッチバージョンは included_patches という配列にすべて羅列されており、これを線形に探索しているコードです。 しかし included_patches は必ず大きい番号順にソートされており、二分探索したほうがほとんどのケースでお得なはずです。 ということで昨日シュッとコードを書いて、出してみました。

github.com 素朴な二分探索です。

/*
 * Return TRUE if patch "n" has been included.
 */
    int
has_patch(int n)
{
    int        h, m, l;

    // Perform a binary search.
    l = 0;
    h = (int)(sizeof(included_patches) / sizeof(included_patches[0])) - 1;
    while (l < h)
    {
        m = (l + h) / 2;
        if (included_patches[m] == n)
            return TRUE;
        if (included_patches[m] < n)
            h = m;
        else
            l = m + 1;
    }
    return FALSE;
}

朝起きたら、無事 v8.2.1973 として取り込まれていました。修正なしで入っていると嬉しいですね。 github.com

ここの処理は大昔から変わっておらず (少なくとも2004年からずっと同じコードでした。もっと遡れるかもしれません) で、今まで見過ごされていたのが不思議なくらいです。 Vimがまた少し速くなってよかったですね。

自分の生産性について考えてみる

組織の生産性を上げるために何かアクションを取りたいのだけど、今は何も計測できていないし、改善案も出せていない。 組織はいろいろな人が同時に動いていて難しいので、まずは自分自身の生産性について考えてみる。

自分はWFHで生産性が下がっているような気はしていたけれど、WFHだから下がっているのか、立場が変わって下がっているのか、そもそも実は下がっていないのかもしれない。 自分の生産性をどう評価するかは置いておいて、まずは素朴にパフォーマンスがでることとでないことを考えてみた。 普段はこういうことは外には書かないんだけど、恥を忍んで出してみる。

得意なこと

  • 一ユーザーとして不満に思っていたことの改善
  • 課題感や解決方法に納得のいくタスク
    • 電気系出身なので「共振」できることってイメージだけど他の人に伝わらないから「共感」とか「納得」って言ってる
  • ユーザー目線でサービスを触ったり告知を読んだりする
  • 一つのものを集中して作り上げる
  • 技術的に正しい改善
  • 既存の実装のリファクタリングまたは再実装
    • 仕様を整理し直してきれいに作りたい
    • 長年の課題を再設計して解決したい
  • 既存の実装の別言語での実装
    • よくやる
  • 実行パスのカバレッジを脳内で上げる
    • 不具合の原因を探る時によくやる
  • シンプルに作り保守的にメンテしていく
    • シンプルな仕様を考えるのもわりと得意
  • データ構造やファイルフォーマットの設計
    • 単に好きってだけ
  • 好きなことなら休日でもコードを書いている
  • OSSを触る、OSSを直す、OSSを作る

苦手なこと

  • 課題感や解決方法に共感できないタスク
    • 仕事はやらねばならぬことは理解している
    • やれといわれたらやるがスピードは出ない
  • 複数のタスクを同時に進める
    • とくに面倒なタスクが同時に目の前にあると急激に集中力が落ちる
  • 自分から様子を見に行く
    • 組織の生産性が上がることはわかるがポーリングは大変
    • 呼びつけてくれ…
  • 隙間時間に仕様を把握する
    • 一日集中したい
  • 旗振り役
    • アサイン決めたり期限を決めたり…
  • 組織の問題点を指摘する
    • 現状維持になってしまう
    • 変えていくのが正義なのは知っている
    • 組織がどうあるべきかの知識がない
  • 評価のためのアクションプランを考える
    • したことを評価基準にあわせてそれっぽく書いている
  • 本を読む
    • 一年に一冊くらいしか読まない

苦手なことを減らすには

  • 意識して苦手なことを克服する時間を取る
    • 意識して本を読む
      • 自分はもっと本を読むべきって数年思ってるけど、目の前に面白い問題があったらコード書いちゃう
  • それっぽい本を読んで方法論を学ぶ
  • タスクを直列にしてそれぞれに集中して片付ける
    • 意識してこれもやる
      • 昔ためしたポモドーロは合わなかった
  • 苦手なことを補ってくれる人を捕まえる
    • 自分が苦手なことが得意な人はいるので相談する
    • 苦手なことは移譲する
      • やりすぎると自分の成果は?となる
  • 得意なことを活かせるロールにつく

この休日でもう少し深堀りして考えてみる。

Regexp::AssembleのGo実装 rassemble-go を作りました

PerlにはRegexp::Assembleという便利なライブラリがあります。 複数の正規表現を受け取り、それらのいずれかにマッチする正規表現を構築するためのライブラリです。

my $ra = Regexp::Assemble->new;
$ra->add( 'ab+c' );
$ra->add( 'ab+\\d*\\s+c' );
$ra->add( 'a\\w+\\d+' );
$ra->add( 'a\\d+' );
print $ra->re; # prints (?:a(?:b+(?:\d*\s+)?c|(?:\w+)?\d+))

このライブラリのGo実装を金曜日の夜から書き始めて、ようやく形になってきたので公開しました。

package main

import (
    "fmt"
    "log"

    "github.com/itchyny/rassemble-go"
)

func main() {
    xs, err := rassemble.Join([]string{"abc", "ab", "acbd", "abe"})
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(xs) // a(?:b[ce]?|cbd)

    xs, err = rassemble.Join([]string{
        "北海道", "青森県", "岩手県", "宮城県", "秋田県", "山形県", "福島県", "茨城県",
        "栃木県", "群馬県", "埼玉県", "千葉県", "東京都", "神奈川県", "新潟県", "富山県",
        "石川県", "福井県", "山梨県", "長野県", "岐阜県", "静岡県", "愛知県", "三重県",
        "滋賀県", "京都府", "大阪府", "兵庫県", "奈良県", "和歌山県", "鳥取県", "島根県",
        "岡山県", "広島県", "山口県", "徳島県", "香川県", "愛媛県", "高知県", "福岡県",
        "佐賀県", "長崎県", "熊本県", "大分県", "宮崎県", "鹿児島県", "沖縄県",
    })
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(xs) // 北海道|(?:青森|岩手|宮[城崎]|秋田|山[口形梨]|福[井岡島]|茨城|栃木|
                    // 群馬|埼玉|千葉|(?:神奈|石|香)川|新潟|(?:富|和歌|岡)山|長[崎野]|岐阜|
                    // 静岡|愛[媛知]|三重|[佐滋]賀|兵庫|奈良|鳥取|島根|(?:[広徳]|鹿児)島|
                    // 高知|熊本|沖縄)県|東京都|京都府|大(?:阪府|分県)
}

ライブラリ用途を意図していますが、動作確認 (とこのブログでの説明) のためにcliツールとしても使えるようにしています。

 % go get github.com/itchyny/rassemble-go/cmd/rassemble
 % rassemble abcd abd acd ad
ab?c?d
 % rassemble $(head -n30 /usr/share/dict/words)
a(?:a(?:l(?:ii)?|m|rd(?:vark|wolf))?|ba(?:c(?:a(?:te|y)?|i(?:nat(?:e|ion)|s(?:cus|t))|k|tinal(?:ly)?)?)?)?|A(?:a(?:ni|r(?:on(?:i(?:c(?:al)?|t(?:e|ic)))?|u))|b(?:ab(?:deh|ua))?)?

作ったきっかけは、以下のブログを見たことでした。

本当はこのツールのアイデアが思いついたときには、GoのRegexp::Assemble実装も作ってやろうと思っていたのですが、難しくて断念しました…。このツールのアイデア自体は数年前にあったのですが、Regexp::Assembleがネックになって作れておらず、結局その実装を諦めた結果このツールを世に送り出すことができました。 Regexp::AssembleのGo移植は誰かやってほしい…!

GoのテストをCIで簡単に並列実行する | おそらくはそれさえも平凡な日々

確かに本家の実装を覗いてみるとそこそこ行数があり、複雑なコードで頭がクラクラします。 しかし、Regexp::Assembleは正規表現のパースのコードも含んでおり、そこがかなりの行数を占めています。 Goで実装する場合は、パースは標準ライブラリにまかせてしまえば良いので、正規表現のマージ処理に専念することができます。 また、本家を一行ずつ変換する必要はなく、テストケースを眺めながら処理を想像しつつ書いていけば、移植はそんなに難しくありません (実は私も本家のソースコードはほとんど読んでいません)。


基本的には前方がまとまっている方が正規表現は速いはずですから、トライ木の正規表現版を作れば良いわけです。 例えば

abcde
abcfg
afcfg

この3つの単語からトライ木を作ると

a -> b -> c -> d -> e
 \         \-> f -> g
  \-> f -> c -> f -> g

のようになりますから

a(?bc(?:de|fg)|fcfg)

となりますし、これが正規表現だとしても同じ話です。 a\w\d*(?:abc)?a, \w, \d* (?:abc)? がそれぞれ一つの塊と考えて、これが一致するだけ貪欲に前方をまとめるだけです。

面白いのは後方をまとめる処理です。 Regexp::Assembleの例には次のような例が載っています。

my $ra = Regexp::Assemble->new;
$ra->add( 'ab+c' );
$ra->add( 'ab+\\d*\\s+c' );
$ra->add( 'a\\w+\\d+' );
$ra->add( 'a\\d+' );
print $ra->re; # prints (?:a(?:b+(?:\d*\s+)?c|(?:\w+)?\d+))

後方をまとめないと (?:a(?:b+c|b\d*\s+?c|\w+\d+|\d+)) のようになってしまいます。 実はこれも難しい処理ではありません。 | で結合された、前方がばらばらの複数の正規表現を、二重ループで後ろが一致するものを束ねればよいのです。 もしかしたら、うまく実装したらオーダーが下がるかもしれませんが、最初は素朴に実装するのがよいでしょう。


最初に前方をまとめてしまい、その後に後方をまとめる処理を行えばよいはずです。 文字列として最短の正規表現を生成することはゴールではありません。 前方一致を優先させた正規表現を作ればよいはずです。

例えば、最初に例としてあげた a(?bc(?:de|fg)|fcfg) は、 a(?:bcde|[bf]cfg) と書くほうが文字数は少なくなります。 これは後方一致を優先させた結果ですが、 a を見た後に複数の分岐があり、それらがいずれも b を確認する必要があります。 もう少しわかりやすい例をあげると、

a00
a01
a10
a11
a22
b00
b01
b10
b11

これを前方一致でまとめると (rassemble-goの出力結果は)

a(?:[01][01]|22)|b[01][01]

となりますが、

[ab][01][01]|a22

のほうが短いですよね。 しかし、こちらの「短い」正規表現は、先頭の文字が a かどうかを複数の分岐で行っています。

解きたいのは、短い正規表現を吐くことではなく、前方一致を優先させてまとめ、程々に後方もまとめた正規表現を生成することなのです。 ですから、前方一致を貪欲にまとめていけば良いのです。 本家のソースコードをしっかりと確認したわけではありませんが、この実装で概ね本家と同じ正規表現を生成できています。

 % rassemble 'ab*c*d?' 'abcc*d?' 'ad?'
a(?:(?:b*|bc)c*)?d?   # c*, d? がまとまっている
 % rassemble abacination abaction abalienation abarticulation abbreviation abdication abduction aberration
ab(?:a(?:c(?:ina)?|(?:lien|rticul)a)|(?:brevi|err)a|d(?:ica|uc))tion # tion がまとまっている

もしかしたら後方はまとめなくてもいいんじゃないかと思われるかもしれません。 しかし、実際に後方をうまくまとめないと、冗長な正規表現になってしまうパターンは存在します。

abcde
acde
abde
abce
abe
ace
ade
ae

これを後方をまとめる処理を行わないと、

a(?:b(?:c(?:de|e)|de|e)|c(?:de|e)|de|e)

となりますが、後方をまとめることで

 % rassemble abcde acde abde abce abe ace ade ae
ab?c?d?e

とすることができます。 かなり簡潔になりました。 これがうまく動いたときは感動しました。 かなり恣意的な例ですが、後方をまとめないと指数関数的に長くなってしまう (圧倒的にまとめられる) 正規表現はあるのです。

実際にはここまで極端な例はあまりないと思いますが、都道府県を結合するときもなんとなく「県」はまとまってほしいわけです。 上記にrassemble-goを使ったときの出力を書いておきましたが、実際に多くの県がまとまっていますね。 前方一致を優先させることから、「大分県」が他の県と結合せずに「大阪府」とまとまっています。 前方一致としては 山[口形梨]|福[井岡島]、後方一致としては (?:富|和歌|岡)山(?:神奈|石|香)川(?:[広徳]|鹿児)島 のようにまとまっています。 地名が山や川や島でまとまるのは納得感があります。 福島が島じゃなく福でまとまっているのは前方一致優先ですね。

ところで、上記の例 (a(?:[01][01]|22)|b[01][01]) をRegexp::Assembleにわたすと次のような形になります。

a(?:0[01]|1[01]|22)|b(?:0[01]|1[01])

のようになり、 [01] の部分が冗長になっています。 このように、rassemble-goはいくつかのケースでRegexp::Assembleと動作が異なることが分かっています。

 % rassemble ab?c ac
ab?c # Regexp::Assembleではa(?:b?)?c
 % rassemble ab+c ac
ab*c # Regexp::Assembleではa(?:b+)?c
 % rassemble abcde abc bbcde bbc cbcde cbc
[a-c]bc(?:de)? # Regexp::Assembleでは abc(?:de)?|bbc(?:de)?|cbc(?:de)?

冗長な ? の除去や、後方一致の深い判定はやってないようですね。 でも最後の例なんかは、rassemble-goのようにまとめたほうがよい気がします。


Go言語で実装した時に一番悩んだのは、実は文字列の扱いです。 まず、正規表現を自前でパースするのは骨の折れる事ですから、regexp/syntaxパッケージを使うことは最初から決めていました。 このパッケージでパースすると以下のような構造体が返ってきます。

type Regexp struct {
    Op       Op // operator
    Flags    Flags
    Sub      []*Regexp  // subexpressions, if any
    Sub0     [1]*Regexp // storage for short Sub
    Rune     []rune     // matched runes, for OpLiteral, OpCharClass
    Rune0    [2]rune    // storage for short Rune
    Min, Max int        // min, max for OpRepeat
    Cap      int        // capturing index, for OpCapture
    Name     string     // capturing name, for OpCapture
}

例えば abcde という連続する文字列は、

&syntax.Regexp{ Op: syntax.OpLiteral, Rune: []rune("abcde") }

のような形になっています。 これは一文字ずつの木を作りたい時には若干不便です。

例えば、abcd*e?という正規表現abc, d*, e? という3つの *syntax.Regexp になります。 これと abef*g* を結合すると、 ab(?:cd*e?|ef*g*) のようになります。 これは、abcabe を比較して、2文字目まで一致するのでそこまでを切り出して、残りの文字列からまた新しい *syntax.Regexp を作る必要があります。 一文字ずつの構造体になっていれば、このような複雑さはないはずです。

この構造体から自前の構造体に変換しようか結構悩みましたが、今はまだ *syntax.Regexp のまま計算しています。 そして、上でトライ木と述べましたが、実は *syntax.Regexp のまま構文木を操作しています。 一文字ずつに分割したほうが正規表現を表す構造としてきれいですし、一致判定のコードは統一的に書けるでしょう。 しかし *syntax.Regexp 相当の構造体を自前で実装するのは大変です。 構造体に埋め込んで一文字だけを特別扱いするとか、一文字ずつの *syntax.Regexp に分割するとか色々考えましたが、結局それを分割するのにもオーバーヘッドがありますし、コードのきれいさがそこまで見合わないだろうと判断しています。 また、多くの (少なくとも入力の文字列長以上の) 構造体を確保してしまうという、パフォーマンスの懸念もあります。 なので、今の実装の文字列の一致判定は []rune 同士をたどっています。


正規表現をまとめるからには、まとめた正規表現のパフォーマンスが気になるところです。 実際に /usr/share/dict/words の先頭100件、500件、そして1000件をrassemble-goで結合した正規表現と、 | で結合した正規表現を使って、/.../words の先頭2000件の単語をマッチ (MatchString) させたときのパフォーマンスを比較してみました。

BenchmarkRassembleJoin100-16          7065            164284 ns/op
BenchmarkSimpleJoin100-16             1237           1051659 ns/op

BenchmarkRassembleJoin500-16          1634            651378 ns/op
BenchmarkSimpleJoin500-16              224           5405078 ns/op

BenchmarkRassembleJoin1000-16         1112           1038448 ns/op
BenchmarkSimpleJoin1000-16              98          10608690 ns/op

やはりGoの正規表現でも、きちんと先頭をまとめた正規表現を使ったほうが速いことが分かりますね。 苦労して実装したものが実は全く意味なかったとしたらがっくりだったのですが、意味がありそうでよかったですね。

ただし、上記のベンチマークではrassemble-goで正規表現を結合するコストを無視しています。 特に後方一致の計算には時間がかかります (時間がかかると言っても /usr/share/dict/words の全件 (235886単語、2493109バイト) を結合するのに0.6秒くらいです、10000単語なら0.05秒以下で終わります)。 前処理とかプログラムで一回だけ走る箇所に使うのは大丈夫ですが、高いパフォーマンスが求められる箇所でのご利用はお控えください。 そもそも、そういう場面でGoの正規表現を使うのは間違っているのですが…


Regexp::AssembleのGo実装 rassemble-go を作りました。 この休日にがっつり集中して書いたのですが、実装していてめちゃくちゃ楽しかったです。 休日は溶けました。 今はかなり愚直な実装になっているので、もう少しパフォーマンス改善できないか考えているところです。 バグがあったら報告してください。