Skip to content

MR10 Metaprogramming

Dave Strus edited this page Jan 31, 2016 · 1 revision

Metaprogramming

In this unit, we'll do the following:

  • Avoid hardcoding mutant attribute names when possible

We'll learn about the following concepts or tools:

  • Constants
  • Class methods
  • Symbols
  • String#gsub
  • Attribute writer methods
  • Metaprogramming
    • Splat operator
    • Object#send

collect_mutant_data still has a lot of repetition. For each attribute that a mutant has, we are prompting the user and setting the value of the attribute.

If we had a list of the attribute names, we could probably make this much tidier. Let's add such a list to the Mutant class!

We'll store the array of attribute names as a constant on the class. Then we'll add a class method that returns that array.

class Mutant
  ATTRIBUTE_NAMES = [:real_name, :mutant_name, :power]

  def self.attribute_names
    ATTRIBUTE_NAMES
  end

Since attribute_names is a class method, we call it on the class itself—Mutant.attribute_names—not on an instance of the class.

You know what? Now that we have that constant, we can use that to set up our attribute accessors!

attr_accessor *ATTRIBUTE_NAMES

The * is the splat operator. In this case, the splat turns the array into an argument list. Pretty nifty!

How we can make use of this in RosterApplication#collect_mutant_data? Spitting each attribute name onto the screen is easy enough, and it's not too far off from our prompts.

Mutant.attribute_names.each do |attribute|
  print attribute
end

To make that into something that looks more like a prompt, we can replace any underscores with spaces (after converting the name into a string)...

print attribute.to_s.gsub('_', ' ')

...then capitalize it, and add a colon and a space to the end.

print attribute.to_s.gsub('_', ' ').capitalize + ': '

Now for the tricky part. We need to pass the result of gets.chomp to the appropriate method of the mutant instance.

If you have the name of a method as a symbol, you can invoke that method on an object by passing it as an argument to the object's send method. Strictly speaking, what we're doing when we call methods in Ruby is sending a message with the name of that method. Most of the time, we can just do that using the . invocation operator, but when the message (method name) is dynamic, we need to pass it to send.

In other words, mutant.real_name is equivalent to mutant.send :real_name. If the method takes arguments, those arguments are additional arguments to send. So ['a', 'b', 'c'].send(:join, '-') is equivalent to ['a', 'b', 'c'].join('-'), returning "a-b-c".

Remember that when we appear to assign an attribute, like mutant.real_name = 'Jill', we're actually calling a method named real_name= with 'Jill' as an argument: mutant.real_name=('Jill'). So to do this dynamically, it looks like this:

mutant.send "#{attribute}=", gets.chomp

Here's the whole collect_mutant_data method at this point:

def collect_mutant_data
  mutant = Mutant.new
  Mutant.attribute_names.each do |attribute|
    print attribute.to_s.gsub('_', ' ').capitalize + ': '
    mutant.send "#{attribute}=", gets.chomp
  end

  @mutants << mutant
end

That's pretty nice, but I'd rather not refer to Mutant explicitly when getting the array of attribute names. Once we've assigned an instance to mutant, I'd rather refer to the class as mutant.class, rather than hardcoding the class name a second time.

def collect_mutant_data
  mutant = Mutant.new
  mutant.class.attribute_names.each do |attribute|
    print attribute.to_s.gsub('_', ' ').capitalize + ': '
    mutant.send "#{attribute}=", gets.chomp
  end

  @mutants << mutant
end

Here's the whole thing:

#!/usr/bin/env ruby
require 'pry'

class Mutant
  ATTRIBUTE_NAMES = [:real_name, :mutant_name, :power]

  def self.attribute_names
    ATTRIBUTE_NAMES
  end

  attr_accessor *ATTRIBUTE_NAMES

  def description
    "#{mutant_name} (also known as #{real_name}) has an incredible power: #{power}."
  end
end

class RosterApplication
  def start
    @mutants = []
    puts 'Hello, mutant collector!'
  end

  def collect_mutant_data
    mutant = Mutant.new
    mutant.class.attribute_names.each do |attribute|
      print attribute.to_s.gsub('_', ' ').capitalize + ': '
      mutant.send "#{attribute}=", gets.chomp
    end

    @mutants << mutant
  end

  def mutant_descriptions
    @mutants.map(&:description).join("\n")
  end
end

app = RosterApplication.new
app.start

3.times do
  app.collect_mutant_data
end

puts app.mutant_descriptions

If we add more attributes to mutants now, we just have to add them to the ATTRIBUTE_NAMES array! Should we need to add more instance variables that shouldn't be publicly accessible, and shouldn't be set by a user, we just won't include those in the ATTRIBUTE_NAMES array. It's meant to hold only those attributes which should be accessible from the outside, and it serves that purpose nicely.

The descriptions still refer to specific attributes, which makes sense, given the sentence format. But it might be handy to have a method that returns any and all attributes in a hash. Let's do it!