Metaprogramming

For Rookies

Montreal.rb – 19th April 2016

Karim Tarek / @karim_tarek

The purpose of this talk:

  1. Introduce Bundler and how to use it to create a rubygem.
  2. Emphasize the importance of tests.
  3. Introduce few powerfull metaprogramming techniques that will help us refactor some legacy code.

Bundler

“Bundler: The best way to manage a Ruby application's dependencies”

We are going to use a bundle gem command which creates the skeleton for our rubygem

Rubygem

Rubygem: is a standard format for Ruby programs and libraries

let's start


$ gem install bundler
...
$ bundle gem metaprogramming_example --test
Creating gem 'metaprogramming_example'...
MIT License enabled in config
      create  metaprogramming_example/Gemfile
      create  metaprogramming_example/.gitignore
      create  metaprogramming_example/lib/metaprogramming_example.rb
      create  metaprogramming_example/lib/metaprogramming_example/version.rb
      create  metaprogramming_example/metaprogramming_example.gemspec
      create  metaprogramming_example/Rakefile
      create  metaprogramming_example/README.md
      create  metaprogramming_example/bin/console
      create  metaprogramming_example/bin/setup
      create  metaprogramming_example/.travis.yml
      create  metaprogramming_example/.rspec
      create  metaprogramming_example/spec/spec_helper.rb
      create  metaprogramming_example/spec/metaprogramming_example_spec.rb
      create  metaprogramming_example/LICENSE.txt
Initializing git repo in /Users/karim/code/tmp/metaprogramming_example
          

What else?

When you cd into the newly created gem directory, and type rake -T: You'll find that Bundler created few Rake tasks to make our life a little bit easier.

$ cd metaprogramming_example
$ rake -T
rake build            # Build metaprogramming_example-0.1.0.gem into the pkg directory
rake clean            # Remove any temporary products
rake clobber          # Remove any generated files
rake install          # Build and install metaprogramming_example-0.1.0.gem into system gems
rake install:local    # Build and install metaprogramming_example-0.1.0.gem into system gems without network access
rake release[remote]  # Create tag v0.1.0 and build and push metaprogramming_example-0.1.0.gem to Rubygems
rake spec             # Run RSpec code examples
          

Let's talk about metaprogramming?

Is it ...

MAGIC!

Wikipedia's definition:

“Metaprogramming is the writing of computer programs with the ability to treat programs as their data. It means that a program could be designed to read, generate, analyse or transform other programs, and even modify itself while running.”

So, basically

Metaprogramming is...

code that writes code

So, here is the plan...*

  1. We've been asked to work on a repotring tool for the accounting department.
  2. The data is provided by a legacy system (aka untouchable).
  3. We've been provided with some code as a starting point.
  4. Our task is to improve the code and finish the job.
*You can find the original example in CH. 3 of Metaprogramming Ruby 2

The code


class Computer
  attr_reader :id, :data_source

  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
  end

  def mouse
    info = data_source.get_mouse_info(id)
    price = data_source.get_mouse_price(id)
    "Mouse: ​#{info}​ ($​#{price}​)"
  end

  def cpu
    info = data_source.get_cpu_info(id)
    price = data_source.get_cpu_price(id)
    "CPU: ​#{info}​ ($​#{price}​)"
  end
  ...
end
					

Before we start

Is this code fully tested?

Dynamic Dispatch

When we call a method usually we do this:

obj.method
=> something
          
What if we want to do something like this?

array = [:one, :two, :three]
array.each do |item|
  obj.item # This won't work
end
          
We'll have to use Dynamic Dispatch technique (send)

array.each do |item|
  obj.send item # this will work
end
          
* send can accept: strings and symbols as a method name

version 1 (1/3)

Using: Dynamic Dispatch

class Computer
  attr_reader :id, :data_source

  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
  end

  def mouse
    component :mouse
  end

  ...

  def component(name)
    info = data_source.send("get_#{name}_info", id)
    price = data_source.send("get_#{name}_price", id)
    "#{name.capitalize}: ​#{info}​ ($​#{price}​)"
  end
end
					
Object#send

Dynamic Method

Although Dynamic Dispatch technique served us well, we still had to define a method for each computer component, which can still be a lot of work.

def mouse
  component :mouse
end

def cpu
  component :cpu
end

def keyboard
  component :keyboard
end

def memory
  component :memory
end

def motherboard
  component :motherboard
end

def storage
  component :storage
end
          
So, in the case we need to define lots of similar methods, Dynamic Method is the way to go.

version 1 (2/3)

Using: Dynamic Method

class Computer
  attr_reader :id, :data_source

  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
  end

  def self.define_component(name)
    define_method(name) do
      info = data_source.send("get_#{name}_info", id)
      price = data_source.send("get_#{name}_price", id)
      "#{name.capitalize}: ​#{info}​ ($​#{price}​)"
    end
  end

  define_component :mouse
  ...
end
					
Module#define_method

Dynamic Method + Going the extra mile

So, this is much better, right? But we still need to call define_component method lots of times

define_component :mouse
define_component :cpu
define_component :keyboard
define_component :memory
define_component :motherboard
define_component :storage
        
What about doing this?

[:mouse, :cpu, :keyboard, :memory, :motherboard, :storage].each do |component|
  define_component component
end
        
A bit better, still this won't scale nicely, right?

version 1 (3/3)

Using: Dynamic Method + Going the extra mile

class Computer
  attr_reader :id, :data_source

  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
    data_source.methods.grep(/^get_(.*)_info$/) { Computer.define_component $1 }
  end

  def self.define_component(name)
    define_method(name) do
      info = data_source.send("get_#{name}_info", id)
      price = data_source.send("get_#{name}_price", id)
      "#{name.capitalize}: ​#{info}​ ($​#{price}​)"
    end
  end
end
					

Flashback

Here is the code we started with:

class Computer
  attr_reader :id, :data_source

  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
  end

  def mouse
    info = data_source.get_mouse_info(id)
    price = data_source.get_mouse_price(id)
    "Mouse: ​#{info}​ ($​#{price}​)"
  end

  def cpu
    info = data_source.get_cpu_info(id)
    price = data_source.get_cpu_price(id)
    "CPU: ​#{info}​ ($​#{price}​)"
  end
  ...
end
					

Ghost Methods/Dynamic Proxy

When we call a method that doesn't exist, the call gets routed to method_missing and we get NoMethodError

> class Useless; end
> x = Useless.new.whatever
NoMethodError: undefined method `whatever' for #Useless:0x007f934301f580
          
What if we can override method_missing

> class Useless
>   def method_missing(method)
>     puts "Oh! No! #{method} is not defined"
>   end
> end
          
Now...

> x = Useless.new.whatever
> Oh! No! whatever is not defined
          
So, we should be able to use this technique to define our methods on run-time.

version 2

Using: Ghost Methods/Dynamic Proxy

class Computer
  attr_reader :id, :data_source

  def initialize(computer_id, data_source)
    @id = computer_id
    @data_source = data_source
  end

  def method_missing(name)
    super unless respond_to? name
    info = data_source.send("get_#{name}_info", id)
    price = data_source.send("get_#{name}_price", id)
    "#{name.capitalize}: ​#{info}​ ($​#{price}​)"
  end

  def respond_to_missing?(method, include_private = false)
    data_source.respond_to?("get_#{method}_info") || super
  end
end
					
BasicObject#method_missing, Object#respond_to?, Object#respond_to_missing?

Dynamic Methods vs. Ghost Methods

  • The problems with Ghost Methods boil down to the fact that they are not really methods; instead, they’re just a way to intercept method calls.
  • In contrast, Dynamic Methods are just regular methods that happened to be defined with define_method instead of def, and they behave the same as any other method.

Dynamic Methods vs. Ghost Methods

You can follow a simple rule of thumb when in doubt:

use Dynamic Methods if you can and Ghost Methods if you have to.

If you have to use Ghost Methods

  1. always call super
  2. always redefine respond_to_missing?

def method_missing(name)
  super unless respond_to? name
  info = data_source.send("get_#{name}_info", id)
  price = data_source.send("get_#{name}_price", id)
  "#{name.capitalize}: ​#{info}​ ($​#{price}​)"
end

def respond_to_missing?(method, include_private = false)
  data_source.respond_to?("get_#{method}_info") || super
end
          

Open Classes (aka Monkeypatch)

Re-opening a class to change its default behavior is a very powerful metaprogramming technique. For example if you need to add an 'upcase?' method to the String class, all you need to do is:

> irb

> class String
>   def upcase?
>     self == self.upcase
>   end
> end

> 'karim'.upcase?
=> false

> 'KARIM'.upcase?
=> true
					

Open Classes - The Dark Side

If you casually add bits and pieces of functionality to classes, you can accidentally overwrite a method that some other parts of the code base relying on.

questions?