ハトネコエ Web がくしゅうちょう

プログラミングやサーバー・Web制作、チームマネジメントなど得た技術のまとめ

GoとRustで同じCLIアプリケーションを作ってみて感じた2つの違い

Squash and Merge されたブランチも削除できるCLIアプリケーション
git-branch-delete-merged をリリースした話を前回のブログではしました!

nekonenene.hatenablog.com

それは Go言語 で書かれていたのですが、
勉強のために Rust言語 で書き直してみました!

Go版

Rust版

Rust版のほうが、あとで書いただけあってコードの構成がややきれいですね。
初めての Rust なだけあって、Rust ってより Go みたいなコードの書き方ですが……(笑)
【8/26追記: yuki さんに教えてもらって v1.0.4 で修正しています。元はここらへんがすごく Go でした】

それからREADMEにも書いたのですが、Rust版は

brew install nekonenene/tap/git-branch-delete-merged

でインストールできるようにして、さらに扱いやすくなりました!

開発して感じた Go と Rust の違い

同じ動きのアプリケーションを違う言語で書いたことで、
いろいろ得意・不得意な部分を感じることもあったので、
自分の感じたそれぞれの長所をまとめます。

ビルド時間が速いのは Go かも

ローカルで開発していたときもそうですし、
リリースビルドを作成していたときもそうですが、
体感的には Rust は Go よりビルドに時間がかかるように感じました。

今回のアプリケーションでは、
Go がなんと外部ライブラリの使用なしで完成できてしまったので、
外部ライブラリ(crate)の使用がある Rust 版よりコンパイル時間が短いのは
当然とも言えるかもしれません。

とはいえ Rust 版を Go 版のように外部ライブラリなしで作るのはなかなかに大変だと思うので、
その点も総合的要素に含めるのなら、やはり Go のほうがビルドが速いと言ってもいいかもしれません。

有識者から教えてもらいました:

Rustは外部ライブラリの使用がコンパイル時間を伸ばすのはそうなんですが、一番長いのはリンク(ライブラリとかを実行ファイルにまとめていくコンパイラのフェーズのうちの一つ)のフェーズですね(つまり外部ライブラリを使っても使わなくても遅い)。
トレイトを使っている関係で抽象と実装の紐付けがどうしても必要になりますが、その探索が非常に時間がかかると言われています

ロスコンパイルは Go が手軽

リポジトリ.github/workflows/release.yml
各OS用のバイナリファイルを作成しているのですが、
Go は恐ろしいことに ubuntu-latest だけで、Windows, macOS, Linux のどの環境用のバイナリファイルも作れています。

Rust では Windows 用には windows-latestmacOS 用には macos-latest を使う必要がありました。
それゆえ GitHub Actions のファイルも複雑になります。(書くの大変でした)

また、かかる時間も大きく違って、
ビルドをおこないGitHubのリリースページにファイルをアップロードする GitHub Actions の実行時間は、
Go版が 1分10秒前後で済む一方、Rust版は 5分前後かかります。

ここは本当 Go のよく出来ている面だなと思いますし、
このビルドをおこなう GitHub Actions を簡単に書ける GoReleaser Action は便利すぎでした。

コマンド実行の処理は Rust の方がやりやすい

今回作ったアプリケーションでは git コマンドを多用するのですが、
この、他のCLIアプリケーションを呼び出す処理の周辺が、Go はわかりにくく感じました。

https://pkg.go.dev/os/exec#Cmd.Output にあるように、もっとも単純に書くと以下のようになります。

out, err := exec.Command("date").Output()
if err != nil {
  log.Fatal(err)
}
fmt.Printf("The date is %s\n", out)

ですが、問題はこれが異常終了したときで、出力は以下のようになります。

2023/08/25 01:52:58 exit status 129

終了コードしか出ず、 stderr (標準エラー出力)がどうなっていたのかを知る術がありません。

また、終了コードが 0 じゃないけど stdout (標準出力)を出力することも
コマンドによってはあるのですが、それも捕捉できませんね。

これではデバッグがしにくいですし、
ユーザーからエラー報告が来ても原因特定が難しすぎます。

最終的に error と stderr をまとめるラッパーメソッドを書くことができましたが、
情報が少なくてとても苦労しました……。

Rust はもっと直感的です。

use std::process::Command;

fn main() {
    let result = Command::new("git").arg("woooo").output();

    match result {
        Ok(output) => {
            println!("status: {}", output.status);
            println!("stdout: {:?}", String::from_utf8(output.stdout));
            println!("stderr: {:?}", String::from_utf8(output.stderr));
        }
        Err(err) => {
            println!("{:?}", err);
        }
    }
}

これを実行すると

status: exit status: 1
stdout: Ok("")
stderr: Ok("git: 'wooo' is not a git command. See 'git --help'.\n")

と表示されます。

終了ステータスが 0 でなくとも、result は Err とマッチせず Ok に進み、
(gitコマンド自体が見つからない場合なんかは Err のほうに進みます)
output の中には status, stdout, stderr の情報がすべて入っているので取り扱いが容易です。

spawn 関数を使うのもあまり苦労なく済みましたが、
同じようなものをGo言語で実装したときは、どうすればいいかわからず情報も少なく苦労しました……。

Rust はどのディレクトリでも開発できる

Go を使い始めたとき一番面倒に感じたのは、GOPATH内で開発しないといけないことです。
(がんばればGOPATH外での開発も不可能ではないですが、トラブルの原因になりやすいです)

慣れはしましたが、
開発したいと思ったときに ~/.go/src/github.com/nekonenene/ ディレクトリへ移動しないといけないことや、
新しいパソコンに git clone したいときは mkdir -p ~/.go/src/github.com/nekonenene/ して cd ~/.go/src/github.com/nekonenene/ してから
git clone する必要があったりと、細かな不便さがあります。

Rust はこのような Go 特有の面倒さがありませんし、
プロジェクトのあるディレクトリ名とパッケージ名を合わせないといけないなどの制限も存在しません。

引数の扱いは Rust の clap という crate がとても便利

CLIアプリケーションを作るときは引数をとりたいことが多いです。

my-cli-app --config configs/basic.json --dry-run

のように。

Go言語の場合、CLIアプリケーションを作るライブラリとして人気の cobra を使ってもなお、
引数をパースするには多くの記述が必要です

Rustの clap という crate(ライブラリ)はその点優れていて、
少しの記述だけで適切にパースをしてくれますし、ヘルプもいい感じに作ってくれます。

個人的に選ぶなら Go? Rust?

今後CLIアプリケーションを開発するときにGoを選ぶかRustを選ぶか。

毎回の GitHub Actions で時間がかかってしまうのは気になるので、
個人的にはやや Go 寄りではありますが、
clap によるパースやヘルプの生成も魅力的です。

なんにしても、もう少し Rust には慣れておきたいので、
次に作るものは、(急いで作らないといけない場合を除き)Rust になるかなと思います!