CS312 Lecture 5:  Modules

Administrivia

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.

Lecture 5

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.

Structures

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.

Signatures

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.

Abstract Data Types (ADTs)

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.