Goで小さな道具を書くとき、私は機能より先に最後の配り方を想像します。自分のPCで動くだけなら何でもいいのですが、別のWindowsマシンへ渡す段になると、話が急に現実的になります。zipを置いて終わりにしたいですし、変な前提条件は増やしたくありません。
以前、Windows向けの小さなGo製CLIを人に渡したとき、こちらでは普通に動いたのに、相手のPCでは起動しませんでした。原因は大げさなものではなくて、CGOを有効にしたままビルドしていたことでした。あの手のつまずき方は、一度やると忘れません。それ以来、配布前にCGO_ENABLED=0をまず疑うようになりました。
軽くなるのはサイズではなく前提
CGOを切ると、毎回ファイルサイズが劇的に減るわけではありません。そこを期待すると少し肩透かしです。今回、os/user を使うだけの短いプログラムで見比べると、CGOありのバイナリは 2,330,683 バイト、CGOなしは 2,373,476 バイトでした。むしろ少しだけ大きくなっています。
それでも私がCGOを切りたくなるのは、軽くなる場所が別だからです。バイナリそのものではなく、起動に必要な前提が減ります。配布で怖いのは、数十KBの差ではありません。渡した先の環境に、こちらが勝手に期待してしまうことです。
何が変わるのか
試したコードはこれです。
package main
import (
"fmt"
"os/user"
)
func main() {
u, err := user.Current()
if err != nil {
fmt.Println("user lookup failed:", err)
return
}
fmt.Println("hello", u.Username)
}
ビルドはこうしました。
CGO_ENABLED=1 go build -o app-cgo main.go
CGO_ENABLED=0 go build -o app-nocgo main.go
Linux上で file と ldd を見ると、差はかなりはっきり出ます。
$ file app-cgo
ELF 64-bit LSB executable, x86-64, dynamically linked
$ ldd app-cgo
libc.so.6 => /lib64/libc.so.6
$ file app-nocgo
ELF 64-bit LSB executable, x86-64, statically linked
$ ldd app-nocgo
動的実行ファイルではありません
この結果を見ると、気分の問題ではなく、本当に依存の形が変わっています。CGOありのほうは libc を見に行きます。CGOなしのほうは、少なくともこの程度のコードなら単体で閉じます。私はこういう差をかなり信用します。説明のうまさより、ldd の一行のほうが話が早いです。
単体バイナリの雑な強さ
単体バイナリが好きなのは、上品だからではありません。雑に扱えるからです。フォルダへ置けますし、消せますし、別のPCへそのまま投げることもできます。そういう動きが素直にできます。インストーラーの気配を読まなくてよくなるだけで、かなり気が楽になります。
メインPCがWindowsだと、この感覚はわりと切実です。便利な道具ほど、あとで静かに常駐したり、アンインストール後に何かを残したりします。だから私は、配る側に回るときも余計なものを背負わせたくありません。Goのよさは、そういう小さな潔癖さと相性がいいところだと思います。
もちろん、何でもCGOなしで済むわけではありません。Cライブラリに触るなら必要になりますし、SQLiteまわりでも選ぶパッケージ次第では普通に入ってきます。そこは割り切るしかありません。ただ、標準ライブラリで足りる小さな道具まで重くする理由も、あまり感じません。
まず疑う場所がひとつ減る
不具合が出たとき、疑う場所は少ないほうが助かります。コードが悪いのか、入出力が悪いのか、配布物の作りが悪いのかを先に切り分けたいです。その切り分けが先にできるだけでも、頭が散りません。そこへさらに共有ライブラリの都合まで混ざると、急に面倒になります。AIにボイラープレートを書かせる時代でも、この手の面倒だけは消えません。むしろ最後に人間が引き取る種類の面倒です。
だから私は、Goで小さなCLIを書くときほどCGOを切りたくなります。最適化の話というより、態度の話です。自分のPCで確かめたものを、そのまま渡しやすい形へ寄せておきたいです。Goが現代のC言語に見える瞬間は、たぶんこういう地味なところにあります。