Note: For a more complete Ruby
tutorial, download Huw's free eBook (and source code), The
Little Book Of Ruby, from SapphireSteel Software. |
See also: Part
One and Part Three of this series
|
|
Note:
You can download all
the Ruby programs used in this column and run them
either from the command prompt or via an editor/IDE.
The screenshots in this article show an
early beta of Ruby In Steel 'Personal
Edition' – a
free Ruby IDE for Visual Studio 2005. The final
version of Ruby In Steel PE 1.0 was released after
this article was published. A commercial edition
of Ruby In Steel ('Developer Edition') has also
been released, which includes the ultra-fast 'Cylon'
debugger and powerful analytical IntelliSense capabilities.
To download, evaluate or buy Ruby In
Steel, go to the SapphireSteel Software
site: http://www.sapphiresteel.com
|
We ended the last lesson by creating two new classes:
a Thing class and a Treasure class. In spite of the fact
that these two classes shared some features (notably
both had a ‘name’), there was no connection
between them. In a real-world program, classes may often
share numerous features and, if we aren’t careful,
we’ll end up repeatedly writing the same code many
times in order to deal with similar features in different
classes. It therefore makes sense to create a class hierarchy
in which the Treasure class is a descendant of the Thing
class.
Class Hierarchies – Ancestors
and Descendants
In this series, the terms ‘ancestor’ and descendant’ classes
will be used to describe a kind a ‘family relationship’ between
related classes. In the present case, a Treasure is a ‘type of’ Thing
so it makes sense to make this relationship explicit. We’ll code these
classes in such a way that the Treasure class will inherit all the features of
the Thing class – in other words, the Treasure class will be a descendant
of the Thing class. |
The behaviour of Things in general will be coded in
the Thing class itself. The Treasure class will automatically ‘inherit’ all
the features of the Thing class, so we won’t need
to code them all over again. It will then add some additional
features, specific to Treasures.
As a general rule, when creating a class hierarchy,
the classes with the most generalised behaviour are higher
up the hierarchy than classes with more specialist behaviour.
So a Thing class with just a name and a description,
would be the ancestor of a Treasure class which has a
name, a description and, additionally, a value; the Thing
class might also be the ancestor of some other specialist
class such as a Room which has a name, a description
and also exits – and so on…
This diagram shows a Thing class
which has a name and a description (in a Ruby program,
these might be internal variables such as @name and @description plus some methods to access them).
The Treasure and Room classes both descend from
the Thing class so they automatically ‘inherit’ a name and a description.
The Treasure class adds one new item: value – so it now has name, description
and value; The Room class adds exits – so
it has name, description and exits. |
Let’s see how to create a descendant class in
Ruby. Load up the 1adventure.rb program. This starts
simply enough with the definition of a Thing class which
has two instance variables, @name and @description. These
variables are assigned values in the initialize method
when a new Thing object is created. Instance variables
generally cannot (and should not) be directly accessed
from the world outside the class itself due the principle
of encapsulation as explained in the last lesson. In
order to obtain the value of each variable we need a
get accessor method such as get_name; in order to assign
a new value we need a set accessor method such as set_name.
Now look at the Treasure class. Notice how this is declared:
class Treasure < Thing
The angle bracket, < ,
indicates that Treasure is a ‘subclass’,
or descendant, of Thing and therefore it inherits the
data (variables) and behaviour (methods) from the Thing
class. Since get_name, set_name, get_description and
set_description methods already exist in the ancestor
class (Thing) these don’t need
to be re-coded in the descendant class (Treasure).
Classes and Superclasses
The Treasure class has one additional piece of data,
its value (@value) and I have written get and set accessors
for this. When a new Treasure object is created, its
initialize method is automatically called. A Treasure
has three variables to initialize (@name, @description and @value), so its initialize method takes three arguments.
The first two arguments are passed, using the super keyword,
to the initialize method of the superclass (Thing) so
that the Thing class’s initialize method can deal
with them:
super( aName, aDescription )
When used inside a method, the super keyword calls a
method with the same name in the ancestor or ‘super’ class.
If the super keyword is used on its own, without any
arguments being specified, all the arguments sent to
the current method are passed to the ancestor method.
If, as in the present case, a specific list of arguments
(here aName and aDescription) is supplied then only these
are passed to the ancestor class.
NOTE: To gain a better understanding of the use
of super see Super
Nature later on. |
While the classes in the 1adventure.rb program work
well enough, they are fairly verbose due to all those
get and set accessors. Let’s see what we can do
to remedy this. Instead of accessing the value of the
@description instance variable with two different methods,
get_description and set_description, like this…
puts( t1.get_description )
t1.set_description( "Some description" )
…it would be so much nicer to retrieve and assign
values using the same name, description, just as you
would retrieve and assign values to and from a simple
variable, like this:
puts( t1.description )
t1.description = "Some description"
In order to be able to do this, we need to modify the
Treasure class definition. One way of accomplishing this
would be to rewrite the accessor methods for @description as follows:
def description
return @description
end
def description=( aDescription )
@description = aDescription
end
I have added accessors similar to the above in the 3accessors.rb program. There are two differences from the previous
version. First, the accessors are called description rather than get_description and set_description; secondly
the set accessor appends an equals sign (=) to the method
name.
The get and set accessors have now been redefined
in such a way that we can refer to them by the
same name – here description. So our code
can assign a new string like this:
t.description = "a bit faded and worn around
the edges"
It can retrieve the value like this:
puts( t.description )
The Steel IDE’s output window, seen at the
bottom of the screen here, shows the results of
this. |
When you create a set accessor in this way, you must
append the = character to the method name, not merely
place it somewhere between the method name and the arguments.
So this is correct:
def name=( aName )
But this is an error:
def name =( aName )
Attributes
In fact, there is a simpler and shorter way of achieving
the same result. All you have to do is use two special
methods, attr_reader and attr_writer, followed by a symbol
like this:
attr_reader :description
attr_writer :description
You should add this code inside your class definition
but outside of any methods. Calling attr_reader with
a symbol has the effect of creating an instance variable
with a name matching the symbol. Here, the variable would
be called @description. Instance variables are considered
to the ‘attributes’ of an object, which is
why the attr_reader and attr_writer methods are so named.
In Ruby, a symbol is a name preceded
by a colon. Symbol is defined in the Ruby class library
to represent names inside the Ruby interpreter. Symbols
have a number of special uses. For example, when
you pass one or more symbols as arguments to attr_reader (note:
while it may not be obvious, attr_reader is, in fact,
a method of the Module class), Ruby creates an instance
variable and a ‘get’ accessor
method to return the value of that variable; both
the instance variable and the accessor method will
have the same name as the specified symbol. We’ll
have more to say on symbols next month. |
Load up the 4accessors.rb program.
Here you can try out working examples of attribute readers
and writers in action. The Thing class defines a ‘get’ method
accessor for the @name attribute.
The advantage of writing a complete method like this
is that it gives you the opportunity to do some extra
processing rather than simply reading and writing an
attribute value. Here the ‘get’ accessor
uses the String capitalize method
to return the string value of @name with
its initials letters in uppercase. When assigning a value
to @name, we don’t
need to do any special processing so I have given it
an attribute writer:
attr_writer :name
The description attribute
needs no special processing at all so I have used both
attr_reader and attr_writer to
get and set the value of the @description variable.
NOTE: Don’t be confused by the terminology.
In Ruby, an ‘attribute’ is the equivalent
of what many other programming languages call a ‘property’. |
When you want both to read and to write a variable,
the attr_accessor method provides a shorter alternative
to using attr_reader and attr_writer. I have made use
of this to access the value attribute in the Treasure
class:
attr_accessor :value
This is equivalent to:
attr_reader :value
attr_writer :value
Earlier I said that calling attr_reader with a symbol
actually creates a variable with the same name as the
symbol. The attr_accessor method also does this. In the
code for the Thing class, this behaviour is not obvious
since this class also has an initialize method which
explicitly creates the variables. The Treasure class,
however, makes no reference to the @value variable in
its initialize method. The only indication that @value exists at all is this accessor definition:
attr_accessor :value
My code down at the bottom of this source file sets
the value of each Treasure object as a separate operation,
following the creation of the object itself:
t1.value = 800
Even though it has never been formally declared, the
@value variable really does exist, and we are able to
retrieve its numerical value using the ‘get’ accessor:
t1.value
To be absolutely certain that the attribute accessor
really has created @value, you can always look inside
the object using the inspect method. I have done so in
the final two code lines in this program:
puts "This is treasure1: #{t1.inspect}"
puts "This is treasure2: #{t2.inspect}"
The inspect method provides a handy way of peeking inside objects. Here we are
viewing the output in a command window. |
Now let’s see how to put attribute readers and
writers to use in my adventure game. Load up the 2adventure.rb program. You will see that I have created two readable
attributes in the Thing class: name and description.
I have also made description writeable; however, as I
don’t plan to change the names of any Thing objects,
the name attribute is not writeable:
attr_reader :name, :description
attr_writer :description
I have created a method called to_s which returns a
string describing the Treasure object. Recall that all
Ruby classes have a to_s method as standard. My to_s method overrides (and so replaces) the default one. You
can override existing methods when you want to implement
new behaviour appropriate to the specific class type.
I have decided that my game will have two classes descending
from Thing. The Treasure class adds a value attribute
which can be both read and written. Note that its initialize method calls its superclass in order to initialize the
name and description attributes before initializing the
new @value variable:
super( aName, aDescription )
@value = aValue
Here, if I had omitted the call to the superclass, the
name and description attributes would never be initialized.
This is because Treasure.initialize overrides Thing.initialize;
so when a Treasure object is created, the code in Thing.initialize will not automatically be executed.
On the other hand, the Room class, which also descends
from Thing, currently has no initialize method. So when
a new Room object is created Ruby goes scrambling back
up the class hierarchy in search of one. The first initialize method it finds is in Thing; so a Room object’s
name and description attributes are initialised there.
Class variables
There are a few other interesting things going on in
this program. Right at the top of the Thing class you
will see this:
@@num_things = 0
The two @ characters which precede this variable name
define it to be a ‘class variable’. The variables
we’ve used inside classes up to now have been instance
variables, preceded by a single @, like @name. Whereas
each new object (or ‘instance’) of a class
assigns its own values to its own instance variables,
all objects derived from a specific class share the same
class variables. I have assigned 0 to the variable to
ensure that it has a meaningful value at the outset.
Here, the @@num_things class variable is used to keep
a running total of the number of Thing objects in the
game. It does this simply by incrementing the class variable
(by adding 1 using += 1) in its initialize method ever
time a new object is created:
@@num_things +=1
Class and Instance Variables
This diagram shows a Thing class (the rectangle) which contains a class variable,
@@num_things and an instance variable, @name.
The three oval shapes represent ‘Thing
objects’ – that is, ‘instances’ of the Thing class.
When one of these objects assigns a value to its instance variable, @name,
that value only affects the @name variable
in the object itself – so
here, each object has a different value for @name. But when an object assigns
a value to the class variable, @@num_things,
that value ‘lives inside’ the
Thing class itself and is ‘shared’ by all instances of that class.
Here @@num_things equals 3 and that is true for all the Thing objects. |
If you look lower down, you will see that I have created
a Map class to contain an array of rooms. This includes
a version of the to_s method which prints information
on each room in the array. Don’t worry about the
implementation of the Map class; we’ll be looking
at arrays and their methods later in this series.
Find the code down at the bottom of the file and run
the program in order to see how we have created and initialised
all the objects and used the class variable, @@num_things,
to keep a tally of all the Thing objects that have been
created.
In order to understand exactly what is going on when
our sample program executes, you may find it useful to
view the output (shown here in a command window) alongside
the code.
Next month we’ll take
a closer look at the bewildering variety of strings in
Ruby…
To understand how the super keyword works, take a look
at our sample program, super.rb. This contains five related
classes: the Thing class is the ancestor of all the others;
from Thing descends Thing2; from Thing2 descends Thing3,
then Thing4 and Thing5.
Let’s take a closer look at the first three classes
in this hierarchy: the Thing class has two instance variables,
@name and @description; Thing2 also defines @fulldescription (a string which contains @name and @description); Thing3
adds on yet another variable, @value.
These three classes each contain an initialize method
which sets the values of the variables when a new object
is created; they also each have a method named aMethod which changes the value of one or more variables. The
descendant classes, Thing2 and Thing3, both use the super keyword in their methods.
Run the super.rb program
in a command window.
To test out the various bits of
code, enter a number, 1 to 5, when prompted or ‘q’ to
quit
Right down at the bottom of this code unit, I’ve
written a ‘main’ loop which executes when
you run this program. Don’t worry about the syntax
of this; we’ll be looking at loops in a future
lesson. I’ve added this loop so that you can easily
run the different bits of code contained in the methods,
test1 to test5. When you run this program for the first
time, type the number 1 at the prompt and press the Enter
key. This will run the test1 method containing these
two lines of code:
t = Thing.new( "A Thing", "a lovely thing
full of thinginess" )
t.aMethod( "A New Thing" )
The first line here creates and initializes a Thing
object and the second line calls its aMethod method.
As the Thing class doesn’t descend from anything
special (in fact, as with all Ruby classes, it descends
from the Object class) nothing very new or interesting
happens here. The output uses the inspect method to display
the internal structure of the object when the Thing.initialize and Thing.aMethod methods are called. The inspect method
can be used with all objects and is an invaluable debugging
aid. Here, it shows us a hexadecimal number which identifies
this specific object followed by the string values of
the @name and @description variables.
Note: Ultimately all Ruby classes descend from
the Object class. The Object class itself has no
superclass and any attempt to locate its superclass
will return nil. Run our superclasses.rb sample program
to verify this. |
Now, at the prompt, enter 2 to run test2 containing
this code to create a Thing2 object, t2 and call t2.aMethod:
t2 = Thing2.new( "A Thing2", "a Thing2
thing of great beauty" )
t2.aMethod( "A New Thing2", "a new Thing2
description" )
Look carefully at the output. You will see that even
though t2 is a Thing2 object, it is the Thing class’s
initialize method that is called first. To understand
why this is so, look at the code of the Thing2 class’s
initialize method:
def initialize( aName, aDescription )
super
@fulldescription = "This is #{@name},
which is #{@description}"
puts("Thing2.initialize: #{self.inspect}\n\n")
end
This uses the super keyword to call the initialize method
of Thing2’s ancestor or ‘superclass’.
The superclass of Thing2 is Thing as you can see from
its declaration:
class Thing2 < Thing
In Ruby, when the super keyword is used on its own (that
is, without any arguments), it passes all the arguments
from the current method (here Thing2.initialize) to a
method with the same name in its superclass (here Thing.initialize).
Alternatively, you can explicitly specify a list of arguments
following super. So, in the present case, the following
code would have the same effect:
super( aName, aDescription )
While it is permissible to use the super keyword all
on its own, in my view it is distinctly preferable, for
the sake of clarity, explicitly to specify the list of
arguments to be passed to the superclass. At any rate,
if you want to pass only a limited number of the arguments
sent to the current method, an explicit argument list
is necessary. Thing2’s aMethod, for example, only
passes the aName argument to the initialize method of
its superclass, Thing1:
super( aNewName )
This explains why the @description variable is not changed
when Thing2.aMethod is called.
Now if you look at Thing3 you will see that this adds
on one more variable, @value. In its implementation of
initialize it passes the two arguments, aName and aDescription to its superclass, Thing2. In its turn, as we’ve
already seen, Thing2’s initialize method passes
these same arguments to the initialize method of its
superclass, Thing. With the program running, enter 3
at the prompt to view the output. This is the code which
executes this time:
t3 = Thing3.new( "A Thing 3", "a Thing3
full of Thing and Thing2iness", 500 )
t3.aMethod( "A New Thing3", "and yes,
a fabulous new Thing3 description", 1000 )
Note how the flow of execution goes right up the hierarchy
so that code in the initialize and aMethod methods of
Thing execute before code in the matching methods of
Thing2 and Thing3.
It is not obligatory to a override superclass’s
methods as we have done in the examples so far. This
is only required when you want to add some new behaviour.
Thing4 omits the initialize method but implements the
aMethod method. Enter 4 at the prompt to execute the
following code:
t4 = Thing4.new( "A Thing4", "the nicest
Thing4 you will ever see", 10 )
t4.aMethod
When you run it, notice that the first available initialize method is called when a Thing4 object is created. This
happens to be Thing3.initialize which, once again, also
calls the initialize methods of its ancestor classes,
Thing2 and Thing. The aMethod method implemented by Thing4
has no call to its superclasses, however so this executes
right away and the code in any other aMethod methods
in the ancestor classes is ignored.
Finally, Thing5 inherits from Thing4 and doesn’t
introduce any new data or methods. Enter 5 at the prompt
to execute the following:
t5 = Thing5.new( "A Thing5", "a very
simple Thing5", 40 )
t5.aMethod
This time you will see that that the call to new causes
Ruby to backtrack through the class hierarchy until it
finds the first initialize method. This happens to belong
to Thing3 (which also calls the initialize methods of
Thing2 and Thing). The first implementation of aMethod,
however, occurs in Thing4 and there are no calls to super so that’s where the trail ends.
Go to Part 3
March 2006 |