Railsチュートリアルのsample_appに型を導入
ふーが といいます。普段は Ruby on Rails を利用したアプリケーション開発をしているプログラマです。
この記事は Rails アプリケーションへの型導入を試してもらうことを目的とした記事です。主に Ruby on Rails チュートリアル (以下「Rails チュートリアル」と呼称)で学ばれた方向けに「静的型とはなにか」「Ruby での型の扱い」「Rails アプリケーションへの型導入方法」を知っていただくきっかけになればうれしく思います。
この記事では実際に手を動かしながら Rails アプリケーションに型を導入する手順を体験できます。題材として Rails チュートリアルの sample_app を使用させていただいています。
前提
型を導入するにあたり前提となる部分を簡単にご紹介します。「型」と聞いてイメージがつく方はこの段落は飛ばしていただいてかまいません。
型とは何か
型は「データの種類」のことです。
たとえば変数に入っているデータを指して「この変数の型は Integer(数値)だよね」とか「このメソッドの戻り値は文字列だから String だね」といった感じで使います。
この「型」によって使用できるメソッドが決まったりするわけなのですが、Ruby では初学者の方が初めから型を意識してプログラミングをする必要はありません。それは Ruby が「動的型付け言語」だからです。コード上で型を宣言しなくとも、コードの実行時に型が解釈されて動作します。
一方「静的型付け言語」では、コードを書く際にプログラマが変数やメソッドに対する型宣言をします。
静的型付け言語である C と動的型付け言語である Ruby のコードを比べると次のような違いがあります。
#include<stdio.h>
int main (void) {
int i = 1; // int の部分が型宣言
printf("%d\n", i);
}
i = 1 # 型宣言はない
puts i
ここではどちらが良い悪いではなく、「Ruby は動的型付け言語だが、最近静的型を扱える機能が導入された」ということを知っていただきたいです。また、上記の C 言語の例ではプログラム中に型宣言が書かれていますが、Ruby で静的型を書く場合は RBS という別ファイルに記述します。この辺りは後ほど実際に書いて試してみます。
型があることで何がうれしいのか
動的型付け言語である Ruby でなぜ型を扱えるようになったのでしょうか。
その理由の一つとして、型情報があることによって「開発者体験が向上する」ことが挙げられます。具体的には、バグの未然防止に繋がる、コーディングの際の入力補完の精度が上がる、エディタや IDE のコードジャンプ機能の精度が上がる、などがあります。また、メソッドの定義元を見るだけで引数や戻り値の型が一目でわかるため、(型を知りたいだけなら)メソッドの処理を追う必要がなくなります。
JavaScript に対する TypeScript であったり、動的型付け言語である PHP や Python でも静的型が扱えるようになったりしています。
Ruby もその流れに乗って静的型を導入した…と思われがちですが、実は Ruby の目指す型との付き合い方は独自の路線だったりします。もし興味があれば Ruby の生みの親である まつもとゆきひろ さんによる以下の動画などをご覧ください。
Rails アプリケーションでの型の扱い
Ruby は 2020 年 12 月にリリースされた Ruby 3.0 から静的型を扱うライブラリが標準添付されるようになりました。現状まだ Rails アプリケーションへの型導入や運用事例が少ないため、ベストプラクティスは存在しません。今回は Rails チュートリアルの sample_app への型導入を通じて、Ruby での型定義方法や Rails アプリケーションに型導入する雰囲気を感じ取っていただけると良いと思っています。
手前味噌ですが Kaigi on Rails 2022 で型を導入した Rails アプリケーションの運用についてなどを発表しましたので興味のある方は動画をご覧ください。
ほぼ同内容のブログ記事も書いていますのでそちらもあわせてどうぞ。
おことわり
Rails チュートリアルの Rails 6 対応版のコードをもとにした記事になります。2023 年 2 月現在 Rails チュートリアルの最新版は Rails 7 対応のものですが、この記事で使用する gem が Rails 7 では動作しない部分があったためです。
Rails 6 版 sample_app の最終形は GitHub で公開されています。
環境
- Ruby 2.7.6
- Ruby on Rails 6.1.4.1
- RBS 2.8.2
- rbs_rails 0.11.0
- Steep 1.3.0
- Visual Studio Code 1.71.2
VSCode のセットアップ
実際に導入を試してみる前に、1 つだけ準備しておきたいことがあります。それはエディタのセットアップです。今回は Visual Studio Code (以下「VSCode」と表記) を使用します。本来エディタは個々人の好きなものを使っていただけば良いのですが、各エディタで Ruby の型情報をエディタと統合する方法が異なるため、そのすべてをサポートすることはできません。
そこで今回は、無料で使えて手軽にセットアップができる VSCode での設定方法を紹介することにしました。その他のエディタでも型情報を表示させることはできる[1]ようなので、VSCode 以外のエディタでチャレンジしてみたい方はぜひしてみてください(そしてその過程をぜひ公開していただけると需要があると思います!)。
Extensions のインストール
セットアップと言っても 2 つの Extension をインストールするだけです。それぞれ VSCode 上で Extensions の検索窓から検索し、インストールしてください。
Steep
Steep と VSCode を連携するための Extension です。こちらをインストールすると、型情報のあるメソッドなどにマウスホバーしたときに型情報を表示してくれます。以下の画像のように表示されます。
RBS Syntax
rbs ファイルのシンタックスハイライトやインデントのサポートをしてくれる Extension です。rbs はデフォルトでサポートされていないため、この Extension を入れないと文字色がつかず見づらいです。
VSCode のセットアップは以上です。
導入手順
それではここからは実際に手を動かしながら Rails アプリケーションへの型導入を試してみましょう。
sample_app を clone
まずは型を導入する対象のアプリケーションとして Rails チュートリアル の sample_app を手元に用意します。 こちらのリポジトリ で公開されていますが、clone するのに一手間必要なので以下のリポジトリからも clone できます。
ターミナルで以下のコマンドを実行して clone してください。
$ git clone https://github.com/fugakkbn/rails_tutorial_with_signature.git
clone が完了したら README にあるとおりコマンドを実行して開発環境のセットアップを行います。ついでに型導入用のブランチも切っておきます。
$ bundle install --without production
$ bin/rails db:migrate
$ bin/rails test:all
# 後で使用するのでseedファイルも読み込んでおく
$ bin/rails db:seed
# 新しいブランチを作成
$ git switch -c introduce-type-signatures
ここまででセットアップは完了です。
必要な Gem のインストール
続いて型導入に必要な Gem をインストールしていきます。Gemfile
の development
group の部分を以下のように書き換えてください。書き換えたらターミナルで bundle install
を実行します。
.
.
group :development do
+ gem 'rbs', require: false
+ gem 'rbs_rails', require: false
+ gem 'steep', require: false
gem 'web-console', '4.0.1'
gem 'listen', '3.1.5'
gem 'spring', '2.1.0'
gem 'spring-watcher-listen', '2.0.1'
end
.
.
$ bundle install
ここで追加している Gem について簡単に説明しておきます。
RBS
Ruby プログラムに型定義をするための言語です。独自の記法ではあるのですが、「Ruby っぽい記述」ができるため Ruby を書いている方なら無理なく読めるでしょう。Ruby プログラム( .rb
ファイル)とは別のファイル( .rbs
ファイル)として型定義をしていきます。
rbs_rails
Rails 向けに rbs ファイルを生成してくれる gem です。特定のコマンドを実行すると、アプリケーションのコードに対する rbs ファイルを生成してくれます。
Steep
Ruby の静的型解析のための gem です。型解析というのは平たく言うと「型情報とアプリケーションのコードから型的におかしな部分がないかを確認する」ことです。型解析を実行するための steep check
や型定義ファイルの構文チェックをする steep validate
コマンドをよく使います。
Rails 用の型定義ファイルの生成
ここからは型定義ファイルを生成していきます。まずは先ほどインストールした rbs_rails を使用して自動生成するところからはじめます。
Rake Task の定義
rbs_rails の README を見ると、Rake タスク を定義してその Task を実行することで型定義ファイルを自動生成してくれるようです。README を参考に rbs_rails:install
コマンドを実行してみます。
$ bin/rails g rbs_rails:install
すると lib/tasks/rbs.rake
が生成され、以下の Rake タスクが定義されます。
-
rbs_rails:generate_rbs_for_models
: Active Recordモデル用のRBSファイルを生成 -
rbs_rails:generate_rbs_for_path_helpers
: パスヘルパーのRBSファイルを生成 -
rbs_rails:all
: RBS Railsの全タスク(generate_rbs_for_models
,generate_rbs_for_path_helpers
タスクの両方)を実行する
コマンドで確認すると確かに上記のタスクが定義されているようです。
$ bin/rake -T | grep rbs
rake rbs_rails:all # Run all tasks of rbs_rails
rake rbs_rails:generate_rbs_for_models # Generate RBS files for Active Record models
rake rbs_rails:generate_rbs_for_path_helpers # Generate RBS files for path helpers
Rake Task を実行して型定義ファイルを生成
今回はモデルとパスヘルパー両方の型定義を生成したいので、rbs_rails:all
タスクを実行するのがよさそうです。さっそく実行してみましょう。
$ bin/rake rbs_rails:all
実行すると以下の構成で型定義ファイルが自動生成されます。
$ tree sig
sig/
└── rbs_rails/
├── app/
│ └── models/
│ ├── active_storage/
│ │ ├── attachment.rbs
│ │ ├── blob.rbs
│ │ └── variant_record.rbs
│ ├── micropost.rbs
│ ├── relationship.rbs
│ └── user.rbs
├── model_dependencies.rbs
└── path_helpers.rbs
アプリケーション root に sig/
ディレクトリが生成されています。基本的に rbs ファイルは sig/
配下に配置していくことになります[2]。その下に rbs_rails/
ディレクトリがあり、この中に今回 rbs_rails によって自動生成された rbs ファイルが配置されています。
models/
ディレクトリ配下を見ると micropost
, relationship
, user
といった sample_app で使用したモデルの型定義が生成されていることがわかります。
ただしここでは Rails が自動的に定義するメソッドのみが型定義され、自身で def
を使って定義したメソッドの型定義は追加されません。それらは後で別で追加する必要があります。
path_helpers.rbs
はその名の通りパスヘルパーメソッドに対する型定義ファイルです。中身を見てみましょう(長いので折りたたんでいます)。
sig/rbs_rails/path_helpers.rbs
interface _RbsRailsPathHelpers
def rails_mailers_path: (*untyped) -> String
def rails_info_properties_path: (*untyped) -> String
def rails_info_routes_path: (*untyped) -> String
def rails_info_path: (*untyped) -> String
def root_path: (*untyped) -> String
def help_path: (*untyped) -> String
def about_path: (*untyped) -> String
def contact_path: (*untyped) -> String
def signup_path: (*untyped) -> String
def login_path: (*untyped) -> String
def logout_path: (*untyped) -> String
def following_user_path: (*untyped) -> String
def followers_user_path: (*untyped) -> String
def users_path: (*untyped) -> String
def new_user_path: (*untyped) -> String
def edit_user_path: (*untyped) -> String
def user_path: (*untyped) -> String
def edit_account_activation_path: (*untyped) -> String
def password_resets_path: (*untyped) -> String
def new_password_reset_path: (*untyped) -> String
def edit_password_reset_path: (*untyped) -> String
def password_reset_path: (*untyped) -> String
def microposts_path: (*untyped) -> String
def micropost_path: (*untyped) -> String
def relationships_path: (*untyped) -> String
def relationship_path: (*untyped) -> String
def turbo_recede_historical_location_path: (*untyped) -> String
def turbo_resume_historical_location_path: (*untyped) -> String
def turbo_refresh_historical_location_path: (*untyped) -> String
def rails_postmark_inbound_emails_path: (*untyped) -> String
def rails_relay_inbound_emails_path: (*untyped) -> String
def rails_sendgrid_inbound_emails_path: (*untyped) -> String
def rails_mandrill_inbound_health_check_path: (*untyped) -> String
def rails_mandrill_inbound_emails_path: (*untyped) -> String
def rails_mailgun_inbound_emails_path: (*untyped) -> String
def rails_conductor_inbound_emails_path: (*untyped) -> String
def new_rails_conductor_inbound_email_path: (*untyped) -> String
def edit_rails_conductor_inbound_email_path: (*untyped) -> String
def rails_conductor_inbound_email_path: (*untyped) -> String
def new_rails_conductor_inbound_email_source_path: (*untyped) -> String
def rails_conductor_inbound_email_sources_path: (*untyped) -> String
def rails_conductor_inbound_email_reroute_path: (*untyped) -> String
def rails_conductor_inbound_email_incinerate_path: (*untyped) -> String
def rails_service_blob_path: (*untyped) -> String
def rails_service_blob_proxy_path: (*untyped) -> String
def rails_blob_representation_path: (*untyped) -> String
def rails_blob_representation_proxy_path: (*untyped) -> String
def rails_disk_service_path: (*untyped) -> String
def update_rails_disk_service_path: (*untyped) -> String
def rails_direct_uploads_path: (*untyped) -> String
def rails_representation_path: (*untyped) -> String
def rails_blob_path: (*untyped) -> String
def rails_storage_proxy_path: (*untyped) -> String
def rails_storage_redirect_path: (*untyped) -> String
def rails_mailers_url: (*untyped) -> String
def rails_info_properties_url: (*untyped) -> String
def rails_info_routes_url: (*untyped) -> String
def rails_info_url: (*untyped) -> String
def root_url: (*untyped) -> String
def help_url: (*untyped) -> String
def about_url: (*untyped) -> String
def contact_url: (*untyped) -> String
def signup_url: (*untyped) -> String
def login_url: (*untyped) -> String
def logout_url: (*untyped) -> String
def following_user_url: (*untyped) -> String
def followers_user_url: (*untyped) -> String
def users_url: (*untyped) -> String
def new_user_url: (*untyped) -> String
def edit_user_url: (*untyped) -> String
def user_url: (*untyped) -> String
def edit_account_activation_url: (*untyped) -> String
def password_resets_url: (*untyped) -> String
def new_password_reset_url: (*untyped) -> String
def edit_password_reset_url: (*untyped) -> String
def password_reset_url: (*untyped) -> String
def microposts_url: (*untyped) -> String
def micropost_url: (*untyped) -> String
def relationships_url: (*untyped) -> String
def relationship_url: (*untyped) -> String
def turbo_recede_historical_location_url: (*untyped) -> String
def turbo_resume_historical_location_url: (*untyped) -> String
def turbo_refresh_historical_location_url: (*untyped) -> String
def rails_postmark_inbound_emails_url: (*untyped) -> String
def rails_relay_inbound_emails_url: (*untyped) -> String
def rails_sendgrid_inbound_emails_url: (*untyped) -> String
def rails_mandrill_inbound_health_check_url: (*untyped) -> String
def rails_mandrill_inbound_emails_url: (*untyped) -> String
def rails_mailgun_inbound_emails_url: (*untyped) -> String
def rails_conductor_inbound_emails_url: (*untyped) -> String
def new_rails_conductor_inbound_email_url: (*untyped) -> String
def edit_rails_conductor_inbound_email_url: (*untyped) -> String
def rails_conductor_inbound_email_url: (*untyped) -> String
def new_rails_conductor_inbound_email_source_url: (*untyped) -> String
def rails_conductor_inbound_email_sources_url: (*untyped) -> String
def rails_conductor_inbound_email_reroute_url: (*untyped) -> String
def rails_conductor_inbound_email_incinerate_url: (*untyped) -> String
def rails_service_blob_url: (*untyped) -> String
def rails_service_blob_proxy_url: (*untyped) -> String
def rails_blob_representation_url: (*untyped) -> String
def rails_blob_representation_proxy_url: (*untyped) -> String
def rails_disk_service_url: (*untyped) -> String
def update_rails_disk_service_url: (*untyped) -> String
def rails_direct_uploads_url: (*untyped) -> String
def rails_representation_url: (*untyped) -> String
def rails_blob_url: (*untyped) -> String
def rails_storage_proxy_url: (*untyped) -> String
def rails_storage_redirect_url: (*untyped) -> String
end
今までに何度も使った users_path
や microposts_url
のようなパスヘルパー用の型定義が生成されていることがわかります。
gem_rbs_collection で gem の型定義をダウンロードする
続いて Gem の型定義ファイルをダウンロードします。現在 Gem の型定義については gem_rbs_collection というリポジトリに集約されており[3]、そちらからダウンロードすることで使うことができます。
ダウンロードするための設定からしていきます。
$ bundle exec rbs collection init
コマンドを実行すると rbs_collection.yaml
が生成されます。これは gem_rbs_collection
用の設定ファイルです。以下の内容で生成されますが、ひとまず内容を理解する必要はありません。
# Download sources
sources:
- name: ruby/gem_rbs_collection
remote: https://github.com/ruby/gem_rbs_collection.git
revision: main
repo_dir: gems
# A directory to install the downloaded RBSs
path: .gem_rbs_collection
gems:
# Skip loading rbs gem's RBS.
# It's unnecessary if you don't use rbs as a library.
- name: rbs
ignore: true
実際にダウンロードするためのコマンドを実行します。
$ bundle exec rbs collection install
実行すると.gem_rbs_collection/
ディレクトリに、現在 gem_rbs_collection
リポジトリにある gem の型定義ファイルがダウンロードされます。通常このディレクトリはプロジェクトのリポジトリで管理する必要がないため、.gitignore
ファイルに追記しておくと良いでしょう。
.
.
.gem_rbs_collection/
Steep のセットアップ
続いて型解析を実行するために Steep のセットアップをしていきます。
$ bundle exec steep init
これを実行すると Steep 用の設定ファイルである Steepfile
が生成されるます。生成された Steepfile
を以下の内容に置き換えてください。それぞれの行の意味はコメントを参照してください。
target :app do
signature 'sig' # 型定義ファイルの配置ディレクトリを指定
check 'app' # 解析対象のディレクトリを指定
library 'rbs' # 型定義を使用したい標準添付ライブラリを指定
end
これで型解析を実行する準備が整いました。さっそく型解析を実行してみましょう。
$ bundle exec steep check
実行すると、以下のようなエラーや警告が出たのではないでしょうか。
# Type checking files:
........................................................................................................................................................................................F...F................F...F.......F.F.................F...........FF.F....F.F......FF......F.F.......F.F............FFFF....FF..F......FFF..F.F.F...........F..F.FF.F..FFFF.....F.FF..........FFFF.....F.......F..F....F..........FF..
app/controllers/relationships_controller.rb:1:6: [warning] Cannot find the declaration of class: `RelationshipsController`
│ Diagnostic ID: Ruby::UnknownConstant
│
└ class RelationshipsController < ApplicationController
~~~~~~~~~~~~~~~~~~~~~~~
app/controllers/relationships_controller.rb:1:32: [warning] Cannot find the declaration of class: `ApplicationController`
│ Diagnostic ID: Ruby::UnknownConstant
│
└ class RelationshipsController < ApplicationController
~~~~~~~~~~~~~~~~~~~~~
.
.
はい、これでひとまず「エラーや警告は出るが型解析を実行できる状態」になりました。ここまでで第一段階は完了です。これ以降は警告されている型定義の不足などを補うため、手動で型定義を足していく作業になります。
モデルのメソッドの型定義を足していく
ここからは自動生成されていないメソッドなどに対して型定義をしていきます。
簡単のため、steep check
の解析範囲を狭めます。
target :app do
signature 'sig'
check 'app/models/user.rb' # 解析対象をUserモデルのみに絞っている
library 'rbs'
end
この状態で改めて steep check
を実行してみると、以下のエラーや警告が出るはずです(他にも出ていると思います)。
app/models/user.rb:16:2: [warning] Cannot find the declaration of constant: `VALID_EMAIL_REGEX`
│ Diagnostic ID: Ruby::UnknownConstant
│
└ VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
~~~~~~~~~~~~~~~~~
app/models/user.rb:114:36: [error] Type `singleton(::User)` does not have method `new_token`
│ Diagnostic ID: Ruby::NoMethod
│
└ self.activation_token = User.new_token
~~~~~~~~~
1 つ目の warning は VALID_EMAIL_REGEX
定数の型定義が見つからない、という内容の警告です。2 つ目の error は User モデルに new_token
メソッドはないよ、という内容です。User モデルに new_token
メソッドは定義されているのに、おかしいですね。
これらを解決するための型定義を書いてみましょう。sig/app/models/user.rbs
ファイルを追加して、中身を以下のように書きます。
class User < ApplicationRecord
VALID_EMAIL_REGEX: Regexp
def self.new_token: () -> String
end
この状態で再度 steep check
を実行してみましょう。先ほどの警告とエラーが出なくなったことが確認できると思います。
また、VSCode で app/models/user.rb
を開き new_token
メソッドにマウスホバーしてみましょう。すると以下の画像のように、先ほど定義した型情報が表示されるはずです。
この後も多くのメソッド等に型定義をしていくのでぜひマウスホバーして確かめてみてください。また、自分で定義していないメソッドの型情報がどのように表示されるかなども見てみると、おもしろいですよ。
このように、基本的にはエラーや警告を見て型定義の不足を確認した上で追加していく、といった流れで型定義を進めていきます。進め方の雰囲気を掴んでもらえたら、引き続き型定義を進めながら RBS の文法についても見ていきましょう。
RBS の文法
型定義を進めていくために、User モデルの各メソッドに型定義をしながら RBS の文法について説明します。すべての構文を紹介することはできませんが、基本的な部分はこれで抑えられるかと思います。
型定義の対象
型定義をするべき対象としては以下がメインになります。
- クラスメソッド(singleton メソッド)
- インスタンスメソッド
- 定数
- インスタンス変数
可視性が private や protected のメソッドにも型定義ができます。
基本的な構文
先ほど例に出した型定義をもとに説明します。
class User < ApplicationRecord
VALID_EMAIL_REGEX: Regexp
def self.new_token: () -> String
end
まず class User ... end
の構文でどのクラスの型定義なのかを記述します。これは Ruby の構文と同じです。< ApplicationRecord
となっているように継承も表現できますし、同様にして Module の型定義を記述することもできます。
続いて def self.new_token: () -> String
の部分がメソッドの型定義の部分です。def
キーワードに続いて型定義をしたいメソッド名、:
と続き、引数がある場合は引数名とその型を ( )
の中に書きます(このメソッドには引数がないため () の中は空ですが)。そして ->
の後に戻り値の型を書きます。
def <メソッド名>: (<引数の型>) -> <戻り値の型>
メソッド名の前の self
は Ruby と同じくクラスメソッドを表す書き方です。
つまり def self.new_token: () -> String
は「new_token (クラス)メソッドは String 型の戻り値を返すメソッド」だと定義していることになります。
また、少し戻って VALID_EMAIL_REGEX: Regexp
の部分は定数の型定義を記述している部分になります。定数名: 型
の形になり、インスタンス変数も同様の形式で型定義することができます。
<定数名>: <定数の型>
<インスタンス変数名>: <インスタンス変数の型>
これが基本の書き方になります。これをもとにして User モデルの他のメソッドについても型定義を進めてみましょう。
引数を受け取るメソッド
さきほど引数の型を書く箇所を示しましたが、User モデルのメソッドで実例を見てみましょう。
User モデルに以下のメソッドがあります。
def authenticated?(attribute, token)
digest = send("#{attribute}_digest")
return false if digest.nil?
BCrypt::Password.new(digest).is_password?(token)
end
このメソッドは attribute
, token
の 2 つの引数を受け取るので、型定義をするためには引数の型を知る必要があります。この引数の型を確認するためにテストでの使われ方を見てみましょう。
test "authenticated? should return false for a user with nil digest" do
assert_not @user.authenticated?(:remember, '')
end
引数を見ると第一引数は Symbol 型、第二引数は String 型を渡しているようです。またメソッドの最終行を見ると
BCrypt::Password.new(digest).is_password?(token)
となっており、トークンとパスワードダイジェストが一致するかどうかを真偽値で返すメソッドが呼ばれています。つまりこのメソッドの返り値は真偽値になります。
これらを勘案すると authenticated?
メソッドの型定義は次のようになります。
def authenticated?: (Symbol, String) -> bool
このように引数の型は引数の順番に書きます。ちなみに以下のように引数名と型を書いた場合は、キーワード引数の型定義になります。
def authenticated?: (attribute: Symbol, token: String) -> bool
そして真偽値の型を表すのは bool
です。特に true
, false
を表す場合に使います。ちなみに真偽値ではないが真偽判定に使う値を表す場合に使用する boolish
という型もあります。
戻り値を扱わないメソッド
メソッドの中には戻り値を値として使用する訳ではないものも多くあります。例えば User モデルの forget
メソッドがこれにあたります。
def forget
update_attribute(:remember_digest, nil)
end
このメソッドはユーザーがログアウト操作をした際に認証に使用しているダイジェストを破棄するメソッドですが、このようにモデルの属性を更新する処理は戻り値を期待しているわけではありません。このような場合の型定義には void
を使用します。
これを踏まえると forget
メソッドの型定義は次のようになります。
def forget: () -> void
また、モデルの新規追加、削除、メール送信なども同様の考え方で void
を使います[4]。たとえば send_activation_email
, follow
, unfollow
メソッドなどがこれにあたります。
def send_activation_email: () -> void
def follow: (User) -> void
def unfollow: (User) -> void
follow
, unfollow
メソッドでは User
型の引数を受け取っていることに注意してください。
戻り値が ActiveRecord オブジェクトの場合
feed
メソッドは User が follow している User の Micropost を取得するメソッドですが、このメソッドの型はどのように書くのが良さそうでしょうか?このような時は、実際にメソッドを実行してみて確認すると良いでしょう。
$ bin/rails c
> user = User.first
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2022-12-31 09:51:17.052270000 +0000", updated_at: "2022-12-31 09:51:17.052270000 +0000", password_digest: [FILTERED], remember_digest: nil...
> user.feed
=> #<ActiveRecord::Relation [#<Micropost id: 300, content: "Perferendis maiores quod repellendus sed.", user_id: 6, created_at: "2022-12-31 09:52:18.766229000 +0000", updated_at: "2022-12-31 09:52:18.766229000 +0000">, #<Micropost id: 299,...
> user.feed.class
=> Micropost::ActiveRecord_Relation
最後の user.feed.class
で user.feed
の戻り値のクラスを確認した結果、このメソッドの戻り値は Micropost::ActiveRecord_Relation
型であることがわかります。よって feed
メソッドの型定義は次のようになります。
def feed: () -> Micropost::ActiveRecord_Relation
ただこの型は、Micropost 型が含まれていることからわかる通り sample_app 特有の型です。この型はいったいどこから出てきたのでしょうか?
実はこの型は rbs_rails で自動生成した型定義ファイルの中で定義されています。具体的には sig/rbs_rails/app/models/micropost.rbs
の最後の方で以下のように定義されています。
class Micropost < ::ApplicationRecord
.
.
class ActiveRecord_Relation < ::ActiveRecord::Relation
include GeneratedRelationMethods
include _ActiveRecord_Relation[Micropost, Integer]
include Enumerable[Micropost]
end
.
.
end
アクセサメソッド
attr_accessor
などのアクセサメソッドに対しても型定義ができます。User モデルでも以下のようにアクセサメソッドが使われています。
attr_accessor :remember_token, :activation_token, :reset_token
アクセサメソッドの型定義は次の形で記述します。
attr_<accessor|reader|writer> <アクセサメソッド名> (<引数の型>): -> <戻り値の型>
よって、上記のアクセサメソッドの型定義は以下のようになります。
attr_accessor remember_token (): String
attr_accessor activation_token (): String
attr_accessor reset_token (): String
型定義の例
ここまでに紹介した記法で User モデルの型をすべて定義することができます。
class User < ApplicationRecord
attr_accessor remember_token (): String
attr_accessor activation_token (): String
attr_accessor reset_token (): String
VALID_EMAIL_REGEX: Regexp
def self.digest: (String) -> BCrypt::Password
def self.new_token: () -> String
def remember: () -> void
def authenticated?: (Symbol, String) -> bool
def forget: () -> void
def activate: () -> void
def send_activation_email: () -> void
def create_reset_digest: () -> void
def send_password_reset_email: () -> void
def password_reset_expired?: () -> bool
def feed: () -> Micropost::ActiveRecord_Relation
def follow: (User) -> void
def unfollow: (User) -> void
def following?: (User) -> bool
private
def downcase_email: () -> void
def create_activation_digest: () -> void
end
より詳しく RBS の文法を知りたい方は rbs/syntax.md at master · ruby/rbs を参照してください。rbs/rbs_by_example.md at master · ruby/rbs にはサンプルコードもあります。
型定義をしたことによるメリットの確認
User モデルの型定義をすることができましたが、型があると具体的にどんなことがうれしいのでしょうか?それを体感していただくために、以下の内容を試してみてください。
入力補完の精度を確認する
今型定義をした authenticated?
メソッドの第二引数の型を untyped
にしてみましょう。untyped
は「型情報が不明」な状態を表す型[5]です。
def authenticated?: (Symbol, untyped) -> bool
この状態で引数 token
に対してメソッドを呼び出すため .
を入力すると、メソッドの補完候補がエディタ上で表示されます。token
は String 型なので String クラスのメソッドが表示されるとうれしいですが、この状態だと表示されるのは Object
クラスのメソッドばかりです。
では再度、引数 token
の型を String にしてみます。
def authenticated?: (Symbol, String) -> bool
そして先ほどと同じように token
に対してメソッドを呼び出そうとすると、今度は補完候補に String クラスのメソッドが表示されるようになりました。
これが正に型情報による恩恵です。ただし、ここで示した例は RubyMine での例です(すみません)。VSCode だとこのようにわかりやすい結果が出せないため RubyMine の例をお見せしました。ただ、VSCode でも同様に型情報があることによって補完候補が絞られているようなので、上記と同じことをしたらどのような挙動をするかぜひ試してみてください。
型の不一致を検知する
再度 authenticated?
メソッドを例にします。このメソッドは Symbol 型の第一引数と String 型の第二引数を取ります。第二引数にあえて Integer 型の引数を与えるとどうなるでしょうか。
app/controllers/account_activations_controller.rb
の 5 行目で authenticated?
メソッドを呼び出しているので書き換えてみます。
- if user && !user.activated? && user.authenticated?(:activation, params[:id])
+ if user && !user.activated? && user.authenticated?(:activation, 123)
Steepfile の解析対象を以下のように変更した後、steep check
を実行してみましょう。
.
.
check 'app/controllers/account_activations_controller.rb'
.
.
$ bundle exec steep check
# Type checking files:
.....................................
app/controllers/account_activations_controller.rb:5:68: [error] Cannot pass a value of type `::Integer` as an argument of type `::String`
│ ::Integer <: ::String
│ ::Numeric <: ::String
│ ::Object <: ::String
│ ::BasicObject <: ::String
│
│ Diagnostic ID: Ruby::ArgumentTypeMismatch
│
└ if user && !user.activated? && user.authenticated?(:activation, 123)
~~~
上記のようなエラーが出力されているのが確認できるはずです。エラーの内容を読んでみると「型 ::Integer
の値を型 ::String
の引数として渡すことはできません。」と出力されており、期待通り型の不一致を捕捉してくれました。
このように型定義にそぐわない呼び出し方をした場合に検知できると、バグを埋め込んだ状態でリリースすることを防げる可能性が高くなります[6]。
型の自動生成について
ここまで User モデルの型定義は手書きしてきましたが、ツールを使って自動生成することもできます。RBS にはコマンドとして rbs prototype
があり、こちらが型情報を自動生成するためのコマンドになっています。また、同じく Ruby 本体にバンドルされている TypeProf というツールもあります。
ここでは rbs prototype rb
コマンドでの自動生成についてご紹介します。紹介程度で詳しい解説はしませんので、もし詳しく知りたい方は以下の記事がとてもわかりやすいので参考にしてください(RBS を開発されている方の記事です)。
RBS での自動生成
それでは User モデルの型定義ファイルを自動生成してみましょう。自動生成は以下のコマンドを実行するだけです。
$ bundle exec rbs prototype rb app/models/user.rb >> sig/app/models/user-generated.rbs
このコマンドを実行すると、sig/app/models/user-generated.rbs
ファイルが以下の内容で出力されているはずです。
class User < ApplicationRecord
attr_accessor remember_token: untyped
attr_accessor activation_token: untyped
attr_accessor reset_token: untyped
VALID_EMAIL_REGEX: ::Regexp
# 渡された文字列のハッシュ値を返す
def self.digest: (untyped string) -> untyped
# ランダムなトークンを返す
def self.new_token: () -> untyped
# 永続セッションのためにユーザーをデータベースに記憶する
def remember: () -> untyped
# トークンがダイジェストと一致したらtrueを返す
def authenticated?: (untyped attribute, untyped token) -> (false | untyped)
# ユーザーのログイン情報を破棄する
def forget: () -> untyped
# アカウントを有効にする
def activate: () -> untyped
# 有効化用のメールを送信する
def send_activation_email: () -> untyped
# パスワード再設定の属性を設定する
def create_reset_digest: () -> untyped
# パスワード再設定のメールを送信する
def send_password_reset_email: () -> untyped
# パスワード再設定の期限が切れている場合はtrueを返す
def password_reset_expired?: () -> untyped
# ユーザーのステータスフィードを返す
def feed: () -> untyped
# ユーザーをフォローする
def follow: (untyped other_user) -> untyped
# ユーザーをフォロー解除する
def unfollow: (untyped other_user) -> untyped
# 現在のユーザーがフォローしてたらtrueを返す
def following?: (untyped other_user) -> untyped
private
# メールアドレスをすべて小文字にする
def downcase_email: () -> untyped
# 有効化トークンとダイジェストを作成および代入する
def create_activation_digest: () -> untyped
end
先ほど手書きで型定義した user.rbs と似たような内容が出力されていますね。少し違うところとして
def follow: (untyped other_user) -> untyped
# 手書きしたときは以下のように書いた
def follow: (User) -> void
このように
def <メソッド名>: (<引数の型> <引数名>) -> <戻り値の型>
と書くこともできます。
しかし、よく見てみると引数や戻り値の型がほとんど untyped
になっています。rbs prototype rb
コマンドは Ruby コードを静的に解析して型情報を出力しているため、ほとんどが untyped
として出力されます(単純なケースでは String などの型情報が生成される場合もあります)。そのため自動生成した後に改めて型情報を修正したり、誤りがないかを確認する必要があるのが現状です。
今回の User モデルのように小さなコードであれば型定義をするのもそこまで大変な作業ではありませんが、実際に世の中で動いているアプリケーションはもっと大きな規模のコードベースです。それを手作業で型定義するのは容易ではありません。そういった背景もあり、最近はより正確な型情報を自動生成するツールを開発されている方もいらっしゃいます。プログラマーが手作業で型定義しなくても良い未来がくるかもしれません。
おわりに
以上で sample_app への型導入の解説は終わりです。今回は User モデルのみに型定義をしてみましたが、Micropost モデルや Relationship モデルへの型定義にもぜひチャレンジしてみてください。
また、Ruby の型周りはまだエコシステムが成熟しているわけではなく、これからも新しい機能や技術が出てくるものと思われます(先ほどご紹介した型情報の自動生成などもその一つです)。興味のある方はその辺りの動向をチェックしておくとおもしろいかもしれません。
この記事へのフィードバックがありましたら Twitter から DM をいただければと思います。最後までお読みいただきありがとうございました
-
例えば僕は普段 RubyMine という IDE を使っていて、RubyMine ではデフォルトで型情報の表示やシンタックスハイライトがサポートされています。 ↩︎
-
必ずしも sig でなくても問題ありません。Steepfile の
signature
に指定すればどのディレクトリでも動作します。 ↩︎ -
Gem 自体に型定義を含めることもできますし、将来的にはそちらが主流になりそうです。が、型を書くかどうかはメンテナ次第なので第三者が型を書く場合にはひとまず gem_rbs_collection に置く、というのが現状です ↩︎
-
ただしモデルの更新や削除の結果によって処理を分岐したいケースもあるでしょう。その場合は
boolish
型が正しいと言えそうですが、実際どちらにすべきかは僕もよくわかっていません ↩︎ -
TypeScript でいうところの
any
型 ↩︎ -
そのために CI で steep check を実行する必要がありますが、なかなか難しいのが現状です。 ↩︎
Discussion