PS#2 is out. Do it by yourself.
There were a few minor glitches with PS#1 (i.e., wrong version of solution set posted). We apologize for this.
We've seen that SML is a powerful and expressive language. But we've only talked about building very small programs in SML. When we try to use SML to build larger programs, and particularly when the software is being developed by a team of programmers, more language features become handy. One such feature is the module, which is a collection of datatypes, values, and functions that are grouped together as one syntactic unit. A well designed module is reusable in many different programs. Modules also provide a good way to structure group development of software, because they make a convenient way to cut up the program and assign responsibilities to different programmers. Modules are even useful for sufficiently large single-person software projects, because they reduce the amount of information that the programmer needs to remember about the parts of the program that are not currently under development. We've already been using modules when we write qualified identifiers of the form ModuleName.id to access Basis Library functionality. Now we'll see how we can write our own modules.
A module is implemented by a structure
declaration. Let's
suppose that we want to develop a module that can be used to manipulate values
that represent polynomials; that is, expressions of the form a+bx+cx2+dx3+...+
zxn. We'd like to be able at least to create
polynomials, and to add, subtract, and multiply them. The name of the variable
is not important, so all we need to keep track of is the finite sequence of
coefficients a, b,
c, etc. For simplicity we'll assume the
coefficients are integers. This suggests the following simple implementation:
type poly = int list
The first item in the list will be the coefficient a, the second one b, and so on. The number of items in the list will tell us the degree of the polynomial. In addition, we will try to make sure that the list never ends in a trailing sequence of zeros, because that would be inefficient and also might mislead us about the degree of the polynomial. The empty list will represent the polynomial 0. There are many other ways to represent a polynomial, but these are reasonable and allow us to implement the usual operations on polynomials. For example, a function to return the degree of a polynomial:
fun degree(p: poly):int = length(p) - 1
Let's try writing polynomial addition:
fun plus(p: poly, q: poly): poly = case (p, q) of (nil, q) => q | (p, nil) => p | (a::p2, b::q2) => (a+b)::plus(p2,q2)
Actually this doesn't quite work. Why? Because the result might have trailing
zeroes if the two polynomials cancel each other out, and this would mess up the degree
function:
- plus([1,2], [1,~2]); val it = [2,0]: poly - degree(it) val it = 1: int
We can avoid this by checking as follows:
fun plus(p: poly, q: poly): poly = case (p,q) of (nil,q) => q | (p, nil) => p | (a::p2, b::q2) => case (a+b)::plus(p2,q2) of [0] => []= | r => r
- plus([1,2], [1,~2]); val it = [2]: poly
Suppose we want to package up these functions in a reusable module. We start as follows:
structure Polynomial = struct type poly = int list
val zero: poly = [] fun singleton(coeff: int, degree: int):poly = case (coeff, degree) of (0, _) => zero | (c, 0) => [c] | (c, d) => 0::singleton(c, d-1) fun degree(p:poly):int = length(p)-1 fun plus(p:poly, q:poly):poly = case (p,q) of (nil,q) => q | (p, nil) => p | (a::p2, b::q2) => case (a+b)::plus(p2,q2) of [0] => [] | r => r
fun evaluate(p:poly, x:int): int = case p of nil => 0 | a::q => a + x*evaluate(q, x) ... end
We can provide this module to other programmers and they can then create
polynomials using Polynomial.zero
and Polynomial.singleton
and manipulate them with Polynomial.degree
and Polynomial.plus
.
In fact, they don't even have to know that polynomials are really lists of
integers. While the module implementer cares about this, the clients don't have
to. This is very important, because it means that the implementer has the
freedom to change what the poly type is bound to and correspondingly change the
implementation of degree
, plus
, zero
,
etc. to match. For example, the implementer might decide to use the SML vector
type instead of list
, resulting in a more efficient implementation
of polynomials:
structure Polynomial = struct type poly = int vector
val zero:poly = Vector.fromList([]) fun singleton(coeff: int, degree: int):poly = case (coeff, degree) of (0, _) => zero | (c, d) => Vector.tabulate(d+1, fn(n:int) => if n=d then c else 0) fun degree(p:poly):int = (Vector.length p) - 1 ... end
During software development and maintenance, implementers will want to make changes like this. A third different way to implement polynomials is shown in the Recitation 4 notes. If clients of the Polynomial module only use it through the operations that it defines, then the module and the rest of the program will be loosely coupled : changes to one do not affect the correctness of the other. This will give implementers and clients the freedom to work on their code mostly independently. The SML Compilation Manager, which you are using to compile PS2, can be used to assemble a program out of a collection of modules.
To successfully develop large programs, we need more than the ability to
group related operations together, as we've done. We need to be able to use the
compiler to enforce loose coupling. This prevents bad things from happening. For
example, a client programmer using the Polynomial
structure can see
that polynomials are really integer lists and write code like this:
let z: Polynomial.poly = [2,3,4] in ... end
What's wrong with this? Two things: this code depends on the actual type used
to represent polynomials. An implementer cannot change between int list
and int vector
without breaking this code, and therefore we've lost
loose coupling. Second, there is nothing that prevents the client from
constructing lists that violate our no-trailing-zeroes condition. The operations
defined on polynomials will not work properly if polynomials are constructed out
of such lists. In general, a misbehaving client could cause the program to give
wrong answers or even crash with an exception in a module that another
programmer wrote! This is bad because it makes it hard to figure out whose job
it is to fix the problem.
We could write loosely coupled programs even without modules. However, there is one more feature that modules provide that is crucial: they let us enforce loose coupling through the use of signatures. A signature defines the part of a module that is visible (and usable) outside the module. For example, here is a signature for the polynomial module. By convention the name is fully capitalized:
signature POLYNOMIAL = sig type poly val zero:poly val singleton : int*int -> poly val degree: poly -> int val evaluate: poly*int -> int val plus: poly*poly -> poly val minus: poly*poly -> poly val times: poly*poly -> poly end
Notice that by looking at the signature, we can't tell what poly
is. The signature prevents clients from depending on the module in inappropriate
ways, by hiding all the things they're not supposed to know about. The signature
also acts like a defensive perimeter that prevents clients from constructing
values of a declared types except through the operations provided. Thus, the
signature is a contract between the implementer of the module and the
clients of the module. As long as both sides abide by the contract -- the
implementer by providing all of the operations that the signature defines, and
the client, by only using the module in accordance with the signature -- the two
sides can work without stepping on one another's toes. The client doesn't need
to see or think about the code that the implementer is writing, and the
implementer doesn't have to think about the details of how clients are using the
code.
We can turn this approach into a methodology for programming. When we write a structure, we always write a corresponding signature that defines what parts of the structure are exported to the outside. The following syntax is used to indicate that a structure exports a given signature:
structure Polynomial :> POLYNOMIAL = struct type poly = int list ... end
This prevents the clients of the Polynomial module from using their knowledge of what poly is. In fact, the SML interpreter will not even print out values of a type like poly. Without the signature, we can see what poly's really are:
- Polynomial.zero; val it = []: Polynomial.poly
Once the module is protected by its signature, values of the type poly are printed only as a dash:
- Polynomial.zero; val it = - : Polynomial.poly
The signature to a module serves as an interface that that module that specifies exactly what outsiders can see about it. Note that Java has things called interfaces that offer similar functionality; however, the idea of an interface is more general than the Java construct would suggest. In fact, Java has several notions of interface, including "interfaces", the public methods and fields of classes, and the publicly visible components of packages.
Interfaces are so important that the way we develop modules is the opposite of what was presented here. In this lecture, we wrote the Polynomial structure and then added a signature after the fact. The right way develop modules is to figure out the signature (interface) first, then write the structure (module implementation) to match the interface. This approach has two big advantages. First, a lot of design problems become evident when the signature is being written. It's much lower cost in terms of development time to get the design right before trying to implement the module. Another advantage is that code can be written using the interface even before the implementation is complete; the module client and module implementer can work in parallel, speeding up development. And because the interface is known by both parties, it is more likely that when they finish their work, the complete program will work as intended.
Abstraction is the removal of unnecessary details. It is also known as information hiding or encapsulation. We have already seen one form of abstraction in this course already: functional abstraction. A function hides its implementation, and the users of the function only need its type in order to use it. Unless a client happens to have been given the code to the function, the client cannot tell what algorithm is used to compute the result of the function.
Structures and signatures provide a new kind of abstraction: type
abstraction. The signature for Polynomial does not state what the type poly
is; that type is hidden. Thus, poly
is known as an abstract type.
The operations on polynomials have types that mention this abstract type and
allow values of this type to be manipulated without knowledge of the actual type
that poly
is bound to.
A module like Polynomial bears a certain resemblance to a datatype
declaration. Recall that a datatype
declaration introduces two
kinds of things: first, a new named type, and second, a collection of
constructors that can be used to build values of the type and, through the case
statement, to take them apart. For example, the declaration
datatype nat = Zero | Succ of nat
introduces a new type named nat
and constructors Zero
and Succ
, which we can think of as functions Zero :
unit->nat
and Succ: nat->nat
. The primitive operations
supported by a nat
are defined by these constructors.
datatype = type + constructors
Now compare this declaration to our module for implementing polynomials. That
module also defines a new type (poly
) and a set of operations for
manipulating that type. Viewed from the outside, some of these operations (zero
,
singleton
, plus
) construct new values; others (degree)
are observers. It is very common to build modules that look like
Polynomial: they define an abstract type and a set of operations for
manipulating values of that type. These modules are said to define an abstract
data type (ADT).
abstract data type = abstract type + operations
There are two views of an abstract data type: the abstract view, which is defined by the module signature, and the concrete view, which is defined by the module structure. A good abstract data type has the property that it can be used without knowing the concrete type that represents the abstract values, or the actual algorithm being used to implement the operations. We'll talk more in the next lecture about how to achieve this.