Ruby: add custom properties to built-in classes
-
25-09-2019 - |
Question
Question: Using Ruby it is simple to add custom methods to existing classes, but how do you add custom properties? Here is an example of what I am trying to do:
myarray = Array.new();
myarray.concat([1,2,3]);
myarray._meta_ = Hash.new(); # obviously, this wont work
myarray._meta_['createdby'] = 'dreftymac';
myarray._meta_['lastupdate'] = '1993-12-12';
## desired result
puts myarray._meta_['createdby']; #=> 'dreftymac'
puts myarray.inspect() #=> [1,2,3]
The goal is to construct the class definition in such a way that the stuff that does not work in the example above will work as expected.
Update: (clarify question) One aspect that was left out of the original question: it is also a goal to add "default values" that would ordinarily be set-up in the initialize method of the class.
Update: (why do this) Normally, it is very simple to just create a custom class that inherits from Array (or whatever built-in class you want to emulate). This question derives from some "testing-only" code and is not an attempt to ignore this generally acceptable approach.
Solution
Recall that in Ruby, you do not have access to attributes (instance variables) outside of that instance. You only have access to an instance's public methods.
You can use attr_accessor
to create a method for a class that acts as a property as you describe:
irb(main):001:0> class Array
irb(main):002:1> attr_accessor :_meta_
irb(main):003:1> end
=> nil
irb(main):004:0>
irb(main):005:0* x = [1,2,3]
=> [1, 2, 3]
irb(main):006:0> x._meta_ = Hash.new
=> {}
irb(main):007:0> x._meta_[:key] = 'value'
=> "value"
irb(main):008:0>
For a simple way to do a default initialization for an accessor, we'll need to basically reimplement attr_accessor
ourselves:
class Class
def attr_accessor_with_default accessor, default_value
define_method(accessor) do
name = "@#{accessor}"
instance_variable_set(name, default_value) unless instance_variable_defined?(name)
instance_variable_get(name)
end
define_method("#{accessor}=") do |val|
instance_variable_set("@#{accessor}", val)
end
end
end
class Array
attr_accessor_with_default :_meta_, {}
end
x = [1,2,3]
x._meta_[:key] = 'value'
p x._meta_
y = [4,5,6]
y._meta_[:foo] = 'bar'
p y._meta_
But wait! The output is incorrect:
{:key=>"value"}
{:foo=>"bar", :key=>"value"}
We've created a closure around the default value of a literal hash.
A better way might be to simply use a block:
class Class
def attr_accessor_with_default accessor, &default_value_block
define_method(accessor) do
name = "@#{accessor}"
instance_variable_set(name, default_value_block.call) unless instance_variable_defined?(name)
instance_variable_get(name)
end
define_method("#{accessor}=") do |val|
instance_variable_set("@#{accessor}", val)
end
end
end
class Array
attr_accessor_with_default :_meta_ do Hash.new end
end
x = [1,2,3]
x._meta_[:key] = 'value'
p x._meta_
y = [4,5,6]
y._meta_[:foo] = 'bar'
p y._meta_
Now the output is correct because Hash.new
is called every time the default value is retrieved, as opposed to reusing the same literal hash every time.
{:key=>"value"}
{:foo=>"bar"}
OTHER TIPS
Isn't a property just a getter and a setter? If so, couldn't you just do:
class Array
# Define the setter
def _meta_=(value)
@_meta_ = value
end
# Define the getter
def _meta_
@_meta_
end
end
Then, you can do:
x = Array.new
x._meta_
# => nil
x._meta_ = {:name => 'Bob'}
x._meta_
# => {:name => 'Bob'}
Does that help?