💎

【Ruby 55日目】オブジェクト指向 - Refinements

に公開

はじめに

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

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

基本概念

Refinementsは、スコープを限定してクラスやモジュールを拡張する機能です:

  • スコープの限定 - 拡張の影響範囲を特定のスコープに制限できる
  • 安全な拡張 - グローバルな副作用を避けられる
  • モンキーパッチの代替 - より安全にクラスを拡張できる
  • 明示的な有効化 - usingキーワードで明示的に有効化する必要がある

Refinementsを理解することで、安全で保守性の高いコード拡張が可能になります。

基本的な使い方

refineの基本構文

# Refinementモジュールの定義
module StringExtensions
  refine String do
    def shout
      self.upcase + "!!!"
    end

    def whisper
      self.downcase + "..."
    end
  end
end

# スコープ外では使えない
begin
  "hello".shout
rescue NoMethodError => e
  puts "Error: #{e.message}"
  #=> Error: undefined method `shout' for "hello":String
end

# usingで有効化
class MyClass
  using StringExtensions

  def test
    puts "hello".shout     #=> HELLO!!!
    puts "WORLD".whisper   #=> world...
  end
end

MyClass.new.test

usingで有効化

module NumericExtensions
  refine Integer do
    def squared
      self * self
    end

    def cubed
      self * self * self
    end
  end

  refine Float do
    def rounded(digits = 0)
      (self * (10 ** digits)).round / (10.0 ** digits)
    end
  end
end

class Calculator
  using NumericExtensions

  def calculate
    puts 5.squared        #=> 25
    puts 3.cubed          #=> 27
    puts 3.14159.rounded(2)  #=> 3.14
  end
end

Calculator.new.calculate

# クラス外では使えない
begin
  5.squared
rescue NoMethodError
  puts "NoMethodError: squared is not available outside"
end

スコープの限定

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

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

class Statistics
  using ArrayRefinements

  def process(data)
    puts "Sum: #{data.sum}"
    puts "Average: #{data.average}"
  end
end

class OtherClass
  # ArrayRefinementsを使用していない
  def process(data)
    # data.sum は使えない(Array#sumは存在しないと仮定)
    # 標準のreduceを使う必要がある
    puts "Total: #{data.reduce(0, :+)}"
  end
end

stats = Statistics.new
stats.process([1, 2, 3, 4, 5])
#=> Sum: 15
#   Average: 3.0

other = OtherClass.new
other.process([1, 2, 3, 4, 5])
#=> Total: 15

メソッドの上書き

module StringOverrides
  refine String do
    # 既存メソッドを上書き
    def length
      puts "[REFINED] Calling custom length"
      super * 2  # 元のlengthの2倍を返す
    end

    def upcase
      "[UPPERCASE: #{super}]"
    end
  end
end

class CustomProcessor
  using StringOverrides

  def process(text)
    puts "Length: #{text.length}"
    puts "Upcase: #{text.upcase}"
  end
end

# Refinementを使用
processor = CustomProcessor.new
processor.process("hello")
#=> [REFINED] Calling custom length
#   Length: 10
#   Upcase: [UPPERCASE: HELLO]

# 通常のStringは影響を受けない
puts "hello".length  #=> 5
puts "hello".upcase  #=> HELLO

モジュールとの組み合わせ

module Comparable
  # すでに存在するComparableとは別のモジュール
end

module ComparableExtensions
  refine Integer do
    def between_inclusive?(min, max)
      self >= min && self <= max
    end

    def within?(center, radius)
      (self - center).abs <= radius
    end
  end
end

class RangeChecker
  using ComparableExtensions

  def check_value(value)
    if value.between_inclusive?(10, 20)
      puts "#{value} is between 10 and 20"
    end

    if value.within?(15, 3)
      puts "#{value} is within radius 3 of 15"
    end
  end
end

checker = RangeChecker.new
checker.check_value(12)
#=> 12 is between 10 and 20
#   12 is within radius 3 of 15

checker.check_value(25)
# 何も出力されない

複数のRefinementの定義

module Extensions
  # 複数のクラスに対してRefinementを定義
  refine String do
    def palindrome?
      self == self.reverse
    end
  end

  refine Array do
    def second
      self[1]
    end

    def third
      self[2]
    end
  end

  refine Hash do
    def symbolize_keys
      self.transform_keys(&:to_sym)
    end
  end
end

class MultiProcessor
  using Extensions

  def test_all
    # String refinement
    puts "racecar".palindrome?  #=> true
    puts "hello".palindrome?    #=> false

    # Array refinement
    arr = [10, 20, 30, 40]
    puts arr.second  #=> 20
    puts arr.third   #=> 30

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

MultiProcessor.new.test_all

よくあるユースケース

ケース1: DSL構築でのスコープ限定拡張

ドメイン固有言語を構築する際、特定のスコープでのみ拡張を有効にします。

module QueryDSL
  refine Symbol do
    def eq(value)
      { field: self, operator: :eq, value: value }
    end

    def gt(value)
      { field: self, operator: :gt, value: value }
    end

    def lt(value)
      { field: self, operator: :lt, value: value }
    end

    def in(values)
      { field: self, operator: :in, value: values }
    end
  end

  refine Array do
    def and_conditions
      { type: :and, conditions: self }
    end

    def or_conditions
      { type: :or, conditions: self }
    end
  end
end

class QueryBuilder
  using QueryDSL

  def self.build(&block)
    builder = new
    builder.instance_eval(&block)
    builder.conditions
  end

  attr_reader :conditions

  def initialize
    @conditions = []
  end

  def where(condition)
    @conditions << condition
    self
  end

  def and(*conditions)
    @conditions << conditions.and_conditions
    self
  end

  def or(*conditions)
    @conditions << conditions.or_conditions
    self
  end
end

# DSLを使用したクエリ構築
query = QueryBuilder.build do
  where :name.eq("Alice")
  where :age.gt(20)
  and(
    :status.in([:active, :pending]),
    :verified.eq(true)
  )
end

puts query.inspect
#=> [{:field=>:name, :operator=>:eq, :value=>"Alice"},
#    {:field=>:age, :operator=>:gt, :value=>20},
#    {:type=>:and, :conditions=>[
#      {:field=>:status, :operator=>:in, :value=>[:active, :pending]},
#      {:field=>:verified, :operator=>:eq, :value=>true}
#    ]}]

# QueryBuilder外では拡張されたメソッドは使えない
begin
  :name.eq("test")
rescue NoMethodError => e
  puts "Outside scope: #{e.message}"
end

ケース2: テストコードでの一時的な拡張

テストコードで、特定のテストケースでのみ使用する拡張を定義します。

module TestHelpers
  refine String do
    def to_user
      {
        name: self,
        email: "#{self.downcase.gsub(' ', '.')}@example.com",
        created_at: Time.now
      }
    end
  end

  refine Integer do
    def days_ago
      Time.now - (self * 24 * 60 * 60)
    end

    def days_from_now
      Time.now + (self * 24 * 60 * 60)
    end
  end

  refine Array do
    def to_users
      self.map(&:to_user)
    end
  end
end

class UserTest
  using TestHelpers

  def test_user_creation
    # テストデータの簡潔な生成
    user = "Alice Smith".to_user

    puts "Name: #{user[:name]}"
    puts "Email: #{user[:email]}"
    #=> Name: Alice Smith
    #   Email: alice.smith@example.com

    # 時間のテストデータ生成
    past_time = 7.days_ago
    future_time = 3.days_from_now

    puts "7 days ago: #{past_time.strftime('%Y-%m-%d')}"
    puts "3 days from now: #{future_time.strftime('%Y-%m-%d')}"

    # 複数ユーザーの生成
    users = ["Alice", "Bob", "Charlie"].to_users
    puts "Users count: #{users.length}"  #=> Users count: 3
  end

  def test_without_refinements
    # このメソッド内ではRefinementsが有効
    # 実際のテストコード
  end
end

# テスト実行
UserTest.new.test_user_creation

# テストクラス外では拡張は使えない
begin
  "John".to_user
rescue NoMethodError
  puts "Refinements are scoped to the test class"
end

ケース3: ライブラリ内部での安全な拡張

ライブラリ内部でのみ使用する拡張を定義し、利用者に影響を与えません。

module JSONSerializer
  # ライブラリ内部でのみ使用する拡張
  module Refinements
    refine Hash do
      def deep_stringify_keys
        self.transform_keys(&:to_s).transform_values do |value|
          value.is_a?(Hash) ? value.deep_stringify_keys : value
        end
      end

      def compact_deep
        self.each_with_object({}) do |(key, value), result|
          next if value.nil?
          result[key] = value.is_a?(Hash) ? value.compact_deep : value
        end
      end
    end

    refine Object do
      def to_json_value
        case self
        when String, Integer, Float, TrueClass, FalseClass, NilClass
          self
        when Symbol
          self.to_s
        when Array
          self.map(&:to_json_value)
        when Hash
          self.transform_values(&:to_json_value)
        else
          self.to_s
        end
      end
    end
  end

  class Serializer
    using Refinements

    def self.serialize(data)
      # 内部でRefinementsを使用
      json_ready = data.to_json_value

      if json_ready.is_a?(Hash)
        json_ready = json_ready.deep_stringify_keys.compact_deep
      end

      # 実際のJSON文字列に変換
      require 'json'
      JSON.generate(json_ready)
    end
  end
end

# ライブラリの使用
data = {
  name: "Alice",
  age: 25,
  status: :active,
  metadata: {
    created_at: :today,
    tags: [:ruby, :rails],
    optional: nil
  }
}

json = JSONSerializer::Serializer.serialize(data)
puts json
#=> {"name":"Alice","age":25,"status":"active","metadata":{"created_at":"today","tags":["ruby","rails"]}}

# ライブラリ外ではRefinementsは使えない
hash = { a: 1, b: 2 }
begin
  hash.deep_stringify_keys
rescue NoMethodError
  puts "Refinements are isolated within the library"
end

ケース4: 複数の互換性レイヤー

異なるバージョンのAPIに対して、それぞれ独立した互換性レイヤーを提供します。

# V1 APIの互換性レイヤー
module V1Compatibility
  refine Hash do
    def get_user_name
      self[:user_name] || self["user_name"]
    end

    def get_user_email
      self[:user_email] || self["user_email"]
    end
  end
end

# V2 APIの互換性レイヤー
module V2Compatibility
  refine Hash do
    def get_user_name
      user = self[:user] || self["user"]
      user&.dig(:name) || user&.dig("name")
    end

    def get_user_email
      user = self[:user] || self["user"]
      user&.dig(:email) || user&.dig("email")
    end
  end
end

# V3 APIの互換性レイヤー
module V3Compatibility
  refine Hash do
    def get_user_name
      self.dig(:data, :attributes, :name) ||
        self.dig("data", "attributes", "name")
    end

    def get_user_email
      self.dig(:data, :attributes, :email) ||
        self.dig("data", "attributes", "email")
    end
  end
end

class V1APIClient
  using V1Compatibility

  def process_response(response)
    {
      name: response.get_user_name,
      email: response.get_user_email
    }
  end
end

class V2APIClient
  using V2Compatibility

  def process_response(response)
    {
      name: response.get_user_name,
      email: response.get_user_email
    }
  end
end

class V3APIClient
  using V3Compatibility

  def process_response(response)
    {
      name: response.get_user_name,
      email: response.get_user_email
    }
  end
end

# 各バージョンのレスポンス形式
v1_response = { user_name: "Alice", user_email: "alice@example.com" }
v2_response = { user: { name: "Bob", email: "bob@example.com" } }
v3_response = { data: { attributes: { name: "Charlie", email: "charlie@example.com" } } }

# それぞれのクライアントで処理
puts V1APIClient.new.process_response(v1_response).inspect
#=> {:name=>"Alice", :email=>"alice@example.com"}

puts V2APIClient.new.process_response(v2_response).inspect
#=> {:name=>"Bob", :email=>"bob@example.com"}

puts V3APIClient.new.process_response(v3_response).inspect
#=> {:name=>"Charlie", :email=>"charlie@example.com"}

ケース5: 段階的なモダナイゼーション

レガシーコードを段階的に近代化する際、新しいAPIを特定のスコープで試験的に導入します。

# 新しいAPI設計のRefinement
module ModernAPI
  refine Array do
    # モダンな名前でメソッドを提供
    def map_with_index
      self.each_with_index.map { |item, index| yield(item, index) }
    end

    def filter_map
      self.each_with_object([]) do |item, result|
        value = yield(item)
        result << value if value
      end
    end

    def partition_by
      groups = {}
      self.each do |item|
        key = yield(item)
        groups[key] ||= []
        groups[key] << item
      end
      groups
    end
  end

  refine String do
    def blank?
      self.strip.empty?
    end

    def present?
      !blank?
    end

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

# 新しいコードで試験的に使用
class ModernProcessor
  using ModernAPI

  def process_items(items)
    # モダンなAPIを使用
    result = items.map_with_index do |item, index|
      "#{index}: #{item}"
    end

    puts result.inspect
    #=> ["0: apple", "1: banana", "2: cherry"]

    # filter_mapを使用
    numbers = [1, 2, 3, 4, 5]
    squared_evens = numbers.filter_map do |n|
      n * n if n.even?
    end
    puts squared_evens.inspect  #=> [4, 16]

    # partition_byを使用
    words = ["apple", "apricot", "banana", "blueberry", "cherry"]
    by_first_letter = words.partition_by { |word| word[0] }
    puts by_first_letter.inspect
    #=> {"a"=>["apple", "apricot"], "b"=>["banana", "blueberry"], "c"=>["cherry"]}
  end

  def process_text(text)
    if text.blank?
      puts "Text is blank"
    elsif text.present?
      puts "Text: #{text.truncate(10)}"
    end
  end
end

# モダンなAPIを使用
processor = ModernProcessor.new
processor.process_items(["apple", "banana", "cherry"])
processor.process_text("This is a very long text that needs truncation")
#=> Text: This is...

# レガシーコードは影響を受けない
legacy_array = [1, 2, 3]
begin
  legacy_array.filter_map { |x| x }
rescue NoMethodError
  puts "Legacy code continues to work without Refinements"
end

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

注意点

  1. スコープの制限
# BAD: トップレベルでのusing(ファイル全体に影響)
module StringExt
  refine String do
    def shout
      self.upcase + "!!!"
    end
  end
end

# トップレベルでusing(避けるべき)
# using StringExt

# GOOD: クラスやモジュール内でusing
class MyClass
  using StringExt

  def test
    "hello".shout
  end
end
  1. 継承の扱い
module Extensions
  refine String do
    def custom_method
      "custom"
    end
  end
end

class Base
  using Extensions

  def test
    "test".custom_method  # OK
  end
end

class Derived < Base
  # Refinementsは継承されない
  def test2
    # "test".custom_method  # NoMethodError
  end
end
  1. メソッド呼び出しの制限
module NumExt
  refine Integer do
    def doubled
      self * 2
    end
  end
end

class Calculator
  using NumExt

  def test
    5.doubled  # OK: レシーバーが明示的

    # doubled  # NG: レシーバーがない呼び出しは不可
  end
end

ベストプラクティス

  1. 明確な命名
# GOOD: Refinementの目的を明確にする
module StringValidationExtensions
  refine String do
    def email?
      self.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
    end

    def url?
      self.match?(/\Ahttps?:\/\/[\S]+\z/)
    end
  end
end

# GOOD: 用途に応じてモジュールを分離
module StringFormattingExtensions
  refine String do
    def titleize
      self.split.map(&:capitalize).join(' ')
    end
  end
end
  1. 小さく保つ
# GOOD: 単一責任のRefinement
module ArrayStatistics
  refine Array do
    def mean
      return 0 if empty?
      sum.to_f / length
    end

    def median
      return 0 if empty?
      sorted = self.sort
      mid = sorted.length / 2
      sorted.length.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
    end
  end
end

# 別の責任は別のRefinementに
module ArrayFiltering
  refine Array do
    def evens
      self.select(&:even?)
    end

    def odds
      self.select(&:odd?)
    end
  end
end
  1. ドキュメント化
# GOOD: Refinementの目的と使い方を文書化
module DateHelpers
  # Extends Integer with date helper methods
  #
  # @example
  #   class MyClass
  #     using DateHelpers
  #
  #     def test
  #       puts 7.days_ago
  #       puts 3.weeks_from_now
  #     end
  #   end
  #
  refine Integer do
    # Returns a Time object representing the date N days ago
    # @return [Time]
    def days_ago
      Time.now - (self * 24 * 60 * 60)
    end

    # Returns a Time object representing the date N weeks from now
    # @return [Time]
    def weeks_from_now
      Time.now + (self * 7 * 24 * 60 * 60)
    end
  end
end
  1. テストを書く
# GOOD: Refinementsの動作をテスト
module MyRefinements
  refine String do
    def reverse_words
      self.split.map(&:reverse).join(' ')
    end
  end
end

class TestMyRefinements
  using MyRefinements

  def self.run_tests
    # テスト1: 基本的な動作
    result = "hello world".reverse_words
    raise "Test failed" unless result == "olleh dlrow"

    # テスト2: 空文字列
    result = "".reverse_words
    raise "Test failed" unless result == ""

    # テスト3: 単一単語
    result = "hello".reverse_words
    raise "Test failed" unless result == "olleh"

    puts "All Refinements tests passed!"
  end
end

TestMyRefinements.run_tests
  1. prependやincludeとの違いを理解
# Refinements: スコープ限定
module RefinementExample
  refine String do
    def custom
      "refined"
    end
  end
end

# Module include: グローバルに影響
module IncludeExample
  def custom
    "included"
  end
end

class MyString < String
  include IncludeExample
end

# Refinementsは明示的なusingが必要
class TestRefinement
  using RefinementExample

  def test
    "test".custom  #=> "refined"
  end
end

# includeは常に有効
my_str = MyString.new("test")
puts my_str.custom  #=> "included"

Ruby 3.4での改善点

  • Prismパーサーによる最適化 - Refinementsの解析が高速化
  • YJITの最適化 - Refinementsを使用したコードのパフォーマンス向上
  • より明確なエラーメッセージ - Refinementsのスコープエラーがわかりやすく
  • パフォーマンスの改善 - メソッド探索の最適化
# Ruby 3.4では、Refinementsのオーバーヘッドが削減
module FastRefinements
  refine Array do
    def fast_sum
      self.reduce(0, :+)
    end
  end
end

class FastProcessor
  using FastRefinements

  def process(data)
    # Ruby 3.4では、このRefinementの呼び出しが
    # 以前のバージョンより高速
    data.fast_sum
  end
end

# 大量のデータでも効率的
processor = FastProcessor.new
large_array = (1..1_000_000).to_a
result = processor.process(large_array)
puts "Sum: #{result}"

まとめ

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

  • 基本概念と重要性 - スコープ限定、安全な拡張、明示的な有効化
  • 基本的な使い方 - refine構文、using、スコープ制限、メソッド上書き、複数定義
  • 実践的なユースケース - DSL構築、テストヘルパー、ライブラリ内部、互換性レイヤー、モダナイゼーション
  • 注意点とベストプラクティス - スコープ制限、明確な命名、小さく保つ、ドキュメント化

Refinementsは、モンキーパッチの安全な代替手段として、スコープを限定した拡張を可能にします。

参考資料

Discussion