This summer, I’m learning Ruby on Rails at Metis, a 12-week class taught by some great folks from thoughtbot. This post is the first in a series sharing my experience and some of the things I’m learning.
Using object-oriented design in programming is analogous to building scaffolding around a building. Once we have a basic class definition that models a real-world object, adding additional layers of abstraction on top becomes much simpler. This allows us to easily build on more complexity — or levels on the scaffold — without sacrificing readability. A procedural approach is like using a steep rickety ladder to scale the same tall building: you may make it to the top, but it’s a much more difficult climb and the ladder is liable to collapse, dropping you right back to the ground to start over.
The Problem
During the first week at Metis, we began to work as a group on a basic Ruby program that would choose a random integer between 1 and 10, ask the user for a guess, and output a string indicating whether or not the guess was correct.
As a class, we pseudo-coded our program like this:
- Choose the correct answer
- Ask the user to make a guess
- Get the guess
- Compare the guess and the answer
- Provide feedback
The Procedural Solution
Despite having been away from programming for a few years, I had a pretty good idea of how I would approach this problem. Had I put together a solution before we worked through this exercise as a group, I probably would have come up with something like this:
answer = rand(1..10)
print "Please guess a number: "
guess = gets.to_i
if answer == guess
puts "That’s correct!"
else
puts "Incorrect. The answer was " + answer.to_s + "."
end
The Object-Oriented Solution
With that solution in mind, I was somewhat surprised when our instructor guided us through building an object-oriented program for this scenario. It seemed strange to me that we would jump into object-oriented code so quickly; most people in the class did not yet have much exposure to Ruby syntax, let alone to more complex topics like object instantiation and the difference between instance and class methods.
The object-oriented solution we arrived at as a class looked something like this:
class GuessingGame
def initialize
@answer = rand(1..10)
@guess = nil
end
def play
make_guess
print_result
end
private
attr_accessor :answer, :guess
def make_guess
print "Please guess a number: "
@guess = gets.to_i
end
def print_result
if answer == guess
puts "That's correct."
else
puts "Incorrect. The answer was #{answer}."
end
end
end
GuessingGame.new.play
At first glance, this code seems to add additional complexity (and 22 additional lines!) to a problem with a somewhat trivial implementation. But once I began to see the patterns in how objects are defined in Ruby, it became apparent that the object-oriented code was much more elegant, even for such a simple program.
This explanation of what a class is really helped to make this clear: a class is a definition of behavior and state for a real-world entity. It doesn’t actually do anything until an object is instantiated.
In this case, defining the Guessing Game logic in its own class allows the body of the program to consist of a single line: GuessingGame.new.play
. Abstracting make_guess
and print_result
out of the play
method and into separate instance methods makes this code much easier to understand on first read — because we’re relying less on Ruby syntax and more on words from the real-world domain to explain what’s happening.
Extending the Program
The most significant benefit of an object-oriented approach comes when we extend the Guessing Game program with additional functionality: allow the user to play multiple rounds of the guessing game and print the total number of rounds won.
In this example, it would be possible to extend the procedural program to add these new features, but it could quickly become unwieldy and difficult to read, requiring several extra variables and a loop all mashed together. (This becomes even more true as we continue to add additional functionality by, say, allowing multiple guesses in each round and printing the average number of guesses taken by the user each round.)
Extending the object-oriented solution still requires additional variables and a loop, but I’d argue that it’s easier to write, and we can again abstract out some of the syntactic complexity to maintain readability. Here’s the final program:
# Note that we've renamed the GuessingGame class from the previous example to be GuessingGameRound.
class GuessingGameRound
def initialize(round_number)
@answer = rand(1..10)
@guess = nil
@round_number = round_number
@won = false
end
def play
print_round_number
make_guess
print_result
end
def won?
won
end
private
attr_accessor :answer, :guess, :round_number, :won
def print_round_number
puts "===Round ##{round_number}==="
end
def make_guess
print "Please guess a number: "
@guess = gets.to_i
end
def print_result
if answer == guess
@won = true
puts "That's correct."
else
puts "Incorrect. The answer was #{answer}."
end
end
end
class GuessingGame
def initialize(round_count)
@round_count = round_count
@rounds_won = 0
end
def play
introduction
play_rounds
print_statistics
end
private
attr_accessor :round_count, :rounds_won
def introduction
puts "Welcome to the Guessing Game. There are #{round_count} rounds."
end
def play_rounds
1.upto(round_count) do |round_number|
round = GuessingGameRound.new(round_number)
round.play
if round.won?
@rounds_won += 1
end
puts
end
end
def print_statistics
puts "You won #{rounds_won} out of #{round_count} rounds."
end
end
GuessingGame.new(5).play