Understading why attr_accessor in Ruby is faster than a regular method
Setters and getters (or accessors) are very common in various object–oriented programming languages. Some languages have shortcuts to define them, others require programmers to write them by hand (hi, Java!). Ruby does have these shortcuts: attr_reader
generates getters, attr_writer
generates setter, and, finally, attr_accessor
generates both:
class User
attr_accessor :email
# the same functionality:
def email
@email
end
def email=(value)
@email = value
end
end
Writing attr_accessor by hand
Sometimes people who explain metaprogramming use attr_accessor
as the example. It’s a convenient way to explain the concept: attr_accessor
is a method that sits in the singleton class and, when called, defines methods dynamically:
module MyAttrAccessor
def my_attr_accessor(attr_name)
my_attr_reader(attr_name)
my_attr_writer(attr_name)
end
def my_attr_reader(attr_name)
instance_variable_name = attr_to_variable_name(attr_name)
define_method(attr_name) do
instance_variable_get(instance_variable_name)
end
end
def my_attr_writer(attr_name)
instance_variable_name = attr_to_variable_name(attr_name)
define_method("#{attr_name}=") do |value|
instance_variable_set(instance_variable_name, value)
end
end
private
def attr_to_variable_name(attr_name)
"@#{attr_name}"
end
end
Object.extend(MyAttrAccessor)
A couple of benchmarks
One day I wondered if attr_accessor
is just a shortcut or it has a different implementation from what we’ve seen above. Before digging into the Ruby source code, let’s write a benchmark to compare attr_accessor
with methods written by hand:
require "benchmark"
class NativeAccessor
attr_accessor :value
end
class CustomAccessor
def value
@value
end
def value=(v)
@value = v
end
end
n = 10_000_000
Benchmark.bm do |x|
na = NativeAccessor.new
x.report("attr_writer") do
n.times { |i| na.value = i }
end
ca = CustomAccessor.new
x.report("value=") do
n.times { |i| ca.value = i }
end
end
Benchmark.bm do |x|
na = NativeAccessor.new
x.report("attr_reader") do
n.times { |i| na.value }
end
ca = CustomAccessor.new
x.report("value") do
n.times { |i| ca.value }
end
end
Here are the results:
user system total real
attr_writer 0.316426 0.002821 0.319247 ( 0.319398)
value= 0.424947 0.004253 0.429200 ( 0.429279)
user system total real
attr_reader 0.292869 0.002937 0.295806 ( 0.295810)
value 0.349404 0.000952 0.350356 ( 0.350363)
Writer is 34% faster, while reader shows 18% better performance. Why results are so different?
Going down to the bytecode
Let’s take a look at the bytecode for both implementations. Lets’ start with a CustomAccessor
class:
code = <<~RUBY
class CustomAccessor
def value
@value
end
def value=(v)
@value = v
end
end
ca = CustomAccessor.new
ca.value = 1
ca.value
RUBY
puts RubyVM::InstructionSequence.disasm(RubyVM::InstructionSequence.compile(code))
Here is the bytecode:
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(13,8)> (catch: FALSE)
local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] ca@0
0000 putspecialobject 3 ( 1)[Li]
0002 putnil
0003 defineclass :CustomAccessor, <class:CustomAccessor>, 0
0007 pop
0008 opt_getinlinecache 17, <is:0> ( 11)[Li]
0011 putobject true
0013 getconstant :CustomAccessor
0015 opt_setinlinecache <is:0>
0017 opt_send_without_block <calldata!mid:new, argc:0, ARGS_SIMPLE>
0019 setlocal_WC_0 ca@0
0021 getlocal_WC_0 ca@0 ( 12)[Li]
0023 putobject_INT2FIX_1_
0024 opt_send_without_block <calldata!mid:value=, argc:1, ARGS_SIMPLE>
0026 pop
0027 getlocal_WC_0 ca@0 ( 13)[Li]
0029 opt_send_without_block <calldata!mid:value, argc:0, ARGS_SIMPLE>
0031 leave
== disasm: #<ISeq:<class:CustomAccessor>@<compiled>:1 (1,0)-(9,3)> (catch: FALSE)
0000 definemethod :value, value ( 2)[LiCl]
0003 definemethod :value=, value= ( 6)[Li]
0006 putobject :value=
0008 leave ( 9)[En]
== disasm: #<ISeq:value@<compiled>:2 (2,2)-(4,5)> (catch: FALSE)
0000 getinstancevariable :@value, <is:0> ( 3)[LiCa]
0003 leave ( 4)[Re]
== disasm: #<ISeq:value=@<compiled>:6 (6,2)-(8,5)> (catch: FALSE)
local table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] v@0<Arg>
0000 getlocal_WC_0 v@0 ( 7)[LiCa]
0002 dup
0003 setinstancevariable :@value, <is:0>
0006 leave ( 8)[Re]
The first section represents the top level program, and second one stands for the class body. Third and forth define the bytecode for our custom setter and getter. Let’s do the same for the attr_accessor
:
code = <<~RUBY
class NativeAccessor
attr_accessor :value
end
na = NativeAccessor.new
na.value = 1
na.value
RUBY
puts RubyVM::InstructionSequence.disasm(RubyVM::InstructionSequence.compile(code))
Here’s the result:
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(7,8)> (catch: FALSE)
local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] na@0
0000 putspecialobject 3 ( 1)[Li]
0002 putnil
0003 defineclass :NativeAccessor, <class:NativeAccessor>, 0
0007 pop
0008 opt_getinlinecache 17, <is:0> ( 5)[Li]
0011 putobject true
0013 getconstant :NativeAccessor
0015 opt_setinlinecache <is:0>
0017 opt_send_without_block <calldata!mid:new, argc:0, ARGS_SIMPLE>
0019 setlocal_WC_0 na@0
0021 getlocal_WC_0 na@0 ( 6)[Li]
0023 putobject_INT2FIX_1_
0024 opt_send_without_block <calldata!mid:value=, argc:1, ARGS_SIMPLE>
0026 pop
0027 getlocal_WC_0 na@0 ( 7)[Li]
0029 opt_send_without_block <calldata!mid:value, argc:0, ARGS_SIMPLE>
0031 leave
== disasm: #<ISeq:<class:NativeAccessor>@<compiled>:1 (1,0)-(3,3)> (catch: FALSE)
0000 putself ( 2)[LiCl]
0001 putobject :value
0003 opt_send_without_block <calldata!mid:attr_accessor, argc:1, FCALL|ARGS_SIMPLE>
0005 leave ( 3)[En]
The only difference is that attr_accessor
does not generate any bytecode! What does it mean? It means that we have to go to the Ruby source code!
How VM executes attr_reader and attr_writer
Let’s find out what happens when attr_accessor
method is called. Turns out that this method is a C method that is added to the Module
here:
rb_define_method(rb_cModule, "attr_accessor", rb_mod_attr_accessor, -1);
The rb_mod_attr_accessor
function is defined here:
static VALUE
rb_mod_attr_accessor(int argc, VALUE *argv, VALUE klass)
{
int i;
VALUE names = rb_ary_new2(argc * 2);
for (i=0; i<argc; i++) {
ID id = id_for_attr(klass, argv[i]);
rb_attr(klass, id, TRUE, TRUE, TRUE);
rb_ary_push(names, ID2SYM(id));
rb_ary_push(names, ID2SYM(rb_id_attrset(id)));
}
return names;
}
It takes a list of names (in argv
) and defines rb_attr
in the specified class (klass
) for each one. The function called rb_attr
contains the actual definition:
void
rb_attr(VALUE klass, ID id, int read, int write, int ex)
{
ID attriv;
rb_method_visibility_t visi;
const rb_execution_context_t *ec = GET_EC();
const rb_cref_t *cref = rb_vm_cref_in_context(klass, klass);
if (!ex || !cref) {
visi = METHOD_VISI_PUBLIC;
} else {
switch (vm_scope_visibility_get(ec)) {
case METHOD_VISI_PRIVATE:
if (vm_scope_module_func_check(ec)) {
rb_warning("attribute accessor as module_function");
}
visi = METHOD_VISI_PRIVATE;
break;
case METHOD_VISI_PROTECTED:
visi = METHOD_VISI_PROTECTED;
break;
default:
visi = METHOD_VISI_PUBLIC;
break;
}
}
attriv = rb_intern_str(rb_sprintf("@%"PRIsVALUE, rb_id2str(id)));
if (read) {
rb_add_method(klass, id, VM_METHOD_TYPE_IVAR, (void *)attriv, visi);
}
if (write) {
rb_add_method(klass, rb_id_attrset(id), VM_METHOD_TYPE_ATTRSET, (void *)attriv, visi);
}
}
This method does quite a lot: sets up visibility, remembers execution context and, most importantly, adds methods for setter and getter. This behaviour is controlled by read
and write
arguments, attr_accessor
passes both while attr_reader
and attr_reader
set one of these flags to false.
We are going to stop here, because rb_add_method
(which is defined here) is used for adding all methods to classes. However, let’s understand the meaning of the third argument (VM_METHOD_TYPE_IVAR
and VM_METHOD_TYPE_ATTRSET
) passed to it. Ruby has 12 method types and turns out that setters and getters have their own type!
This type is used when bytecode is executed:
static VALUE
vm_call0_body(rb_execution_context_t *ec, struct rb_calling_info *calling, const VALUE *argv)
{
// ...
switch (vm_cc_cme(cc)->def->type) {
// ...
case VM_METHOD_TYPE_ATTRSET:
vm_call_check_arity(calling, 1, argv);
VM_CALL_METHOD_ATTR(ret,
rb_ivar_set(calling->recv, vm_cc_cme(cc)->def->body.attr.id, argv[0]),
(void)0);
goto success;
case VM_METHOD_TYPE_IVAR:
vm_call_check_arity(calling, 0, argv);
VM_CALL_METHOD_ATTR(ret,
rb_attr_get(calling->recv, vm_cc_cme(cc)->def->body.attr.id),
(void)0);
goto success;
}
}
This is the reason why native accessors are faster: they do not execute any bytecode so there is no overhead on setting a context for the execution, it’s just a plain C call. In opposite, when we define a regular method (using def
or define_method
), a bytecode will be generated and VM_METHOD_TYPE_BMETHOD
will be used as the method type.