Chapter 2: Class Hierarchies, Attributes and Class Variables
We ended the last lesson by creating two new classes: a Thing and a Treasure . In
spite of the fact that these two classes shared some features (notably both had a
'name'), there was no connection between them.
Now, these two classes are so trivial that this tiny bit of repetition doesn't really
matter much. However, when you start writing real programs of some complex-ity, your classes will frequently contain numerous variables and methods; and
you really don't want to keep recoding the same things over and over again.
It makes sense to create a class hierarchy in which one class may be a 'special
type' of some other ('ancestor') class, in which case it will automatically inherit
the features of its ancestor. In our simple adventure game, for instance, a Treas-ure is a special type of Thing so the Treasure class should inherit the features of
the Thing class.
Class Hierarchies - Ancestors and Descendants: In this book, I shall
often talk about 'descendant' classes 'inheriting' features from their
'ancestor' classes. These terms deliberately suggest a kind a family re-lationship between 'related' classes. Each class in Ruby has only one
parent. It may, however, descend from a long and distinguished fam-ily tree with many generations of parents, grandparents, great-grandparents and so on.
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, addi-tionally, a value; the Thing class might also be the ancestor of some other special-ist class such as a Room which has a name, a description and also exits - and so
on.
One Parent, Many Children…
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 auto-matically '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.
SUPERCLASSES AND SUBCLASSES
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 the methods get_name, set_name, get_description and
set_description already exist in the ancestor class (Thing) these don't need to be
re-coded in the descendant class (Treasure).
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 as the current method 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 method of the ancestor class.
PASSING ARGUMENTS TO THE SUPERCLASS
Brackets matter when calling the superclass! If the argument list is empty and no
brackets are used, all arguments are passed to the superclass. But if the argument
list is empty and brackets are used, no arguments are passed to the superclass:
# This passes a, b, c to the superclass
def initialize( a, b, c, d, e, f )
super( a, b, c )
end
# This passes a, b, c to the superclass
def initialize( a, b, c )
super
end
# This passes no arguments to the superclass
def initialize( a, b, c)
super()
end
To gain a better understanding of the use of super see the Digging
Deeper section at the end of this chapter
ACCESSOR METHODS
While the classes in this would-be adventure game work well enough, they are
still 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" )
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 accessors1.rb program. Here,
the get accessor is called description and the set accessor is called description=
(that is, it appends an equals sign (=) to the method name used by the corre-sponding get accessor). It is now possible to assign a new string like this:
t.description = "a bit faded and worn around the edges"
And you can retrieve the value like this:
puts( t.description )
'SET' ACCESSORS
When you write 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 )
ATTRIBUTE READERS AND WRITERS
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 like this:
class Thing
attr_reader :description
attr_writer :description
# maybe some more methods here...
end
Calling attr_reader with a symbol has the effect of creating a get accessor (here
named description) for an instance variable (@description) with a name match-ing the symbol (:description).
Calling attr_writer similarly creates a set accessor for an instance variable.
Instance variables are considered to be the 'attributes' of an object, which is why
the attr_reader and attr_writer methods are so named.
Symbols
In Ruby, a symbol is a name preceded by a colon (for example,
:description). The Symbol class is defined in the Ruby class library to
represent names inside the Ruby interpreter. When you pass one or
more symbols as arguments to attr_reader (which is a method of
the Module class), Ruby creates an instance variable and a get acces-sor method. This accessor method returns the value of the corre-sponding variable; both the instance variable and the accessor
method will take the name that was specified by the symbol. So,
attr_reader( :description ) creates an instance variable with the
name, @description, and an accessor method named description().
The accessors2.rb program contains some working examples of attribute readers
and writers in action. The Thing class explicitly 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 initial
letter in uppercase:
def name
return @name.capitalize
end
When assigning a value to the @name attribute, I 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 so I use attr_reader
and attr_writer to get and set the value of the @description variable:
attr_reader :description
attr_writer :description
Attributes or Properties?
Don't be confused by the terminology. In Ruby, an 'attribute' is the
equivalent of what many 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 both 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 the class 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}"
Attribute accessors can initialize more than one attribute at a time if you send
them a list of symbols in the form of arguments separated by commas, like this:
attr_reader :name, :description
attr_writer(:name, :description)
attr_accessor(:value, :id, :owner)
As always, in Ruby, brackets around the arguments are optional but, in my view
(for reasons of clarity), are to be preferred.
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 Treas-ure object. Recall that all Ruby classes have a to_s method as standard. The
Thing.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.
CALLING METHODS OF A SUPERCLASS
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 over-rides 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 at the start of this variable name, @@num_things, define
this 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 @@num_things variable to ensure that it has a meaning-ful 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 to it: += 1) in its initialize method every time a new
object is created:
@@num_things +=1
If you look lower down in my code, 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 implemen-tation of the Map class; we'll be looking at arrays and their methods in a later
chapter.
Scroll to the code down at the bottom of the file and run the program in order to
see how I 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.
Class Variables 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 and is 'shared' by all instances
of that class. Here @@num_things equals 3 and that is true for all the
Thing objects.
Digging Deeper
SUPERCLASSES
To understand how the super keyword works, take a look at my sample pro-gram, 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,
rather inventively, 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 super.rb 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 the 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 which is the
ultimate ancestor of all other classes) 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.
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 declara-tion:
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
often 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 the method,
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 a 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 I have done in the
examples so far. This is only required when you want to add some new behav-iour. 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. How-ever, the aMethod method implemented by Thing4 has no call to its super-classes, 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.
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.
begin
x = x.superclass
puts(x)
end until x == nil
CONSTANTS INSIDE CLASSES
There may be times when you need to access constants (identifiers beginning
with a capital letter) declared inside a class. Let's assume you have this class:
class X
A = 10
class Y
end
end
In order to access the constant A, you would need to use the special scope
resolution operator :: like this:
X::A
Class names are constants, so this same operator gives you access to classes
inside other classes. This makes it possible to create objects from 'nested' classes
such as class Y inside class X:
ob = X::Y.new
PARTIAL CLASSES
In Ruby it is not obligatory to define a class all in one place. If you wish, you can
define a single class in separate parts of your program. When a class descends
from a specific superclass, each subsequent partial class definition may optional-ly repeat the superclass in its definition using the < operator.
Here I create two classes, A and B which descends from A:
partial_classes
class A
def a
puts( "a" )
end
end
class B < A
def ba1
puts( "ba1" )
end
end
class A
def b
puts( "b" )
end
end
class B < A
def ba2
puts( "ba2" )
end
end
Now, if I create a B object, all the methods of both A and B are available to it:
ob = B.new
ob.a
ob.b
ob.ba1
ob.ba2
You can also use partial class definitions to add features onto Ruby's standard
classes such as Array:
class Array
def gribbit
puts( "gribbit" )
end
end
This adds the gribbit method to the Array class so that the following code can
now be executed:
[1,2,3].gribbit