CSE341 Notes for Monday, 4/22/24

We continued our discussion of the Rational module. We found that there was a further problem that sometimes when we reduce_rational we end up with a denominator of 1, which should really be expressed as a whole number. So we added that as an extra case in reduce_rational:

    let rec reduce_rational(r) =
        match r with
        | Whole(i) -> Whole(i)
        | Fraction(a, b) ->
            let d = gcd(a, b)
            in if b = d then Whole(a/d)
	       else Fraction(a/d, b/d)
We can end up with negatives when we call the Fraction constructor, as in:

        reduce_rational(Fraction(-12, -32))
        reduce_rational(Fraction(-12, 32))
        reduce_rational(Fraction(12, -32))
We decided that if both numbers in the fraction are negative, then reduce_rational should convert them to positive numbers and if only one is negative, then it should be the first one. One extra clause for b being negative takes care of both cases:

    let rec reduce_rational(r) =
        match r with
        | Whole(i) -> Whole(i)
        | Fraction(a, b) ->
            let d = gcd(a, b)
            in if b < 0 then reduce_rational(Fraction(-a, -b))
               else if b = d then Whole(a/d)
	       else Fraction(a/d, b/d)
This new version produced the desired results:

        # reduce_rational(Fraction(-12, -32));;
        - : Rational.rational = Fraction (3, 8)
        # reduce_rational(Fraction(-12, 32));;
        - : Rational.rational = Fraction (-3, 8)
        # reduce_rational(Fraction(12, -32));;
        - : Rational.rational = Fraction (-3, 8)
I briefly mentioned that we have a potential problem that a client might construct a fraction that is not in reduced form. So we added the following function along with a comment that clients should use this function rather than the Fraction constructor to construct rational numbers:

        (* client: please always construct fractions with this function *)
        let make_fraction(a, b) = reduce_rational(Fraction(a, b))
Then 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 Not_a_rational

        let make_fraction(a, b) = 
            if b = 0 then raise Not_a_rational
            else reduce_rational(Fraction(a, b))
Putting this all together, we ended up with this second version of the Rational structure:

(* Second version of Rational that includes gcd and reduce_rational to reduce_rational fractions
   to their lowest form and a makeFraction function that allows us to
   guarantee our invariants (no 0 denominator, fractions always reduced). *)

module Rational =
struct
    type rational = Whole of int | Fraction of int * int
    exception Not_a_rational

    let rec gcd(x, y) =
        if x < 0 || y < 0 then gcd(abs(x), abs(y))
        else if y = 0 then x
        else gcd(y, x mod y)

    let rec reduce_rational(r) =
        match r with
        | Whole(i) -> Whole(i)
        | Fraction(a, b) ->
            let d = gcd(a, b)
            in if b < 0 then reduce_rational(Fraction(-a, -b))
               else if b = d then Whole(a/d)
	       else Fraction(a/d, b/d)

    (* client: please always construct fractions with this function *)
    let make_fraction(a, b) = 
        if b = 0 then raise Not_a_rational
        else reduce_rational(Fraction(a, b))

    let add(r1, r2) =
        match (r1, r2) with
        | (Whole i, Whole j)               -> Whole(i + j)
        | (Whole i, Fraction(j, k))        -> Fraction(j + k * i, k)
        | (Fraction(j, k), Whole i)        -> Fraction(j + k * i, k)
        | (Fraction(a, b), Fraction(c, d)) ->
            reduce_rational(Fraction(a * d + c * b, b * d))

   let to_string(r) =
       match r with
       | Whole i        -> string_of_int(i)
       | Fraction(a, b) -> string_of_int(a) ^ "/" ^ string_of_int(b)
end
For the third version, I incorporated 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:

module type = <name> = sig <definitions> end You are more limited in what you can include in a signature. You can generally include just descriptions of the form that various elements take. For example, you can't define any concrete functions. Instead, you include descriptions of the function type using the keyword val:

        module type RATIONAL =
        sig
           type rational = Whole of int | Fraction of int * int
           exception Not_a_rational
           val make_fraction : int * int -> rational
           val add : rational * rational -> rational
           val to_string : rational -> string
        end
There is a convention in OCaml to use all uppercase letters for a signature name and capitalized words for module names. That allows us to reuse the same word, as in this example of a signature called RATIONAL implemented by a module 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 a colon and the name of the signature when we define a module, as in:

        module Rational : RATIONAL = struct ... end
We found several interesting things when we loaded this version of the file into OCaml. The functions gcd and reduce_rational 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_rational 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.

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 to_string call reduce_rational? Should all of our functions call reduce_rational? A better approach is to try to guarantee an invariant that any rational number is in a proper form. Our make_fraction function is supposed to take care of this, but we don't want to rely on the "please client" comment that we included in the file.

Obviously we'd like to have a stronger guarantee. OCaml gives us a way to achieve this. In the signature, we currently list the details of the type:

        module type RATIONAL =
        sig
           type rational = Whole of int | Fraction of int * int
           exception Not_a_rational
           val make_fraction : int * int -> rational
           val add : rational * rational -> rational
           val to_string : rational -> string
        end
We can instead just mention that a rational type will be defined without specifying the details of how it is defined:

        module type RATIONAL =
        sig
           type rational
           exception Not_a_rational
           val make_fraction : int * int -> rational
           val add : rational * rational -> rational
           val to_string : rational -> string
        end
This 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:

        let x = Rational.make_fraction(23, 1);;
        let y = Rational.make_fraction(27, 8);;
        let 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:

        module type RATIONAL =
        sig
           type rational
           exception Not_a_rational
           val make_fraction : int * int -> rational
           val whole : int -> rational
           val add : rational * rational -> rational
           val to_string : rational -> string
        end
We don't have to expose the details of the rational type to let OCaml and clients know that there is something called whole that allows them to construct a rational number from a single int. Because of the naming rules of OCaml, we had to use a lowercase letter for whole because with a capital letter Whole is assumed to be a constructor. This allowed us to again write client code like the following:

        let x = Rational.whole(23);;
        let y = Rational.make_fraction(27, 8);;
        let z = Rational.add(x, y);;
With these changes, we have guaranteed that clients must use either whole or make_fraction 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_rational 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_rational in functions like to_string because we know that it's not possible for a client to construct a rational number that violates our invariant.

Here is the complete fourth version of the Rational structure:

(* Fourth version of Rational that further restricts the signature so that
   the Fraction constructor is not exposed--finally we can guarantee
   invariants because the client must use make_fraction *)

module type RATIONAL =
sig
   type rational
   exception Not_a_rational
   val make_fraction : int * int -> rational
   val whole : int -> rational
   val add : rational * rational -> rational
   val to_string : rational -> string
end

module Rational : RATIONAL =
struct
    type rational = Whole of int | Fraction of int * int
    exception Not_a_rational

    let whole(i) = Whole(i)

    let rec gcd(x, y) =
        if x < 0 || y < 0 then gcd(abs(x), abs(y))
        else if y = 0 then x
        else gcd(y, x mod y)

    let rec reduce_rational(r) =
        match r with
        | Whole(i) -> Whole(i)
        | Fraction(a, b) ->
            let d = gcd(a, b)
            in if b < 0 then reduce_rational(Fraction(-a, -b))
               else if b = d then Whole(a/d)
	       else Fraction(a/d, b/d)

    let make_fraction(a, b) = 
        if b = 0 then raise Not_a_rational
        else reduce_rational(Fraction(a, b))

    let add(r1, r2) =
        match (r1, r2) with
        | (Whole i, Whole j)               -> Whole(i + j)
        | (Whole i, Fraction(j, k))        -> Fraction(j + k * i, k)
        | (Fraction(j, k), Whole i)        -> Fraction(j + k * i, k)
        | (Fraction(a, b), Fraction(c, d)) ->
            reduce_rational(Fraction(a * d + c * b, b * d))

   let to_string(r) =
       match r with
       | Whole i        -> string_of_int(i)
       | Fraction(a, b) -> string_of_int(a) ^ "/" ^ string_of_int(b)
end
I 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:

(* Fifth version of Rational that reimplements the type using an int * int.
   This change would be invisible (opaque) to a client of the structure. *)

module type RATIONAL =
sig
   type rational
   exception Not_a_rational
   val make_fraction : int * int -> rational
   val whole : int -> rational
   val add : rational * rational -> rational
   val to_string : rational -> string
end

module Rational : RATIONAL =
struct
    type rational = int * int
    exception Not_a_rational

    let rec gcd(x, y) =
        if x < 0 || y < 0 then gcd(abs(x), abs(y))
        else if y = 0 then x
        else gcd(y, x mod y)

    let rec reduce_rational(a, b) =
        let d = gcd(a, b)
        in if b < 0 then reduce_rational(-a, -b)
	   else (a/d, b/d)

    let make_fraction(a, b) = 
        if b = 0 then raise Not_a_rational
        else reduce_rational(a, b)

    let whole(a) = (a, 1)

    let add((a, b), (c, d)) = reduce_rational(a * d + c * b, b * d)

   let to_string(a, b) =
        if b = 1 then string_of_int(a)
        else string_of_int(a) ^ "/" ^ string_of_int(b)
end
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.

As a final example, I included a version that uses this new representation of a rational as a tuple and that uses a lazy approach rather than an eager approach to reducing a pair to its lowest terms. The previous versions call reduce_rational both in make_fraction and in add. Instead, we can wait until to_string is called to call reduce_rational because that's the first point in time when the client would notice that we hadn't reduced:

(* Sixth version of Rational that does a "lazy" reduce_rational by only reducing
   in to_string *)

module type RATIONAL =
sig
   type rational
   exception Not_a_rational
   val make_fraction : int * int -> rational
   val whole : int -> rational
   val add : rational * rational -> rational
   val to_string : rational -> string
end

module Rational : RATIONAL =
struct
    type rational = int * int
    exception Not_a_rational

    let rec gcd(x, y) =
        if x < 0 || y < 0 then gcd(abs(x), abs(y))
        else if y = 0 then x
        else gcd(y, x mod y)

    let rec reduce_rational(a, b) =
        let d = gcd(a, b)
        in if b < 0 then reduce_rational(-a, -b)
	   else (a/d, b/d)

    let make_fraction(a, b) = 
        if b = 0 then raise Not_a_rational
        else (a, b)

    let whole(a) = (a, 1)

    let add((a, b), (c, d)) = (a * d + c * b, b * d)

    let to_string(a, b) =
        let (a2, b2) = reduce_rational(a, b)
        in if b2 = 1 then string_of_int(a2)
        else string_of_int(a2) ^ "/" ^ string_of_int(b2)
end
The key point is not whether eager versus lazy computation is better. The key point is that the client can't tell the difference, which means that the implementor has the flexibility to choose either approach.


Stuart Reges
Last modified: Mon Apr 22 14:02:06 PDT 2024