Go言語で日時と文字列を相互変換するライブラリtimefmtを作りました

Go言語でstrftimestrptime相当の関数を提供するライブラリを実装しました。

t, _ := timefmt.Parse("2020/07/24 09:07:29", "%Y/%m/%d %H:%M:%S")
fmt.Println(t) // 2020-07-24 09:07:29 +0000 UTC

str := timefmt.Format(t, "%Y/%m/%d %H:%M:%S")
fmt.Println(str) // 2020/07/24 09:07:29

str = timefmt.Format(t, "%a, %d %b %Y %T %z")
fmt.Println(str) // Fri, 24 Jul 2020 09:07:29 +0000

なぜ作ったか

Go言語の標準ライブラリには日時と文字列を変換する関数がありますが、2006年1月2日の15:04:05でフォーマットを指定するという独自の仕様をとっています。 しかしPythonRubyのようにstrftime(3)strptime(3)のフォーマットをGo言語でも使いたいという人は多く、様々なライブラリが作られてきました。

これらの先人には敬意を表しますが、どのライブラリにも満足できないところがありました。

  • %F %T%r といった複数の情報を含むものが実装されていない
  • %-y-%-m-%-d%_y-%_m-%_dのようにpaddingを消したりスペースにしたりすることができない
  • %10A, %10B %2k:%Mのような幅の指定、%^a %^bのような大文字への変換ができない
  • cgoを使っており、クロスビルドできない
  • 文字列への変換と文字列からの変換は、同じライブラリーで提供したい
    • strconv.Atoistrconv.Itoaがあるように

どのライブラリを改善するにも微妙なところがあり、自分で作ろうと思い至ったわけです。

以上が表向きの理由ですが、本当の理由はgojqに必要だったからです。 jqにはstrftimeとstrptimeがありますので、これをgojqで実現するためにはGo言語でも同じ関数が必須です。 つまり単に日時と文字列を変換したいわけではなくて (それなら標準ライブラリを使えばよい)、strftimestrptimeそのものが必要だったのです。 これまでlestrrat-go/strftimepbnjay/strptimeを使っていました。 lestrrat-go/strftimeにはほとんど不満はありませんでしたが、pbnjay/strptimeは機能的に不十分なこと (%cでパースできないなど) や、古いライブラリでライセンスファイルが置かれていないことなど不安要素が多く、新しいライブラリを作るのには十分な理由となりました。

timefmtは、Parse(source, format string)Format(t time.Time, format string)を提供しています。 strftime(3)strptime(3)のほぼ全てのフォーマット指定子、paddingの調整や幅の指定に対応しています。

パフォーマンス

timefmtライブラリを作り始めたときは、パフォーマンスはそこまで重視していませんでした。 strftimeのライブラリのベンチマーク結果は以下のようになっています。

多くのフォーマットで現状最速のライブラリだと思います。 以下の方針で実装していくと、それなりの速度がでました。

  • フォーマット指定文字列をone-passで辿る
  • 月日や時刻など二桁以下の数字の文字列化を高速化する
  • bytes.Bufferではなく[]byteを使う
  • 文字列の結合を避け、strconv.AppendIntを使う
  • モリーの確保を極力減らす

他のライブラリでは標準ライブラリのフォーマットに変換していたり、メモリー確保に気を使っていなかったりしていました。 標準ライブラリは二文字か三文字読まないとどの指定子か分からないのが遅くなる原因だと思います。 この点はstrftimeのフォーマットは優れていますね。

timefmtはこれ以上チューニングする予定はありません。 今のtimefmt.Formatが遅いという場面では、fmt.Sprintfのような関数も使ってはいけないほどパフォーマンスに厳しい場面でしょう。 loggerのように時刻を固定フォーマットで高速に出力する場面では、フォーマット指定文字列を使わずに直接手で書いてしまえば良いでしょう。 このように気楽に構えておくことによってパフォーマンスチューニングの沼に踏み込みすぎないというのは、ライブラリをシンプルに保つ一つのコツだと思います。

まとめ

Go言語で時刻と文字列を相互に変換するライブラリtimefmtを作りました。 作っておいてなんですが、ほとんどの場面では標準ライブラリを使っておくと良いでしょう。 strftime自体が欲しい場面や、標準ライブラリより少し速いライブラリが欲しいときに役に立つと思います。 gojqへの組み込みは成功し、jqとの互換性は上がったので満足しています。

strftimeには多くの指定子があり、なかなか覚えられない方もいらっしゃるかと思います。 最低限 %Y-%m-%d %H:%M:%S をそらで書けるようになりましょう。 追加で %a %b %e %I %p %Z あたりを覚えておくと便利です。 今回timefmtを作ったことで全て覚えてしまいました。 やはり再実装は深い理解への近道ですね。

gojqのパーサーを書き直しました

jqJSONを絞り込むツールですが、実はれっきとしたプログラミング言語です。 算術演算子、論理演算子、分岐構文、trycatch、そして関数定義があり、ループは再帰関数で実装します。 単に .foo とか .[0] とかでJSONを辿るだけのツールだと思われている方は、builtin関数の定義を見ていただくと良いかと思います。 selectmapのように、よく使われる関数でさえ内部実装になっていない (Cで書かれていない) のは面白いですね。

jqのクエリを思ったように書けないという経験から、jqをより深く知るためにGo言語で再実装したのがgojqです。 去年の4月から開発を始め、8月にブログ記事を書きました。 jqのほぼすべての機能を実装しており、pure Goで書かれているのでGo言語のツールに簡単に組み込むことができます。

この記事公開以降も開発を続けています。

  • --arg, --argjsonの実装
  • YAML入出力オプションの実装
  • モジュールファイルの読み込みの実装
  • input, inputs関数の実装
  • try, catch|= 演算子を組み合わせたときのバグ修正
    • これはjq 1.6でもバグっている問題で、jq -nc '{"x": "10"} | map_values(tonumber? // .)'{"x":10} にならないといけない
  • ライブラリー用途の改善 (context対応・ドキュメントの充実)
  • 実行ファイルのサイズ改善 (v0.9.0: 7.86MiB, v0.10.4: 6.45MiB)

順調に機能開発が進んでいるように見えます。 しかしjqとの差異が小さくなるにつれて、徐々にクエリのパーサーに不満が出てきました。 パーサーは機能とは違ってユーザーからは直接は見えない場所ですから、どう実装しても自由なはずです。 逆に言うと、ユーザーへの大きなメリットでもなければ書き直そうとはなりません。 それでも書き直さなければならないほどの理由がありました。


gojqの開発当初より使っていたparticipleというライブラリーの紹介をします。 participleというのはGo言語でパーサーを作るためのライブラリーです。 LL(k)の文法をパースできます。

例えば、次のように構造体のタグに構文を書いてみます。

type Value struct {
    Object *Object `  @@`
    Array  *Array  `| @@`
    Number string  `| @Number`
    Str    string  `| @String`
    Null   bool    `| @"null"`
    True   bool    `| @"true"`
    False  bool    `| @"false"`
}

type Object struct {
    KeyVals []*ObjectKeyVal `"{" (@@ ("," @@)*)? "}"`
}

type ObjectKeyVal struct {
    Key string `@String ":"`
    Val *Value `@@`
}

type Array struct {
    Vals []*Value `"[" (@@ ("," @@)*)? "]"`
}

あとはレキサー (lexer) を実装しておけば、JSONをパースすることができます。 trailing commaを許したいな (それはもうJSONではないけど) と思ったら以下の修正をするだけです。

type Object struct {
-  KeyVals []*ObjectKeyVal `"{" (@@ ("," @@)*)? "}"`
+  KeyVals []*ObjectKeyVal `"{" (@@ ("," @@)*)? ","? "}"`
}

type Array struct {
-  Vals []*Value `"[" (@@ ("," @@)*)? "]"`
+  Vals []*Value `"[" (@@ ("," @@)*)? ","? "]"`
}

このライブラリーはとても便利で、素早くパーサーを作り上げることができますし、文法の変更も簡単です。 暗黙的なルールは少なく、structの定義から目線を少し右に動かすだけで文法を理解できます。

gojqではjqクエリのパースにparticipleをずっと使ってきました (JSONのパースはencoding/jsonを使っています) が、つらいことがいくつかありました。

  • 演算子の結合の強さを構造体によって解決する必要があるため、結合の強さの数だけ、構造体が深くなる
    • これによってparse時の構造体初期化やコード生成時に構文木を辿るときにオーバーヘッドがある
    • gojqはbuiltin関数を事前にパースしてコード生成してバイナリに含めているため、構造体が深いほど実行ファイルのサイズが大きくなる
      • コマンド実行するたびにbuiltin関数をパースするのは避けたい (jqはそうなっている)
  • パース時のパフォーマンスがyacc的なパーサーと比較すると遅い可能性がある
    • リフレクションを使っている以上はそのオーバーヘッドは避けようがないし、このタイプのパーサージェネレータの中ではかなり頑張っている方であることは強調しておきます
    • goyacc版を実際に作るまでは推測でしかなかったし、どれくらい遅いのかは検討もつかなかった
  • 文字列補間 (string interpolation) のパースができない (参考)
    • participleを使いつつ無理やり実装しようとして、内部の式も含めて文字列にマッチする正規表現生成していたが、文字列補間のネスト数に制限があったし ("\("\("\("\(1+2)")")")"のようなもの) 、実装も明らかにおかしかった。 participleを使ってパーサーを書き始めた頃はこのライブラリーだと文字列補間の実装ができないのに気がついていなかったし、そもそもjq言語に文字列補間があるのを知らなかった (気がする)。
  • jqがyaccを使っているので、その文法定義をLL(k)にわざわざ定義し直すのが面倒
    • どうしてもコーナーケースで差異がでてしまう。jqでエラーになるものがgojqでエラーにならなかったりする。
    • 演算子の強さのみならず結合の向き (や結合がないこと) なども構造体の定義を左右するのが面倒
    • goyaccならシンタックスと独立して構造体の形を定義できる

実装当初から積み上げてきたパーサーを捨てる決断をするのにかなり時間がかかりました。 そもそも最初にparticipleを選んだもの、yaccのような枯れた手法ではなく、モダンなパーサーライブラリーを使いたいという気持ちがあったからです。 長大な生成ファイルを (go getのために) リポジトリに含めなくて良いのも好きな側面です。 しかし、文字列補間をはじめ、participleを使っているがゆえに実装が歪んでしまっている場所が増えてきて、耐えられなくなってしまいました。 そして先日、ようやく決心してgoyaccでのパーサー再実装を行いました。

すべてのテストケースが新しいパーサーで通るまで丸2日、文字列補間を正しく実装するのに1日、そしてconflictの修正やパフォーマンス改善などを行っていたので4, 5日くらいかかりました。 participleでパーサーを作るときの体験があまりに良かったのでyaccスタイルのパーサーは避けていたのですが、今回改めてgoyaccを使ってみたらこれも悪くはないなという感想になりました。

  • participleを使っていたときは、ルールを手で書くなんて大変、participleはcoolと思っていたが、やってみたらそこまで大変ではなかった
    • Goにはunionがないのでtype assertionする必要があって少し面倒なのは仕方ない
      • interface{}を使わない手もあるが、メモリーを多く使うのでおすすめはしない
  • 演算子の結合はgoyaccに任せられるので、構造体を浅くできて最高
    • participle版ではExprの中にLogicの中にAndExprの中にCompareの中にArithの中にFactorの中にTermがありました。goyaccに切り替えたことで、これらの中間の構造体を一掃し、Termまたは二項演算子という形の構造体に統一できました。
    • 構造体の定義は、表現したいものの構造を表すベストな形を目指すべきで、シンタックスに左右されるべきではない
  • レキサーは手で書いたほうが良い
    • 文字列補間があるならなおさらそう
  • conflictを解決していたら大学時代を思い出した

文字列補間 (string interpolation) というのはパーサーを作る上でとてもおもしろいトピックです。 この機能があるかないかで、文字列をパースする難易度が大きく変わってきます。 JavaScript, Ruby, Python, Scala, C#など様々な言語がこの機能を備えています。

 % node -e 'console.log(`${1 + 2}`)'
3
 % ruby -e 'print "#{1 + 2}"'
3
 % python -c 'print(f"{1 + 2}")'
3
 % jq -nr '"\(1 + 2)"'
3
 % node -e 'console.log(`${`${`${`${`${1 + 2}`}`}`}`}`)'
3
 % jq -nr '"\("\("\("\("\(1 + 2)")")")")"'
3
 % jq -nr '"foo \("bar" + "bar") \("\("baz" * 3)")"'
foo barbar bazbazbaz

なぜこの文法のパースは難しいのでしょうか。 文字列補間のない言語の場合、レキサーは文字列を一つのトークンとして返すことができます。 おそらく、文字列トークンを表す正規表現を書くことができるでしょう。 しかし、文字列補間があると文字列自体が木になります。 文字列の中身をパースしているときは、空白文字をスキップしてはいけません。 文字列補間の中身に改行やコメントが入ることもあるでしょうし、ネストすることもできます。 そしてパースエラーが発生したときは、元のプログラムのコードでのオフセット位置を報告すべきです。 これらをきちんと実装するためには、やはりレキサーを手で書き、文字列の中身を丁寧に辿るのがよいと思います。

participleを使っていたときは正規表現で無理やり文字列のトークンを抜き出し、コード生成時に文字列補間を解釈していました。文字列補間がある場合は任意の文字列を表す正規表現はそもそも作ることができませんし (ネスト数を制限すればできるが奇妙な制約になる)、文字列補間内のクエリにシンタックスエラーがある場合に、その位置をユーザーに伝えるのが難しくなります。goyaccに乗り換えてようやく文字列補間をきれいに実装できたとき、このシンタックスをパースするときの「正しいやり方」を理解できました。

文字列補間の閉じトークンをどのように判別するかというのはおもしろい問題です。 私は当初、括弧の深さを計算し、文字列補完の中をたどっているときに、対応しない閉じ括弧が来たら文字列補間の閉じトークンを返すような実装をしていました。 しかしこれは全く不要でした。 他のシンタックスの綴じ括弧と文字列補間の閉じトークンは区別する必要はなく、閉じ括弧はそのまま返してしまえばよいという気付きがありました。 文字列補間の開きと閉じにそれぞれトークンを割り当てないといけないと考えてしまうと、レキサーが無駄に複雑になってしまいます。


パーサーを書き直したことで、速度も大幅に改善しました。 jq言語で書かれたソースコードのパースにかかる時間の比較です (participle版は最初計測ミスかと思いましたが合っていました)。 およそ30倍の速度改善を達成できました。 reflectionベースの実装なので多少はオーバーヘッドがあることは分かっていましたが、ここまで差がつくとは思っていませんでした。 jq, gojq (participle), gojq (goyacc) のパース時間比較

participle版のgojqを除いたグラフは次のようになります。 goyacc版のgojqは、jqと比較しても少し速くなっていることがわかります。

jq, gojq (goyacc) のパース時間比較

ソースコードをパースして終わりではありません。 jq言語には include 文がありますが、これはコード生成を行う必要があります。 以下のグラフは、パースとコード生成の両方を含む速度比較です。

jq, gojq (participle), gojq (goyacc) のinclude時間比較

jqはソースコードのサイズが増えると急に遅くなっていきます。 試しにフィッティングしてみると、コードサイズに対して三乗で時間がかかっているようです。 まだ正確な理由は理解できていないのですが、jqの関数名解決のパフォーマンスは良くないようです (参考: block_bind_self)。 jqのパフォーマンス改善の余地が見つかったのでよかったです。 修正方法が分かりしだいパッチを投げようと思います。 別実装の存在によって本家の改善が進むというのはよくある話ですね。


実は、gojqでの「作り直し」は二回目になります。 初期の実装では再帰下降型の処理系で、これをコード生成して最適化を行うインタープリターに書き直しています。 そして今回パーサーも書き直したので、一番最初の設計は大外れということになるかもしれません。 ベストな設計というのは最初からはなかなかできませんね。 しかし、これまでの積み上げがあったからこそ (カバレッジの高いテストケース)、わずか数日でパーサーを入れ替えることができたのだと思います。 処理系の再実装と比べるとかなり簡単でした。

このような書き換えを行うときのコツは、テストを落とさないようにコミットしていきつつ、切り替えたときに通るテストの数を数えていくことだと思います。 例えばパーサーを入れ替えるときは、新パーサーで試してダメだったら旧パーサーにフォールバックするという処理にしておきます。 テストが落ちないように新パーサーを実装していくのです。 ただし、このやり方だとどれくらいの割合のテストが新パーサーで通るようになったかがわからないので、新パーサーに完全に切り替えたブランチを作っておいて、そちらにmergeしていきつつテストを回していくのです。 通るテストは徐々に増えていき、全てが通ったら旧パーサーを捨てることができます。


participleは、今までgojqの開発を大いに支えてくれました。 シンタックスの全容を把握できていない段階では、素早くパーサーを書き換えていける利点があるでしょう。 しかし、パーサーの扱える構文は限られており、無理にパースしようとすると大変なことになります。 また構文木シンタックスが密結合だと様々な不具合が生じます。

yaccは古典的なツールですが、現代でも大いに活躍できるツールだと思います。 演算子の結合を宣言的に定義できるのはとても楽です。 生成されたパーサーの速度も魅力的です。 ちなみに今回goyaccを触って気になったところを修正するパッチを投げたら、すぐにレビューを頂いて次の日にはmergeしてくれました (cmd/goyacc: print newlines more consistently)。

gojqはv0.11.0で新しいフェーズに突入しました。 v0.10.4では6.45MiBあった実行ファイルのサイズを、4.91MiBまで削減することができました。 今回のリリースよりDocker Hubにもイメージを上げることにしました。 やるべきことは、まだまだあります。 パーサーはjqより少し速くなりましたが、処理系はjqよりも大幅に遅くなるクエリが存在することが分かっています。 gojqとの旅はもう少し続きそうです。

HomebrewのインストーラーをRubyからBashに書き直しました!

みなさんはHomebrewをお使いでしょうか。macOSをお使いの多くの開発者が使っていると思います。

HomebrewのインストーラーはRubyで書かれており、次のコマンドでインストールするようになっていました。

/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

HomebrewがRubyに依存していることは良いのですが (formulaの書きやすさはRubyならでは)、インストーラーの話になると事情が変わってきます。HomebrewのインストールコマンドはmacOSの工場出荷状態でも動く必要があります。こういうものにRubyを使っているのはリスクがあります。

Homebrewの開発メンバーもインストーラーのRuby脱出問題は議論していて、Port installer to Bashというissueも立っていたのですが、数か月経っても誰も動き出す様子がありませんでした。 一月のある日、誰もBash化をやらないなら自分がやってしまおうという気持ちになったので書き直しました。

Rubyで書かれた小さなスクリプト (400行弱) とは言え、処理が1つでも抜けては困ります。 最初に git mv でファイルを移動し、 exit を少しずつずらしながら、順番にBashに書き換えていきました。 CIがすべての分岐を網羅しているわけではないので、細かいところは手でコマンドを打ったり変数の値を指定し実行してみて分岐に入るかどうかを確認したりしながら移行しました。

レビューの打ち返しをやってからしばらく反応がなかったのですが、先日ようやくマージされて、ホームページのインストールコマンドもBashに変わりました。 新しいコマンドは以下のようになります (引き続き以前のRubyのコマンドも使うことができますがWarningが表示されます)。

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"

個人的な開発環境構築スクリプトBashスクリプトで書いており、RubyリスクはHomebrewをインストールするところだけだったので、これでようやくRuby依存をなくすことができました。 新しいmacOSRubyPythonが入っていなくても環境構築できそうです。 よかったですね。

Bashスクリプトを書く能力は現代となってはさほど重要ではありませんが、いざという時に役に立つこともあるので、数百行くらいなら書いちゃうぜという気持ちを持っているとお得です。 仮にBashが滅んだとしても、ログインシェル (zsh?) のスクリプトに書き直すのはRubyからBashに直すよりも簡単なはずなのですぐにできそうです。 Linuxbrewの取り込みも他の方の作業によって完了しています。 BashにしたことでLinuxインストーラーが統一されたのはめでたいですね。

おしまい!

ファイルをエディターで一括リネームするツールを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』も良かった。

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

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

鬼滅の刃 第四話