gcd(~12, ~32)We got the answer from ML that this is ~4. It seems more appropriate to return a nonnegative number for gcd, so we added an extra case in the definition of gcd:
fun gcd(x, y) = if x < 0 orelse y < 0 then gcd(abs(x), abs(y)) else if y = 0 then x else gcd(y, x mod y)We also talked about what reduce should do when given negative values, as in:
reduce(Fraction(~12, ~32)); reduce(Fraction(~12, 32)); reduce(Fraction(12, ~32));We decided that if both numbers in the fraction are negative, then reduce should convert them to positive numbers and if only one is negative, then it should be the first one. So we modified reduce to include these cases:
fun reduce(Whole(i)) = Whole(i) | reduce(Fraction(a, b)) = let val d = gcd(a, b) in if b < 0 then reduce(Fraction(~a, ~b)) else if b = d then Whole(a div d) else Fraction(a div d, b div d) endThis new version produced the desired results:
- reduce(Fraction(~12, ~32)); val it = Fraction (3,8) : rational - reduce(Fraction(~12, 32)); val it = Fraction (~3,8) : rational - reduce(Fraction(12, ~32)); val it = Fraction (~3,8) : rationalThen we looked at the issue of having a value of 0 as a denominator. Division by zero is undefined, so we decided to introduce an exception and to include this in our makeFraction function:
exception NotARational; fun makeFraction(a, b) = if b = 0 then raise NotARational else reduce(Fraction(a, b));Then I discussed the idea of a signature. A signature is a language construct that is somewhat similar to an interface in Java. It allows you to describe language elements abstractly, without revealing the details of the implementation. And like an interface, it indicates exactly what a client has access to. The general form of a signature is as follows:
signature RATIONAL = sig datatype rational = Whole of int | Fraction of int * int exception NotARational val add : rational * rational -> rational val toString: rational -> string val makeFraction: int * int -> rational endThere is a convention in ML to use all uppercase letters for a signature name and capitalized words for structure names. That allows us to reuse the same word, as in this example of a signature called RATIONAL implemented by a structure called Rational. But this is just a common convention. There is no requirement that these names be related to each other.
Given such a signature, you can include a notation in the header of a structure to indicate that you want to restrict access to just those things listed in the signature. We do so by using the characters ":>" and including the name of the signature when we define a structure, as in:
structure Rational :> RATIONAL = struct ... endWe found several interesting things when we loaded this version of the file into ML. The functions gcd and reduce were no longer visible. In Java we would have declared them to be private. Here they are implicitly private because they are not mentioned in the signature. Only those things mentioned in the signature are visible to clients. We found this was true even if we opened the structure. We simply couldn't see the gcd and reduce functions. This is a very useful technique to hide the internal details of an implementation and to avoid cluttering up the namespace.
The notation "Rational :> RATIONAL" is similar to Java's notion of having a class that implements an interface. Each element mentioned in the signature has to be included in the structure. For example, if the signature indicates that a function called add should exist, then the structure must include such a function. In the Ullman book he shows the less common version of this with a simple colon: "Rational : RATIONAL". He later describes the ":>" as a variation. It is more common to use the ":>" restriction, which is known as an opaque implementation. The distinction is not terribly important, so I said we wouldn't spend time talking about it.
Putting this all together, we ended up with the following overall structure:
signature RATIONAL = sig datatype rational = Whole of int | Fraction of int * int exception NotARational val add: rational * rational -> rational val toString: rational -> string val makeFraction: int * int -> rational end structure Rational :> RATIONAL = struct datatype rational = Whole of int | Fraction of int * int; exception NotARational; fun gcd(x, y) = if x < 0 orelse y < 0 then gcd(abs(x), abs(y)) else if y = 0 then x else gcd(y, x mod y); fun reduce(Whole(i)) = Whole(i) | reduce(Fraction(a, b)) = let val d = gcd(a, b) in if b < 0 then reduce(Fraction(~a, ~b)) else if b = d then Whole(a div d) else Fraction(a div d, b div d) end; fun add(Whole(i), Whole(j)) = Whole(i + j) | add(Whole(i), Fraction(c, d)) = Fraction(i * d + c, d) | add(Fraction(c, d), Whole(i)) = Fraction(i * d + c, d) | add(Fraction(a, b), Fraction(c, d)) = reduce(Fraction(a * d + c * b, b * d)) (* dear client: use this function instead of Fraction *) fun makeFraction(a, b) = if b = 0 then raise NotARational else reduce(Fraction(a, b)); end;Then I turned to the question of making sure that we have good rational numbers. We know that our add function will return an answer in lowest terms, but a client might construct a rational number like 12/32. So should toString call reduce? Should all of our functions call reduce? A better approach is to try to guarantee an invariant that any rational number is in a proper form. Our makeFraction function is supposed to take care of this, but we don't want to rely on the "dear client" comment that we included above.
Obviously we'd like to have a stronger guarantee. ML gives us a way to achieve this. In the signature, we currently list the details of the type:
signature RATIONAL = sig datatype rational = Whole of int | Fraction of int * int exception NotARational val makeFraction: int * int -> rational val add : rational * rational -> rational val toString : rational -> string endWe can instead just mention that a rational type will be defined without specifying the details of how it is defined:
signature RATIONAL = sig type rational exception NotARational val makeFraction: int * int -> rational val add : rational * rational -> rational val toString : rational -> string endThis is known as an abstract type. When we use this signature, a client cannot see the Fraction constructor. Unfortunately, a client also can't see the Whole constructor, which would require a client to say things like:
val x = Rational.makeFraction(23, 1); val y = Rational.makeFraction(27, 8); val z = Rational.add(x, y);This is fairly easy to fix. We can simply add a signature for the Whole constructor in the RATIONAL signature:
signature RATIONAL = sig type rational exception NotARational val makeFraction : int * int -> rational val Whole : int -> rational val add : rational * rational -> rational val toString : rational -> string endWe don't have to expose the details of the rational type to let ML and clients know that there is something called Whole that allows them to construct a rational number from a single int. This allowed us to again write client code like the following:
val x = Rational.Whole(23); val y = Rational.makeFraction(27, 8); val z = Rational.add(x, y);We these changes, we have guaranteed that clients must use either Whole or makeFraction to construct a rational number. That means that we have the invariant we were looking for:
(* invariant: for any Fraction(a, b), b > 0 and gcd(a, b) = 1 *)We still need to call reduce in the add function because the arithmetic involved in add can lead to a fraction that needs to be reduced, but we don't have to call reduce in functions like toString because we know that it's not possible for a client to construct a rational number that violates our invariant.
Here is the final version of the structure:
structure Rational :> RATIONAL = struct datatype rational = Whole of int | Fraction of int * int; exception NotARational; fun gcd(x, y) = if x < 0 orelse y < 0 then gcd(abs(x), abs(y)) else if y = 0 then x else gcd(y, x mod y); fun reduce(Whole(i)) = Whole(i) | reduce(Fraction(a, b)) = let val d = gcd(a, b) in if b < 0 then reduce(Fraction(~a, ~b)) else if b = d then Whole(a div d) else Fraction(a div d, b div d) end; fun makeFraction(_, 0) = raise NotARational | makeFraction(a, b) = reduce(Fraction(a, b)) fun add(Whole(i), Whole(j)) = Whole(i + j) | add(Whole(i), Fraction(c, d)) = Fraction(i * d + c, d) | add(Fraction(c, d), Whole(i)) = Fraction(i * d + c, d) | add(Fraction(a, b), Fraction(c, d)) = reduce(Fraction(a * d + c * b, b * d)) fun toString(Whole(i)) = Int.toString(i) | toString(Fraction(a, b)) = Int.toString(a) ^ "/" ^ Int.toString(b); endI mentioned that using a signature with an abstract type, you can use a completely different internal implementation and the client would never even know it. For example, here is an alternative implementation of the signature that implements rationals as a tuple of two ints:
structure Rational2 :> RATIONAL = struct type rational = int * int; exception NotARational; fun gcd(x, y) = if x < 0 orelse y < 0 then gcd(abs(x), abs(y)) else if y = 0 then x else gcd(y, x mod y); fun reduce(a, b) = let val d = gcd(a, b) in if b < 0 then reduce(~a, ~b) else (a div d, b div d) end; fun makeFraction(a, 0) = raise NotARational | makeFraction(a, b) = reduce(a, b); fun Whole(a) = (a, 1); fun add((a, b), (c, d)) = reduce(a * d + c * b, b * d); fun toString(a, b) = if b = 1 then Int.toString(a) else Int.toString(a) ^ "/" ^ Int.toString(b) end;Here we use a type definition rather than a datatype definition because we are introducing a type synonym rather than a new type. It also means that we have to define Whole as a function rather than a constructor. This new structure provides the same functionality to a client as the original and the client would have no way of telling them apart because the signature uses an abstract type. This is a powerful and useful mechanism.
We then spent time discussing the general concept of modules:
What is a module? collection of functions, types, variables separate name space information hiding (encapsulation) mechanism Why have modules? control the name space (e.g., many toString and compare functions) information hiding: limit what we expose to a client security secrets (intellectual property rights) limited contract ability to reimplement and to have multiple implementations guarantee invariants more flexibility in implementation (e.g., eager vs lazy computation)