ゼネットの廣瀬です。
Ruby / Rails は「書きやすい言語・フレームワーク」として有名です。
しかし実務では
- なぜか NoMethodErrorが出る
- updateが失敗しているのに処理が進む
- includesを使ったのにクエリが増える
といった 「仕様を知らないとハマるポイント」 が数多くあります。
実際の開発でも、こうした挙動が原因で
バグ調査に時間を取られるケースは少なくありません。
この記事では、Ruby / Rails 開発でよくある落とし穴を取り上げ、
- なぜ問題になるのか
- どう書けば安全なのか
を実例とともに整理します。
- 1. find_by はヒットしないと nil を返す
- 2.blank? は false を「空」とみなす
- 3. update は失敗しても例外を出さない
- 4. each と map の役割を混同する
- 5. select と pluck の違い
- 6. eager_load / preload / includes の違い
- 7. ||= は false も上書きする
- まとめ
1. find_by はヒットしないと nil を返す
■問題コード
user = User.find_by(id: params[:id]) user.update(name: "foo")
■なぜ問題?
find_by がレコードを見つけられなかった場合は nil を返します。その nil に対してメソッドを呼び出すと、次のエラーが発生します。
NoMethodError: undefined method `update' for nil:NilClass
「必ず存在するはず」という前提で書くと見落としがちです。
Rails には似たメソッドがあります。
| メソッド | 見つからない場合 |
|---|---|
| find | 例外 |
| find_by | nil |
| find_by! | 例外 |
■ 安全な書き方
存在するかもしれない場合
user = User.find_by(id: params[:id]) user&.update(name: "foo")
&. を使うことで
nil の場合でも安全に処理できます。
必ず存在する前提の場合
user = User.find_by!(id: params[:id]) user.update(name: "foo")
find_by! はレコードが無い場合、例外を発生させます。
コントローラーでは 404 エラーとして処理されるため安全です。
2.blank? は false を「空」とみなす
Rails には便利なメソッド blank? / present? があります。
ただしこれは Rubyの真偽値とは別の「空」の概念 です。
0.present? # => true " ".present? # => false(空白は空扱い) [].present? # => false nil.present? # => false false.present? # => false
■問題コード
params[:enabled].blank?
■なぜ問題?
false も blank? では 空扱い になります。
つまり次のようなケースです。
params[:enabled] = false params[:enabled].blank? # => true
本来は「値が設定されている (false)」にも関わらず、
blank? では 「未設定」と判定されてしまいます。
例えば次のコードは誤判定になります。
unless params[:enabled].blank? enable_feature end
params[:enabled] = false の場合でも
blank? => true になるため 意図しない動作になります。
■安全な書き方
boolean の未設定チェックなら
params[:enabled].nil?
blank? は文字列や配列の空チェックには便利ですが、boolean 値の判定には不向きです。nil チェックには nil? を使うようにしましょう。
3. update は失敗しても例外を出さない
■問題コード
user.update(name: nil)
update は バリデーションエラーでも例外を出さず false を返します。
■ なぜ問題?
次の問題が起きます。
- update は失敗しても処理が継続するため、データ不整合が発生しやすい
- バッチ処理や複数レコード更新で、一部だけ更新されてしまう事故が起きる
- ログに何も残らず、原因調査が困難になる
■ 安全な書き方
失敗時のログを残す
unless user.update(name: "foo") Rails.logger.warn(user.errors.full_messages.join(", ")) end
トランザクションでは update!
update! を使うと例外が発生し、トランザクション全体がロールバックされます。
ActiveRecord::Base.transaction do user.update!(name: nil) # 例外発生 → ロールバック log.update!(message: "updated") end
なお、update! を使う場合は ActiveRecord::RecordInvalid 例外が発生するため、必要に応じて rescue で捕捉することも検討してください。
4. each と map の役割を混同する
■問題コード
names = []
users.each { |u| names << u.name }
■なぜ問題?
Ruby の each は「繰り返すだけ」で、戻り値は元の配列です。
result = users.each { |u| u.name }
# => users(意図と違う)
つまり each は 配列を生成するメソッドではありません。
そのため次のようなコードを書くと、
意図しない戻り値になることがあります。
names = users.each { |u| u.name }
names
# => users
names に ["Taro", "Hanako"] のような配列が入ることを期待しますが、実際には users がそのまま返ります。
このような 戻り値の誤解によるバグが発生する可能性があります。
■ 安全な書き方
新しい配列を作る場合は map を使います。
names = users.map { |u| u.name }
Rubyでは次のように書くことも多いです。
names = users.map(&:name)
一方、副作用だけが目的の場合は each を使います。
users.each { |u| u.notify }
5. select と pluck の違い
pluck と select は似ていますが大きく違います。
| メソッド | 返り値 | 特徴 |
|---|---|---|
| pluck | Ruby配列 | 高速 |
| select | ActiveRecord | モデル生成 |
■ 問題コード
ids = User.select(:id).map(&:id)
このコードは動作しますが、パフォーマンス上の問題があります。
不要な ActiveRecord オブジェクトが生成されます。
■なぜ問題?
select は ActiveRecordオブジェクトを生成します。
つまり
- オブジェクト生成
- メモリ使用
- GC負荷
が発生します。
■ 安全な書き方
値だけ欲しい場合は pluck を使う方が高速です。
ids = User.pluck(:id)
モデルとして扱いたいなら select
users = User.select(:id, :name) users.each { |u| puts u.name } # =>モデルの特定の値を使える
6. eager_load / preload / includes の違い
Rails では N+1 問題を解決するために
- includes
- preload
- eager_load
というメソッドがあります。
includes は条件の内容によって内部で preload または eager_load に自動切り替えされるため、意図しないクエリが発行される場合があります。
■問題コード
users = User.includes(:posts) users.each do |user| user.posts.each do |post| puts post.title end end
一見すると N+1 問題を防げているように見えますが、
クエリ条件によっては JOIN が発生しないケース があります。
例えば次のようなコードです。
User.includes(:posts).where(posts: { published: true })
■なぜ問題?
includes は 状況によって preload または eager_load に切り替わります。
| メソッド | 動作 |
|---|---|
| preload | 別クエリで関連を取得 |
| eager_load | LEFT OUTER JOIN で取得 |
例えば
User.includes(:posts).where(active: true)
この場合は 関連テーブルを条件に使っていないため preload になります。
一方で
User.includes(:posts).where(posts: { published: true })
この場合は eager_load(JOIN)に変わります。
つまり includes は クエリ内容によって内部挙動が変わるメソッド です。
■ 安全な書き方
実務ではまずincludesを使うケースが多いです。
ただし、次のようなケースでは挙動を明示した方が安全です。
- SQL を最適化したい
- JOIN を確実に使いたい
- パフォーマンス調査中
その場合はpreload/eager_loadを明示的に使うと
クエリの挙動をコントロールできます。
includes は referencesを組み合わせると JOIN を明示できます
User.includes(:posts).where(posts: { published: true }).references(:posts)
必ず別クエリにしたい場合
User.preload(:posts)
関連テーブルで絞り込みたい場合
User.eager_load(:posts).where(posts: { published: true })
7. ||= は false も上書きする
||= は「左辺が falsy(false または nil)のとき、右辺を代入する」という Ruby の構文です。
false は「値が設定されている」ケースでも普通に使われるため、
||= を使うと 意図しない上書き が起きます。
■問題コード
@flag ||= false
■なぜ問題?
||= はnil または falseのとき再代入します。
例えば次のようなケースです。
@flag = false @flag ||= true @flag # => true
false が設定されているにも関わらず、
true に上書きされてしまいます。
つまり、falseが保持できず正しい結果をキャッシュできません。
■ 安全な書き方
boolean キャッシュではこちらの方が安全です。
@flag = compute_flag if @flag.nil?
まとめ
Ruby はとても書きやすい言語であり、Rails はその特性を活かした開発しやすいフレームワークです。しかしその分、「気づきにくい落とし穴」も多く存在します。
今回紹介したポイントは、実務でも頻繁に見かけるものばかりです。
- 仕様を正しく理解する
- 戻り値の型を意識する
- 例外を活用して失敗に気づく
こうした基本を押さえておくことで、バグを未然に防ぎ、開発のスピードと品質を大きく向上できます。
