ESM アジャイル事業部 開発者ブログ

永和システムマネジメント アジャイル事業部の開発者ブログです。

読みやすいテストコードについて考える

こんにちは、@kasumi8pon です。

アプリケーションの機能の追加開発をするときには、既存のテストに追加や修正を行う必要があります。また、既存のテストは現状の仕様を理解するのにも役に立ちます。そのため、テストコードが読みやすいと追加の開発がとても進めやすいです。ところが、わたしが開発しているアプリケーションのテストコードには、理解しやすく拡張もしやすいコードと、そうではないと感じるコードの両方が存在しました。よい機会なので、両者を比較しながら読みやすいテストコードについて考えてみたいと思います。なお、サンプルコードは RSpec と FactoryBot を利用して記載します。

内容を表す適切なラベルがついているか

メソッドの戻り値をそのままテストしているなど、テストの内容が自明である場合、以下のように説明を省略しても理解は容易です。

describe '#published?' do
  subject { book.published? }
  let(:book) { build(:book, published: true) }

  it { expect(subject).to be true }
end

しかし、期待する値の根拠が自明でないテストを書く場合、説明を記述するとのちの理解の助けになります。

# Bad
describe '#price' do
  subject { book.price }
  let(:book) { build(:book, price: 2_000, bargain_price: 500)

  # なんのケースをテストしてるの?
  it { expect(subject).to eq 1_500 }
end

# Good
describe '#price' do
  subject { book.price }
  let(:book) { build(:book, price: 2_000) }

  it { expect(subject).to eq 2_000 }

  context 'bargain_price がある場合' do
     let(:book) { build(:book, price: 2_000, bargain_price: 500) }

     it 'price から bargain_price の値を引かれた価格となること' do
      expect(subject).to eq 1_500
    end
  end
end

テストに説明があることで、他の箇所を参照せずともどんなテストなのかがわかるようになります。 小さなことに感じますが、仕様がわからないときにはとても理解の役に立ちます。

Feature Spec や System Spec を書く場合は、シナリオが簡潔に記載されているとわかりやすいです。これらのテストは複数の操作から成り立つことが多いため、説明がない場合ひと目でテストの内容を理解することは難しいためです。 操作部分のコードを追ってなんのテストをしているかを調べることはできますが、説明があればすぐ直感的に理解することができます。

describe '本の購入について' do
  # Bad (最後まで読まないとどんなテストかわからない)
  scenario do
    # ...
    # いろいろな操作
    # ...
    expect(page).to have_content '購入しました'
  end

  # Good (最初に何をテストしているのかがわかる)
  scenario '新発売の本の中から本を選んで購入する' do
    # ...
    # いろいろな操作
    # ...
    expect(page).to have_content '購入しました'
  end
end

RSpec には --format オプション(--format option - Command line - RSpec Core - RSpec - Relish)があり、--format documentation と指定することでテスト結果を仕様書のような形で出力することができます。このとき説明が不足していると文章として意味をなさない形になってしまいます。 --format documentation を利用したときに意味が通るようにテストを書くことは、読みやすいテストにも繋がります。

# Bad
本の購入について
  is expected to have text "購入しました"

# Good
本の購入について
  新発売の本の中から本を選んで購入する

構造が適切か

同じレベルのテストが違う階層にまたがっていたりすると、テストの全体像が把握しづらかったり、テストを追加するときに困ることがあります。以下の例では編集が新規登録時の一部の機能のように読めてしまい混乱しますし、削除の機能を追加するときにテストをどこに追加しようか迷ってしまいます。

# Bad (編集は新規登録と何か関係があるのかな?)
describe '本を新規登録する' do
  # 新規登録に関するテスト
  describe '本を編集する' do
    # 編集に関するテスト
  end
end

# Good (独立した同じレベルの機能は、同じ階層に書こう)
describe '本を新規登録する' do
  # 新規登録に関するテスト
end
describe '本を編集する' do
  # 編集に関するテスト
end

この例は極端ですが、現実のプロジェクトでは機能が多くて、テストが本来あるべき階層から飛び出してしまっているものを見かけることがあります。 このようなときも、RSpec の --format documentation で結果を出力して文章として読みづらいものがあると、不適切な階層にテストを書いていることがわかります。

また、テストデータのセットアップも適切な箇所で行うべきです。 下の例では、一見データは 20 件しか作成していないはずなのに、テスト内では 21 件目のデータについて言及しています。よくよく上の方を見ていくと、このテスト内で作成しているデータとは別でデータが作成されているようでした。

# Bad
describe '本の一覧機能について' do
  before do
    create(:book)
  end
  
  describe 'xxx' do
    # 他のテスト
  end

  describe 'ページネーションについて' do
    before do
      create_list(:book, 20)
    end
    
    # 20 冊しかつくっていないはずなのに、21 冊目はどこから出てきたのだろう?
    scenerio '21 冊目の本は 2 ページ目に表示される' do
      # 2 ページ目の表示を確認するテスト
    end
  end
end

必要な箇所のみに絞ってテストデータを生成すれば、読みやすくなります。 また、あとでテストを追加するときに認識していないデータによって想定外の状況が引き起こされることを防げます。

# Good
describe '本の一覧機能について' do
  describe 'xxx' do
    before do
      create(:book)
    end
    # 他のテスト
  end

  describe 'ページネーションについて' do
    before do
      create_list(:book, 21)
    end

    scenerio '21件目の本は 2 ページ目に表示される' do
      # 2ページ目の表示を確認するテスト
    end
  end
end

おわりに

わたしが思う読みやすいテストの特徴の一部を紹介しました。あくまでわたしが感じたことなので、別の書き方が読みやすいという方もいらっしゃると思います。テストの書き方には唯一の正解があるわけではないので当然です。しかし、唯一の正解がなかったとしても、読みやすさを意識せずに書いたコードと意識して書いたコードでは後から読み返したときに大きな違いがあるとわたしは考えています。この記事がチームメンバーや未来の自分の開発のしやすさのためにテストコードの読みやすさについて考えるきっかけになれば嬉しいです。

asdf のススメ

こんにちわ。 仕事以外のすべての時間を原神に捧げている @kajisha です。

asdf とは

asdf の README から引用すると

asdf は単一のCLIツールで複数のランタイムバージョンを管理します。 
asdfは、プロジェクトごとに複数の言語のランタイムバージョンを管理できるCLIツールです。 
これは、gvm、nvm、rbenv、pyenv(およびその他)がすべて1つになっているようなものです。 
言語のプラグインをインストールするだけです!

とあるように anyenv に近いものですが、プラグインでサポートしている言語(言語に限りませんが)が圧倒的に多いのが特徴です。 Ruby, Python, Node.js, Golang, Rust などの主要な言語はもちろん starship, direnv などにも対応しており多彩なプラグインが利用可能です。

参考までに、わたしがインストールしているプラグインは以下のとおりです:

$ asdf current
awscli          2.1.31          /home/hiroshi/.tool-versions
bat             0.18.0          /home/hiroshi/.tool-versions
direnv          2.28.0          /home/hiroshi/.tool-versions
erlang          23.2.7          /home/hiroshi/.tool-versions
ghq             1.1.7           /home/hiroshi/.tool-versions
github-cli      1.7.0           /home/hiroshi/.tool-versions
golang          1.16.2          /home/hiroshi/.tool-versions
haskell         9.0.1           /home/hiroshi/.tool-versions
logtalk         3.00.0          /home/hiroshi/.tool-versions
mysql           8.0.23          /home/hiroshi/.tool-versions
neovim          nightly         /home/hiroshi/.tool-versions
nodejs          15.8.0          /home/hiroshi/.tool-versions
postgres        13.2            /home/hiroshi/.tool-versions
python          3.9.2           /home/hiroshi/.tool-versions
ruby            3.0.0           /home/hiroshi/.tool-versions
rust            1.50.0          /home/hiroshi/.tool-versions
sbcl            2.1.2           /home/hiroshi/.tool-versions
sqlite          3.35.2          /home/hiroshi/.tool-versions
starship        0.51.0          /home/hiroshi/.tool-versions
terraform       0.14.8          /home/hiroshi/.tool-versions

asdf のインストール

asdf のインストールは、各OSのパッケージマネージャからインストールすることも可能ですが、 ここでは、 Git を使ってインストールします。

$ git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.8.0

シェルに設定を追加します。わたしの普段使いのシェルは fish なので、fish を前提に記述します。

~/.config/fish/config.fish に以下の行を追加します。

source ~/.asdf/asdf.fish

fish の補完が効くようにします。

$ mkdir -p ~/.config/fish/completions; and ln -s ~/.asdf/completions/asdf.fish ~/.config/fish/completions

fish 以外のシェルのインストールについては、公式のドキュメントを参照してください。

asdf のプラグインを追加してみる

実際に Ruby を asdf で管理してみましょう。

$ asdf plugin add ruby

どのバージョンがあるのかリストしてみます。

$ asdf list all ruby
1.8.5-p52
1.8.5-p113
1.8.5-p114
1.8.5-p115
1.8.5-p231
1.8.6
1.8.6-p36
1.8.6-p110
1.8.6-p111
1.8.6-p114
...このあといっぱい続く

asdf の Ruby プラグインである asdf-ruby は内部で ruby-build を使っているので、rbenv を利用している方にはなじみのあるリストが表示されるはずです。 補足ですが asdf-ruby の ruby-build はリリース版を利用しているので master の ruby-build を利用する場合は、 環境変数 ASDF_RUBY_BUILD_VERSIONmaster を指定するとよいです。

それでは ASDF_RUBY_BUILD_VERSION=master を指定して 3.1.0-dev を入れてみましょう。

asdf でプラグインの特定のバージョンをインストールするのは asdf install [plugin] [version] という共通の構文になっています。 ちなみに [version] のところに latest と入れると最新版がインストールされるようになっています。常に最新版を追っかけたい人には便利な機能です。

$ env ASDF_RUBY_BUILD_VERSION=master asdf install ruby 3.1.0-dev
Downloading ruby-build...
Cloning into '/home/hiroshi/.asdf/plugins/ruby/ruby-build-source'...
remote: Enumerating objects: 120, done.
remote: Counting objects: 100% (120/120), done.
remote: Compressing objects: 100% (98/98), done.
remote: Total 11427 (delta 86), reused 39 (delta 16), pack-reused 11307
Receiving objects: 100% (11427/11427), 2.42 MiB | 4.25 MiB/s, done.
Resolving deltas: 100% (7553/7553), done.
Already on 'master'
Cloning https://github.com/ruby/ruby.git...
Installing ruby-master...
Installed ruby-master to /home/hiroshi/.asdf/installs/ruby/3.1.0-dev

ちゃんと入ったかどうか確認してみましょう。どのバージョンがインストールされているか確認するコマンドは asdf list [plugin] です。

$ asdf list ruby
  2.7.2
  3.0.0
  3.1.0-dev

インストールできたみたいですね。自分の環境全体で 3.1.0-dev を使うようにしてみます。 asdf のコマンドは asdf global [plugin] [version] です。

$ asdf global ruby 3.1.0-dev
$ ruby --version
ruby 3.1.0dev (2021-03-24T18:31:10Z master b25361f731) [x86_64-linux]

ただし、特定のディレクトリ(プロジェクト) では、別のバージョンを使いたいこともあると思います。その場合は、該当のディレクトリで指定のバージョンを使うようにします。 asdf のコマンドは asdf local [plugin] [version] です。

$ cd path/to/rails/project/
$ asdf local ruby 3.0.0
$ ruby --version
ruby 3.0.0p0 (2020-12-25 revision 95aff21468) [x86_64-linux]

これで、このディレクトリ以下では 3.0.0 が使われるようになります。

asdf のプラグインをつくるのはとても簡単

asdf がサポートしているプラグインが多い理由は、asdf プラグインのインタフェースが統一さているからです。 公式のドキュメントにあるように最低限必要なシェルスクリプトは以下の3つです:

Required Scripts
- bin/list-all - lists all installable versions
- bin/download - download source code or binary for the specified version
- bin/install - installs the specified version

わたしがつくった ghqプラグインを参照してみていただければわかると思いますが、 そこまで複雑なシェルスクリプトではありません。 (ちょっといいわけするとプラグインをつくった当時は bin/download はなかったような気がしてて作ってないです……)

プラグイン開発の詳細はドキュメントを参考にするか、既存のプラグインをベースに変更していくのでもよいと思います。 プラグインをつくったら公式に PR してとりこんでもらいましょう。 参考までにわたしが出したPRをはりつけておきます。(PR の Description になにも書いてなくてすみません……)

さいごに

asdf はサポートしているプラグインがいっぱいあってプラグインの開発も簡単です。 asdf を気にいってくれたら幸いです。

見積りのための「スパイク」

こんにちは。@junk0612です。今回は、普段の仕事の中でぶつかった疑問を解決した話をお送りします。

解決したかった問題

今関わっているプロジェクトでは、実際のエンドユーザーとは別にお客様の社内の方が使う画面で RailsAdmin を使っています。これまで RailsAdmin 上での画像のアップロードを取り扱ったことがありませんでしたが、新しく機能追加をするにあたって RailsAdmin から画像をアップロードできるようにするのが一番良さそうだという話になりました。

画像のアップロードに関しては CarrierWave を使っていて、RailsAdmin と CarrierWave の組み合わせを意外と誰も試したことがなかったため、見積りにあたって「できる方法があるのか、かかる手間はどれくらいか」を知りたいという要求が生まれ、その調査を僕が担当することになりました。

まずは調査結果

できます。それも簡単です。

たとえば作成画面なら、config/initializers/rails_admin.rb

config.model(Model) do
  create do
    field :image, :carrierwave
  end
end

と書くだけです (真ん中の行に :carrierwave というタイプを指定するのがミソ)。これで画像のアップロードフォーム付きの作成画面ができあがります。

ずいぶん簡単ですが、これは RailsAdmin 内に RailsAdmin::Config::Fields::Types::Carrierwave というクラスが存在しているためです (参考)。つまり RailsAdmin は CarrierWave に対応済みだった、ということになります。

「スパイク」とは

上記のような、「機能開発とは別の、技術的検証や調査のためのタスク」を「スパイク」と呼びます。スパイクは、チームがきちんとした根拠を持って見積りや技術的な決定をするために必要なタスクです。

『アジャイルな見積りと計画づくり』p.170 には以下のように書かれています。

スパイクとはイテレーション計画に含めるタスクの一種で、何らかの知見を得たり、疑問を解消することを目的に取り組む作業のことだ。(中略) チームは修正の影響範囲を十分に見極めることができなかったので、タスクを2つに分けたのである。1つはスパイクで、もう1つは大雑把な見積りを入れたプレースホルダー的なタスクである。スパイクを実施すれば2つ目のタスクへの見通しが得られるので、このタスクをより正確に見積もれるようになるのだ。

見積り時にあるタスクの実現方法がわからない場合、その方法の調査にかかる時間も予測できないということはままあります (実際に、今回の問題は調べれば簡単にわかることでしたが、少なくとも見積りのときは誰も知らなかったことです)。そういうとき、何もかもがあやふやなまま決め打ちで見積るしかないこともありますが、スパイクしてみるというのは一つの強力な選択肢です。実際にきちんとしたものを作ってみなくても、少し時間をかけて調査すれば、様々な情報が得られます。得られた情報を元にすれば、見積りの確度はぐっと上がりますし、なにより決定に対して自信が持てます1

スパイクは実装方法の道のりをすべて明らかにするのが目的ではありません。そうできれば見積りの確度は最も高まるでしょうが、実際に実装するわけではないし、もしかしたらその見積り結果を元に実装しないという結論に至るかもしれないからです。時間がかかりすぎては元も子もないので、ある程度の時間を区切って調査し、ここまではわかったというところで再度見積る、というのが効果的な利用方法だと思います。


  1. ちなみに、「調べても何もわからなかった」というのも一つの情報です。これは「簡単にはわからなかった == 実装でも調査から始めなければならず、時間がかかりそう」という推測ができるからです。

Rails / OSS パッチ会オンライン 2021年3月のお知らせ

2021年3月の Rails / OSS パッチ会を 3月25日(木)にオンライン開催します。

この会をひとことでいうと、日頃のお仕事で使っている Rails をはじめとする OSS への upstream にパッチを送る会です。

会には Ruby と Rails のコミッターである顧問の a_matsuda もいますので、例えば Rails に送るパッチのネタがあるけれど、パッチを送るに適しているかの判断やパッチを送る流れが悩ましいときなど a_matsuda に相談して足がかりにするなどできます。

開催時間は 17:00-19:00 となりますがご都合のあう方はぜひご参加下さい。Zoom あたりのテレビ会議システムを使います。

当日の招待 URL は Idobata の esminc/rails ルームで共有する予定です。

idobata.io

特に募集ページなど設けませんが、上記理由から Idobata のアカウントが必要になると思います。

以下、前回の活動が関わる成果です。

ima1zumi: rurema/doctree

github.com

osyo-manga: Ruby

github.com

yucao24hours: seory

github.com

先日アナウンスされた RubyKaigi Takeout 2021 に向けた話題などあるかもしれません。

その他の開催方針については以下の Gist に記していますので、ご参照ください。

Reboot Rails/OSS meetup online · GitHub

仕様把握と開発をしやすくするためにコミット時に気をつけていること

こんにちは @color_box です。 チームとしてサービス開発に関わっていると、普段の開発の中で、自分の書いたものではないコードや、数年前に作成されたコードを修正する機会が多くあります。

そのような時に修正しやすいと感じたコミットやそれに関わる有用な心がけを紹介しようと思います。

TL;DR

開発しやすい環境のために下記2つを行う

  • コミットメッセージ内に関連するチケットへのリンクを記載する
  • 1つの修正は1コミットの粒度にして、コミットにテストを含める

前提

特定のコードを修正する時、その修正によって別の不具合が出ないようしなくてはいけません。 そのため、修正対象となるそのコードがなぜそうなっているかを把握する必要があります。 その時参照すべきものとして、コードそのものに加え、その機能の仕様書やテストなどがあります。

しかし、コードからそのような資料を探りづらいと、仕様の把握に時間がかかります。 チケットシステム内を検索したり、チャットの過去ログを漁ったりする必要が出てきます。 そして、そのような調査は時間がかかります。

それらの資料をコードから探せるようになっていると資料検索に使う時間が減ります。 無駄な時間が減り、開発に集中できるため、開発しやすくなります。

そのような開発をしやすい環境のために私がコミット時に心がけている事を書いていきます。

コミットメッセージにチケットへのリンクを書く

このルールはコード(コミット)から、関連資料に到達しやすくなるためのルールです。

git blameで、コードからコミットを辿れます。 その時、コミットメッセージにチケットへのリンクがあれば、機能修正or機能追加時に使用されたチケットを参照しやすいです。

チケット上では仕様変更についての議論が残っています。それらからコードのもととなった仕様の変遷を把握できます。 コードを修正する上で、この情報があると適切な修正や提案を行うことが可能です。

もちろん、コミットメッセージにそういった情報があるのが一番良いのですが、コードの細かい箇所となると、そういった情報がコミットメッセージに載っていないこともあります。 そういう時はチケットまでさかのぼって見に行く必要があります。

ひとつの変更理由は、一つのコミットで

このルールは1つのコミットが特定の1つの修正と紐付いていることを保証するためのルールです。 1つの修正とそれに関連するテストが1つのコミットにまとまっていると、どのテストがどの修正と紐付いているかが明確となります。

機能について理解を深める時、コードを読むのに加えてE2Eテストを読みます。 このルールが守られていると、読むべきテストをコードから探しやすくなります。

テストを仕様理解のための資料として捉え、コードからテストを補足しやすくするためのルールです。


この記事に書いたルールは、下記例外を除いて多くのプロジェクトで機能するルールだと思われます。

  • 仕様に関する議論が対面口頭で行われ、それに関する記録が殆ど残らないようなプロジェクト
  • 仮説検証が目的で、生存期間が短いことが確定している使い捨てプロジェクト

私がプロジェクトで開発しやすい環境にするために心がけているルールを紹介させていただきました。 どなたかのお役に立てば幸いです。

GitHub Actions との距離を縮めるツールたち

試行錯誤しながら何かを作っているとき、ひとつひとつの作業に対するフィードバックに時間がかかるとなかなか捗らないことがあります。特にCI/CDの設定では、リモートのサーバ上で何が起きているのかを把握するのに手間や時間がかかったりして思うようにいかないことがあったりするのではないかと思います。今回は、GitHub Actions の設定をするうえで、手元のマシンでやれることの幅を広げる便利なツールを紹介します。

Act

act を使うことで、手元のマシン上で GitHub Actions のYAMLファイルを読み込んで実行できます。act コマンドを叩くと .github/workflows 以下のYAMLファイルが読み込まれ、定義されている job が Docker コンテナ上で実行されます。act の提供する環境は GitHub Actions と100%互換性があるわけではないのでいくらか注意が必要ですが、すぐに結果が返ってくるので、記述方法を調べつつトライアンドエラーを繰り返す際に役立ちます。またデバッグ用途だけでなく、ローカル用の Makefile を用意する代わりに act を叩くといった task runner 的な使い方もできます。act の素晴らしさについて詳しくはリポジトリの README を確認していただくとして、ここでは act を使ううえで私がつまずいた箇所を中心にいくつかの注意点を紹介します。

ソースコードのチェックアウトに PAT が必要

GitHub Actions で定義する job としては、最初のステップとして actions/checkout でリポジトリからコードを clone してくるというのがよくあるパターンだと思いますが、 actions/checkout は GitHub の personal access token (PAT) に依存しているので、act から使う場合は自分で渡してあげる必要があります。act はデフォルトで .secrets ファイルを secrets として読み込みますので、手軽な方法としてはそのファイルに token を記述しておくことができます。

GITHUB_TOKEN=...

GitHub Actions との違いについて

act がデフォルトで使う Docker イメージには、GitHub Actions の用意する環境で提供されているものが含まれていなかったりします。例えば sudo や Docker が無くて act のときだけ失敗する、といったことが起こりえます。そのため、両方の環境に対応させるためにちょっとした変更が必要な場合があります。act の実行時には ACT という環境変数が設定されるので、それを元に環境ごとに動作を変える、ということが可能です。多用すると見通しが悪くなりそうなので悩ましいですが。

if [ -n "$ACT" ]; then
  # https://docs.docker.com/engine/install/debian/
  # https://docs.docker.com/compose/install/
  curl -fsSL https://get.docker.com | sh -
  curl -L "https://github.com/docker/compose/releases/download/1.28.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
  chmod +x /usr/local/bin/docker-compose
fi

docker-compose build...

別の手段として、より互換性の高い Docker image を使うという手段もあるようです。

User Namespace Remapping を有効にした Docker Daemon について

セキュリティ上の理由や コンテナ上で作られたファイルのパーミッション問題 のために user namespace remapping を有効にして Docker を起動している場合、act を走らせるには --userns=host を渡す必要があります。act は host network mode でコンテナを起動する都合上、 user namespace remapping を利用できません (詳細)。remapping を有効にしたまま act を実行しようとすると、次のような Docker のエラーメッセージが表示されてしまいます。

Error: Error response from daemon: cannot share the host's network namespace when user namespaces are enabled

remapping は、コンテナ作成時に --userns=host を渡すことで一時的に無効にできます。act からも同じオプションを渡せるように パッチを投げました。 取り込まれましたが、まだリリースされていないので、現時点 (2021/03/02) では master のコードをビルドして使う必要があります。

action-tmate

ここでもうひとつ別のツールを紹介します。act は便利な一方、完全な互換性は期待できないので、GitHub の runner 上で直接確認しなければならない場合もあるでしょう。Circle CI や Travis CI みたいに、一時的に SSH でつなげられると便利ですよね。 mxschmitt/action-tmateTmate を使って GitHub Actions の実行環境に SSH 接続する手段を提供します。手元には ssh があれば大丈夫。調査したい箇所の前後でこの action を呼ぶことで、Tmate のセッションが開始されます。

- name: Setup tmate session
  uses: mxschmitt/action-tmate@v3
  if: ${{ always() }}

GitHub Actions ではあるステップが失敗すると、後続のステップはデフォルトでスキップされるので、if: ${{ always() }} をつけておくとよいでしょう。また、この action はデフォルトで sudo を使うので、 sudo のないコンテナ上で呼び出す場合は、sudo: false をオプションとして渡す必要があります。

ちなみに act がデフォルトで提供する環境では、action-tmate が依存しているパッケージが足りなかったりで簡単には動かせそうにありませんでした。とはいえ、act の場合は手元から触れるコンテナ上で実行されるため、目的のコンテナ上でシェルを立ち上げれば済む話で、わざわざ ssh で接続する必要はなさそうです。

おわりに

GitHub Actions の実行環境をより身近にし、開発を助けるツールについて紹介しました。よりよい開発環境づくりのお役に立てれば幸いです。

Ruby組み込みライブラリの型を書くのはそんなに大変じゃなかった

aikyoです。

はじめに

Ruby3.0で、型定義を書けるRBSが導入されました。 私は以前にRubyのFileクラスの型定義を書いたので、Cで書かれたRubyの組み込みライブラリの型定義を書くのもそこまで大変ではないよということについて書いていきます。

現在のRBSの状況

ただ、現在のRBSでは、まだRuby本体に組み込まれているクラスについても型定義が書かれていないものがあります。 なので、型検査をしたいと思ってもRBSに型定義がなければ自分で書くことになります。

Fileクラスの型定義を書いたとき

型定義を書こうと思ったはいいものの。何を参考に型を書くといいのかよくわかりませんでした。現在は TypeProfで型定義を生成してくれたりしますが、正しく推定できない場合があります。 そうなると、やはりソースコードを読んで型定義を書くしかないと思いました。

私はそれまでにRubyのソースコードをほとんど読んだことがなく、Cも詳しくありませんでした。 そのため、Rubyのソースコードを読むのは心理的な抵抗がありましたが、やってみると意外と難しいものではありませんでした。 というのも型定義を書くためには処理を正確に知る必要はなく、関数の引数と戻り値の型がわかれば良いからです。 それであれば型が分かるところまで処理を追うだけで良いのでだいぶ楽でした。

具体例

例として、File.chmod について見ていきます。

File.chmodのドキュメントによると、chmod(mode, *filename) -> Integerのように、第1引数にmode, 第2引数以降にfilenameをとってIntegerを返すようです。

それではRubyのソースコードを見ていきます。なお、Rubyのソースコードは3.0.0を使っています。

/* file.c */
static VALUE
rb_file_s_chmod(int argc, VALUE *argv, VALUE _)
{
    mode_t mode;
    apply2args(1);
    mode = NUM2MODET(*argv++);
    return apply2files(chmod_internal, argc, argv, &mode);
}

第1引数

File.chmod の第1引数が NUM2MODET に渡されています。 NUM2MODETRB_NUM2INT のエイリアスで NUM2INT と同じです。

/* include/ruby/internal/arithmetic/mode_t.h */
#define NUM2MODET RB_NUM2INT

/* include/ruby/internal/arithmetic/int.h */
#define NUM2INT    RB_NUM2INT

NUM2INT はドキュメントに記載があります。https://docs.ruby-lang.org/ja/latest/function/NUM2INT.html NUM2INTFixnumFloatBignum または to_int で型変換できる値をとります。 念の為、さらに処理を追って引数の型を特定してもよいのですが長くなるのでここではこれで止めておきます。

File.chmod の使い方として Float はさすがに除いてよいとして第1引数は Integer または to_int で型変換できるものとなります。 RBSでは、to_int で型変換できるというものが既に定義されています。

# core/builtin.rbs
interface _ToI
  def to_i: -> Integer
end

type int = Integer | _ToInt

これらを使うと、File.chmodの第1引数の型は int とできます。

第2引数

次にFile.chmodの第2引数を見ていきます。

/* file.c */
static VALUE
rb_file_s_chmod(int argc, VALUE *argv, VALUE _)
{
    mode_t mode;
    apply2args(1);
    mode = NUM2MODET(*argv++);
    return apply2files(chmod_internal, argc, argv, &mode);
}

argvをインクリメントしているため apply2files に渡されているのはFile.chmodの第2引数以降となります。 File.chmod の第2引数が apply2files に渡されているのでその定義を見ていきます。 apply2files はここにのせるには長いので、File.chmod の第2引数にあたる argv に関する部分のみのせておきます。

/* file.c */
static VALUE
apply2files(int (*func)(const char *, void *), int argc, VALUE *argv, void *arg)
{
    ...
    for (aa->i = 0; aa->i < argc; aa->i++) {
    VALUE path = rb_get_path(argv[aa->i]);
        ...
    }
    ...
}

File.chmod の第2引数以降を順に rb_get_path の引数として渡していますので、 rb_get_path を見ます。

/* file.c */

VALUE
rb_get_path(VALUE obj)
{
    return rb_get_path_check_convert(rb_get_path_check_to_string(obj));
}

VALUE
rb_get_path_check_to_string(VALUE obj)
{
    VALUE tmp;
    ID to_path;

    if (RB_TYPE_P(obj, T_STRING)) {
    return obj;
    }
    CONST_ID(to_path, "to_path");
    tmp = rb_check_funcall_default(obj, to_path, 0, 0, obj);
    StringValue(tmp);
    return tmp;
}

rb_get_path に渡された引数は rb_get_path_check_to_string に渡されます。 rb_get_path_check_to_string では引数が String ならそのまま、そうでないなら引数に to_pathto_str があればそれを実行して String に変換しています。 これらのことから、File.chmod の第2引数以降は可変長でそれぞれは String または to_pathto_str を持つものとわかります。

戻り値

最後に戻り値の型を考えます。 apply2files については戻り値の部分だけのせておきます。

/* file.c */
static VALUE
rb_file_s_chmod(int argc, VALUE *argv, VALUE _)
{
    mode_t mode;
    apply2args(1);
    mode = NUM2MODET(*argv++);
    return apply2files(chmod_internal, argc, argv, &mode);
}

static VALUE
apply2files(int (*func)(const char *, void *), int argc, VALUE *argv, void *arg)
{
    ...
    return LONG2FIX(argc);
}

chmod の戻り値は apply2files と同じで、 LONG2FIX(argc) の戻り値と同じなので Integer とわかります。

最終的な型定義

結果として以下のような型定義を書くことができました。

def self.chmod: (int mode, *(string | _ToPath) file_name) -> Integer

まとめ

  • Cで書かれたRubyの組み込みライブラリでも型を書くだけなら気にするべきことは多くない。
  • Cに詳しくなくてもなんとかなる。

おわりに

Cで書かれたRubyの組み込みライブラリの型定義を書いたときのことについて紹介してきました。 これを読んだ方が型定義を書くときの参考になれば幸いです。