💎

【Ruby 52日目】オブジェクト指向 - respond_to_missing?

に公開

はじめに

Rubyのrespond_to_missing?について、Ruby 3.4の仕様に基づいて詳しく解説します。

この記事では、基本的な概念から実践的な使い方まで、具体的なコード例を交えて説明します。

基本概念

respond_to_missing?は、method_missingと組み合わせて使う重要なメソッドです:

  • respond_to?との整合性 - method_missingで対応するメソッドをrespond_to?でも認識させる
  • メソッドの存在確認 - 動的メソッドが利用可能かどうかを正しく判定できる
  • リフレクションのサポート - methodメソッドなどのリフレクション機能との互換性
  • 必須の実装 - method_missingを使う場合は必ずrespond_to_missing?も実装すべき

respond_to_missing?を正しく実装することで、動的メソッドが透過的に機能します。

基本的な使い方

問題: respond_to_missing?なしの場合

class WithoutRespondToMissing
  def method_missing(method_name, *args)
    if method_name.to_s.start_with?("dynamic_")
      "Dynamic method: #{method_name}"
    else
      super
    end
  end
end

obj = WithoutRespondToMissing.new

# メソッドは呼べる
puts obj.dynamic_hello  #=> Dynamic method: dynamic_hello

# しかしrespond_to?は正しく動作しない
puts obj.respond_to?(:dynamic_hello)  #=> false(不整合!)

解決: respond_to_missing?を実装

class WithRespondToMissing
  def method_missing(method_name, *args)
    if method_name.to_s.start_with?("dynamic_")
      "Dynamic method: #{method_name}"
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    method_name.to_s.start_with?("dynamic_") || super
  end
end

obj = WithRespondToMissing.new

# メソッドは呼べる
puts obj.dynamic_hello  #=> Dynamic method: dynamic_hello

# respond_to?も正しく動作
puts obj.respond_to?(:dynamic_hello)  #=> true(正しい!)
puts obj.respond_to?(:other_method)   #=> false

基本的なパターン

class DynamicAccessor
  def initialize
    @data = {}
  end

  def method_missing(method_name, *args)
    method_str = method_name.to_s

    if method_str.end_with?("=")
      # セッター
      key = method_str.chomp("=").to_sym
      @data[key] = args.first
    elsif @data.key?(method_name)
      # ゲッター
      @data[method_name]
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    method_str = method_name.to_s
    # セッターまたは既存のキー
    method_str.end_with?("=") || @data.key?(method_name) || super
  end
end

obj = DynamicAccessor.new
obj.name = "Alice"
obj.age = 25

puts obj.respond_to?(:name)   #=> true
puts obj.respond_to?(:age)    #=> true
puts obj.respond_to?(:email)  #=> false

include_privateパラメータ

class PrivateChecker
  def method_missing(method_name, *args)
    if method_name.to_s.start_with?("public_")
      "Public dynamic method"
    elsif method_name.to_s.start_with?("private_")
      "Private dynamic method"
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    method_str = method_name.to_s

    if method_str.start_with?("public_")
      true
    elsif method_str.start_with?("private_")
      include_private  # include_privateがtrueの時のみ認識
    else
      super
    end
  end
end

obj = PrivateChecker.new

puts obj.respond_to?(:public_method)           #=> true
puts obj.respond_to?(:private_method)          #=> false
puts obj.respond_to?(:private_method, true)    #=> true

superの重要性

class Base
  def base_method
    "Base method"
  end
end

class Derived < Base
  def method_missing(method_name, *args)
    if method_name.to_s.start_with?("custom_")
      "Custom method"
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    method_name.to_s.start_with?("custom_") || super
  end
end

obj = Derived.new

# 基底クラスのメソッドも認識
puts obj.respond_to?(:base_method)   #=> true
puts obj.respond_to?(:custom_test)   #=> true
puts obj.respond_to?(:unknown)       #=> false

methodメソッドとの連携

class MethodSupport
  def method_missing(method_name, *args, &block)
    if method_name.to_s.start_with?("calc_")
      operation = method_name.to_s.sub("calc_", "")
      ->(a, b) { a.send(operation, b) }
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    method_name.to_s.start_with?("calc_") || super
  end
end

obj = MethodSupport.new

# methodメソッドが正しく動作
add_method = obj.method(:calc_+)
puts add_method.call(10, 5)  #=> 15

multiply_method = obj.method(:calc_*)
puts multiply_method.call(4, 3)  #=> 12

よくあるユースケース

ケース1: Active Record風のfind_by_*メソッド

動的なfinder メソッドを実装します。

class User
  attr_reader :id, :name, :email, :age

  @@users = []

  def initialize(id:, name:, email:, age:)
    @id = id
    @name = name
    @email = email
    @age = age
    @@users << self
  end

  def self.method_missing(method_name, *args)
    if method_name.to_s =~ /^find_by_(.+)$/
      attribute = $1.to_sym
      value = args.first
      @@users.find { |user| user.send(attribute) == value }
    elsif method_name.to_s =~ /^find_all_by_(.+)$/
      attribute = $1.to_sym
      value = args.first
      @@users.select { |user| user.send(attribute) == value }
    else
      super
    end
  end

  def self.respond_to_missing?(method_name, include_private = false)
    method_str = method_name.to_s
    method_str =~ /^find_(all_)?by_.+$/ || super
  end

  def self.all
    @@users
  end
end

# データを作成
User.new(id: 1, name: "Alice", email: "alice@example.com", age: 25)
User.new(id: 2, name: "Bob", email: "bob@example.com", age: 30)
User.new(id: 3, name: "Charlie", email: "charlie@example.com", age: 25)

# 動的finderメソッド
alice = User.find_by_name("Alice")
puts alice.email  #=> alice@example.com

users_25 = User.find_all_by_age(25)
puts users_25.map(&:name).join(", ")  #=> Alice, Charlie

# respond_to?も正しく動作
puts User.respond_to?(:find_by_email)  #=> true
puts User.respond_to?(:find_all_by_age)  #=> true
puts User.respond_to?(:invalid_method)  #=> false

ケース2: 設定オブジェクトの型安全性

型チェック付きの動的設定を実装します。

class TypedConfiguration
  SCHEMA = {
    host: String,
    port: Integer,
    ssl: [TrueClass, FalseClass],
    timeout: Numeric,
    retries: Integer
  }

  def initialize
    @settings = {}
  end

  def method_missing(method_name, *args)
    method_str = method_name.to_s

    if method_str.end_with?("=")
      key = method_str.chomp("=").to_sym
      validate_and_set(key, args.first)
    elsif SCHEMA.key?(method_name)
      @settings[method_name]
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    method_str = method_name.to_s
    key = method_str.end_with?("=") ? method_str.chomp("=").to_sym : method_name
    SCHEMA.key?(key) || super
  end

  private

  def validate_and_set(key, value)
    unless SCHEMA.key?(key)
      raise ArgumentError, "Unknown setting: #{key}"
    end

    expected_types = Array(SCHEMA[key])
    unless expected_types.any? { |type| value.is_a?(type) }
      raise TypeError, "#{key} must be #{expected_types.join(' or ')}, got #{value.class}"
    end

    @settings[key] = value
  end
end

config = TypedConfiguration.new

# 正しい型で設定
config.host = "localhost"
config.port = 3000
config.ssl = true
config.timeout = 5.0

puts config.host  #=> localhost
puts config.respond_to?(:port)  #=> true

# 型エラー
begin
  config.port = "invalid"
rescue TypeError => e
  puts "Error: #{e.message}"
  #=> Error: port must be Integer, got String
end

# 未定義のキー
puts config.respond_to?(:unknown)  #=> false

ケース3: プロキシオブジェクトのデバッグ

メソッド呼び出しをログに記録します。

class LoggingProxy
  def initialize(target, logger: $stdout)
    @target = target
    @logger = logger
    @call_count = Hash.new(0)
  end

  def method_missing(method_name, *args, &block)
    if @target.respond_to?(method_name)
      @call_count[method_name] += 1
      log_call(method_name, args)

      start_time = Time.now
      result = @target.send(method_name, *args, &block)
      elapsed = Time.now - start_time

      log_result(method_name, result, elapsed)
      result
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    @target.respond_to?(method_name, include_private) || super
  end

  def stats
    @call_count
  end

  private

  def log_call(method_name, args)
    @logger.puts "[#{Time.now}] Calling #{method_name} with #{args.inspect}"
  end

  def log_result(method_name, result, elapsed)
    @logger.puts "[#{Time.now}] #{method_name} returned (#{elapsed}s): #{result.inspect}"
  end
end

# 使用例
array = [1, 2, 3, 4, 5]
proxy = LoggingProxy.new(array)

proxy.map { |x| x * 2 }
#=> [2025-11-28...] Calling map with []
#   [2025-11-28...] map returned (...s): [2, 4, 6, 8, 10]

proxy.select { |x| x > 2 }
#=> [2025-11-28...] Calling select with []
#   [2025-11-28...] select returned (...s): [3, 4, 5]

puts proxy.stats.inspect
#=> {:map=>1, :select=>1}

# respond_to?も正しく動作
puts proxy.respond_to?(:map)     #=> true
puts proxy.respond_to?(:select)  #=> true

ケース4: Builderパターンの実装

流暢なインターフェースを持つビルダーを作成します。

class SQLQueryBuilder
  VALID_OPERATORS = {
    eq: "=",
    ne: "!=",
    gt: ">",
    lt: "<",
    gte: ">=",
    lte: "<=",
    like: "LIKE",
    in: "IN"
  }

  def initialize(table)
    @table = table
    @conditions = []
    @order = nil
    @limit = nil
  end

  def method_missing(method_name, *args)
    method_str = method_name.to_s

    # where_column_operator パターン
    if method_str =~ /^where_(.+)_(#{VALID_OPERATORS.keys.join('|')})$/
      column = $1
      operator = $2.to_sym
      add_where(column, VALID_OPERATORS[operator], args.first)
      self
    # order_by_column_direction パターン
    elsif method_str =~ /^order_by_(.+)_(asc|desc)$/
      column = $1
      direction = $2.upcase
      @order = "ORDER BY #{column} #{direction}"
      self
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    method_str = method_name.to_s
    method_str =~ /^where_.+_(#{VALID_OPERATORS.keys.join('|')})$/ ||
      method_str =~ /^order_by_.+_(asc|desc)$/ ||
      super
  end

  def limit(n)
    @limit = "LIMIT #{n}"
    self
  end

  def to_sql
    sql = "SELECT * FROM #{@table}"
    sql += " WHERE #{@conditions.join(' AND ')}" unless @conditions.empty?
    sql += " #{@order}" if @order
    sql += " #{@limit}" if @limit
    sql
  end

  private

  def add_where(column, operator, value)
    formatted_value = value.is_a?(String) ? "'#{value}'" : value
    formatted_value = "(#{value.map { |v| v.is_a?(String) ? "'#{v}'" : v }.join(', ')})" if value.is_a?(Array)
    @conditions << "#{column} #{operator} #{formatted_value}"
  end
end

query = SQLQueryBuilder.new("users")

# メソッドの存在確認
puts query.respond_to?(:where_age_gt)  #=> true
puts query.respond_to?(:where_name_eq)  #=> true
puts query.respond_to?(:order_by_created_at_desc)  #=> true

# クエリ構築
sql = query
  .where_age_gte(18)
  .where_status_eq("active")
  .where_role_in(["admin", "moderator"])
  .order_by_created_at_desc
  .limit(10)
  .to_sql

puts sql
#=> SELECT * FROM users WHERE age >= 18 AND status = 'active' AND role IN ('admin', 'moderator') ORDER BY created_at DESC LIMIT 10

ケース5: APIクライアントのラッパー

外部APIへのメソッド呼び出しを動的に処理します。

class APIClient
  VALID_ENDPOINTS = {
    users: "/api/users",
    posts: "/api/posts",
    comments: "/api/comments",
    settings: "/api/settings"
  }

  def initialize(base_url)
    @base_url = base_url
    @headers = {}
  end

  def method_missing(method_name, *args)
    method_str = method_name.to_s

    # get_resource パターン
    if method_str =~ /^get_(.+)$/
      resource = $1.to_sym
      perform_request(:get, resource, args.first)
    # create_resource パターン
    elsif method_str =~ /^create_(.+)$/
      resource = $1.gsub(/_(\w)/) { $1.upcase }.to_sym  # user_posts -> userPosts
      singular = resource.to_s.sub(/s$/, "").to_sym
      perform_request(:post, singular, args.first)
    # update_resource パターン
    elsif method_str =~ /^update_(.+)$/
      resource = $1.gsub(/_(\w)/) { $1.upcase }.to_sym
      singular = resource.to_s.sub(/s$/, "").to_sym
      id = args[0]
      data = args[1]
      perform_request(:put, singular, data, id: id)
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    method_str = method_name.to_s
    resource_pattern = VALID_ENDPOINTS.keys.map(&:to_s).join('|')

    method_str =~ /^(get|create|update)_(#{resource_pattern})$/ || super
  end

  private

  def perform_request(method, resource, data = nil, id: nil)
    endpoint = VALID_ENDPOINTS[resource]
    endpoint += "/#{id}" if id

    # 実際のHTTPリクエストの代わりにシミュレーション
    {
      method: method,
      url: "#{@base_url}#{endpoint}",
      data: data,
      response: "Simulated response"
    }
  end
end

client = APIClient.new("https://api.example.com")

# respond_to?で確認
puts client.respond_to?(:get_users)     #=> true
puts client.respond_to?(:create_posts)  #=> true
puts client.respond_to?(:update_comments)  #=> true
puts client.respond_to?(:invalid_method)  #=> false

# APIリクエスト
result = client.get_users(limit: 10)
puts result.inspect
#=> {:method=>:get, :url=>"https://api.example.com/api/users", :data=>{:limit=>10}, :response=>"Simulated response"}

create_result = client.create_posts(title: "New Post", body: "Content")
puts create_result.inspect
#=> {:method=>:post, :url=>"https://api.example.com/api/posts", :data=>{:title=>"New Post", :body=>"Content"}, :response=>"Simulated response"}

注意点とベストプラクティス

注意点

  1. method_missingとrespond_to_missing?の一致
# BAD: ロジックが一致していない
class BadExample
  def method_missing(method_name, *args)
    if method_name.to_s.start_with?("get_")
      "Getting"
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    method_name.to_s.start_with?("set_")  # method_missingと異なる!
  end
end

# GOOD: ロジックを一致させる
class GoodExample
  def method_missing(method_name, *args)
    if valid_method?(method_name)
      "Getting"
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    valid_method?(method_name) || super
  end

  private

  def valid_method?(method_name)
    method_name.to_s.start_with?("get_")
  end
end
  1. superを必ず呼ぶ
# BAD: superを呼ばない
class NoSuper
  def respond_to_missing?(method_name, include_private = false)
    false  # 常にfalseを返す
  end
end

obj = NoSuper.new
puts obj.respond_to?(:to_s)  #=> false(Objectのメソッドも認識しない!)

# GOOD: superを呼ぶ
class WithSuper
  def respond_to_missing?(method_name, include_private = false)
    method_name.to_s.start_with?("custom_") || super
  end
end

obj = WithSuper.new
puts obj.respond_to?(:to_s)  #=> true(正しく認識)
  1. パフォーマンスへの配慮
# BAD: 毎回正規表現を評価
class SlowCheck
  def respond_to_missing?(method_name, include_private = false)
    method_name.to_s =~ /^(get|set|find)_[a-z_]+_(by|with|for)_[a-z_]+$/ || super
  end
end

# GOOD: シンプルなチェックから始める
class FastCheck
  VALID_PREFIXES = %w[get_ set_ find_]

  def respond_to_missing?(method_name, include_private = false)
    method_str = method_name.to_s
    VALID_PREFIXES.any? { |prefix| method_str.start_with?(prefix) } || super
  end
end

ベストプラクティス

  1. 共通ロジックを抽出
# GOOD: 判定ロジックを共有
class WellStructured
  def method_missing(method_name, *args)
    if supports_dynamic_method?(method_name)
      handle_dynamic_method(method_name, *args)
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    supports_dynamic_method?(method_name) || super
  end

  private

  def supports_dynamic_method?(method_name)
    method_name.to_s.start_with?("dynamic_")
  end

  def handle_dynamic_method(method_name, *args)
    "Handled: #{method_name}"
  end
end
  1. ドキュメント化
# GOOD: 動的メソッドのパターンを明記
class DocumentedDynamic
  # Dynamically responds to the following method patterns:
  #
  # - find_by_<attribute>(value): Finds records by attribute
  # - find_all_by_<attribute>(value): Finds all records by attribute
  # - where_<column>_<operator>(value): Adds WHERE clause
  #
  # Examples:
  #   obj.find_by_name("Alice")
  #   obj.find_all_by_age(25)
  #   obj.where_status_eq("active")
  #
  def method_missing(method_name, *args)
    # implementation
  end

  def respond_to_missing?(method_name, include_private = false)
    # implementation
  end
end
  1. テストを書く
# GOOD: respond_to?の動作をテスト
class TestableClass
  def method_missing(method_name, *args)
    if method_name.to_s.start_with?("test_")
      "Test method"
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    method_name.to_s.start_with?("test_") || super
  end
end

# テストコード
obj = TestableClass.new

raise "Test failed" unless obj.respond_to?(:test_something)
raise "Test failed" if obj.respond_to?(:invalid_method)
raise "Test failed" unless obj.test_something == "Test method"

puts "All tests passed!"

Ruby 3.4での改善点

  • Prismパーサーによる最適化 - respond_to_missing?の解析が高速化
  • YJITの最適化 - respond_to?とrespond_to_missing?の呼び出しが効率化
  • エラーメッセージの改善 - 動的メソッドに関するエラーがより詳細に
  • パフォーマンス向上 - メソッド探索のオーバーヘッド削減
# Ruby 3.4では、respond_to?のパフォーマンスが向上
class OptimizedClass
  def method_missing(method_name, *args)
    "Dynamic: #{method_name}"
  end

  def respond_to_missing?(method_name, include_private = false)
    true
  end
end

obj = OptimizedClass.new

# 大量のrespond_to?呼び出しも高速
1000.times do |i|
  obj.respond_to?("method_#{i}".to_sym)
end

まとめ

この記事では、respond_to_missing?について以下の内容を学びました:

  • 基本概念と重要性 - method_missingとの整合性、メソッド存在確認
  • 基本的な使い方 - 実装パターン、include_privateパラメータ、superの重要性
  • 実践的なユースケース - finder メソッド、型安全設定、プロキシ、ビルダー、APIクライアント
  • 注意点とベストプラクティス - ロジックの一致、super呼び出し、共通ロジック抽出

respond_to_missing?を適切に実装することで、動的メソッドが透過的に機能します。

参考資料

Discussion