Writing your own Classes
We've been spending a lot of time writing complex procedures. We've spent a lot of time telling program objects how to get to the answer:
# set up some scores
scores = [19, 17, 12]
# make an accumulator
accumulator = 0
# start a loop
scores.each do |score|
# accumulate each score
accumulator += score
end
# divide the accumulator, as a float, by the number of scores
accumulator.to_f / scores.length
- Telling an object how to get to the answer might look like: 'Create an accumulator. Add the scores to it. Then divide them.' This is called imperative programming – literally, 'ordering the computer to do things'.
In Chapter 9, we started seeing how we could take these imperative procedures and hide them behind method names. This means that our code looks more like telling an object what we want, by interacting with abstractions:
# average some scores, please
average(scores)
- Telling an object what we want might look like: 'average these scores.' This is called declarative programming – literally, 'saying what we would like to happen'.
When we're writing complex procedures, we're often working in a very imperative world. We're handling things like control flow with conditionals and loops, working with simple objects like arrays, strings, hashes, and integers. It's a tough world, and it's quite unfriendly to non-programmers (and unfriendly to programmers who didn't write the procedure themselves).
Therefore, as programmers, we try to abstract our work to a declarative style wherever possible. This is why, back in Chapter 1, we were first introduced to the 'program world' because it's a declarative world, where you can ask 1
if it's an integer:
1.integer?
And it'll just tell you the answer. Sure, there's probably some imperative procedure going on under the hood. In fact, there definitely is. But we don't have to know about it. That frees us up to think about more high-level program concerns, like how to meet a particular requirement.
These are the kinds of worlds we strive to create as programmers. One very popular route to doing so is to use Object-Oriented Programming (OOP). We've met a bunch of this already, but here's a quick refresher:
# String is a class – an object in the program world.
# When we send the message 'new' to it, the class figures out how to make an instance
# my_string is an instance of the String class. It's also an object in the program world: a new one
my_string = String.new("Hello World")
# We can tell my_string to do things, declaratively
# Here, we'll tell my_string to return its text in uppercase
# We don't really have to care about how the computer makes this happen
# (maybe it iterates through the list of characters and upcases each one?)
# All we care about is that it's easy to do
my_string.upcase
In this chapter, we'll learn how to move from an imperative to a declarative world, by:
- defining new methods on object interfaces, then
- defining whole new objects.
Back to the program world
Remember our view of the program world:
In particular, remember that procedures are running inside methods. We learned how to write methods in Chapter 9 – but the methods we defined weren't obviously part of any given object's interface. So where have they been living?
Global scope
In Chapter 3, we learned that whenever you send a message to an object without specifying the object, that message is sent to the main program object:
+(3)
So, for the past few chapters we've been defining all of our procedures on the main program object. When we write:
players = [
{ :name => "Alice", :sport => "tennis" },
{ :name => "Simo", :sport => "squash" },
{ :name => "Katerina", :sport => "tennis" },
{ :name => "Alex", :sport => "football" }
]
players_by_sport = {}
players.each do |player|
sport = player[:sport]
if players_by_sport[sport] == nil
players_by_sport[sport] = []
end
players_by_sport[sport].push(player[:name])
end
players_by_sport
...we're asking the main program object to do all this work. We're instructing the universe directly.
In Chapter 9, when we moved the averaging procedure into the method named average
:
def average(scores)
scores_accumulator = 0
scores.each do |score|
scores_accumulator += score
end
scores_accumulator.to_f / scores.length
end
We defined this average
method on the main object.
In Ruby, there are no 'objectless methods'.
Defining methods on objects
Remember that the main program object is an instance of the Object
class. Here's how we'd define the average
method on the main object formally:
class Object
def average(scores)
scores_accumulator = 0
scores.each do |score|
scores_accumulator += score
end
scores_accumulator.to_f / scores.length
end
end
average([1, 2, 3])
Notice that we're opening up the class to fiddle with its insides. We're essentially augmenting the gods so they can create slightly different kinds of things - giving the gods new powers, if you will.
The above is simply a more formal way of writing the method named average
because, by default, methods are defined on the main object.
How about if we wanted to define a method on another object? Say, something that would allow us to do:
> my_object = "A string!"
> my_object.say_hi
=> "Hi there!"
That is, when we send the message say_hi
to a string, it should return "Hi there!"
.
say_hi
is not, by default, part of the instance methods available to strings. So, we have to define it, just like we did the average
method on Object
instances:
class String
def say_hi
"Hi there!"
end
end
my_object = "A string!"
my_object.say_hi
Again, we're opening up the String class to fiddle with the kinds of objects it can create. In this case, we're telling the
String
class: 'when you create instances, make sure they have this method too'.
- Define another method on the string above, called
say_hi_to
. It should take one argument, a name. It should return "Hi, #{name}!"
Using object state in methods
Let's do something useful with this information. Our average
method would be even more ace if we could just go:
[1, 2, 3].average
[1, 2, 3].average
feels far more declarative thanaverage([1, 2, 3])
. It uses the familiar dot syntax, too.
To do this, we need to define a new method on Array
instances:
class Array
def average
# Somehow, do the averaging in here
end
end
But we have a problem. In order to average an array of number elements, we need access to those elements. Instances of the Array
class know about their own elements, but how can we access them?
Enter the Ruby keyword self
. self
refers to the 'current object we're inside'. So, when we're messing around defining methods on Array
instances, self
refers to the instance.
class Array
def average
return self
end
end
[1, 2, 3].average
Notice that we get the array instance itself back when we call
self
.
We can use self
to access the array instance's own elements, and average them:
class Array
def average
accumulator = 0
self.each do |element|
accumulator += element
end
accumulator.to_f / self.length
end
end
[1, 2, 3].average
- Define a method on string instances, called
shoutify
. This method should return the string text in uppercase, with an extra exclamation mark on the end. In other words,"hello world".shoutify
should return"HELLO WORLD!"
.
Defining your own classes
We've just seen how to use the class
keyword to 're-open' existing classes and mess around with the instances they make. We can also use this class
keyword to define entirely new classes – new gods – each of which can create whole new instances.
We're going to try and build the following program:
fido = Dog.new
fido.bark
# We should see "Woof!"
We can actually let our error messages tell us what to do at each stage. This is a helpful way to figure out what we need to do next, step-by-step. Let's try running our program as-is:
fido = Dog.new
fido.bark
OK, we have an error: uninitialized constant Dog
. What's this telling us? It's saying "I couldn't find a name 'Dog'". Let's make one!
Dog = "a dog"
fido = Dog.new
fido.bark
I did the simplest thing, and we have a new, explanative error. Apparently the String
instance I made, "a dog"
, doesn't respond to new
. It sounds like we need Dog
to be some kind of object that responds to new
.
What objects respond to new
? Classes! It turns out that Ruby has a class called Class
. It's the god of classes! Instances of the Class
class are, well, classes. We can send new
to it, and get a new class in response:
Dog = Class.new
fido = Dog.new
fido.bark
We've just defined a new class, Dog
. However, we haven't added any methods to it. Let's do that, using the technique we used before with Object
, Array
, and String
:
Dog = Class.new
class Dog
def bark
return "Woof"
end
end
fido = Dog.new
fido.bark
And that's how we define new kinds of object, and new methods on those objects.
We've seen so far that the class
keyword is shorthand for "open up this class, and do things with it". It's actually a bit cleverer than this. If the class given to class
doesn't exist, it will be made on the fly. That means we can tighten the example above down to:
class Dog
def bark
return "Woof"
end
end
fido = Dog.new
fido.bark
This is a more common way to define new kinds of object (and methods on them) in Ruby.
- Add another method to the
Dog
class.
Object properties
Remember how 1
knows about its value (1)? And how instances of String
know about some text? These things are both examples of object state. In fact, a string's internal characters are referred to as properties of the string.
Let's upgrade our Dog class. We'd love to be able to do something like this:
fido = Dog.new
fido.colour = "brown"
fido.observe
# returns "You see a brown dog."
Moreover, we'd love to be able to do something like this:
fido = Dog.new
chelsea = Dog.new
fido.colour = "brown"
chelsea.colour = "white"
fido.observe
# returns "You see a brown dog."
chelsea.observe
# returns "You see a white dog."
fido.observe
# returns "You see a brown dog."
That is, we want instances of the Dog
class – dog objects – to:
- Respond to a method
colour =
by setting a property on themselves - Respond to a method
observe
with a string - Vary that string according to the colour property
- Remember what colour they are, so they always return 'their' string
We already know how to define new methods on Dog
, so let's allow our errors to guide us:
class Dog
def bark
return "Woof!"
end
end
fido = Dog.new
fido.colour = "brown"
fido.observe
We get a useful error: colour=: undefined method 'colour=' for #<Dog:0x1b210>
. (Your memory address, '0x1b210' bit, will probably differ from mine.)
In Chapter 5, we found out that the
0x1b210
(or whatever number you have) is a hexadecimal number referencing the memory address of the dog instance.
We can fix this by simply doing as we're told, defining a method colour=
on dog instances:
class Dog
def bark
return "Woof!"
end
def colour=
end
end
fido = Dog.new
fido.colour = "brown"
fido.observe
Now we have a new error: undefined method 'observe' for #<Dog:0x1b210>
. We can fix this in the same way:
class Dog
def bark
return "Woof!"
end
def colour=
end
def observe
end
end
fido = Dog.new
fido.colour = "brown"
fido.observe
Notice that we're not doing anything with the methods, yet. We're just defining them to get rid of the errors.
OK! Now we don't see any more errors. But – at the moment, observe
just returns nil
.
Remember, empty method procedures just return
nil
in Ruby.
Let's fix that with the string we want:
class Dog
def bark
return "Woof!"
end
def colour=
end
def observe
return "You see a brown dog."
end
end
fido = Dog.new
fido.colour = "brown"
fido.observe
Awesome! Does this work for chelsea
, the white dog, too?
class Dog
def bark
return "Woof!"
end
def colour=
end
def observe
return "You see a brown dog."
end
end
chelsea = Dog.new
chelsea.colour = "white"
chelsea.observe
Ah, the program still suggests that Chelsea is a brown dog. We need to make the program vary according to the colour we provide. We need some way for the Dog
instance to remember the colour we give it.
Here's how Ruby objects can be told to remember values:
class Dog
def bark
return "Woof!"
end
def colour=(colour)
@colour = colour
end
def observe
return "You see a " + @colour + " dog."
end
end
chelsea = Dog.new
chelsea.colour = "white"
chelsea.observe
The above code will work for both Fido and Chelsea. That's because we're saving the colour property, passed as an argument to the colour=
method, to the object state. To save things to the object state, we use an instance variable definied with the @
symbol.
Let's look more closely at what the colour=
method is doing:
def colour=(colour)
@colour = colour
end
- In line 1, we define a new method
colour=
. It takes one argument, which we namecolour
. - In line 2, we set up a new name,
@colour
, and assign it to thecolour
parameter of thecolour=
method. - In line 3, we close the
colour=
method.
Let's give another example of this.
class Person
def give_a_name(name)
@my_name = name
end
def introduce
return "Hello, I'm " + @my_name
end
end
woman = Person.new
woman.give_a_name("Yasmin")
woman.introduce
In the example above, we:
- define a
Person
class with some methods. - assign a new instance of
Person
to the variablewoman
. - Set the property
@my_name
on thePerson
instance referenced bywoman
to a new string,"Yasmin"
. - Call the
introduce
method on thePerson
instance referenced bywoman
, which returns a string,"Hello, I'm Yasmin"
.
Look closer at the method
give_me_a_name
. We can call the parametername
whatever we want, so long as we reference that same parameter in the method procedure.
The following code will do exactly the same thing but I've changed some of the variable and method names around to make a point:
class Person
def call_me(nickname)
@name_i_would_like_to_be_known_by = nickname
end
def introduce
return "Hello, I'm " + @name_i_would_like_to_be_known_by
end
end
woman = Person.new
woman.call_me("Yasmin")
woman.introduce
Since we're free to name variables and methods as we wish, the most sensible implementation of Person
would use nice, terse names:
class Person
def name=(name)
@name = name
end
def introduce
return "Hello, I'm " + @name
end
end
woman = Person.new
woman.name=("Yasmin")
woman.introduce
Using Ruby's syntactic sugar, we can replace the penultimate line with
woman.name = "Yasmin"
.
Methods that set object state – like name=
above – are called setters. We've seen that setter methods can be called anything you like (call_me
is a setter, give_me_a_name
is a setter, and name=
is a setter). The important thing is that they set object state using an instance variable.
Mutating object state
We can change object state, too. Let's build the following program:
robot = Robot.new
robot.legs = 4
robot.add_leg
robot.add_leg
robot.walk
# returns "I'm walking on my 6 legs!"
We already know how to build out the first (legs=
) and the last (walk
) parts:
class Robot
def legs=(number_of_legs)
@number_of_legs = number_of_legs
end
def walk
return "I'm walking on my " + @number_of_legs.to_s + " legs!"
end
end
robot = Robot.new
robot.legs = 4
robot.walk
How does add_leg
work, though? We're changing the @legs
property of the Robot
instance.
Remember +=
?
class Robot
def legs=(number_of_legs)
@number_of_legs = number_of_legs
end
def add_leg
@number_of_legs += 1
end
def walk
return "I'm walking on my " + @number_of_legs.to_s + " legs!"
end
end
robot = Robot.new
robot.legs = 4
robot.add_leg
robot.add_leg
robot.walk
Using initialize
to set up instances
Imagine if String
instances worked like this:
my_string = String.new
string.text = "Some text"
How annoying that would be! And yet, thanks to our obsession with setter methods above, that's exactly what we're doing with our Dog
, Person
, and Robot
classes. Wouldn't it be better to write:
woman = Person.new("Yasmin")
Just like we write my_string = String.new("Some text")
?
We can! Thanks to Ruby initializers.
In Ruby, whenever we call new
on a class, that class builds an object and then runs the method initialize
on it. If we want to store information about an object as part of that object's state – as a property on the object – we need to interfere with this initialize
method.
Ruby automatically defines the
initialize
method, even if you don't write it. That's whynew
still worked for ourDog
,Person
, andRobot
classes before.
Every argument to the new
function is passed to the initialize
function. Therefore, if we want to send the string "brown"
or "white"
to be stored on a dog as soon as we call new
, we need to set an instance variable in the initializer.
class Dog
def initialize(colour)
@colour = colour
end
def observe
"You see a " + @colour + " dog."
end
end
fido = Dog.new("brown")
fido.observe
No setter method needed! Let's do that again, with our Person
class:
class Person
def initialize(name)
@name = name
end
def introduce
return "Hello, I'm " + @name
end
end
woman = Person.new("Yasmin")
woman.introduce
And once more, with our Robot
class:
class Robot
def initialize(number_of_legs)
@number_of_legs = number_of_legs
end
def add_leg
@number_of_legs += 1
end
def walk
return "I'm walking on my " + @number_of_legs.to_s + " legs!"
end
end
robot = Robot.new(4)
robot.add_leg
robot.add_leg
robot.walk
By using multiple parameters with initialize
, we can provide multiple pieces of information to an object at once and assign them in order:
class Person
def initialize(name, age)
@name = name
@age = age
end
def introduce
"Hello, I'm " + @name + ", and I'm " + @age.to_s + " years old."
end
def get_older
@age += 1
end
end
peter = Person.new("Peter", 17)
peter.get_older
peter.introduce
Different kinds of variables
All variables are references to objects in Ruby because everything is an object, from 1
, to "Hello world"
, to the Dog
class.
So far, we've met a few different kinds of variables:
- Regular variables, like
my_string = "Hello World
- Constants, like
ONE = 1
- Variables inside methods, which get their names from parameters, like
scores
in ouraverage
method - Instance variables, like
@name
,@colour
and@legs
.
What's the difference between these kinds of variables? The answer is, broadly speaking, which objects can see them.
What can see an instance variable?
Instance variables are the simplest: they can only be seen by the object that they're 'inside':
class Dog
def initialize(colour)
@colour = colour
end
# We can use @colour in any methods defined on Dog instances
# Such as this one
def observe
"You see a " + @colour + " dog."
end
end
# We can't see @colour from outside instances of the Dog class
# Here, we're trying to access @colour from the main object
# It can't see the instance variable @colour
@colour
If you try to reference an instance variable that an object can't see, you'll get
nil
.
What can see a local variable?
Regular variables are normally referred to as local variables. Here, we're in imperative-land - telling the computer what to do, line-by-line.
If a line defining that local variable has already been executed, that local variable is available to anything that wants it.
# Define a local variable in the main program object
my_variable = 1
# We can access the local variable here because the line above was executed before this one
my_variable
This is the case even if the local variable was defined in a conditional branch that got executed:
if true
# This conditional branch will be executed
my_variable = 1
end
my_variable
One gotcha, strange things happen if you define variables in branches that don't get executed:
if false
my_variable = 1
end
my_variable
The program can still read the name, but the value is set to nil
.
There's one really tricky thing about local variables, and it has to do with methods. Here it is:
def my_method
my_variable = 1
end
my_variable
Why can't the main object see the my_variable
variable, even though it was defined in the lines above? The answer that most makes sense: my_method
didn't get executed yet. We only declared it, but we didn't call the method.
So we can solve it like this, right?
# define the method
def my_method
my_variable = 1
end
# run the method, executing the procedure that defines my_variable
my_method
my_variable
Wrong. For some reason, even though the procedure my_variable = 1
(inside the method my_method
) has now been executed the main object still can't see my_variable
. Why is this?
Scope
Every time we write one of the following:
def
class
We 'open' something.
def
opens a method, so we can define a procedure inside.class
opens a new class, so we can define methods inside.
The keyword end
then 'closes' that something you just opened.
def average
# we opened the average method, so we can define procedures in it
end
class Dog
# we opened the Dog class, so we can define methods in it
end
Variables that we define inside a def...end
or class...end
cannot be seen outside of them:
def average
accumulator = 0
end
class Dog
some_variable = 1
end
accumulator
some_variable
The area of a program in which a variable can be read is called the variable scope. def
and class
are known as scope gates. When the program runs a line containing def
or class
, it enters a new scope. Variables defined inside this scope cannot be read outside of the scope gate. Variables defined outside of the scope gate cannot be read inside it.
my_variable = 1
def my_method
# I'm in a new scope gate! I can't read my_variables
my_variable + my_variable
end
my_method
Confused by the word 'scope'? Think of the scope on top of a sniper-rifle: when you look down it, you can only see a part of the world: the part of the world you can shoot.
One more gotcha, even though files containing Ruby code are all loaded into the program straight away, local variables cannot be read from outside of the file they're declared in. So files act as scope gates, too.
Using scope to understand method parameters
Scope is especially helpful when it comes to understanding method parameters. For each parameter, methods will define a local variable within their scope. The name of that variable will be set to the name of the parameter:
age = 22
def age_reporter(number)
# Whatever we pass as an argument to age_reporter will be assigned to a local variable named 'number'
return "Your age is " + number.to_s
end
age_reporter(age)
The same goes for parameters provided to procedures inside each
loops:
people = ["Steve", "Yasmin", "Alex"]
people.each do |name|
# Each element of people is assigned to a local variable called 'name'
# We can then read that local variable inside this loop
name
end
# But we can't read the local variable outside the loop
name
Since def
is a scope gate, we can't read these parameters outside of their message body:
name = "Sam"
def say_hi_to(person)
return "Hi, " + person
end
# We can't read this
person
This article dives a little deeper into the idea of 'scope' in Ruby.
- Scope is what parts of the program an object can 'see' at any time.
Using Objects to be more declarative
By defining our own classes, we can make our programs super-readable, almost like English. Here's an example:
heathrow = Airport.new
plane = Plane.new
heathrow.land(plane)
heathrow.hangar_report
# => "There is 1 plane in the hangar"
Also, we can stop programmers from doing things that don't fit with our model of how the program should work:
heathrow = Airport.new
heathrow.take_off(plane)
# => "Error: There are no planes to take off"
In the examples above, the object referenced by heathrow
is an instance of the Airport
class. Somehow, it can store planes. At the heart of the heathrow
object, in its state, lives an array – an object we introduced in Chapter 7 as a way to store other objects. Around this array, heathrow
defines methods which are appropriately named for the program being run – some sort of airport simulation. Here, the 'airport simulation' is known as the program domain.
Why don't we just use an array, instead of instances of this Airport
class? That way, the program could work like this:
heathrow = []
plane = "Boeing 737"
heathrow.push(plane)
"There is #{ heathrow.length } plane in the hangar"
The answer is, using arrays describes the program domain less effectively. As programmers, we strive to write code that accurately reflects the program domain.
Here's how Airport
'wraps' an array:
class Airport
def initialize
@hangar = []
end
def land(plane)
@hangar.push(plane)
end
def take_off(plane)
if @hangar.length < 1
return "Error: there are no planes to take off"
end
if @hangar.include?(plane)
@hangar.delete(plane)
return plane
else
return "Error: plane not in hangar"
end
end
def hangar_report
if @hangar.length == 1
"There is 1 plane in the hangar"
else
"There are #{ @hangar.length } planes in the hangar"
end
end
end
heathrow = Airport.new
heathrow.hangar_report
Because the array is referenced only by an instance variable, nothing outside of Airport
instances can read it, so we're protected from programmers trying to pop
a plane from heathrow
, and so on. This essentially limits the interface of the array to only methods we want to allow.
Spend some time playing with the code example above. Write a
Plane
class if you can and land it. The code here is similar to the code introduced in the first week of Makers, so don't worry if it boggles you at the moment.
Complete the mastery quiz for chapter 10
Use your mastery quizzes repository to complete the quiz for chapter 10.