For programmers who are new to Object Oriented Programming, the ideas behind classical inheritance can take some getting used to. This article walks you through the syntax of defining class inheritance in Ruby with explanations of each OOP feature along the way. You will have created many inheriting classes and used them in Ruby code by the end of this exercise.
Create a new Ruby code file and copy the code examples into it as you go. Run the file with the provided driver code and read the output. Try writing your own driver code to try things out new concepts as they're introduced.
In Ruby, all objects are instances of classes. There are a lot of built-in classes, but you can define your own if none of the built-in ones are sufficient for the problem you're trying to solve. This is often the case when you are working in a very specific problem domain. For example, if you want to program a traffic simulation, you probably need objects representing vehicles, which don't come pre-packaged with Ruby.
Let's define a Vehicle
class to see how that would look.
class Vehicle
end
Every class in Ruby starts off like this. The keyword class
is followed by the name of the kind of object in
UpperCamelCase, and the keyword end
on a separate line. The specifics of the class come between those lines.
To use this class in a program, you invoke the .new
class method. Class methods are invoked by writing the class name
followed by a dot and then the name of the method. In many cases, you can tell when documentation is referring to a
class method because it precedes the method name with a dot (.
) character. Let's try it.
my_vehicle = Vehicle.new
puts my_vehicle
If you run the above code after defining the Vehicle
class, you should see a representation of a Vehicle
object
printed to the console. Presently Vehicle
objects aren't very useful, but we can improve that by adding
attributes.
Custom objects become more useful when they are given attributes. Attributes are a combination of values that are
unique to each instance of a class and methods for reading or writing those values. For example, every vehicle instance
might have a value representing the kind of terrain that it can travel on, or it might have a value representing how
it's propelled. Let's add those to our Vehicle
class.
The first step is to initialize the data. It doesn't make sense for a Vehicle
to not have values for those two
attributes, so we should force the programmer to specify values for each when they are creating a new instance. We do
this by creating an instance method named #initialize
, which is a special method name. It gets called automatically on
new instances of our class when the class' .new
method is called. The number sign (#
) is just notation to indicate
that it is an instance method, the same way the dot indicates a class method.
class Vehicle
def initialize(terrain, propulsion)
@terrain = terrain
@propulsion = propulsion
end
end
Adding an #initialize
method is often the second thing you do when defining a new class. The parameters that the
method takes represent the values that vary from one instance of the class to another. In this case, each Vehicle
can
travel on different terrain and have a different method of propulsion. The values of the passed parameters are stored
inside of instance variables so that they can be accessed later on from within the class.
Accessing the values from within the class is useful, but it is also useful to be able to ask an instance of Vehicle
what its values for these attributes are, so we need to expose them. This is done by using the attr_reader
method.
When attr_reader
is called from inside a class declaration, it creates "reader" methods (methods that just return the
value of an instance variable) for each instance variable that matches the names of the symbols that are passed in as
parameters. For example:
class Vehicle
attr_reader :terrain, :propulsion
def initialize(terrain, propulsion)
@terrain = terrain
@propulsion = propulsion
end
end
Adding the attr_reader
statement has generated two methods, one for each parameter. This is the same as:
class Vehicle
def initialize(terrain, propulsion)
@terrain = terrain
@propulsion = propulsion
end
# Manually defined reader methods. It's better to use `attr_reader` for these.
def terrain
@terrain
end
def propulsion
@propulsion
end
end
You can define reader methods manually like this, but it's far more concise to use attr_reader
. Now you can create
Vehicle
objects that can report their values for these attributes later on, like this:
steam_train = Vehicle.new('rails', 'steam')
puts steam_train.terrain
puts steam_train.propulsion
skateboard = Vehicle.new('road', 'manual')
puts skateboard.terrain
puts skateboard.propulsion
Notice that the .new
class method now requires the two #initialize
parameters to be passed into it. Under the hood,
.new
passes each parameter it receives along to the #initialize
method of the new instance.
Now we're getting to a point where we have object that are meaningful to a real problem domain. Often you have to create many instances of a class to solve a problem, and several of those instances will likely have the same values for some of their attributes. We can use inheritance to simplify this process.
Let's say we need a bunch of Vehicle
objects that represent boats. We could define each one separately and specify
that the terrain they travel on is 'water'
, but it would be much simpler to define a Boat
class that inherits from
Vehicle
that automatically specifies that for us.
We can start the new Boat
class the same way we start every class.
class Boat
end
The next step is to define the class that we want to inherit functionality from. The notation for that is to put a
less-than sign (<
) and then the name of the other class after the name of the new class.
class Boat < Vehicle
end
Now, the Boat
class has a reference to the Vehicle
class as its "superclass". This can even be seen by asking the
Boat
class object what its superclass is.
# Should print out `Vehicle`.
puts Boat.superclass
Notice the capital "B" on Boat
in that snippet. Each class can be referred to as an object and it has its own
attributes, such as its superclass. Take a look at the superclass of the Vehicle
class.
# Should print out `Object`.
puts Vehicle.superclass
If you don't specify another class to inherit from, the default superclass for a new class in Ruby is the Object
class.
So, the new Boat
class inherits from the Vehicle
class, and therefore can be used just like a Vehicle
, but when
we try to initialize a new Boat
, it still requires two parameters. This is because there is no #initialize
method
defined in the Boat
class, so when creating a new Boat
it goes to the superclass to find an #initialize
method to
call. Ruby will keep going from one superclass to another to find the #initialize
method until it gets to a class
where one is defined, and then it will call that one. This is the same for every instance method that you call on an
object.
To specify all boats use the 'water'
terrain, we can hard-code that into the #initialize
method of Boat
.
class Boat < Vehicle
def initialize(propulsion)
super('water', propulsion)
end
end
There are a few things going on in the above class definition. The first is that we defined an instance method named
#initialize
that takes one argument: propulsion
. This is the method that will be invoked when we call Boat.new
.
It takes this argument because different Boat
objects can have different methods of propulsion. However, all boats
travel on water, so we don't need to take a parameter for it.
The next thing is the use of the super
keyword. The way the super
keyword works is just like any other method call.
It calls the method of the same name as the one that the program is currently inside of, but in the superclass. In this
case, the keyword super
is inside of the #initialize
method in the Boat
class, therefore it will call the method
of the same name, #initialize
, in the superclass, which is Vehicle
. The #initialize
method of Vehicle
takes two
parameters, terrain
and propulsion
, so they both need to be passed.
This is the completed Boat
class. All it does is extend the Vehicle
class by defaulting the terrain
attribute to
'water'
. All the other instance methods, including the ones defined by attr_reader
, are inherited and can be used
on instances of Boat
.
speed_boat = Boat.new('internal combustion')
puts speed_boat.terrain
puts speed_boat.propulsion
Boat.new
takes one parameter because the #initialize
method in the Boat
class takes one parameter. The #terrain
and #propulsion
instance methods work as expected because they are inherited from Vehicle
and super
was called
inside of Boat#initialize
, passing the parameter values, and the code inside of Vehicle#initialize
set those values
to instance variables.
With these two classes, there is now a new, interesting dynamic to the way you can write a program. If you have some
code that only works when it has an instance of Vehicle
, it will work for instances of Vehicle
and instances of
Boat
. Boats are vehicles, after all. You can prove it by using the #is_a?
method, which is inherited from Object
by all the classes you define.
# Prints out details about the passed `vehicle` parameter.
# Raises an ArgumentError if `vehicle` is not a `Vehicle`.
def display_vehicle(vehicle)
# Raises an error if `vehicle.is_a?(Vehicle)` returns false.
# Note the use of capital "V" `Vehicle` here - the class object.
raise ArgumentError, 'vehicle parameter must be a Vehicle' unless vehicle.is_a?(Vehicle)
# Displays information about the passed `Vehicle`.
puts "The vehicle can travel on #{vehicle.terrain} and is propelled by #{vehicle.propulsion}."
end
steam_boat = Boat.new('steam')
display_vehicle(steam_boat)
In the above example, steam_boat.is_a?(Vehicle)
returns true
, because #is_a?
returns true
if the class object
that is passed into it is the same class or any of the superclasses of the object.
The ability to use Boat
instances when Vehicle
instances are needed is what's known as "polymorphism". A subclass
can be used anywhere its parent classes are needed.
Sometimes classes that inherit from other classes will have new properties that their superclasses don't have. For
example, a sail boat is always propelled by wind, so it makes sense to define a SailBoat
class, but it may also have
an attribute that holds the number of sails that the boat has. Let's define this as a new class. The first step, as
usual, is the basic class declaration.
class SailBoat
end
After that, specify the superclass.
class SailBoat < Boat
end
In this case, we're inheriting from Boat
because a sail boat is a boat. However, we need to specify that all
SailBoat
objects have 'wind'
as their propulsion.
class SailBoat < Boat
def initialize
super('wind')
end
end
The superclass #initialize
method in the Boat
class only takes one parameter, the propulsion, so we only pass it
one. Now we're ready to add a new attribute to this class, the number of sails.
class SailBoat < Boat
def initialize(number_of_sails)
super('wind')
@number_of_sails = number_of_sails
end
end
Just like the attributes we defined earlier, we add a parameter to the #initialize
method because the number of sails
can vary from one SailBoat
to another. Then we save the value that was passed in to an instance variable so that it
can be used later.
Right now, there's no way to ask a SailBoat
how many sails it has after it's instantiated, so we can add an
attr_reader
method for that instance variable.
class SailBoat < Boat
attr_reader :number_of_sails
def initialize(number_of_sails)
super('wind')
@number_of_sails = number_of_sails
end
end
Now we have a useful SailBoat
class that can be initialized with a number of sails. Let's give it a try.
schooner = SailBoat.new(7)
puts schooner.terrain
puts schooner.propulsion
puts schooner.number_of_sails
Instances of SailBoat
have inherited methods as well as methods of its own, such as #number_of_sails
. When they are
invoked on an instance, Ruby looks up which one to use by first looking at the class of the object itself, then the
superclass if it couldn't find the method, then the superclass of the superclass, and so on.
Let practice declaring classes using inheritance. This time we'll define a Bicycle
class that is propelled manually,
and a Fixie
class that can travel on 'road'
and a MountainBike
class that can travel 'off road'
.
First, declare the Bicycle
class.
class Bicycle
end
Then, inherit from Vehicle
.
class Bicycle < Vehicle
end
Then, default the propulsion
parameter to 'manual'
.
class Bicycle < Vehicle
def initialize(terrain)
super(terrain, 'manual')
end
end
The terrain
parameter is still necessary and is passed along to the Vehicle#initialize
method to be saved as an
instance variable. Next, define the Fixie
class.
class Fixie
end
Then, inherit from Bicycle
.
class Fixie < Bicycle
end
Then, default the terrain
parameter to 'road'
.
class Fixie < Bicycle
def initialize
super('road')
end
end
Only one parameter has to be passed to super
because the superclass Bicycle
only takes one parameter to its
#initialize
method. Next, define the MountainBike
class.
class MountainBike
end
Then, inherit from Bicycle
.
class MountainBike < Bicycle
end
Finally, default the terrain
parameter to 'off road'
.
class MountainBike < Bicycle
def initialize
super('off road')
end
end
Now you can make two different kinds of bikes and neither of the Fixie
or MountainBike
#initialize
methods take
any parameters, so they're super easy to create! Try them out by writing your own driver code that produces output to
the console.
So far all the attributes of these classes been initialized to values specified as parameters to the #initialize
method. Sometimes, each new instance of a class will have a starting value for an attribute. For example, each new
MountainBike
might start with its brakes released. It's easy to add these kinds of attributes. You just set them to
their starting value inside the #initialize
method.
Let's add a braking
attribute to the MountainBike
class that represents whether the brakes are being held, but
let's give it a default value instead of taking its initial value as a parameter.
class MountainBike < Bicycle
def initialize
super('off road')
@braking = false
end
end
Now every new MountainBike
instance starts with the brakes released. Of course, we can't read whether an instance of
MountainBike
is braking, so we should add an attr_reader
for it.
class MountainBike < Bicycle
attr_reader :braking
def initialize
super('off road')
@braking = false
end
end
Now we can access the value representing whether the MountainBike
is braking. Let's try it out.
a_mountain_bike = MountainBike.new
puts a_mountain_bike.terrain
puts a_mountain_bike.propulsion
puts a_mountain_bike.braking
Ok, its brakes are off, but how do we change that? We did the right thing by defining the braking attribute as read
only first. All attributes should start as attr_reader
s until you know for a fact that you want to expose the ability
to update them. Exposing a "writer" method as well is as easy as using the attr_accessor
method instead of
attr_reader
. This will also expose a method for updating the value of the instance variable using the equals sign
(=
). This is how it looks.
class MountainBike < Bicycle
attr_accessor :braking
def initialize
super('off road')
@braking = false
end
end
It's a very simple change. The attr_accessor
method creates two instance methods that are equivalent to the following.
class MountainBike < Bicycle
def initialize
super('off road')
@braking = false
end
# Manually defined accessor methods. It's better to use `attr_accessor` for these.
def braking
@braking
end
def braking=(braking)
@braking = braking
end
end
In Ruby, defining a method whose name ends with the equals sign, including automatically generated "writer" methods, is special. You can then use the following syntax for updating the value of an attribute on an object.
a_mountain_bike = MountainBike.new
a_mountain_bike.braking = true
puts a_mountain_bike.braking
Now instances of MountainBike
can break. As mentioned above, not all attributes should have "writer" methods.
Generally you will define your own instance methods on a class that update the values of instance variables.
Most of the functionality of the classes you create will be inside of instance methods. The instance methods you define
will typically change the values of instance variables that were initialized when the object was created. To see this,
let's add front and rear gears to the MountainBike
class.
class MountainBike < Bicycle
attr_accessor :braking
def initialize
super('off road')
@braking = false
@front_gear = 1
@rear_gear = 1
end
end
When a new MountainBike
is created, its front and rear gears are both in position 1
. Let's expose these using
attr_reader
.
class MountainBike < Bicycle
attr_reader :front_gear, :rear_gear
attr_accessor :braking
def initialize
super('off road')
@braking = false
@front_gear = 1
@rear_gear = 1
end
end
Because they are only readers, we can't just add them to the attr_accessor
call for the braking attribute, so we
add a separate line for the attr_reader
s.
The full gear that the bike is in can be calculated using the gears on the front and rear. Let's add an instance method that gets the actual gear that the bike is in.
class MountainBike < Bicycle
attr_reader :front_gear, :rear_gear
attr_accessor :braking
def initialize
super('off road')
@braking = false
@front_gear = 1
@rear_gear = 1
end
def gear
(front_gear - 1) * 6 + rear_gear
end
end
The maximum rear gear is 6
, so the full gear number can be calculated as in the example above. The #gear
instance
method should return 1
for each new MountainBike
until the gears are changed.
a_mountain_bike = MountainBike.new
puts a_mountain_bike.gear
Did you notice that inside the #gear
method the attr_reader
methods #front_gear
and #rear_gear
are being used
instead of directly accessing the instance variables @front_gear
and @rear_gear
? This is a useful convention to
follow because your class behaves in a predictable way to users (programmers using your class.) Some code that has an
instance of MountainBike
can access all the #front_gear
, #rear_gear
, and #gear
methods, and their values are
linked to one another in a predetermined way. This keeps the class' internal code and external, or public, API
consistent. By using existing instance methods inside your new instance methods, the code is more resilient against
future changes, too. The way instance variables are updated can change or the instance variables can be removed an
replaced by computed values. If that happens, then you don't have to fix any code that was using the instance variables
directly.
Be careful when following this convention, though. If the method you're using is a "writer", that is, the name ends with
an equals sign, then you must prefix the method name with self.
. The self
keyword refers to the current object
instance, and you can access its methods using dot-notation just like you can outside the method. For example, if you
wanted to implement a #stop
instance method on MountainBike
that turns on the brakes, it would have to be written
the following way.
class MountainBike < Bicycle
attr_reader :front_gear, :rear_gear
attr_accessor :braking
def initialize
super('off road')
@braking = false
@front_gear = 1
@rear_gear = 1
end
def gear
(front_gear - 1) * 6 + rear_gear
end
def stop
self.braking = true
end
end
Note the use of self.
in the #stop
method. If it was omitted, then Ruby would just think you are defining a new
local variable named braking
instead of invoking the #braking=
instance method.
There is still no way to change the gear that the MountainBike
is in. MountainBike
objects should only be able to
increase or decrease their gears by one at a time. Let's implement an instance method that increases the rear gear by
1
.
class MountainBike < Bicycle
attr_reader :front_gear, :rear_gear
attr_accessor :braking
def initialize
super('off road')
@braking = false
@front_gear = 1
@rear_gear = 1
end
def gear
(front_gear - 1) * 6 + rear_gear
end
def stop
self.braking = true
end
def increase_rear_gear
@rear_gear += 1
end
end
The new #increase_rear_gear
method can't use a "writer" method like the #stop
method did because there is no
writer method for the @rear_gear
instance variable. Let's try this method out.
a_mountain_bike = MountainBike.new
puts a_mountain_bike.gear
a_mountain_bike.increase_rear_gear
puts a_mountain_bike.gear
Alright, now we can increase the rear gear and the total gear goes up.
The rear gear of a MountainBike
as previously mentioned, can't go above 6
. We need to prevent the @rear_gear
instance variable from going above this amount.
The value 6
has special meaning in the context of a MountainBike
. In cases like these, instead of hard-coding the
value 6
into multiple methods in a class, it should be defined as a constant. Constants are defined inside a class
declaration, are named in ALL_CAPS, and can't be changed. They can be referred to inside instance methods of the class
they're defined in by name, but from outside the class they must be prefixed with the scope resolution operator, which
is two colons, (::
). For example:
class MountainBike < Bicycle
MAX_REAR_GEAR = 6
attr_reader :front_gear, :rear_gear
attr_accessor :braking
def initialize
super('off road')
@braking = false
@front_gear = 1
@rear_gear = 1
end
def gear
(front_gear - 1) * MAX_REAR_GEAR + rear_gear
end
def stop
self.braking = true
end
def increase_rear_gear
@rear_gear += 1
end
end
The 6
that was previously being used in #gear
has been replaced by the new constant MAX_REAR_GEAR
. You can get
the value of this constant from outside the MountainBike
class like so:
puts MountainBike::MAX_REAR_GEAR
Note the use of the scope resolution operator, ::
. This can be useful if you need to access the value of the constant
from another class.
Now we can prevent @rear_gear
from going over MAX_REAR_GEAR
like this:
class MountainBike < Bicycle
MAX_REAR_GEAR = 6
attr_reader :front_gear, :rear_gear
attr_accessor :braking
def initialize
super('off road')
@braking = false
@front_gear = 1
@rear_gear = 1
end
def gear
(front_gear - 1) * MAX_REAR_GEAR + rear_gear
end
def stop
self.braking = true
end
def increase_rear_gear
@rear_gear += 1 if rear_gear < MAX_REAR_GEAR
end
end
Note the use of the #rear_gear
method again. The instance variable is just being read here, so we can use the
attr_reader
that is defined.
Sometimes there are actions that can be thought of as being done by the class of objects as opposed to individual
instances. In these cases, class methods are used. For example, the .new
method is an action of the MountainBike
class. We could add a new action to the MountainBike
class if we wanted to. Here's an example of adding a
.repair_kit
class method thar returns an array of tools for fixing MountainBike
s.
class MountainBike < Bicycle
MAX_REAR_GEAR = 6
attr_reader :front_gear, :rear_gear
attr_accessor :braking
def initialize
super('off road')
@braking = false
@front_gear = 1
@rear_gear = 1
end
def gear
(front_gear - 1) * MAX_REAR_GEAR + rear_gear
end
def stop
self.braking = true
end
def increase_rear_gear
@rear_gear += 1 if rear_gear < MAX_REAR_GEAR
end
def self.repair_kit
['wrench', 'pliers', 'pump']
end
end
As you can see, the method name starts with self.
. This is different from the self.
earlier, which was inside an
instance method and therefore referred to the instance. When self.
appears after def
or inside a class method, it
refers to the class object itself, in this case MountainBike
. Now, the MountainBike
class object has a new method
that can be called on it.
puts MountainBike.repair_kit
As you can see, no instance of the class had to be created. The method is used on the class object itself.
To practice working with classes and inheritance in Ruby, try some of the following.
- Finish the
MountainBike
methods for increasing and decreasing the front and rear gears, making sure that the gears can't go below1
and the front gear can't go above3
. - Think of another kind of vehicle that wasn't discussed and come up with ways to define classes that take advantage of inheritance to simplify working with the subclasses.
Good luck!
Hi David, Found a small typo.
https://gist.github.com/gbrl/2322605f12eff6386202602727722e92/revisions