Advanced class features
This chapter introduces advanced class features: operator overloading, duck typing, Struct, and object identity management. These tools make classes more expressive and natural to use.
Principle
In Ruby, operators like ==, +, [] are actually methods. Writing a == b is the same as calling a.==(b). You can therefore redefine them in your own classes to give them meaningful behavior.
Ruby also relies on duck typing: what matters is not the type of an object, but the methods it responds to. "If it walks like a duck and quacks like a duck, then it is a duck."
Overloading == (equality)
By default, == checks whether two variables point to the same object in memory. This is generally not what you want:
class Pokemon
attr_reader :name, :level
def initialize(name, level)
@name = name
@level = level
end
end
a = Pokemon.new('Pikachu', 25)
b = Pokemon.new('Pikachu', 25)
puts a == b # => false (two different objects in memory!)
To compare by value, redefine ==:
class Pokemon
attr_reader :name, :level
def initialize(name, level)
@name = name
@level = level
end
def ==(other)
return false unless other.is_a?(Pokemon)
return @name == other.name && @level == other.level
end
end
a = Pokemon.new('Pikachu', 25)
b = Pokemon.new('Pikachu', 25)
puts a == b # => true (same values)
other.is_a?(Pokemon)checks the type to avoid errors if you compare a Pokemon with something else.- You compare the attributes that define the logical identity of the object.
Overloading [] and []= (key access)
We saw that pikachu[:attack] works when pikachu is a Hash. But if pikachu is a Pokemon object, it does not work by default. You can change that by defining the [] and []= methods.
The idea: a Pokemon has several stats (HP, attack, defense...) stored in an internal Hash. Instead of writing pikachu.stats[:attack], we would like to write pikachu[:attack] directly, as if the Pokemon itself were a Hash.
class Pokemon
attr_reader :name, :level
def initialize(name, level, stats)
@name = name
@level = level
@stats = stats
end
# When we write pikachu[:attack], Ruby calls this method
def [](stat_name)
return @stats[stat_name]
end
# When we write pikachu[:attack] = 60, Ruby calls this method
def []=(stat_name, value)
@stats[stat_name] = value
return value
end
end
pikachu = Pokemon.new('Pikachu', 25, { hp: 35, attack: 55, defense: 40 })
puts pikachu[:attack] # => 55
pikachu[:attack] = 60
puts pikachu[:attack] # => 60
def [](stat_name)is a method whose name is[]. The syntax is unusual, but it is a method name like any other. When Ruby seespikachu[:attack], it translates it topikachu.[](:attack).def []=(stat_name, value)works the same way:pikachu[:attack] = 60becomespikachu.[]=(:attack, 60).- This is the same principle as the
level=setter seen in chapter 8: Ruby translates natural syntax into a method call.
Duck typing and respond_to?
Duck typing means you do not check the type of an object, but the methods it has:
def display_creature(creature)
puts "#{creature.name} Lvl.#{creature.level}" if creature.respond_to?(:name)
end
respond_to?(:name)returnstrueif the object has anamemethod, regardless of its class.- This allows a method to accept any object that has the right methods, not only instances of a specific class.
Struct — quick data classes
Struct automatically creates a class with a constructor, getters, setters, ==, and to_s:
Move = Struct.new(:name, :type, :power, :accuracy)
thunderbolt = Move.new('Thunder', :electric, 90, 100)
puts thunderbolt.name # => Thunder
puts thunderbolt.power # => 90
# == automatically compares all values
other = Move.new('Thunder', :electric, 90, 100)
puts thunderbolt == other # => true
Struct.newcreates a complete class in a single line. It is perfect for simple data objects like moves.- You can add methods to a Struct with a block:
Move = Struct.new(:name, :type, :power, :accuracy) do
def to_s
return "#{name} (#{type}) — Power: #{power}"
end
end
puts Move.new('Thunder', :electric, 90, 100)
# => Thunder (electric) — Power: 90
method_missing — dynamic interception
method_missing is called when you invoke a method that does not exist. You can use it to create dynamic accessors:
class Pokemon
KNOWN_STATS = [:hp, :attack, :defense, :speed]
def initialize(name, stats)
@name = name
@stats = stats
end
def method_missing(method_name, *arguments)
return @stats[method_name] if KNOWN_STATS.include?(method_name)
super
end
def respond_to_missing?(method_name, include_private = false)
return KNOWN_STATS.include?(method_name) || super
end
end
pikachu = Pokemon.new('Pikachu', { hp: 35, attack: 55, defense: 40, speed: 90 })
puts pikachu.attack # => 55
puts pikachu.speed # => 90
puts pikachu.respond_to?(:attack) # => true
puts pikachu.respond_to?(:unknown) # => false
method_missingintercepts the call. If the name matches a known stat, it returns its value. Otherwise,superpropagates the error normally.- Always implement
respond_to_missing?alongside it. Without it,respond_to?would returnfalseeven ifmethod_missinghandles the call. - This mechanism is powerful but should be used sparingly: it makes code harder to understand. Prefer explicit methods when possible.
Object identity
Ruby has several levels of comparison:
a = 'Pikachu'
b = 'Pikachu'
puts a == b # => true (same value)
puts a.equal?(b) # => false (different objects in memory)
puts a.eql?(b) # => true (same value and same type)
==: value equality (the most common, the one you redefine).equal?: memory identity (same object). Never redefine it..eql?: used by Hashes for keys. If you redefine==, also redefineeql?andhash:
class Pokemon
def ==(other)
return false unless other.is_a?(Pokemon)
return @name == other.name && @level == other.level
end
def eql?(other)
return self == other
end
def hash
return [@name, @level].hash
end
end
eql?andhashmust be consistent with==: two equal objects must have the samehash.- Without this, using a Pokemon as a Hash key would give inconsistent results.
freeze, dup and clone
freeze makes an object immutable:
name = 'Pikachu'
name.freeze
puts name.frozen? # => true
# name.upcase! # => Error! FrozenError
dup creates a shallow copy:
original = { name: 'Pikachu', stats: { hp: 35 } }
copy = original.dup
copy[:name] = 'Raichu' # Does NOT modify the original
copy[:stats][:hp] = 999 # DOES modify the original too!
dupcopies the Hash itself, but nested values (like thestatsHash) are shared. This is a "shallow" copy.cloneis similar todupbut also preserves thefrozenstate.- For a deep copy, you can use
Marshal.dump+Marshal.load(seen in chapter 14).
Note: in some Ruby projects, .freeze is not used on constants because it can prevent certain code extension techniques. This is a design choice.
Conclusion
- Ruby operators (
==,[],[]=,<=>,to_s) are methods that you can redefine. - Redefine
==to compare by value. Always check the type withis_a?. respond_to?checks an object's capabilities rather than its type (duck typing).Structcreates complete data classes in a single line.method_missingintercepts calls to nonexistent methods. Always implementrespond_to_missing?. Use sparingly.- If you redefine
==, also redefineeql?andhashfor consistency with Hashes. freezemakes an object immutable.dupcreates a shallow copy.