CS 3500 Fall 2013 Lecture 4: Understanding algebraic specification: the design recipe for implementation ------------------------------------------------- Here is a signature for an ADT and its algebraic specification. The signature lists the methods that the client can use to create and manipulate the ADT. The algebraic specification describes the behavior these methods must implement and the client can 'count on'. This describes the 'abstraction barrier' between the programmer and the client: for programmer it is a contract to fulfill, for the client it is the product to use with the guarantees of how it will behave. If the programmer does not implement the specification as given he is to blame for any failures of the program that are caused by his shortcomings. If the client attempts to use the program in the ways that was not specified and the program fails, she is to blame for the errors. We will analyze every step of the design recipe on this example: FStackInt signature (ADT) empty: --> FStackInt push: FStackInt x int --> FStackInt isEmpty: FStackInt --> FStackInt top: FStackInt --> int pop: FStackInt --> FStackInt size: FStackInt --> int StackInt algebraic specification: isEmpty (empty ()) = true isEmpty (push (s,n)) = false top (push (s, n)) = n pop (push (s, n)) = s size (empty ()) = 0 size (push (s, n)) = 1 + size (s) Given an algebraic specification for the ADT T: ----------------------------------------------- Step 1: Identify the basic creators. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Hints: - Except for the basic creators, each operation is specified by one or more equations - The basic creators are used as arguments in the left side of the equations What are the basic creators? empty() and push (s, n) are the basic creators Step 2: Define an abstract class named T. ----------------------------------------- /** * FStackInt represents a stack of integers */ public abstract class FStackInt { } Step 3: For each basic creator c of the ADT, define a concrete subclass of T. ----------------------------------------------------------------------------- /** * Empty represents an empty stack of integers */ public class Empty extends FStackInt{ } /** * Push represents an non-empty stack of integers */ public class Push extends FStackInt{ } Step 4: For each concrete subclass, declare instance variables -------------------------------------------------------------- with the same types and names as the arguments that are passed to the corresponding basic creator c. /** * Empty represents an empty stack of integers */ public class Empty extends FStackInt{ } /** * Push represents an non-empty stack of integers */ public class Push extends FStackInt{ // other elements on this stack */ FStackInt s; /** the topmost element on this stack */ int n; } Step 5: For each concrete subclass, define a Java constructor ------------------------------------------------------------- that takes the same arguments as the basic creator c and stores those arguments into the instance variables. /** * Empty represents an empty stack of integers */ public class Empty extends FStackInt{ /** * Basic constructor */ public Empty() { } } /** * Push represents an non-empty stack of integers */ public class Push extends FStackInt{ // other elements on this stack */ FStackInt s; /** the topmost element on this stack */ int n; /** * Constructor for Push * @param s the stack onto which we push * @param n the number we push onto the top */ public Push(FStackInt s, int n) { this.s = s; this.n = n; } } Step 6: For each operation f of the ADT that is not a basic creator ------------------------------------------------------------------- declare an abstract method f in the abstract class T. The arguments are the same as given in the specification, except for the first one (which is always of the type T) that becomes 'this' (the receiver for calls to the abstract method f). (read more what to do if there are two arguments of the type T) isEmpty (empty ()) = true abstract boolean isEmptyMethod() isEmpty (push (s,n)) = false top (push (s, n)) = n abstract int topMethod() pop (push (s, n)) = s abstract FStackInt popMethod() size (empty ()) = 0 abstract int sizeMethod() size (push (s, n)) = 1 + size (s) Step 7: For each operation of the ADT, define a static method ------------------------------------------------------------- within the abstract class as follows; -- if the operation is a basic creator c, the static method c should create and return a new instance of the subclass that corresponds to c: /** * represents a stack of integers */ abstract class FStackInt{ /** * Produce an instance of this class */ public static FStackInt empty() { return new Empty(); } /** * Produce a new instance of this class * with the given n on the top * @param s the stack onto which we push * @param n the given n */ public static FStackInt push(push(FStackInt s, int n) { return new Push(s, n); } } -- otherwise, it should invoke the abstract method on the first argument with all the remaining arguments passed to the abstract method: /** is the given stack empty? * @param the given stack */ public static boolean isEmpty(FStackInt s) { return s.isEmptyMethod(); } abstract boolean isEmptyMethod(); /** produce the item on the top of the given stack * @param the given stack * @return the top of the given stack */ public static int top(FStackInt s) { return s.topMethod(); } abstract int topMethod(); /** produce the given stack with the top item removed * @param the given stack * @return the remainder of the given stack */ public static FStackInt pop(FStackInt s) { return s.popMethod(); } abstract FStackInt popMethod(); /** produce the size of given stack * @param the given stack * @return the size of the given stack */ public static int size(FStackInt s) { return s.sizeMethod(); } abstract int sizeMethod(); Step 8: Now for each abstract method implement concrete methods --------------------------------------------------------------- in all subclasses you have defined. If nothing is specified for a subclass, throw an exception (e.g. RuntimeException). isEmpty (empty ()) = true abstract boolean isEmptyMethod() isEmpty (push (s,n)) = false top (push (s, n)) = n abstract int topMethod() pop (push (s, n)) = s abstract FStackInt popMethod() size (empty ()) = 0 abstract int sizeMethod() size (push (s, n)) = 1 + size (s) class Empty extends FStackInt { Empty() {} boolean isEmptyMethod() { return true; } int topMethod() { throw new RuntimeException(…); } FStackInt popMethod() { throw new RuntimeException(…); } int sizeMethod() { return 0; } } class Push extends FStackInt { FStackInt s; int n; Push(FStackInt s, int n) { this.s = s; this.n = n; } boolean isEmptyMethod() { return false; } int topMethod() { return n; } FStackInt popMethod() { return s; } int sizeMethod() { return 1 + s.size(); } } ------------------------------------------------------------------------ For our FSetString that was assigned for the homework the specification also includes some dynamic methods, specifically -- boolean equals (Object o) -- int hashCode () -- String toString () These methods override those defined in the class 'Object'. First we focus on the 'equals' method: When are two sets equal? when they contain the same set of elements. Think about how to verify this - there are several ways to go about it. We may need some helper methods, or may need to rely on some methods we have already designed and implemented. The method must start by checking that the given object is not a 'null' value --- and the argument must be of the type 'Object", because we are overriding the method defined in the class 'Object'. Now for the 'hashCode": Java requires that the 'hashCode' method must 'match' the definition of the 'equals' method as follows: if a.equals(b) == true, then a.hashCode() == b.hashCode() must hold. It is possible for two objects that are not 'equal' to have the same 'hashCode'. In our assignment we ask you to make sure that this is 'unlikely'. (How do we test that something is unlikely???) The 'toString' method: Typically we would like this method to give us a humanly readable text that represents the information stored in the object that invoked the method. At times this is not possible or useful. For our example, there is no special ordering of the elements of the set, and so to display them all makes no sense -- we cannot see whether two sets are the same, and the display of all elements of the set is quite costly. (Come to think of it, with the methods we have designed, the client can write the code to display the contents of the set -- we let you think of how it could be done.) So in our case the 'toString' method is simplified -- it only displays the size of the set -- otherwise we could not run the tests for the method. ------------------------------------------------------------------- Designing test harness for a given specification: There are three ways in which we can write tests - two of them use existing tools, the third one is 'built from scratch', but teaches us about the test harness design and gives us the greatest flexibility. The test harness contains individual test methods that compare some actual and expected values and report their findings: success, failure, or some warning. The harness itself keeps track of the number of test cases, the number of failures, the reports produced by each failure, and at the end produce a summary report. JUnit: JUnit is a professionally used test harness. We only highlight the key features - it can do much more. The JUnit tests use the methods 'assertEqual', 'assertTrue', 'assertFalse', and similar the define the test cases. The method that runs several tests either has a name that starts with 'test' or uses the '@Test' annotation. (Annotation is a Java syntax feature used to provide additional instructions to the compiler.) Additional method 'setup' is used to initialize the data used in the tests. This method is run before every test method is executed. This is good if we want 'fresh' data for each test, but is not helpful, if subsequent tests want to verify that the earlier method tests did not have undesirable side-effects. Typical report only lists the number of failed tests and show the percentage of the tests that passed. Tester library: The Tester library we have used in Fundies 2 has been designed to compare any two objects by their contents - by the information the objects represent, rather than the specific instance of the object. So two instances of a class are considered to be equal if every pair of their fields contains the matching value (the same numeric value, same String, same boolean value, or, if they are instances of other classes, the comparison recursively continues in the same way.) The library has been designed to free the novice programmer from defining the 'equals' method and to make the test-first design possible long before the students were ready to define equality that would represent their needs. The names of the test methods start with 'test' and consume an instance of the 'Tester' class in which the 'checkExpect' methods are defined. The tests are defined in an 'Examples' class that also contains the definitions of the data used in the tests. We can reset the data values at any time by designing and invoking some 'init' method. The report allows us to pretty-print all data defined in the 'Examples' class, the report of failed test displays the unmatched 'actual' and 'expected' values side-by-side with the first place where they differ marked, and with a link to the failed test case. Summary total and failed test count is also given. Just like in JUnit, the order in which the test methods are executed is not specified! Home-grown test harness: One can easily build a test harness that fits the specific project. To start with, one should have a method 'assertTrue' that consumes the test case (that results in a a boolean value indicating a success or a failure), and a String that reports what test generated this result). The method updated the summary values and adds the report String to the final collection of results. Now the test methods consist of collections of the invocations of these methods. The whole test harness starts by initializing the summary values, then runs all tests, and finishes with printing the reports.