ファイルをエディターで一括リネームするツールをGo言語で作った! ― 機能を増やさない信念と、OSSとの付き合い方

ファイルを一括でリネームしたいことはありませんか。私はあります。ということで作りました。

f:id:itchyny:20200109200059g:plain

インストールはHomebrew

brew install itchyny/tap/mmv

または以下のコマンドでできます。

go get github.com/itchyny/mmv/cmd/mmv

スクリーンショットではvimが起動していますが、 $EDITOR が設定されていればそれを使って編集することができます。

エディターでファイル名を編集して一括でリネームするというのは、新しい発想ではありません。 実際、多くのソフトウェア (特にファイラー) がこの機能を実装しています。

私はvimfilerの一括リネーム機能をよく使っていました。 特に不満はないのですが、ファイラーの付属機能である必要はないと考えて、またVimユーザーに限らず広く使ってもらえるように、独立したコマンドとして実装し直すことにしました。 一括リネームを実装するにはどういう技術的挑戦があるのか調べたかったという気持ちもありました。

実装

一括リネームと言ってもそんなシステムコールは当然ありませんから、順番にリネームしていくことになります。 簡単な一括リネームを実装すると次のようになります。

func Rename(files map[string]string) error {
    for src, dst := range files {
        if err := os.Rename(src, dst); err != nil {
            return err
        }
    }
    return nil
}

以上。終わりです。

巡回・スライシング

これで終わったらなんの挑戦もありませんが、厄介なケースがいくつかあります。 まずは巡回しているケースです。

a => b
b => c
c => a

そして、ずれていくケースです。

a => b
b => c
c => d

実際のユースケースとしてはあまりなさそうではあるものの、これらを全く考慮せずに実装してしまうと、ファイルを失う事故が起きてしまいます。 事故が起きないようにするには

  • 検知してエラーを吐く
  • リネームできるようにする

という2つの戦略が考えられます。

頭の中でツールの設計をしているときにこれらのケースをどう扱うかしばらく考えていたのですが、検知できるならリネームする順番もわかるだろうと考えて、最初からリネームできるように実装しました。

それではどのようにリネームすればいいのでしょうか。 まず巡回するケースは、一時的なファイル名を用意すれば、次の順にリネームすることができます。

a => tmp
c => a
b => c
tmp => b

ずれていくケースは、ずれの最後から逆順にリネームすればokです。

c => d
b => c
a => b

このようにすれば、リストにあるファイルを失うことなく、リネームを行うことができます (前提として、同じファイル名へのリネームと、同じファイル名からのリネームは排除しているとします)。

これは明らかにグラフの問題になっています。 巡回を判定するには辿ったノードをマークしていけばいいわけですし、ずれの最後から辿るというのはグラフの葉ノード (leaf node) から矢印を逆順に辿ることを意味しています。 グラフの分岐、つまり同じファイル名(への|からの)リネームを排除しているので、そんなに難しい処理ではありません。

いずれにしても、実際のユースケースではあまり起きないケースだろうなということは分かっています。 しかしファイル名を編集してリネームするUIでユーザーが行を入れ替えたときは、おそらくファイルの入れ替えを期待するだろうと考えて実装してみました。 一括リネームツールを作ろうとしていたらいつの間にかグラフの問題を解いていたというのは面白いですね。

機能を増やさない信念

このツールは、一括リネームを行うただそれだけのツールです。 エディターを立ち上げて、ファイル名の変更を読み取って、リネームしていくだけです。 ただリネーム先のディレクトリがなければ作るという挙動があり、これは以下のように画像を整理するときに便利だと思います。 f:id:itchyny:20200109222031g:plain

やりたかったことは既に実装できています。 このツールに関しては、他の複雑なことは実装しないと思います。 例えばファイルを削除したり、リネームの記録をどこかに保存してundoしたりといったことです。 これらはあまりに複雑で、あまりメンテナンスしたくありません *1。 そしてこれらの機能はmassrenが既に実装してますので、そういうものが欲しい人はmassrenに誘導すればいいかなと思っています。

機能を実装しないというのは、重要な設計指針の一つです。

ソフトウェアは機能が多くなるほど、ユーザーが学ばなければいけないことは増えていきます。 一括リネームなんてそもそもめったにやらないオペレーションなのに、そのツールにいろいろな機能があったとして使いこなせるでしょうか?

何かを作りはじめるときに大事なことは、全部入りを目指すのか、シンプルな独立ツールを目指すのかを決めるということです。 全部入りが悪いと言いたいわけではありません。 一貫したインターフェイスで様々なものを操作できる基盤ツールは素敵です。 素敵ですが、そういうソフトウェアを作るときはより慎重に設計しましょう。 達成したい目標が高いほど、設計の失敗が命取りとなることがあります。

シンプルな独立ツールを目指すのであれば、まずはできる限り機能を削ってみてください。 設定もユーザーとのインタラクションもできるだけ排除しましょう *2。 本当にやりたいことしかできない状態になっても、90%、いえ95%くらいの人はそれで十分でしょう。 ほとんど使われないような機能を足すことは、多くの人にとって難しい印象をもたせてしまいます。 機能が多く難しいという印象をもったツールは使わなくなってしまいます *3

UNIXという考え方―その設計思想と哲学

UNIXという考え方―その設計思想と哲学

ソフトウェアをメンテナンスする上で、複雑にならないように保つというのはとてもむずかしいことです。 著名なエンジニアから機能要望やpull requestが来て悩むこともあれば、取り込まないと書いたコメントがdown voteされることもあります。 しかし、機能を取り込んだ後にそれをメンテナンスするのは (多くの場合) あなた自身です。 安易に取り入れた機能に対して芋づる式に要望が増えたり、他の機能と干渉してバグを生んだりして、手に負えなくなることもあるでしょう *4。 理にかなっている機能要望かどうかを設計指針と照らし合わせて判断する力、そして時には機能を引く判断、これがOSSを長くメンテナンスしていくための生存戦略につながるのだと考えています。

まとめ

ファイルを一括でリネームするツールを作りました。

  • ファイル名の一覧をエディターで編集し、その結果をもとにファイルを移動する
  • 移動先のディレクトリが存在しなければ作成してからファイルを移動する

私にとっては既に十分であり、常用するツールになると思います。 そして今のこの気持ちを保ち続けることができたら、一年経ってもこのツールの機能をすべて思い出し、使いこなすことができるでしょう *5

最後になりましたが、様々なフィードバックをしていただいたvim-jpのみなさま、ありがとうございました。

*1:ファイルを削除するだけなのにそんなに複雑なのかと思われるかもしれません。ファイルの削除という危険な操作は、ユーザーによって様々な設定がされている場合があります。例えば単なる削除ではなくて~/.Trash/への移動にするとか、確認が欲しいとか、そういう話です。こういうものを実装し始めると、それは一括リネームツールの範疇を超えてしまいます。挙動をオンオフできるようにしたり、移動先を指定できるようにしたりといった要望が容易に想像つきます。

*2:逆に、すべてを設定できるようにするという発想もあります。茨の道ですが、そちらのほうが適切なケースもあるでしょう。しかし、設定同士が干渉したときの処理が複雑になり、メンテできなくなることが多い気がします。プログラミング言語ソースコードのフォーマッターは両極端で面白いです。

*3:標準のコマンドに関しては、機能が多くて難しくても使わざるを得ないのが現実でしょう。lsやpsのオプションを使いこなしている人はどれくらいいるのでしょうか。使いこなせなくても使うのは、どんな環境でも入っていて換えが効かないからでしょう。こういう分野で代替ツールがユーザー数を伸ばすのは至難の業です (batexaはすごい)。

*4:それはあなたの能力が低いからだと言われるかもしれませんが、その通りなのです。会社に勤めていると趣味OSSに割ける時間は限られていますし、睡眠は重要です (このブログを書くために多少睡眠を削っているわけですが…)。バグ報告や機能追加要望が多いと徐々にさばけなくなり、メンテ疲れしてしまいます。うまくいけば報酬を得てより多くの時間を割くとか、他の人にメンテナンスを委譲するとかできるかもしれませんが、多くの場合はうまく行かないので、自分が使える時間と実装能力、機能の需要やソフトウェアの品質などのバランスを取りながら、メンテナンスしていくことになります。自分に手に負えなくなりそうな機能であれば、要望を弾くのも大事です。時には他の代替ツールに引導を渡す判断が重要になる場面もあるかもしれません。

*5:3日前に誕生したばかりのツールです。ユーザーが増えて色々と言われるうちに気が変わって機能追加を始めるかもしれません。

2019年を振り返って

今年は仕事の部署異動があり気分一新したわけですが、思うようにパフォーマンスを出せず悩んでいたような気がします。前半もチームのために頑張っていた気がするんですがすべて忘れました。

今日は実家でgoreのGo modules対応をやってました。いい加減modules対応していないのやばいよなと思って一所懸命packagesパッケージのコードを読んでいます。まだ確認しないといけないパターンは沢山ありそうですが、年始にはマージする予定です (と書いて追い込んでおく)。goreは本当に便利なのでmodulesごときで死なせたくないですね…

今年はgojqを作れたのは大きいですね。これは本当にいいプロダクトなはずなんですが、宣伝がうまく行ってなくてイマイチですね。docker関連ツールの組み込みあたりを狙えたら本望なんですが、その前に英語で紹介記事を書かないとだめですね。しかしこれを作ったことで一年前よりも言語処理系に対するイメージがはっきりしてきたような気がします。jqのセマンティクスは結構特殊なんですけどね。

社内の諸事情から作ったgithub-migratorも便利なツールなのですが、ユースケースがニッチ過ぎてイマイチですね… 社内ドキュメントは書いてあるのでみんな移行してね。

今年は社用パソコンが変わってかなり快適になりました。自作cliツールのHomebrew tapリポジトリsetupリポジトリを整備できたのは大きいですね。環境セットアップが (理想的には) コマンド一つで立ち上がるようになりました。環境セットアップスクリプトCIを通っている安心感もありますね。

最近社内で圏論勉強会をやっていて、圏論に対する理解がかなり深まったのは大きな収穫です。随伴とか普遍的構成、極限あたりの考え方がだいぶ理解できて自信が付きました。普遍的構成の考え方ってプログラミングと相性がいいんですよね (一意の射が存在することが関数を定義できるのと同じなので)。ちゃんとプログラミングの世界に成果を持ち帰りたいところです。

旅行は夏に長崎に行きました。色づく世界の明日からやsolaの聖地を訪れることは良かったのですが、坂道の多い街なので歩きまわって疲れましたね。色づく世界の明日からのカットを回るにはもうちょっと下調べが必要でしたね。とある公園から山を降りるときにミスって墓地に迷い込んで大変でした。稲佐山からの夜景はめちゃくちゃきれいでした。

今年の良かったアニメは『ベルゼブブ嬢のお気に召すまま。』『やがて君になる』『かぐや様は告らせたい~天才たちの恋愛頭脳戦~』『女子高生の無駄づかい』『まちカドまぞく』『鬼滅の刃』あたりでしょうか。かぐや様の古賀葵さんめっちゃくちゃ良かったですね。ラジオ『令和最初の告RADIO』も良かった。

来年もいい一年になりますように、きっとなりますように。

錆兎「努力は、どれだけしても足りないんだよ。知ってるだろ、それはお前も」

鬼滅の刃 第四話

GitHub Enterprise から GitHub への移行ツールをGoで作りました!

弊社ではGitHub Enterprise (以下GHE) からGitHubへの移行が進んでいます。今年頭のプラン改変GitHub ConnectActionsAppsの充実などGitHubの機能強化が後押しとなりました。GHEのメンテナンスコストも徐々に重荷になってきていました。

リポジトリを移行するにあたって問題となるのが、これまでの歴史をどこまで新リポジトリに移行するかということです。もちろんgitのログはそのまま移行できますが、以下のようなものも移行したいと言われると色々と考えることが出てきます。

  • issueやpull requestのコメントやレビュー、ラベル
    • コードコメントからの参照もあるし、リポジトリ間も相互にリンクしている
      • 番号を維持したい
  • projectやmilestone
    • スプリントのフローが依存している
      • 今のカンバンをそのまま移行したい

これらをすべて移行するツールをGoで作りました。

f:id:itchyny:20191224220759j:plain

  • issueを番号を維持したまま移行します
    • 番号を維持することで、コメントのリンクはリポジトリのURLを変更するだけでOKです
    • ラベルもそのまま移行します
  • pull requestはissueに変換して移行します
    • レビューコメントもissueへのコメントに変換します
    • 番号が維持されるので、merged commitからのリンクも維持されます
  • プロジェクトやマイルストーンも、ほぼそのまま移行します
    • こちらも番号が維持されます
    • プロジェクトのautomationsはAPIで指定できないので移行しない
  • webhookの設定も移行します
    • webhookの設定は、手でやるとイベントを選ぶ必要があり面倒です

類似ツールとの比較

もともとfastlane社のissue移行ツールがありました。 issueのコメントを、それを書いた人が分かる形でリポジトリを移行する素晴らしいツールです。 issueやpull requestの移行にはIssue import APIを使っています。 tableタグでアイコンを出すなど、見た目の上でもかなり参考にしています。

issue番号を維持するというのはaereal/migrate-gh-repoにアイディアをもらいました。 issue間の相互リンク (#128のようなもの) やmerged commitのメッセージなど、issue (pull request) へのリンクが壊れると大変な箇所は意外とたくさんあります。 projectやmilestoneを移行するのにもissue番号が維持されているのは必要なことなのです (このことを気がつかせてくれました)。

issue・pull request番号を維持しつつコメントもレビューコメントもすべて移行する、それがgithub-migratorです。

リポジトリの歴史をどこまで捨ててよいかというのは様々な意見があると思います。 gitのログさえ残っていればよいという人もいれば、すべてのコメントやレビューに価値があり残すべきと考える人もいるでしょう。 GHEは当分運用されると分かっていても、いつか来る撤退の日に向けて、移行時にできるだけ情報を吸っておきたいというのが私の気持ちです。

実装

言語選択は、自分が書けて好きな (書いていて苦にならない) 言語と、社内で書ける人が多く手元で動かせる言語で共通集合を取ってGo言語一択でした。

GitHubAPIを叩く部分は自前でクライアントの実装を行っています。 golang/go-githubはIssue import APIに対応していない (undocumentedなAPIなので仕方ない) のと、構造体のメンバーがポインターだらけで使いにくいです。 APIクライアントは自前で作ったほうがAPIへの理解が深まるし、クライアントのドキュメントとにらめっこしなくてもよいし、リトライとかキャッシュとか変なところではまらなくてよいと思っています。

github-migratorは、issue一覧やコメント一覧、プロジェクトカード一覧など、様々なものの一覧APIを叩いています。 GitHubAPIは基本的にどんなリソースも100件ずつしか取れません。 ページングはレスポンスのLinkヘッダーを見て行います (参考)。

APIクライアントはページングのAPIをどのように扱ったらいいのでしょうか。 ユーザーとしては、ページングの切れ目を意識したくはありません (少なくとも今回のユースケースでは)。 そこで、イテレータを返してページングがユーザーに見えないようにしてみました。

// Issue represents an issue.
type Issue struct {
    ID int `json:"id"`
    // ...
}

// Issues represents a collection of issues.
type Issues <-chan interface{}

// Next emits the next Issue.
func (is Issues) Next() (*Issue, error) {
    for x := range is {
        switch x := x.(type) {
        case error:
            return nil, x
        case *Issue:
            return x, nil
        }
        break
    }
    return nil, io.EOF
}

// ListIssues lists the issues.
func (c *client) ListIssues(repo string, params *ListIssuesParams) Issues {
    // ...
}

使う側は、次のような感じです。

   issues := cli.ListIssues("sample/repo", &ListIssuesParams{})
    for {
        issue, err := issues.Next()
        if err != nil {
            if err == io.EOF {
                return nil
            }
            return err
        }
        fmt.Printf("%#v\n", issue)
    }

次のページとかページあたりの数とかを気にせず、一重のループで全件辿れるのは素敵です。 このやり方は基本的に全件たどりたいという今回のようなパターンには合うと思います (カーソルベースのページングには合わない)。

github-migratorはissueの番号を維持するように実装されています。 実はこれはそんなに簡単ではありません。 まずimportを順番に、エラーを確認しながら行う必要があります。 並列に作成して片方が失敗してしまうと、別のissueがその番号をとってしまいます。 そして番号は飛ぶことがあります。 issueやprojectが削除されたらその番号は欠番になりますし、issueはすでに別のリポジトリに移転されているかもしれません。 欠番は新しいリポジトリでも欠番でなくてはなりません (実はAPIでissueを削除することはできないため、github-migratorでは空のissueを作って削除は諦めています・projectは削除しています)。

リポジトリに何千件とissueがあると、そこそこ時間がかかってしまいます。 github-migratorは、いつ中断されても、同じコマンドで再開できるように設計されています。

GitHub間のリポジトリの移行ならば問題はないのですが、GHEからの移行で一番大きな問題になるのはユーザーの対応です。 IDが異なるユーザーがいるかも知れませんし、GHEに存在するユーザーがGitHubに存在しないかもしれません。 同じIDのユーザーが存在するのだけど実は別人の可能性もあります。 コメントを書いた人が同一IDの別人にリンクされたり、あるいはIDが異なる人のアイコンが出なかったりすると少し不便です。 こういう対応は人間にしかわかりませんから、ユーザーIDの対応を設定してもらうことにしました。 受け取った設定を元に、issueのauthorやメンションなどを置換するようにしています。

テスト

github-migratorは、APIを叩いて情報を収集し、APIを叩いて投稿するツールです。 移行元の情報と投稿する情報の対があれば、それが一つのテストケースになります。

Go言語ではテストケースを一覧で定義してループで回すTable driven testsスタイルが推奨されますが、構造体が複雑になるとこれさえ書くのが億劫になってきます。 そういうときはYAMLファイルにテストケースを書いてしまうのがおすすめです。 複雑な構造体を書く必要はありませんし、JSONとの変換のテストにもなっています (実はgojqでも同じようにテストケースを書いています)。

GitHub APIクライアントのモックはFunctional options patternで行っています。 必要なAPIのみをモックしたり、最初はサーバーが落ちていて2回叩いたらOKを返すなど (これは今回はやってませんが)、APIクライアントのモックには適した方法だと思っています。 APIクライアントを使うツールをテストするときは、頑張ってローカルにサーバーを立ててテストするよりも、モックしてしまうほうがよいでしょう。 テストをたくさん書いて通しても、本番サーバーに向けて落ちるものは落ちます (メソッドが間違ってたりヘッダーが足りなかったり)。 本番サーバー相当のバリデーションをテストに書くのはあまりにもナンセンスです (docker imageが提供されていないか探すほうがよい)。 作っているものがAPIクライアントlibraryそのものであり、その品質を高めたいという場合はサーバーを立ててもいいと思いますが、そうでない場合は頑張りすぎないほうが良いと思います。

まとめ

GitHubリポジトリ移行ツールをGoで作りました。 issueのコメントやレビュー、プロジェクトやマイルストーンなどをほぼすべてそのまま移行するツールです。

GitHub APIクライアントは自前で書く選択を取りました。 おかげでGitHubAPI v3にはそこそこ詳しくなったと思います。 自前で書くのはあまりおすすめできませんが、今回はundocumentedなAPIを叩く必要があり、また二週間ほどで作り上げる必要があったので (問題があったときにissueを立てて待ったりするのに律速されたくなかった)、すべて自分で書いてしまいました。 事前に必要なAPIが分かっていて、そんなに複雑なプロトコルでなければ、自前で書くのもアリだなと思いました。

私の所属しているチームのほぼすべてのリポジトリを今回作ったツールでGitHubに移行しました。 issueの移行もそうですが、projectsの移行をきちんと実装していたおかげで、スプリントの進行を妨げることなく (ポチポチとカンバンを作り直すことなく)、GitHubに移行することができました。 GitHub ActionsDependabotRenovateなどのモダンな開発ツールを勢いよく取り入れて、より効率よく開発を回し、コードの健全性を維持していきたいと思います。

開発環境構築スクリプトのCIをGitHub Actionsで回す

小ネタですが、開発環境の構築はスクリプト化して、CIを回そうという話です。

開発環境を構築することは年にそう何回もあるわけではないですが、スクリプトを一発叩いて必要なツールが揃うようにしておくと便利です。私は素朴にシェルスクリプトで書いています。好きな言語で書けばいいと思いますが、macOSは将来的にRubyやPythonといったスクリプト言語を排除しようとしていて、不安ですね。Ansibleみたいなのを使ってもいいと思います。私はちょっと苦手で…

あくまで私用のスクリプトなので使わないでください。

このスクリプトを叩いてしまえば、iTerm2やVim、tmux、自分のdotfilesの配置と言語処理系のインストール、Google ChromeやSlackのインストールを行ってくれます。モダンなプロジェクトならdockerさえあればいいんでしょうが、なかなかそういうわけにはいかないですよね。

この環境構築スクリプトを作り始めてから、普段使っている自作cliツールのHomebrewルールをきちんと書くようになりました。当初は環境セットアップ時にもビルドすればいいのではと思っていたのですが、コンパイルに時間のかかる言語だとセットアップにも時間がかかってしまうので、やはりGitHub Releasesからビルド済みのバイナリを落としてくるだけのほうが構築時は楽ですね。

あと、dotfilesでbootstrap的なことをやるのはあまりおすすめしません。もともとdotfilesを配置してzshを叩けば色々とプラグインをとってくるみたいな処理を.zshrcに書いていたのですが、環境構築のスクリプトを作ったらそちら側にまとめられますし、一度セットアップしたらプラグインディレクトリのチェックなどは不要になるので、rcファイルは大幅に削減できました。

環境構築スクリプトを育てていると、どうしても今のPCの環境には適用できるけれど実は新規PCには適用できなくなっているということは起きてしまいます。まっさらな状態からセットアップすることはめったにありませんからね。具体的にはディレクトリのないところにsymlinkを貼るとか、セットアップの前の方で入れているツールを後の方で使っているのだけどPATHが通っていないとか、そういうケースです。

そこでCIを回しましょうという話です。最近、GitHub Actionsのmacos-latestでCIを回すようになりました。実際にCIを回したら、必要なディレクトリを作るのを忘れていたり、諸々通らないことが発覚しました。

CIを回すと、環境構築にかかるおよその時間がわかります。もちろんスペックの違いや、GitHubとのレイテンシの違い (Actionsはcloneが異常に速い) などもありますが、概ね20分でセットアップが完了するようです。一つコマンド叩けば概ね普段の環境が立ち上がるのはやはり良いですね。

おしまい

jqのGo実装 gojq を作りました! ― スタックマシン型インタープリタによるイテレータセマンティクスの実装

jqはとても便利なコマンドです。 JSONを返すAPIを実装するときや、SaaSAPIから特定の情報を抜き出してシェル変数に代入するときなど、web開発や運用には欠かせないツールとなっています。

しかし、私にとってjqのクエリを一発で書くのは容易ではなく、思い通りの出力が得られないことがよくありました。 難しいエラーメッセージに悩まされて、jqで書くのを諦めて別の言語で書き直すこともありました。 jqの十八番と思える場面で使いこなせないのは、なかなか悔しいものがあります。

ツールを使うのが難しいなら、同じものを作ってしまえばよいのです。

  • jqの全ての機能を実装する
  • jqを言語としてきちんと書けるようになる
  • jqを完全に理解する

jqの全ての機能を自分で実装してしまえば、jqがどういうものか、クエリがどのように処理されるのか、詳しくなれるはずです。 jqを得意な言語と言えるようになって、クエリを自由自在に書けるようになるはず。 そう考えた私は、Go言語でjqを実装することにしました。

すでにjqの大部分の機能は実装し終わり、わりと満足しています。 おそらくjqの機能をここまでカバーした別実装は他にはないと思います。 jqのかなり深いところまで理解できましたし、ビルトイン関数も全て頭に入ったので、リファレンスを見なくても複雑なクエリをすばやく書けるようになりました。

しかし、gojqを実装する過程では、処理系の設計ミスによる書き直しやjqならではのスタックの難しさなど、様々な困難がありました。 jqの言語としての特徴を説明した上で、gojqの実装過程で学んだことについて説明していきたいと思います。

jqの特徴

多くの人は、JSONを絞り込むツールとしてjqを使っていると思います *1

 % echo '{"foo":{"bar":42}}' | jq '.foo.bar'
42
 % echo '[{"id":1,"name":"alice"}, {"id":2,"name":"bob"}, {"id":3,"name":"charlie"}]' \
     | jq -r '.[] | select(.id == 2) | .name'
bob

jqはJSONを絞り込むだけではなく、JSONイテレータを出力することもできます。

 % jq -n '1, 2, [3], {"foo":4}'
1
2
[
  3
]
{
  "foo": 4
}
 % echo '{"foo":[1,2,3]}' | jq '.foo[]'
1
2
3
 % echo '{"foo":[1,2,3]}' | jq -c '..'
{"foo":[1,2,3]}
[1,2,3]
1
2
3

いきなり出力がたくさんのJSONになって手に負えなくなる、そういう経験はないでしょうか。 そうなった場合は、いかなるときでも [ ] で囲うことで配列に戻すことができます。

 % echo '{"foo":[1,2,3]}' | jq -c '[..]'
[{"foo":[1,2,3]},[1,2,3],1,2,3]
 % echo '{"foo":[1,2,3]}' | jq -c '[.foo[]]'
[1,2,3]

実は、jqには配列そのものの構文はありません。 [1,2,3] は配列に見えますが、これは 1,2,3 というイテレータに、[ ] という配列構築子を適用させているに過ぎません *2

 % jq -n '1,2,3'
1
2
3
 % jq -n -c '[1,2,3]'
[1,2,3]
 % echo '{"foo":[1,2,3]}' | jq -c '[.foo[], 4, 5]'
[1,2,3,4,5]

jqは、JSONイテレータを扱う言語です。 jqのクエリのあらゆるもの、nullfalsetrue{}1,2,3(1, 2) * (3, 4) など、全てJSONイテレータとして捉える事ができます。 "hello, world" のように一つの文字列を返すイテレータもあれば、empty のように何も返さないイテレータdef f: 1, f; f のように無限に 1 を返すイテレータなど、様々なものがあります。

 % jq -n '(1, 2) * (3, 4)'
3
6
4
8
 % jq -n -c '[range(1;5) * range(1;5)]'
[1,2,3,4,2,4,6,8,3,6,9,12,4,8,12,16]
 % jq -n -c 'def f: 1, f; [limit(10; f)]'
[1,1,1,1,1,1,1,1,1,1]
 % jq -n -c '[[0,1] | while(.[0]<100; [.[1], .[0]+.[1]]) | .[0]]'
[0,1,1,2,3,5,8,13,21,34,55,89]

(1, 2) * (3, 4)3, 6, 4, 8 になる、こんなに面白い言語は他にあるでしょうか。

jqで最も大事な演算子は何でしょうか。 それは , (コンマ演算子) です。 jqを理解すること、それはすなわちコンマ演算子を理解することであり、それが生み出すイテレータがどのように評価されていくかを理解することです。 JSONイテレータを処理する言語であるという認識がないと、jqのコードを書くことはできません。

イテレータの話は少し置いておいて、関数の話をしておこうと思います。 これまでも例に出していますが、jqでは関数を定義できます。

 % echo '[1,2]' | jq 'def f(a; b): a * 10 + b; f(.[]; .[])'
11
21
12
22
 % jq -n -c 'def f($n): if $n == 0 then empty else $n, f($n - 1) end; [f(10)]'
[10,9,8,7,6,5,4,3,2,1]

クエリには、常に入力があることに気をつけてください。 n倍するという関数は、次のように書けます。

 % echo '1 2 3' | jq 'def ntimes($n): . * $n; ntimes(3)'
3
6
9

平均値を取る関数は、次のように書けます。

 % echo '[1, 3, 6]' | jq 'def mean: add / length; mean'
3.3333333333333335

引数をとらないように見える関数でも、常に入力があることを意識する必要があります *3

たかがJSONを処理するだけのツールなのに関数定義なんて必要なのか、そのように思われるかもしれません。 しかし、mapselect という絞り込みの必需品たちは、jqの関数として実装されています。

def map(f): [.[] | f];

def select(f): if f then . else empty end;

keystostring のようにC言語で実装されている関数もありますが、mapselectfirstallmap_valuessubなど、多くのビルトイン関数がjqで実装されています (参考)。 .. というすべての要素を辿って出力するフィルターさえ、次のように定義されるrecurse関数へのエイリアスとなっています。

def recurse: recurse(.[]?);
def recurse(f): def r: ., (f | r); r;

多くのビルトイン関数がjqで実装されているという事実は大切なことです。 C言語で書く関数や構文をできるだけ限定し、メモリーリークなどのCで書くことに起因するバグを減らすのみならず、ビルトイン関数自体が処理系のテストケースとなっています。 jq .. と書いただけでも、上記のrecurse関数のコンパイル、関数内の関数定義、引数 f の処理、 .[]? の処理など、様々なことが処理系の中で起きているのです。

jqクエリの処理系を作ること、それはjqのビルトイン関数がきちんと動く処理系を実装することでもあるのです。 そして、jqで書かれたビルトイン関数のパフォーマンスを改善することが、すなわち言語処理系としてのパフォーマンスを改善することにつながるのです。

channelによる実装と挫折

3月にjoのGo実装を作ってまもなく、jqをGoに移植したいという気持ちが高まっていました。 網羅的にjqの機能を知りたい、プログラミング言語としてきちんとjqのコードを書けるようになりたい、そのためには自分で処理系を作らずにはいられないと思い至ったのです。

jqがJSONイテレータを扱う言語であるという淡い認識ができた頃、Goのchannelで実装できるのではないかと考えるようになりました。 JSONをデコードしたものは絶対にchannelにならないので、channelを使えばJSONイテレータを区別できます。

言語の処理系を、全く別の言語の機能を用いて作るというのは興味深いことだと思いませんか *4。 Go言語の特徴の一つであるchannelを用いたインタープリタが、jqのテストケースを全てパスしたら面白いに違いない。 どうせ実用上はそこまでパフォーマンスは求められないだろうし、Go言語で書いたらそこまで酷い速度にはならないだろう。 そう考えた私は、構文木を直接評価する形の素朴なインタープリタを実装していきました *5

"hello, world" は文字列を一つ返すイテレータといいましたが、何らかの値をイテレータに変換するには次のような関数を用いて変換できます。

func unitIterator(v interface{}) <-chan interface{} {
    c := make(chan interface{}, 1)
    defer func() {
        defer close(c)
        c <- v
    }()
    return c
}

配列構築子は、中のクエリを全て消費して一つの配列を返します (エラーハンドリングは省略しています)。

func (env *env) applyArray(x *Array, c <-chan interface{}) <-chan interface{} {
    a := []interface{}{}
    for v := range env.applyQuery(x.Query, c) {
        a = append(a, v)
    }
    return unitIterator(a)
}

for文でイテレートできるのは、channelを使ったイテレータの良いところだと思います。 二項演算子の評価は、両辺をイテレートして関数の評価結果をイテレータに流すといった具合です。

func binopIterator(l <-chan interface{}, r <-chan interface{}, fn func(l, r interface{}) interface{}) <-chan interface{} {
    d := make(chan interface{}, 1)
    go func() {
        defer close(d)
        ls := reuseIterator(l)
        for r := range r {
            for l := range ls() {
                d <- fn(l, r)
            }
        }
    }()
    return d
}

左辺をイテレートするときに reuseIterator なるものを使っていることに注意してください。 例えば (1, 2) * (3, 4) では、初回のループで 3, 6、次のループで 4, 8 を出力するので、左辺のイテレータは何度もイテレートしないといけないのです。 このためには、左辺は何度も最初から消費し直せるようにしなければなりません。 reuseIterator を実装するのは楽しいので、読者への課題とします *6

改訂2版 みんなのGo言語

改訂2版 みんなのGo言語

channelベースのイテレータを使った実装は、ある時点まではかなりうまく行っていました。 reduceforeach というjq特有のシンタックスも実装できたときには満足していましたが、徐々にある問題が見えてきました。

パフォーマンスがあまりにもひどいのです。 jq -n 'range(10000)' は0.1秒もかかりませんが、gojqだと4分もかかってしまうのです。 なぜ数字を順番に出力するだけなのにこんなにかかるのかと思われるかもしれませんが、range関数は次のように定義されており、この関数を実行しないといけません。

def range($n): 0 | while(. < $n; . + 1);

def while(cond; update):
  def _while: if cond then ., (update | _while) else empty end;
  _while;

このwhile関数というのは面白い関数です。 jqにはループ構文がありませんが、再帰を使えばループを表現できます *7。 つまりjqが反復処理ができる言語であることの証左となっているわけです *8

こんなにパフォーマンスが悪いのは、channelを使ったのが良くなかったのでしょうか。 イテレータはchannelを使わずとも次の値を返すメソッドを持つ構造体で実装できます。 全てのchannelをやめて構造体に実装を変更してみましたが、それでも40秒ほどまでしか縮まりませんでした *9。 4分と比較すればchannelを使うのをやめた分パフォーマンスは良くなっていますが、それでも0.1秒もかからない本家を思い出したら比べ物になりません。

gojqの実装を始めた当初、jqの全てがJSONイテレータであるというセマンティクスは、Go言語のchannelによって直接表現できるに違いないという思いがありました。 それは概ね当たっており、jqの多くのテストケースをパスする処理系を作ることができました。 しかし、評価時にあらゆるものをchannelで扱うのは相当なオーバーヘッドがあることが明らかになってきました。 channelをやめることで数倍の速度改善が得られましたが、jqと比較したら何千倍も遅い処理系です。 実際のユースケースではこのような複雑なクエリは評価されないとは言え、特定のケースで何千倍も遅い後発のインタープリタはどう頑張っても魅力的には思えません。

構文木を直接たどって実行する評価器は大きなオーバーヘッドを伴います。 変数も実行時に名前で探さなくてはいけませんし、構文をたどるときに多くの分岐処理が必要になります。 構文が複雑になるほど、つまり構文木が深くなればなるほど、実行時のパフォーマンスは落ちていきます。 関数の実行処理も複雑になりますし、構文上のエラーを素早く返すこともできません。

このパフォーマンスの問題には、ずいぶん悩まされました *10。 何週間もかけて作った処理系のパフォーマンスがあまりに酷くて、参ってしまったのです。 また一から処理系部分を書き直すのか、それともgojqプロジェクトをここで諦めるのか。 せっかく積み上げてきたコードを捨てるのも忍びなく、かと言ってここまで作ってきた処理系を見放すのもできず、落ち込んで二週間ほどgojqのコードを触れない時期がありました。

スタックマシンによる実装

実行時に構文木をたどっていては、jqの速さには到底追いつけません。 そもそも追いつけないとかそういうレベルですらなく、range(10000)に何十秒もかかっているようではお話になりません。 やはりインタープリタの処理系は、構文木を実行時にたどっているようでは速度が出ないのです。 私はそれまでに書いてきた評価器を捨てて、スタックマシン方式のインタープリタに書き直す決心をしました。

仮想マシンの簡単な説明をしておきます。 例えば 1 * 2 + 3 * 4 を評価したいとしましょう。 スタックマシン方式のインタープリタでは、この式の構文木を次のような命令列に変換します。

0: push 1
1: push 2
2: call multiply
3: push 3
4: push 4
5: call multiply
6: call add
7: ret

実行時には、スタックを用意して命令を順番に解釈していきます。

[]
[ 1 ]         # 0: push 1
[ 1, 2 ]      # 1: push 2
[ 2 ]         # 2: call multiply
[ 2, 3 ]      # 3: push 3
[ 2, 3, 4 ]   # 4: push 4
[ 2, 12 ]     # 5: call multiply
[ 14 ]        # 6: call add
[]            # 7: ret => output: 14

とてもシンプルな例ですが、全てのスタックマシン方式のインタープリタ言語はこの延長にあるものです。 Ruby (YARV) もPython (CPython) もJVMもWebAssemblyも、みんなスタックマシンを使っています *11仮想マシンの命令セットをどう設計するかが、そのインタープリタ言語を特徴づけています。

jqもスタックマシン方式のインタープリタ言語です。 他の言語と大きく違うのは、スタックの状態を保存して復帰できるということです。

例えば (1, 2) * (3, 4) を評価することを考えましょう。 まずjqが面白いのは、右辺を先に評価するということです *12。 そして、jqではカンマ演算子fork という命令にコンパイルします *13

0: fork 3
1: push 3
2: jump 4
3: push 4
4: fork 7
5: push 1
6: jump 8
7: push 2
8: call multiply
9: ret

この命令列を実行すると、次のようにスタックは変化します。

[]
[]        # 0: fork 3 => forks: [ (3, []) ]
[ 3 ]     # 1: push 3
[ 3 ]     # 2: jump 4
[ 3 ]     # 4: fork 7 => forks: [ (3, []), (7, [3]) ]
[ 3, 1 ]  # 5: push 1
[ 3, 1 ]  # 6: jump 8
[ 3 ]     # 8: call multiply
[]        # 9: ret => output: 3
[ 3 ]     # backtrack => restore stack ([3]) and pc (7), forks: [ (3, []) ]
[ 3, 2 ]  # 7: push 2
[ 6 ]     # 8: call multiply
[]        # 9: ret => output: 6
[]        # backtrack => restore stack ([]) and pc (3), forks: []
[ 4 ]     # 3: push 4
[ 4 ]     # 4: fork 7 => forks: [ (7, [4]) ]
[ 4, 1 ]  # 5: push 1
[ 4, 1 ]  # 6: jump 8
[ 4 ]     # 8: call multiply
[]        # 9: ret => output: 4
[ 4 ]     # backtrack => restore stack ([4]) and pc (7), forks: []
[ 4, 2 ]  # 7: push 2
[ 8 ]     # 8: call multiply
[]        # 9: ret => output: 8
[]        # backtrack => no forks, exit

fork命令はオペランドに復帰時のプログラムカウンタ (pc) の位置を持っており、実行時にはスタックの状態と一緒に保存します。 この状態は値のスタックとは別のスタックにpushされます。 バイトコードのいちばん最後まで来たら、forkされた状態を復帰してpcをセットします。 復帰すべき状態がなくなったらプログラムを終了します。

コンマ演算子fork命令にコンパイルされます。 この命令の実行時には、スタックの保存と復帰、pcの移動が行われます。 これによって、 (1, 2) * (3, 4)3, 6, 4, 8 となるという挙動を実装しているのです。

問題は、スタックの状態をどのように保存するかということです。 プログラムにもよりますが、スタックは長く伸びることがあります。 fork命令のたびにスタックを深くコピーしていては、良いパフォーマンスが出るようには思えません。

保存と復帰できるスタックは、片方向連結リスト (singly-linked list) によって実装できます。 スタック上に連結リストを構築し、先頭の index と、forkによって後で使われるかもしれない最大のindex (ここでは limit と呼ぶことにします) を持っておきます。 値をpushするときは、 indexlimit よりも大きければ index + 1 に、そうでなければ limit + 1 に値を保存します。 fork時は、 limitindex に更新して、 indexlimit の組をforkのスタックに保存します。 indexlimit を戻すだけでスタックの状態は復帰します。 このスタックの実装はjqgojqで少し異なるので、ぜひ見比べてみてください。

jqにおいて最も大事な演算子, (コンマ演算子) であると述べたのを覚えているでしょうか。 この演算子は、そのまま fork命令にコンパイルされます。 この命令こそがjqの処理系の要であり、そしてjqをjqたらしてめているのです。 この演算子の実装を理解してようやくjqを理解したと言えるのだと思います。

gojqをスタックマシン方式のインタープリタに書き換えた結果、range(10000)の実行時間が0.2秒ほどになりました。 40秒から0.2秒ですから、200倍の改善、channelを使った実装と比べると1000倍の改善です。 jqが0.1秒程度ですから悪くありません。 rangeのパフォーマンス改善はすなわちwhile関数のチューニングであり、末尾再帰の最適化も必須でした。 このような最適化を行うのは私も初めてのことであり、とても勉強になりました。

ビルトイン関数の事前パース

jqは多くのビルトイン関数を持っています。 これらの関数は、jqがクエリを実行するときに必ずパースされています。 jq .foo のような簡単なクエリだとしても、必ず全てのビルトイン関数のパースが行われています。

gojqを作る前から、これは無駄ではないかという思いを持っていました。 jqの実際のユースケースを考えると、関数の九割九分は使われていないと思います。 jqのパーサーがいくらパフォーマンスがよくても、実行時に常にパースされるという性質があると、関数は増やさないでおこうという方向に動いてもおかしくありません。 便利な関数はどんどん増えてほしいものです。

gojqでは、コンパイル前にビルトイン関数をパースしてしまい、構文木をバイナリに埋め込むようにしています。 ここで問題になるのが、どのように構文木をバイナリに埋め込むかということです。

当初は構文木を何らかのデータフォーマットに変換して埋め込み、実行時にdecodeするという方式を考えていました。 しかしこれではjqに抱いていた問題をあまり解決できていません。 そしてあまり良さそうなフォーマットが見つかりませんでした。 gobはこういうことをするのには良いフォーマットだと思いますが、0 (数値) や空文字へのポインタをうまく扱えないので諦めました。

結局、ビルトイン関数の構文木をGo言語のソースコードとして出力し、コンパイルすることにしました。 そのためにastgenというライブラリを作って使っています。

package main

import (
    "go/printer"
    "go/token"
    "os"

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

type X struct {
    X int
    Y *Y
    Z Z
}

type Y struct {
    M string
}

type Z struct {
    S string
    I int
    B bool
}

func main() {
    x := &X{
        Y: &Y{"hello"},
        Z: Z{B: true},
    }
    n, err := astgen.Build(x)
    if err != nil {
        panic(err)
    }
    printer.Fprint(os.Stdout, token.NewFileSet(), n)
}
&X{Y: &Y{M: "hello"}, Z: Z{B: true}}

値へのポインタは次のように出力されます。

type X struct {
    S *string
    I *int
    B *bool
}

func main() {
    s := "hello"
    i := 42
    b := true
    x := X{&s, &i, &b}
    n, err := astgen.Build(x)
    if err != nil {
        panic(err)
    }
    printer.Fprint(os.Stdout, token.NewFileSet(), n)
}
(func(h string, x int, t bool) X {
    return X{S: &h, I: &x, B: &t}
})("hello", 42, true)

関数やチャンネルはもちろん扱えませんが、gojqの構文木程度ならばかなりうまく扱えています (生成コードはこちらです)。 gobと異なり、組み込み型へのポインタやポインタへのポインタなどもうまく扱えます *14

func main() {
    s := "hello"
    p := &s
    pp := &p
    for _, x := range []interface{}{s, p, pp, &pp} {
        n, err := astgen.Build(x)
        if err != nil {
            panic(err)
        }
        printer.Fprint(os.Stdout, token.NewFileSet(), n)
        fmt.Println()
    }
}
"hello"
(func(h string) *string {
    return &h
})("hello")
(func(h string) **string {
    h1 := &h
    return &h1
})("hello")
(func(h string) ***string {
    h1 := &h
    h11 := &h1
    return &h11
})("hello")

go generateでコード生成を行うのは、Go言語ならではです。 コード生成はGoの楽しみの一つですね。

処理系を書き換えたことで、ビルトイン関数をコンパイルしたバイトコードをバイナリ埋め込んでしまうという最適化もありえます。 ただ、これは行き過ぎた最適化ではないかと感じており踏み切れていません。 構文木をコードで埋め込むくらいがいいバランスではないかと思っています。

jqを超えて次のステージへ

gojqは、jqの完全なクローンを目指しているわけではありません。 jqの良くない部分は修正し、より良いcliツールを目指していきたいと思っています。

jqの数字の実装は64bit浮動小数点数であり、任意精度整数をサポートしていません。 JSONを絞り込むだけのツールに任意精度の整数なんていらないでしょうと思われるかもしれませんが、17桁を超えるIDの数字や、ナノ秒の情報など、数字の精度が落ちては困る場面はたまによくあります *15

gojqは任意精度の整数をサポートしています。 入力のJSONの中の整数の精度を保持するだけではなく、任意精度整数での演算をサポートしています。

 % echo '{"foo": 4722366482869645213696}' | jq .foo
4722366482869645000000
 % echo '{"foo": 4722366482869645213696}' | gojq .foo
4722366482869645213696
 % jq -n '123456789 * 987654321'
121932631112635260
 % gojq -n '123456789 * 987654321'
121932631112635269
 % gojq -n '123456789123456789 * 987654321987654321'
121932631356500531347203169112635269
 % gojq -n '987654321987654321 % 123456789123456789'
9000000009
 % gojq -n -r 'def fact($n): if $n < 1 then 1 else $n * fact($n - 1) end; range(40;51) | "\(.)! = \(fact(.))"'
40! = 815915283247897734345611269596115894272000000000
41! = 33452526613163807108170062053440751665152000000000
42! = 1405006117752879898543142606244511569936384000000000
43! = 60415263063373835637355132068513997507264512000000000
44! = 2658271574788448768043625811014615890319638528000000000
45! = 119622220865480194561963161495657715064383733760000000000
46! = 5502622159812088949850305428800254892961651752960000000000
47! = 258623241511168180642964355153611979969197632389120000000000
48! = 12413915592536072670862289047373375038521486354677760000000000
49! = 608281864034267560872252163321295376887552831379210240000000000
50! = 30414093201713378043612608166064768844377641568960512000000000000

現在は四則演算と剰余、比較演算子しかサポートしていませんが *16、わりと楽しめるのではないかと思います。 例えば Project EulerのProblem 162^{1000} の各桁の和を求めよという問題ですが、gojqを使うと次のように解くことができます。

 % gojq -n 'def pow2($n): if $n < 1 then 1 else 2 * pow2($n - 1) end;
     pow2(1000) | tostring | explode | map(. - 48) | add'

Project EulerのProblem 20100! の各桁の和ですね。

 % gojq -n 'def fact($n): if $n < 1 then 1 else $n * fact($n - 1) end;
     fact(100) | tostring | explode | map(. - 48) | add'

オーバーフローを心配することなく整数を自由に計算できるのは安心感がありますね。 Project Eulerをjqで解くチャレンジをした人はこれまでいたのでしょうか。 私もjqのポテンシャルを証明するために、何問か解いてみようと思います。

まとめ

jqのGo言語実装を作りました。 ほぼ全ての関数を実装しており、エイリアスを貼ってもほとんどの人は気が付かないと思います。 クエリが誤ったときのエラーメッセージを改善しており、クエリを書いたりjqを理解するための手助けになるのではないかと思っています。

jqは完全に理解しました。 もう自信を持ってjqのクエリを書けるようになったのです。 数か月前はjqのエラーメッセージに悩まされていたので、gojqを作ってよかったと思います。

バイトコードへのコンパイルは、本質的には再帰関数を一つのループに変換することです。 再帰的に構文木をたどって評価を行う処理系は、実装はわかりやすくて楽ですが後々に大きな禍根を残すことになります。 セマンティクスを正確に分析し、バイトコードと評価器に落とすのは、楽しい作業です。 JSONイテレータというセマンティクスは、保存と復帰のできるスタックとforkという命令に対応していることがわかりました。

インタープリタを実装する上で最も大事なことは、スタックとバイトコードデバッグできるようにしておくことです。 特にjqにはforkというスタックの保存と復帰という複雑な動きがあります。 出力コードが間違っているとスタックがめちゃくちゃになったりクラッシュしたりするので、デバッグできるようにしておくことは大事です。

プログラミング言語の処理系を作るのは、とても楽しいことだと思います。 インタープリタ言語を一つ触っていれば、大まかな構造はどの言語も同じなので、処理系のコードが読みやすくなります。 みなさんもぜひ、言語処理系に挑戦してみてください。

Go言語でつくるインタプリタ

Go言語でつくるインタプリタ

*1:そもそもjqを言語として見ている方が少ないのではと思っています。jqはプログラミング言語であり、スタックマシン型のインタープリタ言語です。-fオプションを使うとjqのソースコードを読み込むこともできますし、import文もありますので再利用可能なjqスクリプトを書くこともできます。

*2:これはjqの設計において大胆で、かつ優れた判断だと思います。なかなか思いつきません。JSONを扱うのだからJSONの配列も構文に組み込んでしまいそうになります。実際には、中にクエリがないことを確認してバイトコードの出力前に配列を構築するという最適化処理が入っています。この処理はかなり複雑なので、gojqでは実装していません。

*3:この平均値の書き方はJ言語のmonadic forkにそっくりなのですが、なかなか賛同が得られません。jqの構文上の制約から、monadic forkのかなり限定的な部分に限られていますし、hookはありません。J言語のforkは良い機能ですが、hookはわかりにくいですよね。

*4:jqの初期バージョンがHaskellによる実装だったことを知る人は少ないでしょう。この初期の実装において、コンマ演算子がJQ Monadのmplusであるということはかなり興味深いことです。

*5:この判断が後の悲劇に繋がります。

*6:答えは iterator.go にあります。

*7:このwhile関数は、末尾再帰の最適化を行わないとパフォーマンスがひどくなります。この意味で、jqの処理系は、末尾再帰最適化が求められています。しかし、そもそも構文木をたどっている実装では、末尾再帰最適化とかそういう高尚なレベルにすら到達していません。

*8:どちらかというとuntil関数のほうが重要かもしれません。変数をJSONのオブジェクトに格納して、それを更新する処理を update に、終了条件を cond に書けば、あらゆる繰り返し処理をjqでエミュレートできます。実際、Brainfuckのインタープリタをjqで書いた例ではuntilが使われています。break や continue も、それを管理するためのフラグと if then else end に分解すれば除去できるはずです。

*9:goroutineを立ててchannelに値を入れていくイテレータの実装を、構造体に状態を持たせてNextで次の値を返すように修正するのは、かなり困難なものでした。詳しくは該当のコミットを参照してください。

*10:実はパフォーマンス以外にもクリアできない課題がありました。それはpath関数です。path関数は、引数を評価したときにたどったJSONのパスを返す関数です。|= や += といった更新系の演算子は全てpath関数を用いて実装されており、この関数がないとjqの機能の半分は作れないと言っても過言ではありません。path関数は他の関数と比べるとかなり実装が難しいことがわかり、インタープリタの方式をjqと同じものに変更するのに十分な動機となりました。構文木を直接評価するインタープリタのままでも実装できた可能性はありますが、パフォーマンスの問題もあって、このまま突き進むよりも一旦インタープリタそのものを見直したほうが良いと判断したのです。

*11:Luaの実装がスタックマシンからレジスタマシン方式に変更されたことは興味深い事実です。The Implementation of Lua 5.0を参照してください。また、Ruby (YARV) がなぜレジスタマシン方式を取らなかったかはこちらのスライドで触れられています。

*12: (1,2|debug) * (3,4|debug) のようなコードで確認してみてください。

*13:実際に出力されるコードから少し簡略して書いています。実際のバイトコードは jq -n --debug-trace --debug-dump-disasm '(1, 2) * (3, 4)' で確認してみてください。gojqではmake build-debugでデバッグビルドを行うことができます。

*14:当初は構文木上で数値を float64 のポインタで扱っており、0.0を表現するためにはgobは利用できませんでした。現在は後述の任意精度整数を表現するために、数値は構文木では string としており、組み込み型へのポインタはgojqには必須ではなくなっています。しかし拙作のastgenよりもgobを使うメリットがありませんし (実行バイナリのサイズくらいか)、実行時のパフォーマンスが落ちるのは嫌なので、astgenを今も使っています。

*15:実際、jqのissue218には多くのコメントが寄せられています。頻出するわけではありませんが、困る場面ではめちゃくちゃ困るという感じですね。そのようなAPIが一つあれば、そのAPIに対してはずっとjqは使えないということになりますから。

*16:数学関数に渡すと浮動小数点数に変換されます。また、除算で割り切れないときも浮動小数点数になります。