Writing your own methods
Most Ruby procedures are wrapped up in methods, which are defined 'inside' objects. When you call a method on an object:
1.positive?
That object executes a procedure inside itself:
# Inside 1
if self > 0
return true
else
return false
end
And then returns the outcome of the procedure true
.
We've written a lot of procedures now. They've used conditional logic, loops, iterators, and several kinds of specialised object. Every time we've written a procedure, we've defined it on the main
object, which has carried out the procedure for us.
In this chapter, we'll learn how to give these procedures a name, by wrapping them inside methods. In Chapter 10, we'll learn how to define these methods on objects other than the main
object.
Methods are reusable procedures
Let's say that we have the following program specification:
We're a school. Our students have just finished taking their final test. We have test scores for each class of students, and we want to know the average for each class. We also want to know the average for the whole school.
Here's some (horrible!) code to solve this problem. As you can see – it's really repetitive.
# Here are the scores
test_scores_for_class_1 = [55, 78, 67, 92]
test_scores_for_class_2 = [48, 99, 91, 70]
test_scores_for_class_3 = [56, 58, 61, 98, 100]
# Set up the accumulators
class_1_overall_score_accumulator = 0
class_2_overall_score_accumulator = 0
class_3_overall_score_accumulator = 0
# Add together for class 1
test_scores_for_class_1.each do |score|
class_1_overall_score_accumulator += score
end
# Add together for class 2
test_scores_for_class_2.each do |score|
class_2_overall_score_accumulator += score
end
# Add together for class 3
test_scores_for_class_3.each do |score|
class_3_overall_score_accumulator += score
end
# Get the average for each class by dividing the sum by the number of scores
class_1_average = class_1_overall_score_accumulator.to_f / test_scores_for_class_1.length
class_2_average = class_2_overall_score_accumulator.to_f / test_scores_for_class_2.length
class_3_average = class_3_overall_score_accumulator.to_f / test_scores_for_class_3.length
# Print out the averages
puts "Class 1 average: " + class_1_average.to_s
puts "Class 2 average: " + class_2_average.to_s
puts "Class 3 average: " + class_3_average.to_s
# Store the averages in a variable
average_scores_for_classes = [class_1_average, class_2_average, class_3_average]
# Now set up an accumulator for the school scores
school_overall_score_accumulator = 0
# Add together for the school
average_scores_for_classes.each do |class_average|
school_overall_score_accumulator += class_average
end
# Get the average for the whole school by dividing the sum by the number of classes
school_average = school_overall_score_accumulator.to_f / average_scores_for_classes.length
# Print out the school average
puts "School average: " + school_average.to_s
# And return it to the REPL
school_average
Which will return:
Class 1 average: 73.0
Class 2 average: 77.0
Class 3 average: 74.6
School average: 74.86666666666666
Oh my gosh. That was horrible. So much typing. So effortful.
Isn't there a better way? Of course there is.
Defining methods
In the example above, we can use algorithmic thinking to extract some rules:
- Accumulate the scores.
- Divide the accumulation by the number of scores.
- Return the result.
We can write a procedure that will carry out these rules. And, using a method, we could give that procedure a name. That's what a method is - a named procedure.
Variables are named objects. Methods are named procedures.
Here's how we define a method, using def
:
def method_name
# Whatever the procedure does goes in here
end
Just like variables, we need to define a name for the method. We can use this name to call the method later:
def hello_world
return "Hello World!"
end
hello_world
To reduce the repetition of our code above, we're going to define a method called average
:
def average
end
And we're going to put the procedure for finding an average inside it.
Right now, we're defining the method on the program object. That's why we can call our method without referencing an object using dot syntax. In Chapter 10, we'll define methods on other objects. If you're confused, go remind yourself about messages and objects using Chapter 3.
Return values from methods
Remember, when we send an object a message, the object invokes a method using that message. Then, the object carries out a procedure. Finally, the object returns something back to whoever sent the original message.
To designate what the return value should be from a method, use the return
keyword:
def gimme_five
# We want the return value to be 5
return 5
end
gimme_five
Remember from Chapter 4 that procedures are executed by the object instruction-by-instruction. When an object hits a return
statement, it'll stop executing the procedure inside the method, and just return whatever it's told to. In the example above, the object on which gimme_five
is defined – the main
object – proceeds through the procedure, stopping when it sees return 5
and returns 5
as the return value.
This 'stop when you see return' gimmick means that any instructions after a return
instruction won't be executed:
def stop_halfway
return "Stop here"
sum = 1 + 1
return sum
end
stop_halfway
Play around with the example above. How can we return
sum
instead?
Implicit returns
In Ruby, any methods that don't contain a return
statement will secretly add one on the last line of the procedure. So the following two examples are identical:
def hello
return "Hello World!"
end
hello
def hello
"Hello World!"
end
hello
This kind of secret returning is called using an implicit return (as opposed to actually writing one out, which is an explicit return) and helps keep Ruby so beautifully clean and readable.
Empty method procedures
Remember that nil
is a Ruby object used to represent the 'absence of anything'. When we define an empty method – one with no procedure inside – Ruby will secretly add a line containing nil
to it. So, again, the following two examples are identical:
def do_nothing
end
do_nothing
def do_nothing
nil
end
do_nothing
Because of implicit returns (see the section just before this one),
do_nothing
is actually executing the following procedure:return nil
.
Method parameters
When we call a method on an object, we often provide it with arguments to use during the procedure:
vegetables_i_like = ["broccoli", "radishes"]
# push takes one argument
vegetables_i_like.push("peas")
Defined on the array vegetables_i_like
is a procedure that does something like this:
# inside vegetables_i_like
self.elements = self.elements + "peas"
In the case above, the object "peas"
is provided as an argument to the method push
. Then, in the procedure, the "peas"
object is used somehow.
Method parameters are temporary names for objects provided as arguments. They're set up at the beginning of the procedure, then they go away at the end, just like the each
loop we saw in Chapters 7 and 8:
def hello(person)
return "Hello, " + person + "!"
end
hello("Tommy")
Method parameters define a variable inside the method procedure. The name of the variable is set to the parameter name, and the value is set to whatever argument you pass to the method when you execute it:
def hello(person)
# In a couple of lines time, 'person' will equal "Tommy"
return "Hello, " + person + "!"
end
hello("Tommy")
Just like with the each
loop, it doesn't matter what we call the parameter, so long as we refer to that parameter name in the method procedure. I've called this parameter person
because that's a clear name for the procedure to use. But name
would work, as would nickname
, or individual
, or chicken
:
def hello(chicken)
# In a couple of lines' time, 'chicken' will equal "Tommy"
return "Hello, " + chicken + "!"
end
hello("Tommy")
In this way, the object currently executing a procedure can use other objects to vary the return value:
def make_cake(flour_exists)
if flour_exists
return :cake
else
return nil
end
end
make_cake(true)
Writing a method procedure
Inside the def
...end
statement, we write the procedure we want to run. Let's refactor our horrible score averager using methods. Start by defining a method called average
:
def average
end
We want the procedure inside average
to work with an array of numbers, so we'll give it one parameter representing that array of numbers, scores
. That way, when we call average
we can provide an argument. That argument will be accessible (under the temporary name scores
) to the procedure inside average
:
def average(scores)
end
Remember, it doesn't matter what we call the parameter. The method will assign a variable inside itself, called
scores
, equal to whatever we pass into it. I've called the parameterscores
because that's what we're going to pass to average. Butnumbers
would work, as wouldtest_scores
, ordigits
, orchickens
. So long as we refer to the parameter name we defined in the method procedure, we're good to go.
From the averaging procedure we wrote earlier, we know the first thing we do is accumulate the scores together:
def average(scores)
scores_accumulator = 0
scores.each do |score|
scores_accumulator += score
end
end
Then, we divide the accumulated result by the number of scores:
def average(scores)
scores_accumulator = 0
scores.each do |score|
scores_accumulator += score
end
scores_accumulator.to_f / scores.length
end
Then, we return the result:
def average(scores)
scores_accumulator = 0
scores.each do |score|
scores_accumulator += score
end
result = scores_accumulator.to_f / scores.length
return result
end
Actually, we can use Ruby's implicit return policy to leave that last step out of the procedure as the result will be implicitly returned.
Tada! We now have an average
method we can call whenever we want:
def average(scores)
scores_accumulator = 0
scores.each do |score|
scores_accumulator += score
end
scores_accumulator.to_f / scores.length
end
average([15, 23, 16, 100, 19])
What's the benefit of this? Well, now we can use average
to refactor our first solution to the test scores problem.
Refactoring code using methods
First up, let's define and liberally apply our average
method to our earlier code:
# Define the average method
def average(scores)
scores_accumulator = 0
scores.each do |score|
scores_accumulator += score
end
scores_accumulator.to_f / scores.length
end
# Here are the scores
test_scores_for_class_1 = [55, 78, 67, 92]
test_scores_for_class_2 = [48, 99, 91, 70]
test_scores_for_class_3 = [56, 58, 61, 98, 100]
# Get the average for each class by using our method
class_1_average = average(test_scores_for_class_1)
class_2_average = average(test_scores_for_class_2)
class_3_average = average(test_scores_for_class_3)
# Print out the averages
puts "Class 1 average: " + class_1_average.to_s
puts "Class 2 average: " + class_2_average.to_s
puts "Class 3 average: " + class_3_average.to_s
# Store the averages in a variable
average_scores_for_classes = [class_1_average, class_2_average, class_3_average]
# Get the average for the whole school by using our method
school_average = average(average_scores_for_classes)
# Print out the school average
puts "School average: " + school_average.to_s
# And return it to the REPL
school_average
Which, as before, will return:
Class 1 average: 73.0
Class 2 average: 77.0
Class 3 average: 74.6
School average: 74.86666666666666
OK, this is much easier to read. But we can do even better, by eliminating some of these variables using referential transparency:
# Define the average method
def average(scores)
scores_accumulator = 0
scores.each do |score|
scores_accumulator += score
end
scores_accumulator.to_f / scores.length
end
# Here are the scores
test_scores_for_class_1 = [55, 78, 67, 92]
test_scores_for_class_2 = [48, 99, 91, 70]
test_scores_for_class_3 = [56, 58, 61, 98, 100]
# Print out the averages
puts "Class 1 average: " + average(test_scores_for_class_1).to_s
puts "Class 2 average: " + average(test_scores_for_class_2).to_s
puts "Class 3 average: " + average(test_scores_for_class_3).to_s
# Print out the school average
puts "School average: " + average([average(test_scores_for_class_1), average(test_scores_for_class_2), average(test_scores_for_class_3)]).to_s
# And return it to the REPL
average([average(test_scores_for_class_1), average(test_scores_for_class_2), average(test_scores_for_class_3)])
Over-simplifying
We could go deeper. If we move the printing of averages into our average
method, we can get rid of a few more cluttered lines:
# Define the average method
def average(scores)
scores_accumulator = 0
scores.each do |score|
scores_accumulator += score
end
puts (scores_accumulator.to_f / scores.length).to_s
scores_accumulator.to_f / scores.length
end
# Here are the scores
test_scores_for_class_1 = [55, 78, 67, 92]
test_scores_for_class_2 = [48, 99, 91, 70]
test_scores_for_class_3 = [56, 58, 61, 98, 100]
# Print out the averages
average(test_scores_for_class_1)
average(test_scores_for_class_2)
average(test_scores_for_class_3)
# Print out the school average, and return to the REPL
average([average(test_scores_for_class_1), average(test_scores_for_class_2), average(test_scores_for_class_3)])
But is this really better?
- For one, we're printing way too much. Every time we call
average
, it prints something (even if we just want the average value). - For two, that last line is incredibly hard to read.
This is a major lesson of refactoring. It's generally good to tidy up lines of code into methods, but we run into problems when we create methods that do too much. Right now our average
method should really be called average_and_print
.
Methods should do one thing, and do it well. The word
and
in a method name is a major clue that something's been refactored poorly. This principle is called the Single Responsibility Principle, and you'll meet it a lot in the next few months.
Taking a procedure (lots of lines of code) and grouping it into a named method is an example of abstraction. Abstraction results in code that is easier to work with – using the average
method is definitely easier than writing all those lines of code each time – but we lose understanding of what's happening inside the code.
I said in Chapter 2 that naming is one of the hardest problems in programming. The other one is picking the 'right abstraction' – the right way to group and simplify your code. That's what we'll be spending a lot of time on during Makers.
Picking the right abstraction
Picking the right abstraction is really hard. And, you often need to use your abstraction for a while before you know if you made a good choice or not. This means that, as programmers, we get used to writing and deleting code.
Code is cheap! It costs literally nothing. Get used to deleting it, and rewriting it. Practising rewriting code from scratch will be painful for a while, but you'll get much faster and learn a lot in the process. It's easy to get precious over code you've written, but the confidence of knowing you're able to reproduce it from scratch is worth having.
Here are some helpful rules of thumb for picking the right abstraction when writing methods:
- Can you name your method in a simple way, without using the word 'and'? Does it do one thing, and nothing more?
- Can you name your method after what it returns, instead of what it does? For instance,
average(test_scores)
is a better name thanaverages_scores(test_scores)
. For another example,score(hand)
is a better method name thanscores_cards(hand)
.
If you can answer 'yes' to both 1 and 2, your method is more likely to be a good one.
Complete the mastery quiz for chapter 9
Use your mastery quizzes repository to complete the quiz for chapter 9.