Practical Common Ruby

12:04 PM EST Sunday, November 25 2007

As part of my latest effort to learn Lisp, I'm going through Practical Common Lisp. After reading chapter 3, I thought it would be a cool idea to translate that chapter into a language I'm familiar with, Ruby. I'm assuming it will help point out some of the powerful aspects of Lisp, showing how accomplishing the same thing in Ruby is more difficult. So pull up Chapter 3 and follow along at home.

In order to follow along, what I'm doing is using irb, Ruby's version of the Lisp REPL, and saving the code into a script. So create a file called chap3.rb or whatever, and then fire up irb in that same directory:

paulbarry@paulbarry: ~/projects/practical_common_ruby $ irb
irb(main):001:0> load 'chap3.rb'
=> true

Now your script is loaded and you can call whatever methods you have defined. Also, you can run load 'chap3.rb' again and it will reload the script.

So right of the bat, one different we run into is the lack of a property list in Ruby. So in Lisp you have this:

CL-USER> (getf (list :a 1 :b :c 3) :a)
1

It has properties of both a Ruby Array and a Ruby Hash. It's just a list of items, but it's like a Hash in that you can lookup a value by it's key, using the getf function as shown above. So in Ruby, we could use a Hash:

irb(main):001:0> {:a => 1, :b => 2, :c => 3}[:a]
=> 1

Which works for the lookup part, but doesn't preserve insertion order. If we need insertion order, we could use an Array of Arrays and write our own getf function:

def getf(plist, key)
  plist.each do |k, v|
    return v if key == k
  end
  nil
end

irb(main):007:0> getf([[:a, 1], [:b, 2], [:c, 3]], :a)
=> 1
irb(main):008:0> getf([[:a, 1], [:b, 2], [:c, 3]], :d)
=> nil
irb(main):009:0> getf([[:a, 1], [:b, 2], [:c, 3]], :b)
=> 2

In this case, I don't think insertion order is necessary, so we'll just use an Array of Hashes, as it's a little more a part of Ruby.

Now that that's out of the way, we'll set up the global database and the function to make cds:

$db = []

def make_cd(title, artist, rating, ripped)
  {:title => title, :artist => artist, :rating => rating, :ripped => ripped}
end

def add_record(cd)
  $db << cd
end

As you can see, this is just a straight port of the code, which I'll try to stick to throughout. Next up is the dump-db function. Now one little trick that Lisp's format function has is to be able iterate through a list within the format string. This I can say Ruby doesn't have, and it's probably due to the fact that lists are so much a part of Lisp. Here's a more simple example of how it works:

CL-USER> (format t "~{~a~%~}" '(1 2 3))
1
2 
3

Each element of the format string is preceded by a ~ character. So it starts with ~{, which indicates we're working with an element that is a list. The ~} at end just closes the list of things we are doing to each item. The ~a element just effectively prints the argument and the ~% prints a new line character.

As the author points out, this isn't all that different from the ruby % format operator, except for the functionality to iterate over a list. So we'll have to do that ourselves in the Ruby version, but luckily it's not that much work. Here's our first take at it:

def dump_db
  $db.each do |cd|
    cd.each do |k,v|
      puts "%-10s%s" % ["#{k.to_s.upcase}:", v]
    end
  end
end

Here's what that results in:

paulbarry@paulbarry: ~/projects/practical_common_ruby $ irb
irb(main):001:0> load 'chap3.rb'
=> true
irb(main):002:0> add_record(make_cd('Largo','Brad Mehldau',9,true))
=> [{:artist=>"Brad Mehldau", :rating=>9, :ripped=>true, :title=>"Largo"}]
irb(main):003:0> add_record(make_cd('Junta','Phish',9,true))
=> [{:artist=>"Brad Mehldau", :rating=>9, :ripped=>true, :title=>"Largo"}, 
    {:artist=>"Phish", :rating=>9, :ripped=>true, :title=>"Junta"}]
irb(main):004:0> dump_db
ARTIST:   Brad Mehldau
RATING:   9
RIPPED:   true
TITLE:    Largo

ARTIST:   Phish
RATING:   9
RIPPED:   true
TITLE:    Junta

This works except for one issue, the items print out an arbitrary order, since Ruby's Hash doesn't preserve insertion order :(. So we can specify the order in the function if that's something we care about:

def dump_db
  $db.each do |cd|
    %w{title artist rating ripped}.each do |f|
      puts "%-10s%s" % ["#{f.upcase}:", cd[f.to_sym]]
    end
    print "\n"
  end
end

But for the rest of the example I'm going to stick with the random ordered version. The reason I like that it that it prints out everything in the record, even if we add new fields.

So I'm getting tired of re-adding the data, so I'm going to jump down to saving and loading the data.

def save_db(filename)
  open(filename, 'w') do |file|
    file.puts $db.inspect
  end
end

def load_db(filename)
  $db = eval(open(filename){|f| f.read})
end

Here it is in action:

paulbarry@paulbarry: ~/projects/practical_common_ruby $ irb
irb(main):001:0> load 'chap3.rb'
=> true
irb(main):002:0> add_record(make_cd('Largo','Brad Mehldau',9,true))
=> [{:artist=>"Brad Mehldau", :rating=>9, :ripped=>true, :title=>"Largo"}]
irb(main):003:0> add_record(make_cd('Junta','Phish',9,true))
=> [{:artist=>"Brad Mehldau", :rating=>9, :ripped=>true, :title=>"Largo"}, 
    {:artist=>"Phish", :rating=>9, :ripped=>true, :title=>"Junta"}]
irb(main):004:0> save_db "my-cds.db"
=> nil
irb(main):005:0> quit
paulbarry@paulbarry: ~/projects/practical_common_ruby $ irb
irb(main):001:0> load 'chap3.rb'
=> true
irb(main):002:0> load_db "my-cds.db"
=> [{:artist=>"Brad Mehldau", :rating=>9, :ripped=>true, :title=>"Largo"}, 
    {:artist=>"Phish", :rating=>9, :ripped=>true, :title=>"Junta"}]
irb(main):003:0> dump_db
ARTIST:   Brad Mehldau
RATING:   9
RIPPED:   true
TITLE:    Largo

ARTIST:   Phish
RATING:   9
RIPPED:   true
TITLE:    Junta

So now we jump back up to "Improving the User Interaction". This basically translates directly into Ruby, except that we have to create our own y_or_n_p, which is trivial:

def prompt_read(prompt)
  print "#{prompt}: "
  gets.chomp
end

def y_or_n_p(prompt)
  case prompt_read(prompt+" [y/n]").upcase
  when 'Y': true
  when 'N': false
  else y_or_n_p(prompt)
  end
end

def prompt_for_cd
  make_cd(
    prompt_read("Title"),
    prompt_read("Artist"),
    prompt_read("Rating").to_i,
    y_or_n_p("Ripped")
  )
end

def add_cds
  loop do 
    add_record prompt_for_cd
    break unless y_or_n_p("Another?")
  end
end

And here it is in action:

irb(main):021:0> add_cds
Title: Wormwood
Artist: moe.
Rating: 10
Ripped [y/n]: y
Another? [y/n]: y
Title: Animals
Artist: Pink Floyd
Rating: 8
Ripped [y/n]: n
Another? [y/n]: n
=> nil
irb(main):022:0> dump_db
ARTIST:   Brad Mehldau
RATING:   9
RIPPED:   true
TITLE:    Largo

ARTIST:   Phish
RATING:   9
RIPPED:   true
TITLE:    Junta

ARTIST:   moe.
RATING:   10
RIPPED:   true
TITLE:    Wormwood

ARTIST:   Pink Floyd
RATING:   8
RIPPED:   false
TITLE:    Animals

So on to querying the database. We'll use Ruby's find_all iterator rather than Lisp's remove-if-not function. I'll go through each function as the chapter does. First up, selecting an artist:

def select_by_artist(artist)
  $db.find_all{|cd| cd[:artist] == artist}
end

So then we refactor that to pass the selector function (Proc, in Ruby terminology) into the method, and we have a method to create the selector:

def select(selector)
  $db.find_all{|cd| selector.call(cd) }
end

def artist_selector(artist)
  lambda{|cd| cd[:artist] == artist}
end

We call this in Ruby like this:

irb(main):034:0> select artist_selector("Phish")

So next we build the "where" selector, and we'll use Ruby's Hash to stand in for keyword parameters, which are very similar:

def where(p={})
  lambda do |cd|
    (p.has_key?(:title) ? cd[:title] == p[:title] : true) &&
    (p.has_key?(:artist) ? cd[:artist] == p[:artist] : true) &&
    (p.has_key?(:rating) ? cd[:rating] == p[:rating] : true) &&
    (p.has_key?(:ripped) ? cd[:ripped] == p[:ripped] : true)
  end
end

We can call it like this to verify that it works:

irb(main):066:0> select where(:artist => "moe.", :rating => 10)
=> [{:artist=>"moe.", :rating=>10, :ripped=>true, :title=>"Wormwood"}]
irb(main):067:0> select where(:artist => "moe.", :rating => 9)
=> []

By the way, jumping ahead a bit, there's no need to explicitly list each field in the where function. We can simplify that down like this:

def where(p={})
  lambda do |cd|
    r = true
    p.each do |k,v|
      unless cd[k] == v
        r = false
        break
      end
    end
    r
  end
end

So now if you add fields to the cd record, you don't have to touch any of these methods. Onto the update method. This time we'll have Ruby's each iterator stand in for Lisp's mapcar. Also, for the sake of simplicity, we'll modify the actual record in the database, instead of making a copy of the database and updating the global variable to point to the new database.

def update(selector, values={})
  $db.each do |row|
    if selector.call(row)
      values.each do |k,v|
        row[k] = v
      end
    end
  end
end

Again, I feel this is considerably more readable than the Lisp version, plus it doesn't require explicitly listing each field. I suppose readability is in the eye of the beholder. I would imagine Lisp programmers find the end statements in the method above as annoying as Lisp new-comers find the parenthesis in Lisp code, but as you become familiar with the language and the syntax, those annoyances just fade away.

Here it is in action, after adding one more Phish album to the collection, which is never a bad thing:

irb(main):014:0> select where(:artist => "Phish")
=> [{:title=>"Junta", :artist=>"Phish", :rating=>9, :ripped=>true}]
irb(main):015:0> add_cds
Title: Lawnboy
Artist: Phish
Rating: 8
Ripped [y/n]: y
Another? [y/n]: n
=> nil
irb(main):016:0> save_db "my-cds.db"
=> nil
irb(main):017:0> select where(:artist => "Phish")
=> [{:title=>"Junta", :artist=>"Phish", :rating=>9, :ripped=>true}, 
    {:title=>"Lawnboy", :artist=>"Phish", :rating=>8, :ripped=>true}]
irb(main):018:0> update where(:artist => "Phish"), :rating => 7
=> [{:title=>"Largo", :artist=>"Brad Mehldau", :rating=>9, :ripped=>true}, 
    {:title=>"Junta", :artist=>"Phish", :rating=>7, :ripped=>true}, 
    {:title=>"Wormwood", :artist=>"moe.", :rating=>10, :ripped=>true}, 
    {:title=>"Animals", :artist=>"Pink Floyd", :rating=>8, :ripped=>false}, 
    {:title=>"Lawnboy", :artist=>"Phish", :rating=>7, :ripped=>true}]
irb(main):019:0> select where(:rating => 7)
=> [{:title=>"Junta", :artist=>"Phish", :rating=>7, :ripped=>true}, 
    {:title=>"Lawnboy", :artist=>"Phish", :rating=>7, :ripped=>true}]

And for sake of completedness, here's the delete:

def delete(selector)
  $db.delete_if{|cd| selector.call(cd) }
end

And that gives us the whole thing weighing in at 88 lines. It's longer than the Lisp version in terms of number of lines simply because the end statements sit on their own line. We also don't have the duplication that is removed in the final section of this chapter, so that's not necessary.

But the essence of the final section is macros, which seems to be one of the most unique and powerful features of Lisp. In this particular chapter, we've managed to write code that is just as powerful and maintainable, and possibly more readable, without macros. But as I get deeper into Lisp, I'm sure I'll find examples where that's not the case. One observation I have from this so far is that I've never used Ruby's clearly Lisp-inspired lambda feature in my day-to-day Rails work, but maybe I should be.

Posted in  | Tags lisp, Ruby

Comments Feed

Add a Comment