Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save JadedEvan/45fd6fde44a6a6394ccb to your computer and use it in GitHub Desktop.
Save JadedEvan/45fd6fde44a6a6394ccb to your computer and use it in GitHub Desktop.
An article highlighting the difference in using instance methods over attribute accessors in Ruby classes. A fundamental concept that is often overlooked by beginner and intermediate Rubyists that can lead to cleaner, more predictable code

Ruby Design Patterns - Methods over Attributes

Overview

The objective of this article is to highlight the subtle differences between using class attributes and class methods in Ruby. Both offer a valid way to manipulate the state of an instance of a class. Things can get increasingly complex, hard to test and hard to maintain as more instance variables are introduced. Beginner and intermediate Rubyists often miss this subtle but important point which can introduce bugs that may be hard to fix in their native habitat.

The reasons I prefer to use methods over instance variables:

  • Increases predictability of method calls
  • Increases predictability when testing
  • Allow for memoization of expensive calls
  • Set default values
  • Gracefully catch failures

Ruby Fundamental - Attribute Accessors

Ruby's attribute accessors are shortcuts that allow you to get and set values for instance variables within a class. Ruby provides a few ways to accomplish this

  • attr_reader - create a method to get the value of an instance variable
  • attr_writer - create a method to set the value of an instance variable
  • attr_accessor - creates method to both set and get value of an instance variable

You can read more about these basics in the Objects and Attributes from Programming Ruby.

Example of attr_accessor:

class Appointment
  attr_accessor :office
end

@appt = Appointment.new
# No default value after initialization
@appt.office 
=> nil

# Set the value
@appt.office = 'Downtown'

# Read the newly assigned value
@appt.office 
=> 'Downtown'

Now the difference with attr_reader. Note here that we will not be able to set the value for the instance variable.

class Appointment
  attr_reader :office
end

@appt = Appointment.new
# No default value after initialization
@appt.office
=> nil

# Set the value
@appt.office = 'Somewhere'
=> NoMethodError: undefined method `office=` for #<Appointment:0x202342332>

Essentially the attr_reader, attr_accessor, and attr_writer are magically creating methods for you. In the case of our attribute office, the attr_reader above is dynamically creating a method office= which allows value assignment.

Ruby Fundamental - Method Declaration and Value Assignment

We can manually accomplish the same magic as attr_accessor by explicitly declaring those methods

class Appointment

  def office=(place)
    @office = place
  end

  def office
    @office
  end
end

Slightly More Involved

These are the basic building blocks of Ruby classes. Classes become more complex, adding more methods that may mutate the state of many instance variables within a class. Let's examine the usage of attribute accessors and class methods in a slightly more complicated class.

Example #1 - Appointment Class Definition

class Appointment
  attr_accessor :last_appointment, :next_appointment, :office

  def schedule(office)
    @next_appointment = @last_appointment.to_i + 60*60*24*30
    @office = office
    send_reminder(@next_appointment, @office)
  end

  def notify
    puts "Next appointment is #{@next_appointment}."
  end
end

Example #2 - Appointment Class Instantiation

@appt = Appointment.new
@appt.last_appointment
=> nil

# Access the instance variable directly
@appt.next_appointment
=> nil

# Method that relies on the value of `@next_appointment`
@appt.upcoming_appointments
=> "Next appointment is ."

@appt.office
=> nil

# Method that relies on both `@next_appointment` and `@last_appointment`
@appt.schedule('Downtown')
=> nil

@appt.notify
=> "Next appointment is 2592000."

There are several undesirable effects here:

  • Calling #upcoming_appointments gives us two different answers depending on the calling order. The result should be consistent between calls
  • @last_appointment was nil when called. The value was never set.
  • @next_appointment relies on @last_appointment which also was never set. This also returns the wrong value.

Potential solutions to these problems:

  1. Initialize @next_appointment, @last_appointment in Appointment#initialize

A typical solution. In many object oriented languages you pass all the data you need to the constructor. I personally don't like this because everything must then be routed through #initialize. Example:

    def initialize(place)
      @last_appointment = get_last_appointment_from_database
      @office = place
    end
  1. Explicitly set the value for @last_appointment elsewhere

I see this pattern very often in Ruby code. I strongly dislike this because it forces you to clutter other parts of your code with an implementation that really belongs elsewhere. This is messy because your behavior has now leaked into other parts of your code, thereby making it harder to test and more importantly harder to refactor or fix. When you want to change anything, you now have to search and replace those invocations. An example of this approach:

    class Foo
      # pseudocode in another class somewhere else
      def update_patients
        patients.each do |patient|
          @appt = Appointment.new
          @appt.last_appointment = get_last_appointment_from_database
          @appt.schedule('Downtown')
          @appt.save
          patient.send_reminder
        end
      end
    end

Refactor with Instance Methods

Let's rewrite the class using instance methods. The first step here is to remove the line attr_accessor :last_appointment, :next_appointment. We must now explicitly declare those methods:

Example #3 - Revised Appointment Class Definition

class Appointment

  def schedule(location)
    @office = location
    send_reminder(next_appointment, office)
  end

  def notify
    puts "Next appointment is #{next_appointment}."
  end

  def last_appointment
    @last_appointment ||= get_last_appointment_from_database
  rescue => exception
    # Recover however you want
    Time.now.to_i
  end

  def next_appointment
    @next_appointment = last_appointment + 60*60*24*30
  end

  # "||=" sets a default value if none has been set
  def office
    @office ||= 'Downtown'
  end

end

What changed?

  • Remove the @ prefix for the instance variables. This means we're now calling the methods instead of the instance variables. This is a hugely important change
  • Add a method last_appointment which sets the internal instance variable @last_appointment
  • Add a method next_appointment which sets the internal instance variable @next_appointment
  • Add a method office which sets a default value if none have been set

Let's see how that changes the output

@appt = Appointment.new
@appt.last_appointment
=> 1425214800

# Access the instance variable directly
@appt.next_appointment
=> 1427806800

# Method that relies on the value of `@next_appointment`
@appt.upcoming_appointments
=> "Next appointment is 1427806800."

@appt.office
=> 'Downtown'

# Method that relies on both `@next_appointment` and `@last_appointment`
@appt.schedule('Suburbs')
=> 'Suburbs'

@appt.office
=> 'Suburbs'

Benefits of changes:

  • #office now has a default value.

  • last_appointment is now memoized. Memoization is a technique that allows caching of class internals. This can be extremely useful if you're doing operations that are very expensive - calling a database, calling an external web service, etc.

    You can also rescue exceptions. "Expensive" is often synonymous with "unreliable". When you call external services you open the door to a variety of errors - timeouts, 500s, unexpected responses. Defining the method in this way allows us to wrap that call in a rescue block so that our code will fail elegantly.

  • Values for instance attributes are only updated when they should be. Not using an attr_accessor for last_appointment prevents other classes from modifying that value (although if we used an attr_reader that would not be a problem...).

Conclusion

The output for these calls is now far more consistent and predictable than it was before. This is a big advantage not only for where the class is being used but also in regards to the tests. The key takeaway is that you should be using instance methods where possible within your class. Avoid using instance variables (i.e. @variable) where possible except when you're setting the values.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment