Steven S. Skiena
Abstract Data Types
It is important to structure programs according to abstract data types: collections of data with well-defined operations on it
Example: Stack or Queue. Data: A sequence of items Operations: Initialize, Empty?, Full?, Push, Pop, Enqueue, Dequeue
Example: Infinite Precision Integers. Data: Linked list of digits with sign bit. Operations: Print number, Read Number, Add, Subtract, Multiply, Divide, Exponent, Module, Compare.
Abstract data types add clarity by separating the definitions from the implementations.
What Do We Want From Modules?
Separate Compilation - We should be able to break the program into smaller files. Further, we shouldn't need the source for each Module to link it together, just the compiled object code files.
Communicate Desired Information Between Modules - We should be able to define a type or procedure in one module and use it in another.
Information Hiding - We should be able to define a type or procedure in one module and forbid using it in another! Thus we can clearly separate the definition of an abstract data type from its implementation!
Modula-3 supports all of these goals by separating interfaces (.i3 files) from implementations (.m3 files).
Example: The Piggy Bank
Below is an interface file to:
INTERFACE PiggyBank; (*RM*) (* Interface to a piggy bank: You can insert money with "Deposit". The only other permissible operation is smashing the piggy bank to get the ``money back'' The procedure "Smash" returns the sum of all deposited amounts and makes the piggy bank unusable. *) PROCEDURE Deposit(cash: CARDINAL); PROCEDURE Smash(): CARDINAL; END PiggyBank.
Note that this interface does not reveal where or how the total value is stored, nor how to initialize it.
These are issues to be dealt with within the implementation of the module.
Piggy Bank Implementation
MODULE PiggyBank; (*RM/CW*) (* Implementation of the PiggyBank interface *) VAR contents: INTEGER; (* state of the piggy bank *) PROCEDURE Deposit(cash: CARDINAL) = (* changes the state of the piggy bank *) BEGIN <*ASSERT contents >= 0*> (* piggy bank still okay? *) contents := contents + cash END Deposit; PROCEDURE Smash(): CARDINAL = VAR oldContents: CARDINAL := contents; (* contents before smashing *) BEGIN contents := -1; (* smash piggy bank *) RETURN oldContents END Smash; BEGIN contents := 0 (* initialization of state variables in body *) END PiggyBank.
A Client Program for the Bank
MODULE Saving EXPORTS Main; (*RM*) (* Client of the piggy bank: In a loop the user is prompted for the amount of deposit. Entering a negative amount smashes the piggy bank. *) FROM PiggyBank IMPORT Deposit, Smash; FROM SIO IMPORT GetInt, PutInt, PutText, Nl, Error; <*FATAL Error*> VAR cash: INTEGER; BEGIN (* Saving *) PutText("Amount of deposit (negative smashes the piggy bank): \n"); REPEAT cash := GetInt(); IF cash >= 0 THEN Deposit(cash) ELSE PutText("The smashed piggy bank contained $"); PutInt(Smash()); Nl() END; UNTIL cash < 0 END Saving.
Interface File Conventions
Imports describe what procedures a given module makes available.
Exports describes what we are willing to make public, ultimately including the ``MAIN'' program.
By naming files with the same .m3 and .i3 names, the ``ezbuild'' make command can start from the file with the main program, and final all other relevant files.
Ideally, the interface file should hide as much detail about the internal implementation of a module from its users as possible. This is not easy without sophisticated language features.
Hiding the Details
INTERFACE Fraction; (*RM*) (* defines the data type for rational numbers *) TYPE T = RECORD num : INTEGER; den : INTEGER; END; PROCEDURE Init (VAR fraction: T; num: INTEGER; den: INTEGER := 1); (* Initialize "fraction" to be "num/den" *) PROCEDURE Plus (x, y : T) : T; (* x + y *) PROCEDURE Minus (x, y : T) : T; (* x - y *) PROCEDURE Times (x, y : T) : T; (* x * y *) PROCEDURE Divide (x, y : T) : T; (* x / y *) PROCEDURE Numerator (x : T): INTEGER; (* returns the numerator of x *) PROCEDURE Denominator (x : T): INTEGER; (* returns the denominator of x *) END Fraction.
Note that there is a dilemma here. We must make type T public so these procedures can use it, but would like to prevent users from accessing (or even knowing about) the fields num and dem directly.
Subtypes and REFANY
Modula-3 permits one to declare subtypes of types, A <: B, which means that anything of type A is of type B, but everything of type type B is not necessarily of type A.
This proves important in implementing advanced object-oriented features like inheritance.
REFANY is a pointer type which is a supertype of any other pointer. Thus a variable of type REFANY can store a copy of any other pointer.
This enables us to define public interface files without actually revealing the guts of the fraction type implementation.
Fraction type with REFANY
INTERFACE FractionType; (*19.12.94. RM, LB*) (* defines the data type of rational numbers, compare with Example 10.10! *) TYPE T <: REFANY; (*T is a subtype of Refany; its structure is hidden*) PROCEDURE Create (numerator: INTEGER; denominator: INTEGER := 1): T; PROCEDURE Plus (x, y : T) : T; (* x + y *) PROCEDURE Minus (x, y : T) : T; (* x - y *) PROCEDURE Mult (x, y : T) : T; (* x * y *) PROCEDURE Divide (x, y : T) : T; (* x : y *) PROCEDURE Numerator (x : T): INTEGER; PROCEDURE Denominator (x : T): INTEGER; END FractionType.
Somewhere within a module we must reveal the implementation of type T. This is done with a REVEAL statement:
MODULE FractionType; (*19.12.94. RM, LB*) (* Implementation of the type FractionType. Compare with Example 10.12. In this version the structure of elements of the type is hidded in the interface. The structure is revealed here. *) REVEAL T = BRANDED REF RECORD (*opaque structure of T*) num, den: INTEGER END; (*T*) ...
The Key Idea about REFANY
With generic pointers, it becomes necessary for type checking to be done a run-time, instead of at compile-time as done to date.
This gives more flexibility, but much more room for you to hang yourself. For example:
TYPE Student = REF RECORD lastname,firstname:TEXT END; Address = REF RECORD street:TEXT; number:CARDINAL END; VAR r1 : Student; r2 := NEW(Student, firstname:="Julie", lastname:="Tall"); adr := NEW(Address, street:="Washington", number:="21"); any := REFANY; BEGIN any := r2; (* always a safe assignment *) r1 := any; (* legal because any is of type student *) adr := any; (* produces a run-time error, not compile-time *)
You should worry about the ideas behind generic implementations (why does Modula-3 do it this way?) more than the syntactic details (how does Modula-3 let you do this?). It is very easy to get overwhelmed by the detail.
When we think about the abstract data type ``Stack'' or ``Queue'', the implementation of th the data structure is pretty much the same whether we have a stack of integers or reals.
Without generic types, we are forced to declare the type of everything at compile time. Thus we need two distinct sets of functions, like PushInteger and PushReal for each operation, which is waste.
Object-Oriented programming languages provide features which enable us to create abstract data types which are more truly generic, making it cleaner and easier to reuse code.