[RubyJuice] Ruby setter methods gotchas… or not?

This article is more a curiosity than something that will prove useful in the near future, still I think that knowing the corner cases of programming languages is fun and healthy, so let’s start right now!

You were taught that ruby methods always return the last sentence evaluated, so

def hello
  "hello world!"
end

will unsurprisingly return the string "hello world!". This makes totally sense and it’s very practical, discouraging the use of explicit return statements when not strictly required.

So what’s up with writer methods?

You probably already know that you can get them for free using class macros such as attr_writer or attr_accessor. These code snippets are totally equivalent when it comes to their behavior (the first implementation being slightly faster because writtern in C):

class Cat
  attr_writer :name
end

class Cat
  def name=(name)
    @name = name
  end
end

It should come with no surprise that the return value of the above defined method is the name parameter value:

Cat.new.name = 'Tom'
# => "Tom"

What if for some odd reason we want to return something different than the name? Let’s try to achieve that:

class Cat
  def name=(name)
    @name = name
    "dear cat, I dub you #{name}"
  end
end

Cat.new.name = 'Felix'
# => "Felix"

What’s going on? Where’s the string that was supposed to be returned? Well, it happens you cannot change the return value of a setter method, it will always return the parameter. That’s the rule. Still, you can have some fun with that parameter:

class Cat
  def name=(name)
    @name = name << ' the Cat'
  end
end

Cat.new.name = 'Felix'
# => "Felix the Cat"

Eventually we managed to have a different return value ;-) This actually works because we’re just adding some characters to the original string, not returning a totally different object.

Errors are a totally different case, and they behave just like you’d expect:

class Cat
  def name=(name)
    raise "I'm an exceptional cat, I get angry when I get a name!"
    @name = name
  end
end

Cat.new.name = 'Felix'
# RuntimeError: I'm an exceptional cat, I get angry when I get a name!

Anyway surprises are not over yet. What about multiple parameters? You can define such method, but you won’t be able to put it to good use:

class Cat
  def name=(name, surname)
    @name    = name
    @surname = surname
  end
end

Cat.new.name = 'Felix', 'the Cat'
# ArgumentError: wrong number of arguments (1 for 2)

What? the interpreter is expecting 2 arguments, but only one was found… hey we clearly provided 2 arguments! Let’s add parentheses, maybe that’s going to fix it:

  Cat.new.name=('Felix', 'the Cat')
# SyntaxError: (irb):25: syntax error, unexpected ',', expecting ')'
# Cat.new.name=('Felix', 'the Cat')
#                       ^

That’s even worse, and at this point I’m out of ideas… I can’t make it work.

What we’ve learned so far about custom setter methods:

  • they accept only one parameter
  • they always return the parameter, except when an error is raised
  • you can modify the return value of the method only if you mutate the original object

There’s still one use case I’d like to address, multiple args using a splat:

class Cat
  attr_reader :name, :surname
  
  def name=(*args)
    @name    = args.first
    @surname = args.last
  end
end

felix = Cat.new
felix.name = 'Felix', 'the Cat'
# => ["Felix", "the Cat"]

So, finally we found a way to pass multiple params without getting an exception. This actually does work because *args is converted to a single argument, an array, and that’s exactly what is eventually returned by the method call.

Are we done? No, because that method didn’t set the instance variables as you probably expected:

felix.name
# => ["Felix", "the Cat"]
felix.surname
=> ["Felix", "the Cat"]

Ok, let’s try another time:

  class Cat
  attr_reader :name, :surname
  
  def name=(*args)
    @name    = args.first.first
    @surname = args.first.last
  end
end

felix = Cat.new
felix.name = 'Felix', 'the Cat'
felix.name
# => "Felix"
felix.surname
# => "the Cat"

And now it works as expected. Inside the setter method it happens that args gets wrapped with another array; this is confirmed by inspecting args with pry inside the method definition:

[1] pry(main)> args
=> [["Felix", "the Cat"]]

I hope that finding out some quirk behavior was fun for you as it was fun for me, this post ends now but the natural next step would be to explore Ruby C source code and see why things happen this way, and I encourage you to take that step.