Not-So-Basic Basics of OOP
More on object-oriented programming in Ruby
This is the last article in a four-part series on the basics of object-oriented programming in Ruby. Read the previous article here.
The gears turned once we introduced inheritance. And now that we understand the core concepts of object-oriented programming, not only can we start to build durable and versatile programs, or think at a high level of abstraction and solve problems in a clear, systematic way, we can start to understand more nuanced concepts in this article and beyond.
We’ve seen when an instance variable is initialized in a superclass (or mix-in module), it can be accessed in a subclass. The same goes for a class variable, but just like a class and its objects share one copy of a class variable, a subclass and its objects share the same copy.
And just like a subclass can override a superclass’s method, it can override a class variable. If we have a
Spider class, we’d reassign
But since there’s only one copy of a class variable, this reassigns the class variable in the superclass and any other subclasses too!
For this reason, it’s recommended to avoid class variables in inheritance. If we have variables we never want to change, though, we can define constants with uppercase letters. Like class variables, constants are available across the class it’s defined and its subclasses.
We can’t change constants, but we can override them.
Now we can expect the
heterotroph? method to return
true when we call it on an
Animal object, but
'What is that?' when we call it on a
But it seems like by inheriting the
heterotroph? method, the
Dog object inherits the
HETEROTROPH constant even after we overrode it!
When we call
heterotroph? on the
Animal object, Ruby finds the method in the object’s class. The method references
HETEROTROPH, so Ruby checks the object’s class for the constant too. But when we call
heterotroph? on the
Dog object, Ruby doesn’t find the method in the object’s class. It finds the method in the
Animal class, so it checks
Animal for the constant rather than the object’s class. This is because constants are scoped lexically.
Since class variables are scoped at the class level, it doesn’t really matter where they’re defined or where we reference them. There’s only one copy of each class variable anyway. On the other hand, constants are scoped by the block they’re defined: when we reference a constant in a method of a class, we reference the one defined in that class definition, in that lexical scope.
So, if we want
heterotroph? to reference the
HETEROTROPH constant defined in the
Dog class (when we call it on a
Dog object), we have to define a
heterotroph? method in
Dog, where the constant is in lexical scope.
Now when we call
heterotroph? on a
Dog object, it references the
HETEROTROPH constant defined in the expected lexical scope. But what happens if the constant isn’t defined there?
If we call
heterotroph? on a
Dog object now, Ruby still finds the method in the
Dog class and checks
Dog for the
The constant is no longer in lexical scope, so Ruby traverses the inheritance hierarchy to look for it. In this case, Ruby finds it in the
Animal class. (This is different from the method lookup path!)
A constant is encapsulated in such a way it can only be implicitly referenced in the class it’s defined or its subclasses. In another class, though, we can explicitly reference where to find it with the scope resolution operator.
We can also explicitly reference the calling object’s class by calling the
class method on
self. This way, we tell Ruby to check that class for the constant rather than the default lexical scope and inheritance hierarchy.
Earlier, we defined a
heterotroph? method in the
Dog class in order to reference the
HETEROTROPH constant defined in
Dog. But now we can simply inherit the method from the
This is particularly useful when we want to extract methods to a mix-in module without having to worry about scope.
As a result, we bring constant scoping rules in line with how we normally expect to resolve variables!
Methods: Fake Operators
In the previous article, we said we inherit a myriad of methods in both custom and built-in classes. Some are of more value when we override them, like
to_s, but particularly those we can call fake operators.
Many “operators” are actually method calls in Ruby. When we call a setter method like
fluffy.name = 'Floofy', it looks like an assignment operation but is merely a more natural syntax for
fluffy.name=('Floofy'). In a similar vein, when we add two objects, we call the
+ method. When we compare them, we call the
== method. The latter is defined in
BasicObject, so it’s available in every class, but most built-in classes override it as they do
Let’s see how it works by default in a custom class.
Since we haven’t redefined a
== method, we call the
BasicObject class’s that checks if two variables reference the same object or space in memory. It returns
equal? method or comparing object IDs has the same behaviour.
We call the
== method to compare object IDs, but since object IDs are integers, we refer to the
== method. It checks if the integers have the same numeric value rather than reference the same object.
== method checks for value.
We call the
equal? method and compare object IDs to verify
second_string don’t reference the same object, but the
== method returns
true because they have the same value. This is a more meaningful way to compare them than if they reference the same object.
There are also more meaningful ways to compare
second_fluffy. We’ll override the
== method to define what it means for
Dog objects to be “equal”.
Now when we call the
== method on
second_fluffy, it checks if they have the same name. If their names are
String collaborator objects, the method calls the
== method to compare them.
We verify they don’t reference the same object, but they have the same name! And by defining a
== method, we get a
!= method as a bonus.
Some fake operators aren’t defined in
Object, like the
+ method. Since they’re only available in a custom class if we define them, we should only define them if their expected behaviour makes sense there.
In any case, we should follow the conventions established in Ruby’s built-in classes. For example: the
+ method increments the value of an integer by the value of an argument and returns a new integer. The
+ method concatenates the value of a string with the value of an argument and returns a new string. The
+ method concatenates the value of an array with the value of an argument and returns a new array. If we define a
+ method in the
Dog class, then, it should increment or concatenate the value of a
Dog object with the value of an argument and return a new
We can define fake operators however we want, but it’s certainly more meaningful for the
+ method to increment or concatenate rather than, say, scramble a calling object’s name. In turn, this allows us to build more meaningful classes and objects in our programs.
Relationships: Collaborator Objects
From variable scopes to fake operators, we’ve seen everything interrelate in explicit and implicit class inheritance. If no class is an island entire of itself, no object is either. Let’s take a closer look at an object’s encapsulation of state and behaviours in another
Like objects of any class,
Dog objects assign state to instance variables and define behaviours by instance methods. But if state is only assigned to instance variables, how is it defined?
We’ve seen this example enough to know when we instantiate the
Dog object, we assign a state
'Fluffy' to the
@name instance variable. We can call the getter method for
@name to verify.
Good! But the state looks like a string. We’ll call the
class method on it.
So, the state is defined by the
String class after all. In other words, the
Dog object’s state is a
String object! In-stance-ception.
'Fluffy', is a collaborator object since it acts as the state for an object of a different class. An object of any class, even a custom class, can be a collaborator object. That includes the
Say we define a class for pet owners. Should a pet be a subclass, mix-in module, or collaborator object? It may help to think of collaboration as similar to mix-in modules in that it models has-a relationships rather than is-a relationships, but it models them between objects of different classes rather than objects and behaviours. A pet owner is a person (class inheritance), has an ability to groom (mix-in module), and has a pet (collaboration).
Let’s add a pet as a collaborator object, then.
When we instantiate an
Owner object, we can assign any collaborator objects to the
@pet instance variables. If the owner has a dog, though, it’d make sense to assign a
Dog object to
In this case, we do just that and assign a
String object to
@name. So, the getter methods for
@pet will return a
String collaborator object and a
Dog collaborator object respectively.
Recall when we call
puts on an object, it calls
to_s on it too. For the
Dog collaborator object, by default,
to_s returns the name of its class and the encoding of its object ID. But for the
String collaborator object, the
to_s method is called on it and returns a high-level string representation of the object.
pet getter method returns the
Dog collaborator object, we can chain any
Dog instance method onto it as if we call the method on the collaborator object itself.
It’s likely none of this is very surprising. That’s because we’ve been using collaborator objects all along! But closely understanding them helps us to be more precise in how we break up and make up actors in our programs. Since object-oriented programs are not only the interaction of classes but that of objects, collaboration is a powerful tool to study under the hood.
What a long way we’ve come! Take a look at the first article to review the benefits and core concepts of object-oriented programming; we should be able to understand them more clearly and practically now.
Since we have a strong foundation of the basics, we also have the tools to continue building upon it from here on out. That And I OOP! meme may be covered in dust, but object-oriented programming is here to stay.