Ruby setter-methods are a great way to keep code explicit and terse. But beware of making a straight substitution for a regular function, as their return behaviour is different.

Introduction

Ruby setter methods allow us to provide a custom implementation for =-assignments to object attributes. For example


  class Foo
    attr_reader :name

    def initialize
      @name = "default"
    end

    def name=(value)
      @name = value.downcase
    end
  end

    

These methods are a very useful tool and can be used to great effect in making the client code look and feel simpler:


  foo = new Foo()
  foo.name # default

  foo.name = "UPDATED"
  foo.name # updated

    

The Context

The lesson is not specific to Rails, but because I hit the problem in a Rails project I will use this as the context for the following discussion. We will start with very basic User and Profile models, wherein a User has one associated Profile. The Profile has a single attribute of name:


  class User < ApplicationRecord
    has_one :profile

    …

    def set_profile(name)
      self.profile = Profile.create(name: name)
      self.profile
    end

    …
  end

    
The set_profile method creates an Profile record, sets the association and returns the record just created. This works fine and somewhere in a controller we would have called the method, thusly:

  class ProfilesController < ApplicationController
    def create
      @user = get_user(params[:user_id])
      render json: @user.set_profile(params[:profile_name])
    end
  end

    
The controller method creates a new Profile record, assigns it to the @user.profile association and returns a JSON representation of the Profile just created.

The Problem

In a future iteration we need to update a User, along with the associated Profile, from the UsersController:


  class UsersController < ApplicationController
    def update
      @user = get_user(params[:id])
      @user.update(user_params)
      @user.set_profile(params[:profile_name]) # Can we get rid of this using a setter method??
    end

    def user_params
      params.require(:user).permit(… :profile_name)
    end
  end

    
In seeing this code we would like to have a single update statement that is able to handle all of the params that we throw at it, including the profile_name. We see what looks like a simple solution, by simply updating the User model like so:

  class User < ApplicationRecord
    has_one :profile

    …

    def profile_name=(name)
      self.profile = Profile.create(name: name)
      self.profile
    end

    …
  end

    
where we have just replaced the method name :set_profile to be a setter method: :profile_name=.

With this change the update action in the UsersController simplifies as we had intended:


    def update
      @user = get_user(params[:user_id])
      @user.update(user_params)
      # Line below is no longer necessary, as update method will now also set profile association
      # @user.set_profile(params[:profile_name]) 
    end

    
But we will obviously need to update the ProfilesController#create which also relies on the method we have just altered:

  class ProfilesController < ApplicationController
    def update
      @user = get_user(params[:user_id])
      render json: @user.profile_name=(params[:profile_name]) # Here is the problem
    end
  end

    
And herein lies the problem. Our :set_profile method was constructed to return the Profile instance created, and the client code relied on this fact. The method body is still the exact same as before, but the setter method will always return the value passed to it, i.e. the String parameter, params[:profile_name]. Whilst this caught me out, it is the expected behaviour and is stated clearly in the ruby docs:
Note that for assignment methods the return value will be ignored when using the assignment syntax. Instead, the argument will be returned:

  def a=(value)
    return 1 + value
  end

  p(self.a = 5) # prints 5

      

We can resolve the issue simply in one of two ways, but perhaps only after a bit of head-scratching about why this innocuous search-and-replace on a method name has caused a bunch of test failures!

First, we could just set the @user.profile_name in one line, and retrieve the newly created Profile right after:


  class ProfilesController < ApplicationController
    def update
      @user = get_user(params[:user_id])
      @user.profile_name = params[:profile_name]
      render json: @user.profile
    end
  end

    
Alternatively, we can use :send to call the setter method directly, and ensure the explicit return statement is honoured. But, personally, I think this kind of undermines the readability motivation for using the setter method in the first place:

  class ProfilesController < ApplicationController
    def update
      @user = get_user(params[:user_id])
      render json: @user.send(:profile_name=, params[:profile_name])
    end
  end

    

The Lesson

Not so much a lesson here, just a warning. If you are switching an existing method to become a setter method, be careful that you are not relying on a return value different from the value passed into the setter. The setter method will always return the value that has been passed into it.

Comments

There are no existing comments

Got your own view or feedback? Share it with us below …