株式会社はてなを退職しました

本日2021年9月30日が最終出社日でした。1か月間のお休みをいただき、11月から新しい職場で働きます。2015年4月に新卒入社して、Mackerelチームとブックマークチームに所属しました。約六年半、お世話になりました。どちらのプロダクトも携わることができて楽しかったです。

入社時はウェブサービスの実装について何も知らない状態でした。しかしMackerelチームの優秀な先輩と理解あるプロダクトオーナーとディレクターに支えられて、様々なことを学びながらサービスと共に成長できました。サーバーやフロントエンドを書いたり、機能を作ったり閉じたり、年の瀬の夜中に障害対応したり、ioドメインが壊れたり、Linuxカーネルのコードを読んだり、時系列DBをクラウド上で実装したり、競合との差別化を考えたり、お客様とサービスの強み弱みについて議論したりしながら、SaaSビジネスをやる上での色々なことを経験できました。

ブックマークチームでは、アプリAPIの実装や、データウェアハウスの作成、クラウド移転などをやってました。私がチームに入った頃には新しいシステムへの移行が完了しており、コアな機能の設計を考えたり新機能を開発したりする時間はあまりありませんでした。しかしそういうフェーズのプロダクトに合った知見共有の仕組みを作るなど、開発組織の成長に寄与できたと思います。開発エンジニアながらインフラ周りの知識も発揮できたので、クラウド移転はとても楽しい仕事でした。

developer.hatenastaff.com

この2つのチームで開発を経験できたことで、ソフトウェアエンジニアという仕事の面白さや難しさを学ぶことができました。プロダクトのミッションやビジョンの重要性、データ駆動の意思決定を支えるためのデータウェアハウス、長期プロジェクトの見積もりとマネジメント、技術的負債の源とその可視化、非機能要件タスクの消化の難しさ、顧客別対応の危険性、機能を閉じる判断ポイント、技術選択とフルスクラッチの課題、社内サービスのオーナーシップ問題、投資判断とプロトタイピングの工数感、知見共有とチームの生産性、障害対応訓練、信頼性と開発速度のトレードオフ、リモートワーク下でのオンボーディングやインターンなど。組織やプロダクトのあらゆる課題について考える機会がありました。

趣味プログラミングの幅が広がったのも、仕事の影響が大きかったと感じます。jqをGoで実装してみたり、バイナリエディタを作ってみたり、PDFの仕様を調べてみたり、ptraceの勉強をしてみたり、これらは多かれ少なかれ仕事の内容とリンクしています。仕事からアイディアをもらうこともあれば、趣味で培われたコーディングや設計力によって仕事のパフォーマンスを発揮できることもありました。仕事と趣味が近すぎるとオーバーワークになってしまうので、いい距離にある公園の砂場で遊ぶくらいの趣味がいいですね。新しい職場に入ったらまたコードを書くことになるので、有休消化期間は電子工作でもしてみようと思っています。電気系学科の出身ですが何も覚えていないですね。

この度は、さらなる成長機会、次の組織フェーズ、企業を支える仕事を求めて転職することにしました。依然として根深く残るカウボーイ開発的な文化やそこで生まれる組織構造の歪みに悩みながらも、そういった課題について議論したり解決するためのアクションをとれなかったのが心残りです。しかし最近はデータ駆動の意思決定が浸透し始め、スクラムの改善に取り組む人も増えているので、組織として大きく前進していると思います。技術的に成長し続けたい人にも、組織や開発フローの改善が好きな人にもおすすめです。

hatenacorp.jp

共に働いてくれた皆様、日々の業務が円滑に進むよう支えていただいた皆様、業務に関係なく交流していただいた皆様、こっそり応援していただいた皆様、ありがとうございました。これからもソフトウェアエンジニアとして活動していくのと、京都に住んでいるので、今後ともよろしくお願いします。

Go言語の strconv.ParseFloat のとある挙動変更を見つけた話

弊社のとあるGoプロダクトでGo 1.14から1.16へアップデートしたところ、プログラムの挙動が変わる問題が発生しました *1。 ドキュメントに書かれていない strconv.ParseFloat の挙動の変更を踏んでしまったのです。

package main

import (
    "fmt"
    "strconv"
)

func main() {
    fmt.Println(strconv.ParseFloat("1e100x", 64))
    fmt.Println(strconv.ParseFloat("1e1000x", 64))
}

このコードをGo 1.14で実行すると

0 strconv.ParseFloat: parsing "1e100x": invalid syntax
0 strconv.ParseFloat: parsing "1e1000x": invalid syntax

となりますが、Go 1.15や1.16.4では

0 strconv.ParseFloat: parsing "1e100x": invalid syntax
+Inf strconv.ParseFloat: parsing "1e1000x": value out of range

のようになります。 strconv.ParseFloat は引数の文字列全体が浮動小数点数として解釈できなければエラーを返してくれる関数です。 しかし、prefixが大きすぎる浮動小数点数である場合は、別のエラーになっています。

これはバグでしょうか、意図した変更でしょうか。 この挙動の変更を追いかけたときの私の脳内をトレースしてみます。

まずはリリースノートを確認しましょう。 当初、私は1.14と1.16の間ということしか分かっておらず、1.151.16の両方のリリースノートを開きました。 しかし strconv.ParseFloat のエラーの内容を変更するという記述はどこにもありません。 1.16のParseFloatが Eisel-Lemire アルゴリズムを採用したという変更がいかにも怪しくて最初に疑いましたが、正解は1.15の ParseComplex の追加の方でした。

誰かissue報告はしていないでしょうか。意図した変更なので閉じられているか、gotipでは直っている可能性があります。 strconv ParseFloat value out of rangeのようなキーワードで探してみましたが、ヒットしませんでした。

それでは該当ファイルのHistoryやBlameを確認しましょう。 src/strconv/atof.goの変更を追っていると、複素数 (例: 3+4i) をパースする ParseComplex を実装する直前のコミットである 1d31f9b が目に留まると思います。複素数の実部虚部は浮動小数点数も許されており (例: 1e20+3e40i)、 ParseFloat の処理をうまく使うために parseFloatPrefix を導入したようです。

このコミットよく見ていると、パースが最後まで到達していないエラー処理を遅延させたために、 1e1000x のように全体で浮動小数点数として解釈できないものにはprefixをパースしたときのエラーをそのまま返してしまっていることがわかります。コミットの意図を汲み取ると、複素数のパーサーを書くためにリファクタリングしたコミットであり、エラー内容を変更するという意図はなさそうです。この段階で、意図しない挙動変更だったという確信を持ちます。

最後にドキュメントとの矛盾を探します。ParseFloat のエラーについて次のように記述されています。

The errors that ParseFloat returns have concrete type *NumError and include err.Num = s.

If s is not syntactically well-formed, ParseFloat returns err.Err = ErrSyntax.

If s is syntactically well-formed but is more than 1/2 ULP away from the largest floating point number of the given size, ParseFloat returns f = ±Inf, err.Err = ErrRange. https://golang.org/pkg/strconv/#ParseFloat

1e1000x1e100x と同様に syntactically well-formed ではありませんから、ドキュメントからも err.Err = ErrSyntax を返すのが正しいことがわかります。 もしエラーの変更が正しいならば、ドキュメントを修正する必要がありそうです。

該当するコミットにエラー内容までを変更する意図はなさそうなことや、ドキュメントと挙動が異なることから自信を持ったので、issueをたてました。

github.com

数時間後にはCLが作られて、Mergeされていました。こういう素早さはさすがですね。 やはり意図しない変更ということで合っていたようです。次のリリースでは直っていることでしょう。

strconv.ParseFloat の挙動の変更を見つけて報告したお話でした。おしまい。

Vim の doautoall コマンドの問題と 8.2.2596 の挙動変更について

Vimdoautoallコマンドはいろいろな問題のあるコマンドで、私にとって悩みの多い機能でした。 しかし 8.2.2596 の変更によって問題のすべてが解決しました。 本記事ではこのコマンドの問題点とこのパッチの意味について解説します。 なおコミットメッセージは:doautocmdとなっていますが、:doautoallが正解です。

この記事は、バッファとウィンドウ、タブ(ページ)、イベントに関して理解していることを前提としています。 これらについてのヘルプは以下を参照してください。 vim-jp.org vim-jp.org


doautoallコマンドは、すべてのバッファに対して指定したイベントを発火するコマンドです。 例えばdoautoall FileTypeはすべてのバッファにFileTypeコマンドを発火します。

このコマンドの困る挙動の例を挙げましょう。 例えばWinEnterイベントで、ウィンドウ一覧を取得してそのバッファ名を (デバッグの場所として) statuslineに表示してみましょう。

autocmd WinEnter * let &statusline = join(
      \ map(
      \   range(1, winnr('$')),
      \   'v:val . ": " . bufname(winbufnr(v:val))'
      \ ), ' | ')

タブで他のファイルを開いたときの挙動を見てみましょう。

edit foo.txt | vnew bar.txt | tabe baz.txt | tabp
doautoall WinEnter

8.2.2596よりも古いVimでは、以下のようになるはずです。

1: baz.txt | 2: bar.txt | 3: foo.txt

現在のタブには2つしかウィンドウがありませんから、baz.txtが表示されるのは奇妙です。

もう一つ困る挙動の例を挙げましょう。 先程のコマンドを改善して、アクティブウィンドウがわかるようにしてみます。 現在のウィンドウ番号はwinnr()で取得できます。

autocmd WinEnter * let &statusline = '%t / ' . join(
      \ map(
      \   range(1, winnr('$')),
      \   'v:val . ": " . (v:val == winnr() ? "[" : "") .' .
      \   'bufname(winbufnr(v:val)) . (v:val == winnr() ? "]" : "")'
      \ ), ' | ')

今度は現在のタブで3つのウィンドウを開いてみます。

edit foo.txt | vnew bar.txt | vnew baz.txt | wincmd p
doautoall WinEnter

古いVimでは以下のようになるでしょう。

bar.txt / 1: [baz.txt] | 2: bar.txt | 3: foo.txt

今のバッファはbar.txtですから、baz.txtがアクティブに見えているのは奇妙です。

以上の挙動は、statuslineWinEnterに限った話ではありません。 doautoallコマンドは、発火できるイベントに制限がありませんから (例えばdoautoall WinEnterの意味を考えているとおかしなコマンドに思われますが)、実際にユーザーはどんなイベントでも発火することができます。 なので何らかのイベントのトリガーでwinnr()で現在のウィンドウを記録している、bufname()で現在のバッファ名を取得している、winrestcmd()winsaveview()を記録している場合など広いユースケースで問題になります。 意味のわからないコマンドだとしても、Vimが実行を許している以上はVimプラグインで考慮する必要があります。 echomsgではなくstatuslineを使ったのは、最終的なウィンドウの状態がユーザーから見たときの挙動を左右するからです。


doautoallの問題点を大きく分けるとautocommand window (用語は:h win_gettype()より) とバッファの発火順があります。 このコマンドがイベントを発火するときに、現在のバッファやウィンドウを変更し、イベントハンドラの中でどのバッファかわかるようになっています。 あるバッファが現在のタブのどのウィンドウにも表示されていない場合は、autocommand windowという一時的なウィンドウ (コード上はaucmd_win) にバッファを紐付けて、それをアクティブウィンドウとした上でイベントを発火しています。 多くのイベントハンドラはこの特殊なウィンドウで実行されることを意図していないでしょう。 Vim 8.2.0996 (正確には8.2.0991からですが返り値に統一感がないということで変更された) より win_gettype() という関数でautocommand windowかどうかを判別できるようになっています。 この特殊なウィンドウが、1つ目の困る挙動で裏のタブで開いているバッファ名が表示された理由です。

2つ目の問題は発火順です。 doautoallは素朴にバッファ番号順にイベントを発火していました。 イベントハンドラは、イベントがdoautoallで発火されたことを検知できません。 autocommand windowは特殊なウィンドウなので区別する手段が作られましたが、最後のバッファが現在のタブの非アクティブウィンドウに表示されている場合は、やはりアクティブウィンドウを勘違いしてしまいます。

この問題を解決するのが、8.2.2596で取り込まれた以下の修正です。 解決方法は簡単で、順番を入れ替えて、現在のウィンドウに表示されているバッファのイベントを最後に実行するというものです。 これによってdoautoallが引き起こすあらゆる問題が解決しました。 イベント発火の順序の入れ替えという少し不安になる変更でしたが、Bramはあっさりと受け入れてくれました。 パッチがmergeされてから一週間が経ち、特に問題になっていないようですので大丈夫でしょう。 github.com


私は lightline.vim というプラグインを作ってメンテナをしており、このdoautoallコマンドによってアクティブウィンドウのstatuslineが正しく表示されないという問題には随分悩まされてきました (#435, #444, #447, #448, #556)。 特にセッションファイルにdoautoall SessionLoadPostというコマンドが含まれていることから、セッションの文脈でバグ報告が上がることが多い現象です。 しかし問題の本質はdoautoallコマンドの挙動だったので、トリッキーなworkaroundを入れる (例: カーソル移動で更新とかタイマーで再チェックとか) ことなく根本から修正できてよかったです。

autocommand windowwin_gettype()によって検知できるようになりました (8.2.0996, 8.2.0991)。 現在のウィンドウに表示されているバッファのイベント発火は最後に行われるようになりました (8.2.2596)。 この2つの修正によってすべての悩みが解決しました。 以上がこのdoautoallコマンドの問題点とそれに対する修正に関するすべてになります。 この修正によってlightline.vimのみならず、他の多くのプラグインが助かっているかと思います。

実際に手でdoautoallを打つことはめったにないですが、Vimプラグインのメンテナをしているとそういう機能とも真面目に向き合う必要があります。 ソフトウェアの問題への修正の多くはincrementalであり、dirty workaroundを行うのが容易いことが多いでしょう。 しかし、起きている現象の本質を考えることで、ソフトウェアを本当に良いものにしていけるのだと思います。 本質を捉えるのは難しいこともありますが、これができるプログラマになりたいものです。

Pythonのstrptime %zに関するバグ報告をした

timefmt-goというGoライブラリを公開してメンテしているのですが、最近タイムゾーン周りの対応が弱いことに気がついて実装していました。タイムゾーンオフセットの表記は +0900 のようにコロンを入れないほうが一般的だと思いますが、RFC3339では +09:00 のようにコロンありの形もvalidです。strftime では %:z によりこの形を出力し、strptime%z はコロンありなし両方の形をparseする必要があります。GNU拡張のstrftimeでは %::z を使うと秒まで表記し、%:::z ではオフセットの精度によっていい感じに表記するようです (分精度なら分までなど)。

 $ date +%z
+0900
 $ date +%:z
+09:00
 $ date +%::z
+09:00:00
 $ date +%:::z
+09

strptimeの他の言語の挙動を確認しているときに、Python (3.9.2) で思わぬエラーが発生することに気が付きました。

>>> from datetime import datetime
>>> datetime.strptime('Z', '%z')
datetime.datetime(1900, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
>>> datetime.strptime('z', '%z')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/lib/python3.9/_strptime.py", line 568, in _strptime_datetime
    tt, fraction, gmtoff_fraction = _strptime(data_string, format)
  File "/usr/local/lib/python3.9/_strptime.py", line 453, in _strptime
    if z[3] == ':':
IndexError: string index out of range

%z が大文字の Z を解釈するのは正しいのですが、それ以外のおかしな入力のときは ValueError となるべきです。

>>> datetime.strptime('x', '%z')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/lib/python3.9/_strptime.py", line 568, in _strptime_datetime
    tt, fraction, gmtoff_fraction = _strptime(data_string, format)
  File "/usr/local/lib/python3.9/_strptime.py", line 349, in _strptime
    raise ValueError("time data %r does not match format %r" %
ValueError: time data 'x' does not match format '%z'

例外の型が違うということは except ValueError のようなキャッチをすり抜けてしまうということですね。ざんねんでした。

早速ソースコードを読んでいると、フォーマットを正規表現に変換するときに大文字小文字を無視するオプションを使っていて (%aMonでもMONでもマッチするように)、UTCを表す Z が意図せず z にもマッチしているようでした。

            'z': r"(?P<z>[+-]\d\d:?[0-5]\d(:?[0-5]\d(\.\d{1,6})?)?|Z)",
# 略
    def compile(self, format):
        """Return a compiled re object for the format string."""
        return re_compile(self.pattern(format), IGNORECASE)
# 略
        elif group_key == 'z':
            z = found_dict['z']
            if z == 'Z':
                gmtoff = 0
            else:
                if z[3] == ':':
                    z = z[:3] + z[4:]

考慮漏れだとわかったので、既存のissueがないことを確かめて新たにチケットを切りました。 bugs.python.org GitHubではないので面倒かと思ったのですが、OpenIDですぐにログインできて、本名も要求されずとても簡単でした (Linux Foundationとかと比べると遥かに)。 次の日には興味を持った人がパッチを投げてくれました。 月や曜日のマッチングにはignore caseフラグが必要なのでどう修正するのかわからなかったのですが、 (?-i:PATTERN) で一時的に無視することができるのですね。知らなかった。 github.com 3.9.3では直るんじゃないでしょうか。 世界から一つバグが消えてよかったですね。

Pythonstrptime正規表現で実装されていて結構遅いと思いますが、あまり気にしない文化なのでしょうか。 日時なんて大した長さの文字列ではないからこれで良いのかもしれません (しかも strptime('999', '%H%M%S') みたいなケースも対応している)。 timefmtは極力アロケーションを減らし、strconv.Atoiすら避けてカリカリにチューニングしているので、Pythonのライブラリはだいぶ富豪的だなと思いました。 終わり。

itchyny.hatenablog.com

Go言語のorderedmapパッケージを改善した

Go言語で書かれたorderedmapというサードパーティパッケージがあります。 github.com

Goのmapには順序がなく、JSONをデコードすると順序が失われ、それをエンコードするとオブジェクトのキーの順序にソートされます。 これに困る人はそこそこいるようで、順序を保持するmapはいくつか実装されてきました。 その中の一つが、orderedmapというパッケージです。 シンプルなインターフェイスが気に入っています。

orderedmapパッケージの利用例

package main

import (
    "encoding/json"
    "fmt"
    "log"

    "github.com/iancoleman/orderedmap"
)

func main() {
    src := `{ "z": 1, "x": 2, "y": 3 }`

    fmt.Println("# map[string]interface{}")
    var v map[string]interface{}
    if err := json.Unmarshal([]byte(src), &v); err != nil {
        log.Fatal(err)
    }
    bs, err := json.MarshalIndent(v, "", "  ")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%s\n\n", bs)

    fmt.Println("# OrderedMap")
    o := orderedmap.New()
    if err := json.Unmarshal([]byte(src), &o); err != nil {
        log.Fatal(err)
    }
    bs, err = json.MarshalIndent(o, "", "  ")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%s\n\n", bs)
}
# map[string]interface{}
{
  "x": 2,
  "y": 3,
  "z": 1
}

# OrderedMap
{
  "z": 1,
  "x": 2,
  "y": 3
}

しかし、このパッケージのUnmarshal (JSONOrderedMap の変換) の実装は、正直あまりきれいではありませんでした。 v0.1.0までの実装は以下のようになっていました。

  • map[string]interface{}Unmarshal する
  • オブジェクトの各キーに対して、JSON文字列の中の出現位置を探す
    • キーの "エスケープして、 strings.LastIndex で探す
    • JSON文字列を探したインデックスまでを切り取って } を付け加えたときにvalidなJSONかチェックする
      • validなJSONでなければ、ネストされたJSONのキーにヒットしまっているので、更に前にたどってキーのポジションを探す
  • オブジェクトの各値に対して
    • オブジェクトであれば、OrderedMapへの変換を再帰的に行う
    • 配列であれば、各要素の変換を再帰的に行う
  • オブジェクトのキー一覧を、出現位置でソートする

オブジェクトのキーの順序を保持したい → キーの文字列の出現位置を探索してソートすれば良い、という素朴な発想でこういう実装になっているのだと思いますが、この処理には様々な問題があります。

  • オブジェクトのキーを文字列から探すときに " しかエスケープしておらず、{ "\n": 1 } のようなものはうまくUnmarshalできない
    • { "A\u0041A\u0041A": 0 } のようなキーもありうるので、strings.LastIndex でポジションを探す方針には限界がある (指数関数的な個数のエスケープを試す必要がある)
  • パフォーマンスが良くない (#2)
    • validなJSONかかどうかをチェックするという処理でいちいち json.Unmarshal している (orderedmap.go#L178-L192)。
    • 探索範囲を絞ったりスペースをスキップしたりするために、文字列のアロケーションが多い
  • ネストしたJSONに対処するために実装が複雑で、一見してバグっていない自信がない (主観)
    • 重複キーに対しては結構怪しい気がする

個人的には好きなパッケージですが、Unmarshalの実装が複雑であまり使いたくないという気持ちがありました。 しかし作者も課題感を持っているようでしたし、解決策も思いついたので、実装し直すことにしました。

標準パッケージのencoding/json*DecoderにはToken() (Token, error)という関数があります。 JSONトークンを一つずつデコードする関数です。 *DecoderJSONのステートを持っているので、単なるlexerではなくてvalidなJSONトークン列を返してくれます。 これを使います。

  • map[string]interface{}Unmarshal する
  • JSONから *Decoder を作り、dec.Token() を使ってトークンをたどっていく
    • キーはスライスに追加する
      • 重複キーは後ろに移動する
    • 値の最初のトークンによって、オブジェクトか配列かその他の場合に分かれる
      • 値がオブジェクトの場合
        • map[string]interface{}またはOrderedMap (重複キー) の場合はOrderedMapに変換する処理を行う
        • それ以外の場合 (型が違う) は重複キーなので、OrderedMapへのデコードを行うが得られた値は捨てる
      • 値が配列の場合
        • 配列の各要素に対してオブジェクトか配列の場合は再帰的にデコードを行う
        • それ以外の場合 (型が違う、配列のインデックスを超えているなど) は重複キーなので、デコードを行うが得られた値は捨てる

この実装によって様々な点が改善されました。

  • オブジェクトのキーがエスケープされていても問題なく動く
    • { "A\u0041A\u0041A": 0 } のようなものでもOK
  • 以前の実装では何度も文字列上を走査する必要があったが、この実装では二回スキャンすればよい
    • 実装を突き詰めてJSONパースを自前で持てば一回で済むが、そこはencoding/jsonに任せて小さく実装している
  • オブジェクトのキー一覧をソートする必要がない
    • ただし重複キーが大量にある場合のパフォーマンスは良くない
  • 文字列のアロケーションが大幅に減少し、パフォーマンスも改善される
  • 実装が比較的読みやすい

ベンチマークは以下のように改善されています。 アロケーションは半分以下に抑えられ、パフォーマンスも三倍近く改善しています。

benchmark                     old ns/op     new ns/op     delta
BenchmarkUnmarshalJSON-16     125485        37536         -70.09%

benchmark                     old allocs    new allocs    delta
BenchmarkUnmarshalJSON-16     964           389           -59.65%

benchmark                     old bytes     new bytes     delta
BenchmarkUnmarshalJSON-16     49885         13174         -73.59%

一方、Marshal (OrderedMapJSONへの変換) にも改善を入れました。 Unmarshal ほどの問題はありませんでしたが、オブジェクトのキーのエスケープが足りていない ({ "\n": 1 } 相当を出力すると改行が入りvalidなJSONにならない) とか、文字列のアロケーションが多いといった問題を解決しています。 文字列の結合をbytes.Bufferに置き換える単純なお仕事です。

ベンチマークは以下のようになっています。 アロケーションはかなり減りましたが、パフォーマンスとしては7%程度の改善にとどまっています。

benchmark                   old ns/op     new ns/op     delta
BenchmarkMarshalJSON-16     15687         14535         -7.34%

benchmark                   old allocs    new allocs    delta
BenchmarkMarshalJSON-16     94            33            -64.89%

benchmark                   old bytes     new bytes     delta
BenchmarkMarshalJSON-16     9309          2195          -76.42%

以上の改善は、v0.2.0としてリリースされています。 個人的には実装がかなりクリアになり (まあ自分で書いたし)、パフォーマンスも良くなったので、安心してこのパッケージを使えるようになりました。 また一つ世界が良くなりましたね。 おしまい!