💎
【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
注意点とベストプラクティス
注意点
- スコープの制限
# 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
- 継承の扱い
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
- メソッド呼び出しの制限
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
ベストプラクティス
- 明確な命名
# 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
- 小さく保つ
# 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
- ドキュメント化
# 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
- テストを書く
# 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
- 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