What is an object, and why would I use one?
The primary role of objects in programming is the decomposition of solutions - that is, the goal is to break a problem and its solution into small, manageable units. The result is increased modularity, maintainability and robustness, in the program's code. A Simple ExampleFor an example, let us consider a fraction, like one half. We can represent a fraction in terms of its numerator and its denominator. Therefore, we have two distinct data members. We might also consider a few operations, like Add and Subtract. These operations are termed member functions (or member procedures, or, in general, member methods). Figure 1.2 illustrates an object that defines a fraction, in terms of data and operations. Objects and InstancesOnce you have defined an object, you can create it. Just as you cannot live in a blueprint of a house, you must "build" your object in order to use it. In this case, the building process is that of allocating memory for an instance of an object, according to the "blueprint" of the object. This process of creating an object is called instantiation (that is, to create an instance). Constructors and DestructorsThere are two special types of member method that give the object control over how it is created and destroyed: its constructor and destructor, respectively. A constructor is simply a method that is automatically called when the object is instantiated. A constructor can take parameters, just like a normal method, but cannot specify a return type. When an instance goes out of scope, its destructor is automatically called. Destructors neither accept parameters nor have a return type. TerminologyIn C++, Java, C# and other popular object-oriented languages, the term "class" is used to denote what IB calls an "object." Be careful when speaking of "objects," because the term often means different things in each language. This text will use IB's definitions, and will give code in PURE. | |||||||||||||
What is encapsulation?The term "encapsulate," in object oriented programming, refers to the process of grouping of related data and operations in named objects. In procedural programming languages, the programmer's ability to integrate data and operations was very limited. For example, to implement the Add member method of the Fraction object (described above in Figure 1.2), one could define a method such as: // Postcondition: The values of NUMERATOR and DENOMINATOR are modified // such that they reflect the result of adding their old values with the // numerator and denominator passed in parameters 3 and 4. procedure AddFractions( ref NUMERATOR integer, ref DENOMINATOR integer , val NUMERATOR_TO_ADD integer, val DENOMINATOR_TO_ADD integer ) // ... (Code) ... endprocedure AddFractionsWhile functional, this definition is cumbersome. Using a record (struct), a more succinct definition could be achieved: newtype FRACTION record declare NUMERATOR integer declare DENOMINATOR integer endrecord // Postcondition: The fields of FRACT are updated such that they reflect the // result of adding FRACT_TO_ADD to their original values. procedure AddFractions( ref FRACT is FRACTION, val FRACT_TO_ADD is FRACTION ) // ... (Code) ... endprocedure AddFractionsThis is clearer and easier to work with, but we can do better. Using an object, we can encapsulate the add operation within the fraction object. The following is a partial pseudo-code representation of the object: newtype FRACTION object declare NUMERATOR integer declare DENOMINATOR integer // Postcondition: The value of FRACT_TO_ADD is added to the instance // on which this operation is performed. procedure Add( val FRACT_TO_ADD is FRACTION ) // ... (Code) ... endprocedure Add endobject FRACTIONIn this example, all of the relevant data and operations are encapsulated by the object. This makes working with fractions more intuitive. In general, encapsulation makes code easier to work with by making it more modular. The other advantage is that encapsulation allows for data hiding, which is described in the next section. |
What is data / information hiding?Data hiding is another tool provided by most object-oriented languages. It is, in general, the process of separating the implementation of an object from its clients. This allows the client to use the object in an abstract way. A more thorough discussion of this practice is given in the following section. At its most basic level, data / information hiding means designating some members accessible to calling code and others as hidden from calling code. In PURE, the keywords accessible and hidden are appended to data members and member methods, as in the following example, updating the FRACTION class. newtype FRACTION object // Note: by convention, hidden members of an object should be // prefixed with an underscore (_). declare _NUMERATOR integer hidden declare _DENOMINATOR integer hidden // Postcondition: The value of FRACT_TO_ADD is added to the instance // on which this operation is performed. procedure Add( val FRACT_TO_ADD is FRACTION ) accessible // ... (Code here can access _NUMERATOR and _DENOMINATOR // of itself and of FRACT_TO_ADD) ... endprocedure Add endobject FRACTION If client code attempts to read or write from a hidden data member or call a hidden member method, the compiler will error. In this way, objects can define strict contracts between themselves and client code. This increase in control can result in robust code. |
By Example: How can I apply objects to a problem?Consider a vector graphics program (e.g. Microsoft PowerPoint) that will, initially, define one shape: a rectangle. Let's consider some ways we might abstract a rectangle, in Figure E.1.
Evaluating the options is difficult without a clear picture of what the rectangles will be used for. If rectangles will be read more frequently than written, E.1.a might represent the best choice, because all four points are known, in memory. However, E.1.a defines two superfluous points (B and D) that could be easily calculated from A and C, so we might opt for E.1.b or E.1.c, because they make better use of memory. While adequate planning might allow us to predict the immediate demands on the rectangle, and define it accordingly, future changes to the system could invalidate the assumptions we are making. In OOP, the impact of these sorts of changes (that is, those to the specific implementation of objects) can be minimized using a technique known as black boxing or information hiding. A 'black boxed' rectangle object does not expose information about how it is internally implemented, but instead defines a "contract" of accessible member methods that client code can rely on. If the contract is well-defined, major changes to the internal structure of the object can be made without having to change client code. For a rectangle, our contract might include methods that:
From Figure E.1, it seems that one common feature for rectangles is a point. Therefore, it is worth considering a point, before considering a rectangle. So, let's create an object that represents a point. First, we should ask exactly what functionality we need from a point - that is, develop a contract. In the case of a vector drawing program, it should define a position in two dimensions. It would also be useful to have a way of representing the point as a string, for display and debugging purposes. So, we might build a point that looks something like: newtype POINT object // Declare two type real data members to define a point in X and Y. declare _X real hidden declare _Y real hidden // Define a constructor for the class. We take two parameters // (one for X and one for Y.) By PURE conventions, CONSTRUCTORs are // declared hidden (ie. they cannot be called directly by client code). procedure CONSTRUCTOR( val X real, val Y real ) hidden // Initialize data members. _X <-- X _Y <-- Y endprocedure CONSTRUCTOR // Define methods that return and manipulate _X and _Y. function X() result real accessible return _X endfunction X function Y() result real accessible return _Y endfunction Y procedure SetX( val VALUE real ) accessible _X <-- VALUE endprocedure SetX procedure SetY( val VALUE real ) accessible _Y <-- VALUE endprocedure SetY // TO_STRING: Returns a string representation of the instance // in the format "(X, Y)". function TO_STRING( ) accessible // (assume CONVERT_TO_STRING is defined elsewhere). declare S is string S <-- "(" S <-- concat(S, CONVERT_TO_STRING(_X)) S <-- concat(S, ", ") S <-- concat(S, CONVERT_TO_STRING(_Y)) S <-- concat(S, ")") return S endfunction TO_STRING endobject POINTNote that the POINT object does not define a destructor. In most languages, you can rely on the compiler to automatically generate a destructor for such a simple object. Generally speaking, you need only define your own destructor if you are using dynamic memory allocation to allocate member variables. The same rule applies to a copy constructor (responsible for creating a duplicate of an instance of the same type), which can also be generated by the compiler, for this object. Note that the methods X and Y are often referred to as accessor methods, and that SetX and SetY are often referred to as mutator methods. With our POINT object defined, we can define an object that represents a rectangle. We will use the representation shown in E.1.b, in this example. newtype RECTANGLE object // Declare two type real data members to define a point in X and Y. declare _A is POINT hidden declare _C is POINT hidden // Constructor: Create from points A and C. procedure CONSTRUCTOR( val A is POINT, val C is POINT ) hidden // This constructor is especially easy, for us. _A <-- A _C <-- C endprocedure CONSTRUCTOR // Constructor: Create from point A, a Δx and a Δy. // Note: Technically, PURE does not allow more than 1 constructor per // object, but this limitation is ignored, here. (This is constructor // overloading.) procedure CONSTRUCTOR( val A is POINT , val DELTA_X real, val DELTA_Y real ) hidden // We know A. _A <-- A // C must be calculated. Note how we create the POINT: // TYPE( constructor parameters ). _C <-- POINT( A.X() + DELTA_X, A.Y() + DELTA_Y ) endprocedure CONSTRUCTOR // Define accessor and mutator for A. function A() result is POINT accessible return _A endfunction A procedure SetA( val VALUE is POINT ) accessible _A <-- VALUE endprocedure SetA // Define accessor and mutator for B. function B() result is POINT accessible // Calculate as B( C.x, A.y ). return POINT( C.X(), A.Y() ) endfunction B procedure SetB( val VALUE is POINT ) accessible // Affects C.x and A.y. _C.SetX(VALUE.X()) _A.SetY(VALUE.Y()) endprocedure SetB // Define accessor and mutator for C. function C() result is POINT accessible return _C endfunction C procedure SetC( val VALUE is POINT ) accessible _C <-- VALUE endprocedure SetC // Define accessor and mutator for D. function D() result is POINT accessible // Calculate as D( A.x, C.y ). return POINT( A.X(), C.Y() ) endfunction D procedure SetD( val VALUE is POINT ) accessible // Affects A.x and C.y. _A.SetX(VALUE.X()) _C.SetY(VALUE.Y()) endprocedure SetD // Define accessors and mutators for Δx and Δy. function DELTA_X() result real accessible // Calculate using A.x and C.x. return (C.X() - A.X()) endfunction DELTA_X procedure SetDELTA_X( val VALUE real ) accessible _C.SetX(_A.X() + VALUE) endprocedure SetDELTA_X function DELTA_Y() result real accessible // Calculate using A.y and C.y. return (C.Y() - A.Y()) endfunction DELTA_Y procedure SetDELTA_Y( val VALUE real ) accessible _C.SetY(_A.Y() + VALUE) endprocedure SetDELTA_Y // Define accessors for width and height. function WIDTH() result real accessible return abs(DELTA_X()) endfunction WIDTH function HEIGHT() result real accessible return abs(DELTA_Y()) endfunction HEIGHT // TRANSLATE: Moves rectangle such that A is at NEW_A. procedure TRANSLATE( val NEW_A is POINT ) accessible // Preserve Δx and Δy in _C. _C.SetX( NEW_A.X() + ( C.X() - A.X() ) ) _C.SetY( NEW_A.Y() + ( C.Y() - A.Y() ) ) // Then just set _A. _A <-- NEW_A endprocedure TRANSLATE // TO_STRING: returns a string representation of the instance // in the format "(A(X, Y), B(X, Y), C(X, Y), D(X, Y))". function TO_STRING( ) accessible declare S is string S <-- "(A" S <-- concat(S, A().TO_STRING()) S <-- concat(S, ", B") S <-- concat(S, B().TO_STRING()) S <-- concat(S, ", C") S <-- concat(S, C().TO_STRING()) S <-- concat(S, ", D") S <-- concat(S, D().TO_STRING()) S <-- concat(S, ")") return S endfunction TO_STRING endobject RECTANGLENote how the functions A(), B(), C() and D() all work the same way for the caller (each returns the associated point), even while the returns for B() and D() are actually calculated "on the fly." This makes the TO_STRING method far easier to write, because it can leverage the work done by B() and D(). Also, it is worth noting that WIDTH() and HEIGHT() reference the DELTA_X() and DELTA_Y() methods. When a method call to a member method is made from within another member method, it assumed that it is being invoked with the same instance, as this example demonstrates. (This is the same idea as that of accessing instance data members from member methods.) | |||||||||
|
Polymorphism is an inventive way of saying that two different versions of a method with the same name and parameter list may exist as part of two different objects. We have already used polymorphism, in the above example: the TO_STRING methods of POINT and RECTANGLE are polymorphic. Clearly, calling TO_STRING on an instance of POINT will execute different code than calling TO_STRING on an instance of RECTANGLE. To appreciate one advantage of polymorphism, consider the result of its absence. We would have to define a method POINT.POINT_TO_STRING, in order to avoid a conflict with RECTANGLE, which is redundant - we just need POINT.TO_STRING. So, polymorphism makes function names less cumbersome, and makes objects easier to work with. The other advantage of polymorphism comes into play in the overridden methods of inherited classes (see the next section). |
|
As if OOP wasn't already cool enough, almost all OOP languages allow programmers to use inheritance to organize objects and facilitate code reuse. If we return to our initial analogy between an object and a blueprint, one could think of the child object as a transparent sheet, laid over the parent blueprint. The programmer then writes on this sheet, so s/he can write over some of the old members and add new ones. Just as it would be easy to overlay another transparent sheet, a child object can be the parent of another object, and so on. Also, because the sheets are transparent, changes to the parent object(s) affect child objects. A full blueprint for each child is obtained by "taking a picture" of the parent object through the transparencies. For example, if we were to create an object SQUARE, we could say that because a square is a rectangle, we should take the characteristics of a rectangle and impose rules that make it into a square. So, in OOP terms, we would create the SQUARE object as a child of the RECTANGLE object. newtype SQUARE object inherits RECTANGLE // (... make necessary changes to implementation, here ...) endobject SQUARE The process of changing member methods between the parent and the child is called overriding. As mentioned above, this overriding is a form of polymorphism because the child object and the parent object (in most cases - there are exceptions, but they are beyond the scope of this document) considered as different objects. To override a method in PURE, you just redefine it in the child class. Note: So far as IB is concerned, a child object may specify a maximum of one parent objects, though multiple inheritance is supported by languages such as C++. Also, no child object will ever be a parent to another object, in IB's domain. |
|
Consider an algorithm that arranges an array of 4 RECTANGLEs such that they form a chain, with point A's connecting with point C's, for some reason. procedure ARRANGE_RECTANGLES( ref RECT_ARRAY is RECTANGLE array[0..3] ) declare I integer // Start at 2nd item. for I <-- 1 upto 3 do // Translate rectangle to C of previous rectangle. RECT_ARRAY[I].TRANSLATE( RECT_ARRAY[I - 1].C() ) endfor endprocedure ARRANGE_RECTANGLES Consider the tracing of the call: ARRANGE_RECTANGLES( {
(A(1,1), C(2,2))
(A(3,3), C(5,5))
(A(6,6), C(9,9))
(A(10,10), C(12,12))
} )
| |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
The advantages of object-oriented programming have been detailed in the above sections. Usually, the goal of OOP is to write more modular code while maximizing code re-use, thereby reducing the coding cost of the system. It is a also a valuable tool for problem decomposition. This is made possible using the core OOP techniques, as follows:
The disadvantages of OOP are as follows:
Overall, OOP is a very valuable and popular scheme for solving programming problems. |
Revised: April 30, 2003 by John Lees-Miller.