Beginning OOP with Ruby
This post attempts to guide a beginner through object-oriented progamming in Ruby, showing them the thought process behind both designing and interacting with a library, and problem solving in general with Ruby.
To accomplish this, we will solve a simple geometry problem:
- Describe a collection of objects that can calculate the perimeter of a polygon with an arbitrary number of sides.
Breaking it down
The first thing we want to do to tackle this problem is to break apart the problem into a series of smaller, bite sized questions. “Good Ruby code” will read a lot like English - I think you’ll find the syntax we end up writing will almost look like English answers to these questions. You probably already know the answers to these (which is why I picked this as an example), but read them and pay attention to the language that I use.
1) What is perimeter?
Perimeter is the total distance around an object, in our case a 2D polygon with any number of sides.
2) What is a polygon?
A polygon is an object made up of lines.
3) What is a line?
A line is the shortest distance between two points in space. Since we’re dealing with finite lines, they have a start and an end.
4) What is a point?
A point is a location in space. For our purposes, this location is on a 2D plane, bound by coordinates.
In the next section, we’ll convert these concepts into Ruby code. Re-read this section once we’re done and see how what we said here is directly represented in the answer we come up with.
Concepts into code
From the previous section, you can see that we will need three objects to describe our problem. Using the class
keyword, we can form a basic layout:
# A location in 2D space.
class Point
end
# A finite segment (or distance) between two points.
class Line
end
# A collection of lines that describe a 2D shape.
class Polygon
end
And from here, we can already describe how we’re going to solve our problem, by creating a #new
instance of a Polygon
, and calling #perimeter
on it:
Polygon.new(___).perimeter
In Ruby, and in most OOP languages, a
class
describes a “template” for an object. An object is usually a collection of data, and a set of methods or functions that allow you to interact with that data or give it logic. We first describe the objects with aclass
, and create new instances of them withThing.new
.
In the next sections, we’ll work with one approach to solve this from the ground up, to figure out how to make a Polygon
to get what we need, and explore some properties of Ruby along the way.
Objects
Point
Consider any kind of object around you. A pen, a car, your computer - they all have properties, or attributes. Ruby objects can be given attributes that let you describe and store data inside them. We do this with the @var
syntax, like so, describing what defines a Point
in 2D space:
# A location in 2D space.
class Point
def initialize(x, y)
@x = x
@y = y
end
end
def
defines a new method, or an instruction on how to do something with an object.initialize
is a special method that is called whenever you call#new
on an object to make a new instance of one.
We can now make a new Point
like so:
point = Point.new(1.5, -3.0)
puts point.inspect #=> #<Point:0x2ae37f0 @x=1.5, @y=-3.0>
The attributes we’ve defined (@x
, @y
) can only be accessed inside of the class’s own methods. We’re going to need to use these attributes of a point in order to do our calculations. To do so, we can define methods that will return the values of our attributes:
# A location in 2D space.
class Point
def initialize(x, y)
@x = x
@y = y
end
def x
@x
end
def y
@y
end
end
Since this is such a common thing in Ruby, the language has shortcuts to quickly define such simple methods that reflect an objects attributes:
attr_reader :var
- Creates a read-only method for attribute@var
.attr_writer :var
- Creates a write-only method for attribute@var
.attr_accessor :var
- Creates both kinds of methods to read and write to this attribute.
So, our Point
can be described simply as:
# A location in 2D space.
class Point
attr_reader :x
attr_reader :y
def initialize(x, y)
@x = x
@y = y
end
end
In our case, we will only need to ever read these attributes, so I just used
attr_reader
.attr_accessor
would have also worked here.
Playing with our new readers:
point = Point.new(1.5, -3.0)
puts point.x #=> 1.5
puts point.y #=> -3.0
You can read more about attributes, readers, setters, and more here.
What more could we add to our Point
class? With any two points, we can calculate the distance between them.
Ruby has a rich standard library, which includes some common mathematical functions. We need the pythagorean theorem here to calculate the distance between two points, and if we do some searching we can see Ruby already provides a method for us to calculate this instead of having to implement it ourselves:
https://ruby-doc.org/core-2.4.0/Math.html#method-c-hypot
Math.hypot(3, 4) #=> 5.0
Let’s implement it to our Point
. (Note: I’ve omitted the rest of the class we already wrote to save space, ad nauseum)
# A location in 2D space.
class Point
# Calculates the distance to another Point object
def distance(other)
x_difference = x - other.x
y_difference = y - other.y
Math.hypot x_difference, y_difference
end
end
Here, the
x
inx_difference = x - other.x
is actually calling this objects ownattr_reader
method internally that fetches its@x
attribute. In another language, you may write something likeself.x
to refer to this value. You can do that here too in Ruby, but in most cases you can omitself.
, because Ruby understands what you mean.
Our new #distance
method will take another Point
object as an argument, read its @x
and @y
attributes, and do the maths to return our result implicitly by being the last line of the method.
Let’s try it out:
point_a = Point.new(1.5, -3.0)
point_b = Point.new(2, 1.0)
puts point_a.distance point_b #=> 4.031128874149275
We can now:
- Describe a
Point
. - Measure the distance between any two
Point
s.
Line
Let’s refer back to our definition of a Line
.
# A finite segment (or distance) between two points.
class Line
end
From what we know, we can now give our Line
s two attributes. We’ll call them start
and stop
.
I would use the word
end
here instead ofstop
, but this is a Ruby keyword - you cannot have Ruby keywords as variable names. You could be slightly more verbose if you wanted too, i.e.,start_point
andstop_point
and this would also be acceptable.
# A finite segment (or distance) between two points.
class Line
attr_reader :start
attr_reader :stop
def initialize(start, stop)
@start = start
@stop = stop
end
end
Great. What other properties does a finite line have? Ah, right - length.
But - we’ve already implemented this! We already know the distance between any two Point
objects.. let’s use it:
# A finite segment (or distance) between two points.
class Line
def length
# We use our attribute reader methods that we set before
# to access this line's start and stop points
start.distance stop
end
end
I’ll use the same two points from before and test it:
point_a = Point.new(1.5, -3.0)
point_b = Point.new(2, 1.0)
line = Line.new(point_a, point_b)
puts line.length #=> 4.031128874149275
When talking about Ruby code, what we’ve done here is “wrapped” the
#distance
behavior of aPoint
object - or rather, we “abstracted” it - to give us the length of any kind of line we could describe.
Now we can:
- Describe a
Point
. - Measure the distance between any two
Point
s. - Create a
Line
from any twoPoint
s. - Measure the
#length
of thatLine
.
Take note of how we described the problem in simplest terms, and are slowly building up layers of objects and methods in order to craft our solution.
This leaves one last piece to our puzzle.
Polygon
We can now move on to describe a Polygon
as a collection of Line
s.
# A collection of lines that describe a 2D shape.
class Polygon
attr_reader :segments
def initialize(segments = [])
@segments = segments
end
end
Arguments for methods can be given a default value using the above syntax in
def initialize
. Here, I’m makingsegments
default to an empty array.
We’re going to design Polygon
to accept an Array
of Line
.
Our new Polygon
can be instantiated like so..
Polygon.new [Line.new .., Line.new .., Line.new ..]
Thinking about Lines
abstractly, we can use an enumerator called #reduce
to help us calculate the perimeter. Ruby standard library to the rescue again!
#map
will iterate through each element and perform a certain process that we define, and return a new collection with the results.array = [1, 2, 3, 4] array.map { |e| e + 1 } #=> [2, 3, 4, 5]
The expression
{ |e| a + 1 }
is called a block. The the variable in the pipes (|e|
) represents the element of the array at the current iteration. You can call this variable whatever you like.#reduce
will iterate through and take each element of the array, and perform some operation that describes a process to reduce a collection down to a single element. Here, we’ll be using it to calculate the sum of a list of numbers (the lengths of the segments).# Calculate the sum of all elements in an array array = [1, 2, 3, 4] array.reduce { |a, e| a + e } #=> 10
In
#reduce
, thea
in|a, e|
represents an accumulator - an empty variable that will be returned once the enumeration is finished, and persists through each iteration of the enum.e
represents the current element of the iteration.
You can go here for more examples and other iterators you can use. Learn them well! They’re the bread and butter of any rubyist!
Back to Polygon
- let’s use #reduce
to calculate the perimeter.
# A collection of lines that describe a 2D shape.
class Polygon
def perimeter
segments.map { |e| e.length }.reduce { |a, e| a + e }
end
end
There is a shorter way to express this too. Since in both enums, we’re only calling a single method, you can pass a symbol that represents a method to call on all elements of that array. This could be written as
segments.map(&:length).reduce(:+)
. Remember this handy shortcut for simple iterators - you can read more about it in the previously linked documentation forEnumerable
.
Let’s put this all together and see how all of our objects and concepts we’ve discussed link up.
lines = [
Line.new(Point.new(3.0, 1.0), Point.new(5.0, 1.25)),
Line.new(Point.new(5.0, 1.25), Point.new(4.0, -1.25)),
Line.new(Point.new(4.0, -1.25), Point.new(3.0, 1.0))
]
polygon = Polygon.new(lines)
puts polygon.perimeter #=> 7.170361291090916
We can now calculate the perimeter of a polygon with as many sides as we like!
In Review
So, we’ve come to a solution. Check out where we started this problem and observe the steps we took to solve it. You may have very well come to something different - and that’s okay if you see some other way to do this that I didn’t! That is a wonderful thing about Ruby and most other languages - there’s no single right answer, and there’s a lot of room to be creative. Programming is as much a science as it is an art.
Some takeaways:
- We did the maths, but it’s not very pretty. That’s okay - it’s often best to make things work first and then make them attractive and easy to use later. Can you think of some ways to describe Polygons in fewer terms?
- The Ruby standard library is very rich, and there’s documentation everywhere. Don’t be afraid to google something, regardless of how obscue you think it might be - there may already be a built-in answer at your fingertips.
- Play with your food. I learned the most from Ruby by playing with it, in projects and even just in the interpreter,
irb
. Do you have Ruby? Then you haveirb
- try executingirb
at your command line. You can test Ruby expressions and such here, line by line, without having to make a whole test file. Don’t be afraid of error messages! Read them through, they’re often quite telling. - Break problems down into their simplest components and attributes, and give them as much logic (methods) as you can on their own with its current level of data available to it (i.e., we didn’t make
Point
able to calculate perimeter, because it doesn’t know what aLine
is yet - this doesn’t make sense without additional layers of data!) . - Good Ruby code will read almost like an English sentence, and reflect the problem that you’re solving. We solved this problem of perimeter with Polygons, Points, and Lines - adding the
#length
of eachsegment
into a total.
Moving forward
There’s tons of cool things out there that you can do with Ruby, and maybe even problems in your own life that you can use to cook up Ruby solutions for.
Check out http://awesome-ruby.com/ for tons of community created packages - see what you can make with them!
Learn to read documentation like those on http://www.rubydoc.info/. Then you’ll be a real library ninja, and can answer a lot of your own questions! (Hopefully!)
When you’re stuck, there’s a lot of helpful ruby people out there. StackOverflow, IRC, etc. - or even me! I can be reached at wherever you’re viewing this post, or at my email, or on Discord - I’m z64#2639
.
If you liked this article, you can see some others that my friends wrote.
Full code
# A location in 2D space.
class Point
attr_reader :x
attr_reader :y
def initialize(x, y)
@x = x
@y = y
end
# Calculates the distance to another Point object
def distance(other)
x_difference = x - other.x
y_difference = y - other.y
Math.hypot x_difference, y_difference
end
end
# A finite segment (or distance) between two points.
class Line
attr_reader :start
attr_reader :stop
def initialize(start, stop)
@start = start
@stop = stop
end
def length
start.distance stop
end
end
# A collection of lines that describe a 2D shape.
class Polygon
attr_reader :segments
def initialize(segments = [])
@segments = segments
end
def perimeter
segments.map(&:length).reduce(:+)
end
end
lines = [
Line.new(Point.new(3.0, 1.0), Point.new(5.0, 1.25)),
Line.new(Point.new(5.0, 1.25), Point.new(4.0, -1.25)),
Line.new(Point.new(4.0, -1.25), Point.new(3.0, 1.0))
]
polygon = Polygon.new(lines)
puts polygon.perimeter