LOOPE - Lingo Object Oriented Programming Environment by Irv Kalb

Previous Chapter

Table of Contents

Next chapter

 

Section 1 - Parent Scripts and Objects

Chapter 4 - What's so great about objects?

We have seen what an object looks like, how you create an object from a parent script, and how the code and data of an object interact. We have a foundation for how an object works - but why is it a good thing? The answer lies in a fundamental OOP concept called encapsulation. Encapsulation refers to the fact that objects require that data (properties) and code (methods) exist together. The data and all the code that affect that data are in one place - in the same script. It turns out that this is a very important and powerful concept. It allows you to write very modular, reusable code.

You can think of an object as a container, it is a repository for both data and code. But more importantly, the container "owns" the properties inside it. Remember that the scope of a property variable is limited to the script in which it is defined. Therefore, only the methods of an object itself are allowed to access the properties within that object (that is, to get the value for any of the object's properties, or to set a new value for any of the object's properties). If you define two parent scripts, A and B, only the methods in A can access the properties defined in A, and only the methods in B can access the properties defined in B. If you have other code in your project, say handlers in one or more movie scripts or sprite scripts, those handlers cannot access the property variables in either A or B. This leads to simplified debugging. If you know that a particular property variable in an object is somehow getting the wrong value because of a programming error, then the problem must be in some method of the object that defines that property variable.

Compare this with global variables and movie level scripts. Global variables and the scripts that access them have no sense of encapsulation at all. By definition, a global variable can be accessed by any handler residing in any script anywhere in the program. In practice, declaring and using a large number of globals variables can lead to what I call "global soup". That is, a large number of global variables floating around in a very large bowl of code. Code that is written to use global variables this way is commonly referred to as "spaghetti code" - a mish-mash of entangled threads of code which are very difficult to untie. Programs written this way tend to just "grow" without any real design. It is often difficult to read these types of programs because there is no centralization. Handlers access data whenever and wherever they need to. These types of programs are often prone to errors because these interactions can be scattered all over a program, and programmers might not fully grasp the subtleties of how the code interacts with the data.

As the amount of code and the number of global variables that you use in a program grows, you may realize that you really would like some way, some approach to allow you to organize or group the code and variables. As an analogy, look no further than the top of your desk. Imagine if every piece of paper you had was sitting on your desktop. You would have instant access to everything, but you would quickly find it difficult to remember where everything is and what everything does. Your first obvious move might be to group related papers and stick them into folders. In the computer, we have an exactly analogous situation. No one keeps all their files on their computer desktop. Instead we create folders and sort related files into them. And of course, as a logical extension, we create folders within folders, etc.

Using a similar approach, when attempting to organize the code and variables in a program, you recognize that certain pieces of code and data are related to each other. A number of different handlers may access the same variable or variables (e.g., a set of handlers which make additions to, deletions from, or find elements in a list). Or two or more variables may seem to logically be related (e.g., a list and an index which keeps track of the "current" item). The first step to thinking in an OOP way is to recognize these groupings. This is the essence of starting to think about designing and using objects.

 

Example of independent objects

For example, let's say that you are writing a program that has the following requirements. The user must be able to move between a number of different "places" (implemented as markers in a Director movie), and the program must be able to retrace the user's steps. That is, it must keep a history of where the user has been so that the user can click on a Back button similar to the way you can navigate in a browser. A second requirement is that the program must keep track of what the user has seen, and if the user quits the program and restarts, the program must restore the state of what has been seen by the user. This is typically done by writing out a status file and reading it back later when the program is restarted.

To implement the first requirement you would have some data (a list) that functions as a stack of most recently visited places. The code would allow you to add ("push") a new place onto the history of places, and would allow you to get ("pop") the most recently visited place. You can group this code and data into a parent script to define a navigation object as follows.

-- Navigation script
property plHistory -- "Stack" of places visited in order

on
new me
  plHistory = []
  return
me
end

-- "Push" new place onto the stack
on mNewPlace me, newPlaceName
  add(plHistory, newPlaceName)
end

-- "Pop" previous place off the stack and go there
on mGoBack me
  nPlaces = count(plHistory)
  if nPlaces = 0
then
    alert("No more places to go back to")
    return
  end
if
  previousPlace = plHistory[nPlaces]
  deleteAt(plHistory, nPlaces)
  go previousPlace
end



You would also need some data that would maintain the user's state: a list of place names that the user has been to. Then we need some code that updates this list, and some code that reads and writes this information to and from a file. This code and data can be logically grouped together into a second parent script as a tracking object.

-- Tracking script
property plPlaces -- property which is a list of places that have been visited
property pTrackingFileName -- the name of the file we use for tracking

on
new me
  pTrackingFileName = "tracking"
-- could be passed in as a variable
  places = getPref(pTrackingFileName)
  
  -- If there is no such file, then initialize to an empty list
  if
voidp(places) then
    plPlaces = []
  else
    plPlaces = value(places)
  end
if
  return
me
end

-- If this place is not in the list, add it
on mVisited me, thisPlace
  if
getOne(plPlaces, thisPlace) = 0 then
    add(plPlaces, thisPlace)
  end
if
end

-- Write out the current state of the world
on mWrite me
  setPref(pTrackingFileName, string(plPlaces))
end


These two objects, the navigation object and the tracking object have nothing to do with each other. Each parent script is responsible for maintaining the state of its own little universe. All the code and data that handle navigation are encapsulated into one parent script, and all the code and data that handle tracking are encapsulated into a second parent script. You can think of each of these as small programs that act independently in response to messages. By coding these objects this way, these objects become easily transportable, that is, they could easily be reused in other programs. Encapsulation like this is also a great aid in debugging. If an error shows up during navigation, we know that the problem must be in the navigation object or in the places where calls are made to the navigation object. Similarly, if an error shows up in tracking, then the culprit must be in the tracking object or where calls are made to it.

 

 

Interface versuss Implementation

As a programmer who writes object oriented code, you must live in two worlds; "inside" the object and "outside" the object. These are two very distinct and different worlds each having their own view of what can and cannot be done. It order to really understand OOP, you must become comfortable with living and working in these two worlds and you must be able to switch your point of view instantaneously.

As the designer and implementer of an object, you work "inside" the object. Your role is to design a set of properties and interrelated methods that solve a set of requirements. In our first example, our object solved the problem of modeling a bank account. As the developer, you decide what properties are needed to represent and maintain the state of the object. You also develop algorithms for manipulating the data and turn those algorithms into methods of the object. As the developer, you write the code for how the object does what it does. The set of methods in the object which are available to be called from outside the object are collectively called the application program interface (also known as API, and sometimes called simply the interface) for the object.

However, outside the object, you are a user of (or a client of) that object. You can communicate with an object using only the object's API. When you are outside the object, you are "not allowed" to look at the implementation. You must ignore how the object does what it does, and rely only on the API that the object extends to you. You must trust that the object can take care of itself.

If you have ever used a Director XTRA, then you have had experience with this type of programming. All XTRA's have an API which is the set of routines that are available to be called from a Lingo program. For example, there is a standard XTRA that ships with Director called FileIO. The main functions of the XTRA are to allow programmers to read from and write files to the user's hard disk. From the Director's message window, we can see the following:

-- Welcome to Director --
put interface(xtra "FileIO")
-- "xtra fileio -- CH 18apr97
new object me -- create a new child instance
-- FILEIO --
fileName object me -- return fileName string of the open file
status object me -- return the error code of the last method called
error object me, int error -- return the error string of the error
setFilterMask object me, string mask -- set the filter mask for dialogs
openFile object me, string fileName, int mode -- opens named file. valid modes: 0=r/w 1=r 2=w
closeFile object me -- close the file
displayOpen object me -- displays an open dialog and returns the selected fileName to lingo
displaySave object me, string title, string defaultFileName -- displays save dialog and returns selected fileName to lingo
etc.

This is the API of the FileIO XTRA. It is the set of routines that are available to the user of FileIO. We are on the "outside" of the XTRA, so we really don't care how the XTRA is implemented. We don't care about what variables it maintains or what algorithms are used to implement these calls. In fact, there are Mac and Windows versions of FileIO which have completely different code because they are talking to different operating systems. However, because FileIO has a well defined API, we can ignore all these issues. We trust that FileIO will do the right thing when we call its routines. Here is an example of how to use the FileIO XTRA to read text from a file. This comes directly from the Macromedia Tech Note #3192:

The following handler reads a text file into a field castmember named "myfield". The displayOpen method allows the end user to select a file from a standard Macintosh or Windows Open dialog box:

on readFromFile
  if objectP(myFile) then set myFile = 0 --Delete the instance if it already exists
  set myFile = new(xtra "fileio") -- Create an instance of FileIO
  if the machinetype = 256 then
    setfiltermask (myfile, "All files,*.*,Text files,*.txt") -- set the filter mask (Win)
  else
    setfiltermask (myfile, "TEXT") -- set the filter mask (Mac)
  end if
  set fileName = displayOpen(myFile) -- display the "Open" dialog
  if not voidP(filename) and not (filename = EMPTY) then
    openFile(myFile, filename, 1) -- open file that user selected
    if status(myFile) = 0 then
      set theFile = readFile(myFile) -- Read the file into a Lingo variable
      put theFile into field "myField" -- display the text in a field
    else
      alert error(myfile,status(myfile)) -- display error message
    end if
  end if
  closeFile(myFile) -- Close the file
  set myFile = 0 -- Dispose of the instance
end

Notice that the style of using this XTRA is exactly the same as calling the methods of an object. You create an instance of the XTRA using the "new" method, you use the methods of the FileIO XTRA (in this case, setfiltermask, displayOpen, openFile, status, readFile, and closeFile), then dispose of the instance when you are through using it.

Here is a simple analogy from the real world. Think of a television and a remote control. The television can be thought of as an object and the remote control is outside of the TV object giving commands to the TV object. When you press the up or down channel button on the remote, what you see is that the TV goes up or down one channel. But let's looks at that in more detail. The remote just sends a message to the TV telling it to go up or down one channel. The remote doesn't know what channel the TV is on - it doesn't want to know and shouldn't need to know. And it doesn't know how the TV changes channels, it just asks the TV to go to the next channel higher or lower. The TV internally keeps track of the current channel and a list of valid channels. When the TV gets the message to go up one channel, it goes to the next channel that it knows about. It is the exact same idea for the volume. When you press the volume up button on the remote, the remote sends a message to the TV asking it to increase the volume. The TV knows the current volume level and does whatever it needs to do to increase it. The mute button is just a simple toggle button (two state button - on or off). When you hit the mute button on the remote, the remote sends a message asking the TV to toggle its mute-state of the TV. The TV knows if it currently is muted or not, and when it receives the mute message, it flips it current state.

In fact, this concept of sending a message to an object is so fundamental, that the term "send a message to an object" is synonymous with the phrase "call a method of an object". Yet another term that is often used is that you "tell an object to do something". OOP programmers use these three terms interchangeably because they all have the exact same meaning.

 

The traditional external view of an object is that it is like a black box. An object is considered "black" because you are not allowed to see inside the box. The inner workings of the box are shielded from you. You make calls to the object's methods, and the object does what it needs to do to react to those messages. Now this raises an important and interesting point. If you think of an object as a black box that simply responds to a set of messages, then you could easily replace that black box with a different black box that responds to the same set of messages.

Here we have another huge benefit of OOP. As long as you maintain the API of an object, the code inside an object can change at will without any affect to the rest of the overall system. This fact allows you as a programmer to separate out whole areas of a project and work on them independently from the rest of a project. It allows for a way to compartmentalize large pieces of software by breaking them down into smaller pieces. Each piece is responsible for a dedicated part of the overall program. This modularization allows project teams to work independently on pieces of an overall system. Agreeing on and sticking to an API is the key.

Of course, if a change is made to an element of the API of an object, then all users of that object must make sure that all the calls which have been affected have been changed. If you add a parameter to a method, change the name of a method, change the ordering of parameters, etc, then all callers of that method must also change to stay in synch.

The key thing to remember here is that if the API of an object does not change, then you are free to change the implementation of that object with no impact on the rest of the system. Inside the object you can change the names of properties, change algorithms to increase performance, change the types of property variables to keep track of the data of the object - all without worrying that another piece of code somewhere else in the program will somehow unexpectedly affected.

 

ACCESSOR METHODS

Sometimes, when you are outside of an object, you need to know the value of a property inside an object. Thinking back to our earlier example of a bank account, it seems obvious that from outside a BankAccount object, we might want to know the value of the balance in the account. In the code of the BankAccount object, there is a property variable pBalance that is used to keep the value of the current balance. In cases like this, you might think that you want to have "direct access" to a property variable from outside the object. In fact, Lingo syntax does allow you to get at properties of object directly. The following is completely legal and will work correctly:

x = goBankAccount.pBalance

(or in the older style before "dot syntax": x = the pBalance of goBankAccount)

In my strongest possible voice, I discourage you from using it and I will soon explain why. In real coding, it should be forbidden, outlawed, not even considered. This syntax is only appropriate in one case - debugging. If you are debugging an object, it is OK to use the message window to find the value of a property value like this:

put goBankAccount.pBalance

So, what would be the proper way to programmatically find the value of an object's property? If code outside of an object needs access to a property of an object, then the object should be written to have one or two special methods called an accessor methods for that property. These types of methods are sometimes referred to as "getters" and "setters" because that is what they do. The "getter" method just returns the value of a property, a "setter" method assigns a new value for a property. Typical "getter" and "setter" methods for a property in an object look like this (here affecting a property called "pSomePropertyVariable"):

on mGetSomething me
  return pSomePropertyVariable
end


on mSetSomething me, newValue
  pSomePropertyVariable = newValue
end

An accessor method is a gateway that an object provides to allow code outside of itself to get or set the value of one of its property variables. For example, in our bank account object, simple accessor methods would be written like this:

on mSetBalance me, newBalance
  pBalance = newBalance
end

on mGetBalance me

  return pBalance
end

Now that the accessor methods have been written, whenever you need to get the current balance, you would write:

theBalance = goBankAccount.mGetBalance()

Conversely, if you want to set a new balance, you do this:

goBankAccount.mNewBalance(someNewBalanceAmount)

This extra layer of coding may seem trivial or even downright painful. However, it supports the key concept of encapsulation by ensuring that objects are responsible for maintaining their own data. Using accessor methods separates the implementation from the internal representation. Here are two reasons why writing and using accessor methods is vital.

Let's go back to our bank account object for two examples. First, imagine that you had written a large amount of code using a BankAccount object. Further, let's say that you had written many lines of code such as this:

theBalance = goBankAccount.pBalance

Then, because it might make it easier to explain, you decided to change the name of the property "pBalance" to "pAmount". So, you bring up the BankAccount parent script, and change all occurrences of pBalance to pAmount. But also, you need to search through all of the code in your program to find all places where you wrote code such as the line above and change all of them to:

theBalance = goBankAccount.pAmount

Because you may use the property pBalance in other objects, you would have to check each line that contains pBalance and decide on a line-by-line basis whether or not you should change this occurrence to pAmount. This can lead to unexpected errors that are difficult to track down. Now let's consider what would happen if you used accessor methods. If you had used an accessor method such as mGetBalance, then all you need to do would be to change pBalance in the BankAccount script to pAmount. The accessor method:

on mGetBalance me
  return pBalance
end


would change to:

on mGetBalance me
  return pAmount
end

And you would be done. By using an accessor method, we have an easy way to make this change without affecting anything outside of the object.

Next is an even more important reason for using accessor methods. Imagine that in our bank account, we kept a value of an interest rate and did some calculations based on that interest rate:

property pInterestRate

on new me, initialDeposit, password, interestRate ...
  pInterestRate = interestRate
  -- etc.

Further, you have a number of places where you need to get the interest rate from the object, so you have some code like this:

x = goBankAccount.pInterestRate

Now, as the program evolves, the requirements of the program change. Instead of a constant interest rate, the interest rate changes based on the amount of money in the account. If you have less than $1000 in the account, the interest rate is 3%. If the balance is more than $1000 but less than $5000 then the interest rate is 4%. If the balance is $5000 or more, then the interest rate is 5%. If you had used code like the above, you would have to change all these occurrences of to implement a formula. Instead, if you had used an accessor method, you would simply change the object:

on mGetInterestRate me
  if pBalance < 1000
then
    interestRate = 3
  else
if pBalance < 5000 then
    interestRate = 4
  else

    interestRate = 5
  end
if
  return interestRate
end

 

Utility code

This concept of binding the properties and methods together is so fundamental, that it doesn't make sense to even think about executing a method of an object without its properties. In fact, if a method of an object doesn't refer to any of the properties of that object, then the method probably shouldn't be inside an object at all. Code like this is called utility code and belongs in a different cast member. For example, let's say that you need to force a string to uppercase or lowercase. Here is some code which implements these functions (for American English):

on ConvertCharToLowerCase charIn
  valOfCharIn = charToNum(charIn)
  if valOfCharIn >= 65
then -- 65 is "A"
    if valOfCharIn <= 90
then -- 90 is "Z"
      valLower = valOfCharIn + 32
-- 32 is difference between upper and lower case letters
      charOut = numToChar(valLower)
      return charOut
    end
if
  end
if
  
  -- Fall through, character in was lower case, just return it
  return charIn
end

on ConvertCharToUpperCase charIn
  valOfCharIn = charToNum(charIn)
  if valOfCharIn >= 97
then -- 97 is "a"
    if valOfCharIn <= 122
then -- 122 is "z"
      valUpper = valOfCharIn - 32
-- 32 is difference between upper and lower case letters
      charOut = numToChar(valUpper)
      return charOut
    end
if
  end
if
  
  -- Fall through, character in was lower case, just return it
  return charIn
end


on ConvertStringToLowerCase stringIn
  nChars = length(stringIn)
  stringOut = ""
  repeat
with charIndex = 1 to nChars
    thisChar = char charIndex of stringIn
    stringOut = stringOut & ConvertCharToLowerCase(thisChar)
  end
repeat
  return stringOut
end


on ConvertStringToUpperCase stringIn
  nChars = length(stringIn)
  stringOut = ""
  repeat
with charIndex = 1 to nChars
    thisChar = char charIndex of stringIn
    stringOut = stringOut & ConvertCharToUpperCase(thisChar)
  end
repeat
  return stringOut
end

This functionality is universal - it does not need to be restricted to working inside one particular object. The handlers ConvertStringToLowerCase and ConvertStringToUpperCase take a string as a parameter and return a string value. This is a perfect example of "structured coding" techniques. The handler operates only on data that is passed in - there is no reference to any outside data. These handlers can be called by any method of any number of objects or by movie level handlers. Therefore, I would create a movie script called "Utilities" and have this code live there.

 

Previous Chapter

Table of Contents

Next chapter