💎

【Ruby 53日目】オブジェクト指向 - オープンクラス

に公開

はじめに

Rubyのオープンクラスについて、Ruby 3.4の仕様に基づいて詳しく解説します。

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

基本概念

オープンクラスは、Rubyの最も強力で柔軟な機能の一つです:

  • クラスの再オープン - 既存のクラスを開いてメソッドを追加・変更できる
  • 標準ライブラリの拡張 - StringやArrayなどの組み込みクラスも拡張可能
  • 柔軟性と危険性 - 強力だが慎重に使う必要がある
  • モンキーパッチとの関係 - オープンクラスの一般的な利用法

オープンクラスを理解することで、Rubyの柔軟性を最大限に活用できます。

基本的な使い方

クラスの再オープン

class Person
  def initialize(name)
    @name = name
  end

  def greet
    "Hello, I'm #{@name}"
  end
end

person = Person.new("Alice")
puts person.greet  #=> Hello, I'm Alice

# クラスを再オープンしてメソッドを追加
class Person
  def goodbye
    "Goodbye from #{@name}"
  end
end

puts person.goodbye  #=> Goodbye from Alice

組み込みクラスの拡張

# Stringクラスにメソッドを追加
class String
  def shout
    self.upcase + "!!!"
  end

  def palindrome?
    self == self.reverse
  end
end

puts "hello".shout  #=> HELLO!!!
puts "radar".palindrome?  #=> true
puts "ruby".palindrome?   #=> false

Arrayクラスの拡張

class Array
  def sum
    self.reduce(0, :+)
  end

  def average
    return 0 if self.empty?
    sum.to_f / self.length
  end

  def second
    self[1]
  end

  def third
    self[2]
  end
end

numbers = [1, 2, 3, 4, 5]
puts numbers.sum      #=> 15
puts numbers.average  #=> 3.0
puts numbers.second   #=> 2
puts numbers.third    #=> 3

Hashクラスの拡張

class Hash
  def symbolize_keys
    self.transform_keys { |key| key.to_sym }
  end

  def deep_merge(other_hash)
    merge(other_hash) do |key, old_val, new_val|
      if old_val.is_a?(Hash) && new_val.is_a?(Hash)
        old_val.deep_merge(new_val)
      else
        new_val
      end
    end
  end
end

hash = { "name" => "Alice", "age" => 25 }
puts hash.symbolize_keys.inspect  #=> {:name=>"Alice", :age=>25}

config1 = { server: { host: "localhost", port: 3000 } }
config2 = { server: { port: 8080, ssl: true } }
merged = config1.deep_merge(config2)
puts merged.inspect
#=> {:server=>{:host=>"localhost", :port=>8080, :ssl=>true}}

Numericクラスの拡張

class Numeric
  def seconds
    self
  end

  def minutes
    self * 60
  end

  def hours
    self * 60 * 60
  end

  def days
    self * 24 * 60 * 60
  end
end

puts 5.seconds  #=> 5
puts 2.minutes  #=> 120
puts 1.hours    #=> 3600
puts 7.days     #=> 604800

# 実用例
sleep_time = 30.seconds
puts "Sleeping for #{sleep_time} seconds"

Integerクラスの拡張

class Integer
  def times_with_index
    result = []
    self.times do |i|
      result << yield(i)
    end
    result
  end

  def factorial
    return 1 if self <= 1
    (2..self).reduce(1, :*)
  end

  def even?
    self % 2 == 0
  end

  def odd?
    self % 2 != 0
  end
end

# ブロック付きメソッド
squares = 5.times_with_index { |i| i ** 2 }
puts squares.inspect  #=> [0, 1, 4, 9, 16]

puts 5.factorial  #=> 120
puts 4.even?      #=> true
puts 7.odd?       #=> true

よくあるユースケース

ケース1: ActiveSupportスタイルの拡張

Rails ActiveSupportのような便利なメソッドを追加します。

class String
  def blank?
    self.strip.empty?
  end

  def present?
    !blank?
  end

  def truncate(max_length, omission: "...")
    return self if self.length <= max_length
    self[0...max_length - omission.length] + omission
  end

  def titleize
    self.split(' ').map(&:capitalize).join(' ')
  end

  def humanize
    self.gsub(/_/, ' ').capitalize
  end
end

class Array
  def in_groups_of(size)
    result = []
    self.each_slice(size) { |group| result << group }
    result
  end

  def extract_options!
    last.is_a?(Hash) ? pop : {}
  end
end

class NilClass
  def blank?
    true
  end

  def present?
    false
  end
end

# 使用例
puts "   ".blank?  #=> true
puts "hello".present?  #=> true
puts "This is a very long text".truncate(10)  #=> This is...
puts "hello world".titleize  #=> Hello World
puts "user_name".humanize  #=> User name

numbers = [1, 2, 3, 4, 5, 6, 7]
puts numbers.in_groups_of(3).inspect  #=> [[1, 2, 3], [4, 5, 6], [7]]

args = ["arg1", "arg2", { option: "value" }]
options = args.extract_options!
puts options.inspect  #=> {:option=>"value"}
puts args.inspect     #=> ["arg1", "arg2"]

ケース2: デバッグ用ヘルパー

開発時に便利なデバッグメソッドを追加します。

class Object
  def tap_inspect(label = nil)
    tap do |obj|
      prefix = label ? "#{label}: " : ""
      puts "#{prefix}#{obj.inspect}"
    end
  end

  def debug_methods
    (self.methods - Object.methods).sort
  end

  def class_hierarchy
    ancestors = self.class.ancestors
    ancestors.each_with_index do |ancestor, index|
      puts "  " * index + ancestor.to_s
    end
  end
end

class Array
  def debug_stats
    {
      length: self.length,
      first: self.first,
      last: self.last,
      sample: self.sample(3),
      types: self.map(&:class).uniq
    }
  end
end

# 使用例
result = [1, 2, 3]
  .map { |x| x * 2 }
  .tap_inspect("After map")
  .select { |x| x > 2 }
  .tap_inspect("After select")
#=> After map: [2, 4, 6]
#   After select: [4, 6]

data = [1, "two", 3.0, :four, true]
puts data.debug_stats.inspect
#=> {:length=>5, :first=>1, :last=>true, :sample=>[...], :types=>[Integer, String, Float, Symbol, TrueClass]}

"test".class_hierarchy
#=> String
#     Comparable
#     Object
#     Kernel
#     BasicObject

ケース3: DSL構築のためのクラス拡張

ドメイン特化言語を作るための拡張を行います。

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

    if method_str.end_with?("=")
      key = method_str.chomp("=").to_sym
      self[key] = args.first
    elsif self.key?(method_name)
      self[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?("=") || self.key?(method_name) || super
  end

  def to_object
    obj = Object.new
    self.each do |key, value|
      obj.define_singleton_method(key) { value }
    end
    obj
  end
end

class Proc
  def curry_all
    ->(*args) {
      if args.length >= arity
        call(*args)
      else
        ->(*more_args) { call(*args, *more_args) }
      end
    }
  end
end

# 使用例:設定DSL
config = {}
config.host = "localhost"
config.port = 3000
config.ssl = true
puts config.inspect  #=> {:host=>"localhost", :port=>3000, :ssl=>true}

# ハッシュをオブジェクトに変換
settings = { name: "MyApp", version: "1.0.0" }.to_object
puts settings.name     #=> MyApp
puts settings.version  #=> 1.0.0

# カリー化
multiply = ->(a, b, c) { a * b * c }.curry_all
double = multiply.(2)
puts double.(3, 4)  #=> 24

ケース4: バリデーション拡張

データバリデーションを簡単にする拡張を追加します。

class String
  def email?
    self =~ /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
  end

  def url?
    self =~ /\Ahttps?:\/\/[\w\-._~:\/?#\[\]@!$&'()*+,;=]+\z/
  end

  def numeric?
    self =~ /\A\d+(\.\d+)?\z/
  end

  def phone?
    self =~ /\A\d{2,4}-?\d{2,4}-?\d{4}\z/
  end
end

class Integer
  def between?(min, max)
    self >= min && self <= max
  end

  def positive?
    self > 0
  end

  def negative?
    self < 0
  end
end

class Array
  def all_unique?
    self.length == self.uniq.length
  end

  def all_present?
    self.none? { |item| item.nil? || item.to_s.strip.empty? }
  end
end

# 使用例
puts "user@example.com".email?  #=> true
puts "invalid-email".email?     #=> false

puts "https://example.com".url?  #=> true
puts "not a url".url?            #=> false

puts "123".numeric?   #=> true
puts "12.34".numeric?  #=> true
puts "abc".numeric?    #=> false

puts "090-1234-5678".phone?  #=> true

puts 25.between?(18, 30)  #=> true
puts 5.positive?          #=> true
puts -3.negative?         #=> true

puts [1, 2, 3].all_unique?      #=> true
puts [1, 2, 2].all_unique?      #=> false
puts ["a", "b", "c"].all_present?  #=> true
puts ["a", "", "c"].all_present?   #=> false

ケース5: ユーティリティメソッドの追加

よく使う処理を簡潔に書けるようにします。

class String
  def to_bool
    return true if self =~ /^(true|t|yes|y|1)$/i
    return false if self =~ /^(false|f|no|n|0)$/i
    nil
  end

  def snake_case
    self.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
        .gsub(/([a-z\d])([A-Z])/, '\1_\2')
        .tr('-', '_')
        .downcase
  end

  def camel_case
    self.split('_').map(&:capitalize).join
  end
end

class Array
  def to_hash_with(keys)
    Hash[keys.zip(self)]
  end

  def rotate_until(element)
    index = self.index(element)
    return self unless index
    self.rotate(index)
  end

  def partition_by(&block)
    self.group_by(&block).values
  end
end

class Hash
  def compact
    self.reject { |k, v| v.nil? }
  end

  def slice(*keys)
    keys.each_with_object({}) { |k, hash| hash[k] = self[k] if self.key?(k) }
  end

  def except(*keys)
    self.dup.tap { |hash| keys.each { |key| hash.delete(key) } }
  end
end

# 使用例
puts "true".to_bool    #=> true
puts "false".to_bool   #=> false
puts "yes".to_bool     #=> true
puts "invalid".to_bool #=> nil

puts "UserProfile".snake_case  #=> user_profile
puts "user_profile".camel_case  #=> UserProfile

values = ["Alice", 25, "alice@example.com"]
keys = [:name, :age, :email]
puts values.to_hash_with(keys).inspect
#=> {:name=>"Alice", :age=>25, :email=>"alice@example.com"}

list = [1, 2, 3, 4, 5]
puts list.rotate_until(3).inspect  #=> [3, 4, 5, 1, 2]

numbers = [1, 2, 3, 4, 5, 6]
puts numbers.partition_by(&:even?).inspect  #=> [[2, 4, 6], [1, 3, 5]]

hash = { a: 1, b: nil, c: 3, d: nil }
puts hash.compact.inspect  #=> {:a=>1, :c=>3}

data = { name: "Alice", age: 25, email: "alice@example.com", password: "secret" }
puts data.slice(:name, :email).inspect  #=> {:name=>"Alice", :email=>"alice@example.com"}
puts data.except(:password).inspect     #=> {:name=>"Alice", :age=>25, :email=>"alice@example.com"}

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

注意点

  1. 既存メソッドの上書きに注意
# BAD: 既存のメソッドを知らずに上書き
class String
  def length
    "Always 5"  # 元のlengthメソッドを壊してしまう
  end
end

puts "hello".length  #=> Always 5(本来は5を返すべき)

# GOOD: メソッドが存在するか確認
class String
  unless method_defined?(:safe_length)
    def safe_length
      self.size
    end
  end
end
  1. 名前空間の衝突
# BAD: よくある名前のメソッドを追加
class Array
  def process
    # 他のライブラリと衝突する可能性
  end
end

# GOOD: プレフィックスを付ける
class Array
  def myapp_process
    # 衝突の可能性を減らす
  end
end
  1. グローバルな影響
# BAD: すべてのStringに影響する拡張
class String
  def dangerous_method
    self.upcase!  # 破壊的メソッドは危険
  end
end

# GOOD: 新しいクラスを作る
class MyString < String
  def safe_method
    self.upcase
  end
end

ベストプラクティス

  1. Refinementsを使う(Ruby 2.0+)
# GOOD: スコープを限定した拡張
module StringExtensions
  refine String do
    def shout
      self.upcase + "!!!"
    end
  end
end

class MyClass
  using StringExtensions

  def test
    "hello".shout  #=> HELLO!!!(ここだけで有効)
  end
end

# このスコープ外では使えない
# puts "test".shout  # NoMethodError
  1. モジュールで拡張を管理
# GOOD: 拡張をモジュールにまとめる
module CoreExtensions
  module String
    def titleize
      self.split(' ').map(&:capitalize).join(' ')
    end
  end

  module Array
    def sum
      self.reduce(0, :+)
    end
  end
end

# 明示的に適用
String.include(CoreExtensions::String)
Array.include(CoreExtensions::Array)

puts "hello world".titleize  #=> Hello World
puts [1, 2, 3].sum           #=> 6
  1. ドキュメント化とテスト
# GOOD: 拡張を明確にドキュメント化
module MyExtensions
  # Adds utility methods to String class
  #
  # @example
  #   "hello".shout #=> "HELLO!!!"
  #
  module StringMethods
    # Converts string to uppercase with exclamation marks
    # @return [String] uppercased string with "!!!"
    def shout
      self.upcase + "!!!"
    end
  end
end

String.include(MyExtensions::StringMethods)

# テストコード
raise "Test failed" unless "hello".shout == "HELLO!!!"
puts "Extension test passed!"

Ruby 3.4での改善点

  • Prismパーサーによる最適化 - クラス再オープンの解析が高速化
  • YJITの最適化 - 拡張メソッドの呼び出しが効率化
  • 警告の改善 - 既存メソッドの上書き時により明確な警告
  • Refinementsの改善 - スコープ制御がより洗練
# Ruby 3.4では、クラス拡張のパフォーマンスが向上
class String
  def custom_reverse
    self.reverse
  end
end

# 大量呼び出しでもオーバーヘッドが小さい
1000.times do
  "hello".custom_reverse
end

まとめ

この記事では、オープンクラスについて以下の内容を学びました:

  • 基本概念と重要性 - クラスの再オープン、標準ライブラリの拡張
  • 基本的な使い方 - String、Array、Hash、Numericの拡張
  • 実践的なユースケース - ActiveSupport風拡張、デバッグヘルパー、DSL、バリデーション、ユーティリティ
  • 注意点とベストプラクティス - メソッド上書き、名前空間、Refinements、モジュール管理

オープンクラスは強力な機能ですが、慎重に使用する必要があります。

参考資料

Discussion