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.


