Skip to content

Instantly share code, notes, and snippets.

@davidvandusen
Last active May 17, 2021 01:10
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 20 You must be signed in to fork a gist
  • Save davidvandusen/ab859658701f07d59045 to your computer and use it in GitHub Desktop.
Save davidvandusen/ab859658701f07d59045 to your computer and use it in GitHub Desktop.
Ruby classical inheritance review

Classical Inheritance in Ruby

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.

A simple class

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.

Instance 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.

Class inheritance

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.

Querying inheritance and polymorphism

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.

New attributes

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.

Class inheritance practice

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.

Default and writable attributes

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_readers 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.

Custom instance methods

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_readers.

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.

Class constants

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.

Class methods

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 MountainBikes.

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.

More practice

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 below 1 and the front gear can't go above 3.
  • 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!

@gbrl
Copy link

gbrl commented May 1, 2016

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment