We are going to build some classes to represent animals. Initially, we are going to make some naive code and then we'll improve it by using the OOP principles.
While reading this article, create your files with classes and try some code in your IRB console. Keep the files you create saved for upcoming lessons.
First, make sure you have the code from the previous article Class vs object: examples. You should have a file named animal.rb with a single class Animal.
In your Animal class add a type parameter and assign it to the @type instance variable. It should look like this:
class Animal
def initialize(type, number_of_legs, name = "Unknown")
@id = Random.rand(1..1000)
@name = name
@number_of_legs = number_of_legs
@type = type
end
endIf you try the previous code in IRB you will see that you can't access or modify any of the attributes in either class. This is due to Ruby's encapsulation. Because of this encapsulation the instance variable can't be accessed outside the object, only by methods inside of it.
But then how do you interact with an object? Well, you declare public methods. By default in Ruby any method declared is public (can be called anywhere), but you can also declare them as private (can only be called inside that class).
So, we could create a public method to fetch @name's value and another one to modify it. These types of methods are called getter (get a value) and setter (set a value) methods. The getter is called the same as the corresponding instance variable and the setter is called the same, but it should be followed by = (this is Ruby's special syntax for setters).
Now that you have a better understanding, let's add some getters and setters so we can interact with the attributes. In animal.rb modify Animal by adding the following:
class Animal
...
def id
@id
end
def type
@type
end
def number_of_legs
@number_of_legs
end
def name
@name
end
def name=(value)
@name = value
end
endGreat! now we can access Animal's attributes and modify name if needed.
Try it out in your IRB console
require "./animal.rb"
animal_1 = Animal.new("dog", 4, "Rex")
animal_1.id
animal_1.type
animal_1.name
animal_1.number_of_legs
animal_2 = Animal.new("cat", 8)
animal_2.name
animal_2.name = "Fluffy"
animal_2.nameRuby offers you a nice way to create your getters and setters!
Instead of writing two methods to get and set a name:
def name
@name
end
def name=(value)
@name = value
endYou can declare them by using:
attr_reader :name
attr_writer :name Or you can make it even shorter by using:
attr_accessor :nameOf course if you need only a getter method you will use only attr_reader. Likewise, if you need only a setter method, you will use only attr_writer.
Next, we want to see our animals speak (return a string) depending on their type. If we didn't have abstractions we would have to make a method that checks the instance's class and gives the string depending on that. Something like this:
def speak(animal)
if animal.type == "dog"
"Woof, woof"
elsif animal.type == "spider"
"..."
end
endThis implementation of speak depends on internal knowledge of the instance - in this case knowing that it has an attribute type and that it has those values. This presents the following problems:
- Have to maintain
type, and if it changes or is removed this method will break. - To add a new animal we need to also add another
elsif. - This code might be repeated across different parts of our source code without us noticing.
To make this code better we can leverage abstractions and instead of having a method using internals we have a public method that uses those same internals. The main difference is that if we eventually want to change the internals of Animal we only need to update the speak method, the same to add a new animal type. So, let's update the speak method as follows:
class Animal
...
def speak
if @type == "dog"
"Woof, woof"
elsif @type == "spider"
"..."
end
end
...
endTry it out in your IRB console
require "./animal.rb"
animal_1 = Animal.new("dog", 4, "Rex")
animal_2 = Animal.new("spider", 8, "Wilma")
animal_1.speak()
animal_2.speak()So next we got a request for some specific functionality when the animal is of a certain type:
- If a dog we want to be able to
bring_a_stick. - If a spider we want to be able to
make_a_web.
This would look something like this:
class Animal
...
def bring_a_stick
if @type == "dog"
"Here is your stick: ---------"
end
end
def make_a_web
if @type == "spider"
"www"
end
end
endTry it out in your IRB console
require "./animal.rb"
animal_dog = Animal.new("dog", 4, "Rex")
animal_spider = Animal.new("spider", 8, "Wilma")
animal_dog.bring_a_stick()
animal_spider.bring_a_stick()
animal_dog.make_a_web()
animal_spider.make_a_web()If you test these methods out you'll notice that the method that is not for the current animal still exists, it just returns nil. You might think this is not that bad, but in reality it can be bad because that nil value can be passed through your entire codebase, end up in a place where it is not valid and causes an exception, and you might not be able to easily figure out where that value came from.
So, how can we improve this? Inheritance. We can make classes (Dog and Spider) that inherit from our Animal class and add the specific methods we want for them. Also, we can go a step further and set @number_of_legs in initialize and some specific attributes for each.
Remove the bring_a_stick and make_a_web methods from your Animal class.
Create a dog.rb file with the class Dog as follows:
require "./animal.rb"
class Dog < Animal
def initialize(color, name = "Unknown")
super("dog", 4, name)
@color = color
end
def bring_a_stick
"Here is your stick: ---------"
end
endCreate a spider.rb file with the class Spider as follows:
require "./animal.rb"
class Spider < Animal
def initialize(web_strength_level, name = "Unknown")
super("spider", 8, name)
@web_strength_level = web_strength_level
end
def make_a_web
"www"
end
endNow if you try the bring_a_stick or make_a_web methods in the incorrect class instance you'll get an exception and they are a bit simpler each.
Try it out in your IRB console
require "./dog.rb"
require "./spider.rb"
dog = Dog.new("black", "Rex")
spider = Spider.new(85, "Wilma")
dog.bring_a_stick()
spider.bring_a_stick()
dog.make_a_web()
spider.make_a_web()Now that we have specific classes for Dog and Spider how about we use that and a bit of the magic of polymorphism to refactor the code for speak. In this new version, we can have some generic text ("grrrr") for animals and a specific one for specific animals.
Modify speak in Animal to return "grrrr"
class Animal
...
def speak
"grrrr"
end
...
endAdd a speak method in Dog that returns "Woof, woof"
class Dog < Animal
...
def speak
"Woof, woof"
end
endAdd a speak method in Spider that returns "..."
class Spider < Animal
...
def speak
"..."
end
endTry it out in your IRB console
require "./animal.rb"
require "./dog.rb"
require "./spider.rb"
animal = Animal.new("lion", 4, "Rex")
dog = Dog.new("black", "Rex")
spider = Spider.new(85, "Wilma")
animal.speak()
dog.speak()
spider.speak()We are going to add the following two features to our objects by composing them:
- a method to reduce the number of legs by one.
- a method to tell if the animal likes a type of food or not.
Method to reduce the number of legs by one.
Let's create a class called Remover (in a file remover.rb) that will receive the number of legs and optionally how many to reduce them by.
class Remover
def decrease(number, step = 1)
number -= step
end
endSo, how can we use this? Well, we can use this directly in a method in our Animal class, but remember to add the require for file remover.rb.
require "./remover.rb"
class Animal
...
def remove_leg
remover = Remover.new()
@number_of_legs = remover.decrease(@number_of_legs)
end
...
endGreat! Now you can reduce @number_of_legs if needed. Now on to the next feature.
Try it out in your IRB console
require "./animal.rb"
require "./dog.rb"
require "./spider.rb"
animal = Animal.new("lion", 4, "Rex")
dog = Dog.new("black", "Rex")
spider = Spider.new(85, "Wilma")
animal.number_of_legs
dog.number_of_legs
spider.number_of_legs
animal.remove_leg()
dog.remove_leg()
spider.remove_leg()
animal.number_of_legs
dog.number_of_legs
spider.number_of_legsMethod to tell if the animal likes a type of food or not
For this we are going to create some classes (NoFood, DogFood, and SpiderFood) that will have a list of foods and a method to tell you if one is on the list or not (abstraction!).
You can create the classes in a single file called foods.rb.
class NoFood
def is_liked?(food)
false
end
end
class DogFood
def is_liked?(food)
["meat", "vegetable", "fruit"].member?(food)
end
end
class SpiderFood
def is_liked?(food)
["insect", "bug"].member?(food)
end
endNext, we modify our classes to set @liked_food.
Modify dog.rb to require foods.rb and set DogFood as @liked_food.
...
require "./foods.rb"
class Dog < Animal
def initialize(color, name = "Unknown")
super("dog", 4, name)
@color = color
@liked_food = DogFood.new()
end
...
endModify spider.rb to require foods.rb and set SpiderFood as @liked_food.
...
require "./foods.rb"
class Spider < Animal
def initialize(web_strength_level, name = "Unknown")
super("spider", 8, name)
@web_strength_level = web_strength_level
@liked_food = SpiderFood.new()
end
...
endAnd finally, modify animal.rb to require foods.rb and set NoFood as @liked_food. Also, we need to add a method likes_food? that uses @liked_food and will be inherited by Dog and Spider
...
require "./foods.rb"
class Animal
def initialize(type, number_of_legs, name = "Unknown")
...
@liked_food = NoFood.new()
end
...
def likes_food?(food)
@liked_food.is_liked?(food)
end
endExcellent, now you can tell what food your animal likes and in the future you can make it changeable!
Try it out in your IRB console
require "./animal.rb"
require "./dog.rb"
require "./spider.rb"
animal = Animal.new("lion", 4, "Rex")
dog = Dog.new("black", "Rex")
spider = Spider.new(85, "Wilma")
animal.likes_food?("meat")
dog.likes_food?("meat")
spider.likes_food?("meat")
animal.likes_food?("bug")
dog.likes_food?("bug")
spider.likes_food?("bug")The diagram below shows the classes (with their attributes and methods) and the relationships between them.
Taking a look at the diagram you can see 3 has many relationships:
Ownerhas manyAnimalsAnimalhas manyVisitsVethas manyVisits
But actually, the ones involving Visit are part of a many-to-many of Animal and Vet. So for now, we will do the remaining relationship, which is a simple has many.
We are going to create a file owner.rb which defines the Owner class with attributes @name and @animals. That attribute @animals is what holds the relationship and to manage it we will also create a method add_animals to add animals to it. So this class ends up looking like this:
class Owner
attr_accessor :name
attr_reader :animals
def initialize(name)
@name = name
@animals = []
end
# Instead of setter for entire collection a method to add animals one by one
def add_animal(animal)
@animals.push(animal)
end
endNow you can have an owner and give it animals.
Try it out in your IRB console
require "./animal.rb"
require "./dog.rb"
require "./spider.rb"
require "./owner.rb"
dog = Dog.new("black", "Rax")
spider = Spider.new(85, "Bob")
animal = Animal.new("lion", 4, "Some name")
alex = Owner.new("Alex")
alex.animals
alex.add_animal(dog)
alex.animals
alex.add_animal(spider)
alex.animals
alex.add_animal(animal)
alex.animals.map {|animal| animal.name}
alex.animals.count
alex.animals.first.name
alex.animals.first.number_of_legsIf you take a look at the diagram you will see the 3 possible belong to relationships, the opposite end of the has many ones. Again, we will only tackle the Animals belongs to Owner relationship.
To make this relationship possible we only need to add an attr_accessor for @owners in Animal (animal.rb).
...
class Animal
attr_accessor :owner
...
endTry it out in your IRB console
require "./animal.rb"
require "./dog.rb"
require "./spider.rb"
require "./owner.rb"
dog = Dog.new("black", "Rax")
spider = Spider.new(85, "Bob")
animal = Animal.new("lion", 4, "Some name")
alex = Owner.new("Alex")
alex.animals
alex.add_animal(dog)
alex.animals
alex.add_animal(spider)
alex.animals
alex.add_animal(animal)
alex.animals.last.owner.name
animal.owner
animal.owner = alex
animal.owner
animal.owner.name
alex.animals.last.owner.nameYou might have noticed that although the relationship should go both ways immediately it doesn't. To fix this you need to add the animal to the owner and then add the owner to the animal. This easily presents a problem in which a programmer can forget about this and the object results in a not-updated state.
To fix this we adapt our solution to manage both sides of the relationship in both cases. We need to modify our implementations of add_animal in Owner and create our setter for @owner in `Animal.
In owner.rb
class Owner
...
def add_animal(animal)
@animals.push(animal)
animal.owner = self
end
endIn animal.rb
class Animal
attr_reader :owner
...
def owner=(owner)
@owner = owner
owner.animals.push(self) unless owner.animals.include?(self)
end
...
endNow any time we add an animal to an owner the owner of the animal is set as well, and vice versa.
Try it out in your IRB console
require "./animal.rb"
require "./dog.rb"
require "./spider.rb"
require "./owner.rb"
dog = Dog.new("black", "Rax")
spider = Spider.new(85, "Bob")
animal = Animal.new("lion", 4, "Some name")
alex = Owner.new("Alex")
alex.animals
dog.owner
alex.add_animal(dog)
dog.owner
dog.owner.name
alex.animals
spider.owner
alex.add_animal(spider)
spider.owner
spider.owner.name
alex.animals
animal.owner
alex.add_animal(animal)
animal.owner
animal.owner.name
alex.animals.count
alex.animals.first.name
alex.animals.first.number_of_legs
second_animal = Animal.new("cat", 4, "Kitty")
second_animal.owner
alex.animals.count
second_animal.owner = alex
second_animal.owner
alex.animals.count
alex.animals.last
alex.animals.last.nameFinally, we are going to tackle the many-to-many relationship between Animal and Vet. This relationship is done through the class Visit which besides having the @animal and @vet involved it also has @date.
First, let's create the Vet class in a file named vet.rb. We will initialize an empty list for the visits and a getter for it, and later you will see how we put elements in it:
class Vet
attr_reader :visits
attr_accessor :name, :address
def initialize(name, address)
@name = name
@address = address
@visits = []
end
endNow let's modify the Animal class in animal.rb to include @visits and a getter for it:
...
class Animal
attr_reader :owner, :visits
def initialize(type, number_of_legs, name = "Unknown")
@id = Random.rand(1..1000)
@name = name
@number_of_legs = number_of_legs
@type = type
@liked_food = NoFood.new()
@visits = []
end
...
endAnd last, we create the class Visit in a file named visit.rb. This class will have 3 attributes @date, @animal, and @owner. All of them will be given as part of the constructor and at the same time we will add the visit to @visits of the animal and owner:
class Visit
attr_reader :animal, :vet
attr_accessor :date
def initialize(date, animal, vet)
@date = date
@animal = animal
animal.visits << self
@vet = vet
vet.visits << self
end
endAnd that's it. We implemented the entire UML class diagram relationship.
require "./animal.rb"
require "./dog.rb"
require "./spider.rb"
require "./owner.rb"
require "./visit.rb"
require "./vet.rb"
dog = Dog.new("black", "Rax")
spider = Spider.new(85, "Bob")
vet_maria = Vet.new("Maria", "New York")
vet_john = Vet.new("John", "San Francisco")
visit_1 = Visit.new("2017-12-22", dog, vet_maria)
visit_2 = Visit.new("2017-12-31", dog, vet_maria)
dog.visits.count
dog.visits.map { |visit| visit.date }
vet_john.visits.count
vet_maria.visits.count
vet_maria.visits.map { |visit| visit.animal.name }
visit_3 = Visit.new("2017-11-11", spider, vet_john)
visit_4 = Visit.new("2017-10-10", spider, vet_maria)
spider.visits.count
spider.visits.map { |visit| visit.date }
vet_john.visits.count
vet_john.visits.map { |visit| visit.animal.name }
vet_maria.visits.count
vet_maria.visits.map { |visit| visit.animal.name }If you spot any bugs or issues in this activity, you can open an issue with your proposed change.

