Most Ruby developers are likely familiar with 'Pass-by-Reference-Value'. And if you see something like this:
var1 = "Hello, World"
var2 = var1
var2.gsub!("World", "Ruby")
print var1 # => Hello, Ruby
You can easily figure out what's going on.
But how about a more complex example:
class DummyClass
attr_accessor :value
def initialize(value)
@value = value
end
end
dummy = DummyClass.new("Hello, World!")
puts "Before service action:"
puts dummy.value
class ServiceClass
def initialize(value)
@value = value
end
def perform_action
local_value = @value
local_value.gsub!("World", "Ruby")
end
end
service = ServiceClass.new(dummy.value).perform_action
puts "After service action:"
puts dummy.value # => Hello, Ruby!
In this particular example, it's still quite clear, especially after the first example. But it might be a totally different situation when you work on a real project.
The Real-World Trap
While the behavior is clear in a small snippet, it becomes a stealthy bug in a production environment. I recently encountered this while working with:
- an ActiveRecord model with a string attribute;
- a service for interpolation strings;
The goal was to replace placeholders (e.g., %%BRAND%%) within a template string. The implementation looked something like this:
Interpolator.custom_interpolate(source, brand_name)
Semantically, a method named interpolate suggests it returns a new string. However, because the service used a mutating method (like gsub!) on the source object, it accidentally updated the ActiveRecord model's attribute in memory before the model was even saved.
The Takeaway
Even if you understand Ruby's object model, it is incredibly easy to overlook side effects when passing objects through multiple layers of abstraction. When in doubt, use non-mutating methods or explicitly .dup your inputs to ensure your "read-only" data stays that way.
Top comments (0)