Go 1.18 で go run -mod=mod の非互換変更に気がついて報告した

Go 1.18 がリリースされて一か月程経ちました。 先日 go run を (go generate経由で) 使っているコードの挙動を見ていたら、モジュール周りの挙動が Go 1.17 と異なることに気が付きました。

Go 1.16 以降、go getgo mod tidy のような一部のコマンドを除くと、go.mod に足りない依存パッケージがあるときはエラーになります。 go rungo testなどでもgo.modを更新したい場合は、-mod=modというフラグを指定します。

go runは、Goのファイルのコンパイルと実行を行うコマンドです。 パッケージを引数として利用されることが多いコマンドですが、Goのファイルを指定して実行することもできます。

❯ cat main.go
package main

func main() {
        println("Hello, world!")
}

❯ cat go.mod
module tmp

go 1.18

❯ go run .
Hello, world!

❯ go run main.go
Hello, world!

Goのファイルを指定して実行する場合はcommand-line-argumentsパッケージという特殊な名前が付けられています。 これはほとんどドキュメントに記述されていませんが、go listのようなコマンドで確認することができます。

❯ go list .
tmp

❯ go list main.go
command-line-arguments

さて、コードが依存しているパッケージがgo.modにない場合はどうなるでしょうか。 go runコマンドはデフォルトではgo.modを更新しないので、依存パッケージが足りない場合はエラーになります。

❯ cat main.go
package main

import "golang.org/x/sys/unix"

func main() {
        println(unix.Getpid())
}

❯ cat go.mod
module tmp

go 1.18

❯ go run .
main.go:3:8: no required module provides package golang.org/x/sys/unix; to add it:
        go get golang.org/x/sys/unix

❯ go run main.go
main.go:3:8: no required module provides package golang.org/x/sys/unix; to add it:
        go get golang.org/x/sys/unix

では -mod=mod をつけるとどうなるでしょうか。 足りない依存をgo.modgo.sumに追加するフラグですので、go.modgolang.org/x/sys/unixパッケージが追加されます。

❯ go run -mod=mod .
go: finding module for package golang.org/x/sys/unix
go: found golang.org/x/sys/unix in golang.org/x/sys v0.0.0-20220412211240-33da011f77ad
10664

❯ cat go.mod
module tmp

go 1.18

require golang.org/x/sys v0.0.0-20220412211240-33da011f77ad

ファイルを指定した時も同じ挙動を期待しますよね。 しかし、Go 1.18.1の挙動は以下のようになります。

❯ cat go.mod
module tmp

go 1.18

❯ go run -mod=mod main.go
go: finding module for package golang.org/x/sys/unix
10820

❯ cat go.mod
module tmp

go 1.18

あれれ、go.modが更新されませんね。 足りない依存パッケージの検索やダウンロードはされ、コードは実行されましたが、go.modは更新されません。 これはgo buildなど、他のコマンドも同じ挙動になります。

実は Go 1.17 ではこの挙動ではありませんでした。 ファイルを指定した場合も-mod=modを指定すればgo.modが更新されていました。 この挙動に依存していたわけではなく、違いに気がついたのは偶然でした (Dockerイメージをpullしておらず、ホストマシンのGoとバージョンがずれていた)。

Go 1.18のリリースノートマニュアルを確認しましたが、いずれにもこの挙動に関する記載はありません。 たしかにcommand-line-argumentsパッケージは特殊な立ち位置で、特にモジュールとの兼ね合いは難しくなっています。 しかし、ユーザーに影響がある挙動の変更ならば、リリースノートに書いて欲しいですよね。 そういう気持ちを込めて、バグトラッカーに報告してみました。 github.com 無事、リグレッションの判断がされたので、Go 1.18へのバックポートもされるようです。 すぐに修正CLが投稿されました。 go runコマンドで-mod=modとGoファイルを指定した時の挙動の変更に気がついて報告したという話でした。

2021年を振り返って

今年は大きな転機が訪れた年でした。六年半勤めたはてなを退職し、11月からサイボウズで働いています。開発体制の規模やプロセス、文化の違いに戸惑いながらも、楽しく働いています。モブプロを中心とする開発は新しく入社した身としてありがたいです。技術的な負債の解消を続けながら、さらに踏み込んで顧客への価値提供を加速するための組織作りに貢献したいと思っています。そこそこ頂いているので、それに見合った働きができるように頑張ります。

住環境も大きく変わりました。六年住んだ1Rを離れ、1LDKに引っ越しました。ゆとりのある部屋の大きさになったことでQOLが上がりました。また引越したタイミングで昇降デスクやベッド、本棚など色々と買い揃えました。三口コンロになったこともあり、料理をする機会が増えました。ただ部屋が広いためか、あるいは24時間換気のせいなのか、エアコンの効きが悪くて寒いです。

OSS活動としては、粛々とgojqの改善を続けていました。末尾再帰呼び出しのパフォーマンスを改善したり、コマンドライン引数のパーサーを自前実装にして、jqとの互換性を高めたりしてました。gojqはライブラリとしてghやArgo CD、fqなど様々なツールに組み込まれていて嬉しい限りです。ただ、転職してから仕事で気を張っているのか、アウトプット量が減ってしまいました。来年は何か新しいものを作りたいです。

今年の見たアニメの中では『不滅のあなたへ』『かげきしょうじょ!!』『白い砂のアクアトープ』が良かったです。ドラマは『最愛』『監察医 朝顔』『漂着者』、映画は『マスカレード・ナイト』あたりでしょうか。引っ越しをしてからアマゾンプライムでよく映画を観ています。

来年がさらにいい一年になりますよう願っています。

パロナ「自分の生き方は与えられるものじゃない!自分で勝ち取るんだ!」

不滅のあなたへ 第5話

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

本日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を行うのが容易いことが多いでしょう。 しかし、起きている現象の本質を考えることで、ソフトウェアを本当に良いものにしていけるのだと思います。 本質を捉えるのは難しいこともありますが、これができるプログラマになりたいものです。