Io Programming/Beginner's Guide/Objects
Objects
[edit | edit source]Io is a dynamically-typed, dynamically-dispatched, object-oriented programming language, very much like Self and Smalltalk. Up until this point, we have only been dealing largely with the most primitive things in the language.
The time has come, however, for the reader to learn about objects and how they are used within the Io environment.
What are Objects?
[edit | edit source]An object, used in the context of computer programming, is an abstraction of a concept or tangible thing which, anthropomorphically, you can tell what to do. For example, this statement:
Io> writeln("Hello world!")
can be seen as telling the computer to write a line to the screen. An object oriented language allows us to embrace this concept and take it to a level where it is a bit more useful for our needs. If you refer back to your first Io program, there was one line in it which read this:
you := File standardInput readLine
The expression is evaluated in the following order:
- First, File is evaluated. We're telling the computer to get us a File, whatever that is (we needn't be concerned with this just yet).
- Next, we tell the File to give us the standard input.
- Next, we tell that thing to read a line of text.
- Finally, we assign that to you.
What happens if we ask the computer itself to give us a standard input object?
Io> standardInput
·
Exception: Object does not respond to 'standardInput' --------- Object standardInput Command Line 1
What we're seeing is an error message. It's composed of several components, which you should familiarize yourself with. You'll be seeing a ton of these things as you start out.
- The top line (Exception: Object does not...) is telling us that Object doesn't respond to the standardInput message.
- The bottom part is a traceback, which tells us where the error occurred. This is invaluable when trying to figure out where something has gone wrong.
So far, we have seen how objects are used to partition some namespace into useful libraries. But, we really haven't seen anything yet; objects are substantially more than just a fancy name for libraries.
How do Objects Work?
[edit | edit source]In order to see how to employ objects to make our programs more modular, it is useful to know "how they work," so to speak.
It's useful to think of an object as this thing that can follow orders. What orders it can follow are determined by what appears in its slots.
For example, let's make a little object of our own. The object-oriented equivalent to the infamous hello world program is none other than:
Io> Account := Object clone Io> Account balance := 0 Io> Account withdraw := method(amount, self balance = self balance - amount) Io> Account deposit := method(amount, self balance = self balance + amount)
I've elided the outputs from the Io interpreter here for brevity. But what we've just done is:
- created an Account object.
- configured the object to have a balance of zero.
- taught the Account object how to withdraw something (presumably cash)
- taught the Account object how to deposit something
We can query this object for the current account balance easily enough:
Io> Account balance ==> 0
This works because executing Account is enough to tell the interpreter, "OK, I'm now running in the context of this Account thing." The next instruction is balance. The interpreter sees that Account clearly implements a balance, and so executes it. In this case, its value is zero. Hence, the ==> 0
report.
Another way of looking at it, and indeed, the preferred way, is to think, "Hey! Account! You there! Balance, now!". In other words, we're telling the Account object to give us the balance. As you can see, this is not really a whole lot different from File standardInput readLine -- the latter simply has more commands, but the mechanics is exactly the same.
Next, let's deposit some cash in our account.
Io> Account deposit(100) ==> 100
This produces an unexpected result -- why did it return 100, and not nil? It's because the last thing it computed was self balance + amount, which evaluates to 0+100 when you think about it.
Take a closer look -- when we execute deposit(100), the deposit slot is fetched in the Account object, just as balance was above. And as with balance, the interpreter executes this slot. However, instead of it just being an unadorned value, it is a method. Remember when I said we'd get to methods in a bit?
- Observation
If you're trying to tell objects to do things, then you are obviously giving them messages, which these objects know how to interpret (somehow). In this case, both balance and deposit are messages. However, the method of interpreting these messages is entirely up to the object itself. Hence the name, method. Now that we've resolved one mystery, we've introduced another -- we will get to the significance of methods versus procedures in just a bit.
- NOTE
Mathematically speaking, deposit is not a function, because it has what are called side-effects. This means that evaluating the message will cause some change in state that persists beyond the evaluation of the message. Pure mathematical functions never do this. And now you know why we don't refer to deposit as a function either!
When running inside deposit, we see that we need to reference our balance with self balance. Here, self is clearly referring to the object in which the method is running, thus granting us access to our object's data.
- Observation
By default, all slots are local to the inner-most lexical scope. In other words, there are no global variables.
- Exercise
Try to figure out how Account withdraw(50)
works!
Inheritance
[edit | edit source]I promise, this will be the last diversion before we can see how objects truly become useful. We need to find out how inheritance works, because it is a corner-stone in Io.
Looking again at our Account object above, the creation of the object involved cloning Object. In Io, everything is ultimately a kind of Object, but if you're creating something truly new and unique, you clone Object directly.
What this is saying is, well, an Account is an Object. But, we go on to specialize this object, with a balance, and a few methods to adjust the balance appropriately.
But, as with all things in Io, there is more to this very simple example than meets the eye. Consider, if we're always bossing objects around by sending them messages, where, then, does self get sent?
If you answered Object, you are not quite correct. Remember when I made the observation that, by default, everything in Io is always local? Method variables are no exception. The order in which Io looks things up is as follows:
- Look first in the method itself first. There is no local variable named self.
- Look next in the Account object. Nope, nothing here either.
- Look next in Object. AHA!
Each object implements a list of prototypes, which it uses as a source of inspiration, if you will, to determine how to handle messages. If you send an object a message that it doesn't know how to handle, then it'll consult with its prototypes to find out how. If, and only if, it fails in this task, do you get that infamous error message, Object does not respond to whatever.
Money, Money, Money!
[edit | edit source]Now that we've seen the basics of what objects are, and how they can inherit from other objects, let's see how we can put this to good use.
For starters, let's assume that you want multiple accounts. There are many different kinds of accounts, but we'll stick with a basic savings account. These accounts, over time, accrue interest. But, accounts from different banks accrue interest at different rates. How do we manage this additional bit of complexity?
First, we need a savings account:
Io> SavingsAccount := Account clone
As we have seen here, we've created a new object, which relies on Account as its inspiration for how to behave. We can verify it has no methods of its own:
Io> SavingsAccount slotNames foreach(println) type ==> type
Well, OK, it has one slot, type, which when invoked will return SavingsAccount
. But nothing besides that. And, yet, we can still use it like a normal account object:
Io> SavingsAccount balance ==> 0
A savings account will typically have some interest rate:
Io> SavingsAccount interestRate := 0.045
With this, we can estimate how much we'll have at the end of the year:
Io> SavingsAccount yearEndEstimate := method( )-> self balance * self interestRate + self balance )-> )
So when this executes, the order of search is to try to find balance first in the method itself, then in SavingsAccount, then in Account, where it'll actually be defined.
Suppose we need bank accounts for your family. We can now do something like this:
Io> MomsAccount := SavingsAccount clone Io> DadsAccount := SavingsAccount clone Io> WifesAccount := SavingsAccount clone
So, we should be able to keep track of different accounts independently:
NOTE: this section describes behavior that does not happen with current Io version. See the Talk page for details.
Io> MomsAccount deposit(450) Io> DadsAccount deposit(450) Io> WifesAccount balance ==> 900
WHOA, how'd that happen? It looks like depositing all happens into a single balance!
- Exercise
Can you figure out why?
Regarding Classes, Prototypes, and Objects
[edit | edit source]You'll hear this term a lot in communities dedicated to object oriented programming, "instantiate an object of class x", where x is whatever class they happen to be talking about. For example, to create a new list, you might see mention of a certain kind of list class.
Io does not have any classes, and this is precisely why all the accounts of the preceeding section went into a single balance. Since none of the derived objects implements its own balance message, it assumes that the object's prototype knows how to handle it. It turns out, this assumption was a mistaken one.
How do we, then, instantiate an object such that it creates its own balance slot every time we need it? We do this with an init method:
Io> SavingsAccount init := method(self balance := 0)
In doing this, we have converted SavingsAccount from just any ol' object, into a class. We can now logically reason about SavingsAccount objects all day long, as if they were types of SavingsAccount. And, indeed, they are exactly that.
We can now re-instantiate our family's accounts:
Io> MomsAccount := SavingsAccount clone Io> DadsAccount := SavingsAccount clone Io> WifesAccount := SavingsAccount clone
What happens here is that, right after the cloning process, SavingsAccount init is sent to ensure the object is properly initialized.
Io> MomsAccount deposit(450) Io> DadsAccount deposit(450) Io> WifesAccount balance ==> 0
Note that we didn't have to manually implement, or re-implement, the withdraw or deposit methods. Our assumption that the basic Account object knows how to handle withdrawls and deposits do still hold. The only thing that was mistaken was where to withdraw from or deposit to. Therefore, by creating an account type that was aware of this confusion, we could take the opportunity to clarify before any further program code was executed.
It should be noted that all classes are prototypes -- templates for use by another object. But, not all prototypes are necessarily classes. As we've seen, Account is also a prototype, but strictly speaking, it is not a class. It took SavingsAccount and its init method to preserve the assumptions that allowed us to treat it as a type rather than a thing. Yet, we are able to freely define types in terms of things if we want to, as we've done in this example. In class-based object-oriented languages, this kind of flexibility is out of the question.
You Needn't Get Everything Right the First Time
[edit | edit source]One point to remember when writing OO programs is, you don't need to get it right the first time. Above, I said that we can have multiple kinds of accounts, each with different interest rates, but the way the software so far has evolved, it's not really easily done. So, let's fix things:
Io> Account init := method(self balance := 0) Io> SavingsAccount removeSlot("init") Io> AccountWithInterest := Account clone Io> AccountWithInterest yearEndEstimate := SavingsAccount getSlot("yearEndEstimate") Io> SavingsAccount removeSlot("yearEndEstimate") Io> SavingsAccount setProto(AccountWithInterest)
With a small handful of statements, we've just re-engineered the entire type hierarchy. Now, Account is the (so-called) base class, and SavingsAccount is now just a specialized kind of AccountWithInterest. Software which relied on SavingsAccount should still run, since we didn't change in any fundamental way what SavingsAccount objects do.
Now, we can go so far as to define what a CheckingAccount is:
Io> CheckingAccount := AccountWithInterest clone Io> CheckingAccount interestRate := 0.015
This is why folks rarely use checking accounts to save up money.
Io> MomsChecking := CheckingAccount clone ...etc...
Obviously, this is a very simplistic example, but it shows you that changes to the relationships between objects, prototypes, and classes can be changed, potentially even while a program is still running. They are not quite so static as they are with other languages.
That being said, I want to make it clear, that this kind of programming is great for exploratory work, but you should not depend on this for production code. If you "hot-fix" software like this, be sure to fix the program's source code accordingly, so that such hot-fixes aren't needed in the future.
One approach towards achieving this is test-driven development. However, this process is well beyond the scope of this book. Here, my task is to teach you how to write software in Io. It is not, however, how to design software. If you wish to know more about this, please see [1].