Lecture 12: Defining sameness for complex data, part 2
Using double-dispatch to test for sameness
Motivation
In Lecture 11: Defining sameness for complex data, part 1 we took up the question, “How can we test when two values are ‘the same’?” We introduced two new language mechanisms: casting to try to convince Java to treat an identifier as if it were declared at a more specific type, and instanceof to check whether a given value actually is of a particular type. But our implementations were brittle and suffered from clumsy helper methods that were not useful outside this limited context, and still potentially could throw exceptions and crash. Plus, our design sense should be warning us: any time we feel tempted to try to do a type-test in Java, we ought to find a better way...preferably using dynamic dispatch.
Reflexivity: every object should be the same as itself.
Symmetry: if object x is the same as object y, then y is the same as x.
Transitivity: if two objects are both the same as a third object, then they are the same as each other.
Totality: we can compare any two objects of the same type, and obtain a correct answer.
// In test method in an Examples class Circle c1 = new Circle(3, 4, 5); Circle c2 = new Circle(4, 5, 6); Circle c3 = new Circle(3, 4, 5); Rect r1 = new Rect(3, 4, 5, 5); Rect r2 = new Rect(4, 5, 6, 7); Rect r3 = new Rect(3, 4, 5, 5); Square s1 = new Square(3, 4, 5); Square s2 = new Square(4, 5, 6); Square s3 = new Square(3, 4, 5); t.checkExpect(c1.sameCircle(c2), false) t.checkExpect(c2.sameCircle(c1), false) t.checkExpect(c1.sameCircle(c3), true) t.checkExpect(c3.sameCircle(c1), true) t.checkExpect(r1.sameRect(r2), false) t.checkExpect(r2.sameRect(r1), false) t.checkExpect(r1.sameRect(r3), true) t.checkExpect(r3.sameRect(r1), true) t.checkExpect(s1.sameShape(s2), false) t.checkExpect(s2.sameShape(s1), false) t.checkExpect(s1.sameShape(s3), true) t.checkExpect(s3.sameShape(s1), true) // Comparing a Square with a Rect of a different size t.checkExpect(s1.sameShape(r2), false) t.checkExpect(r2.sameShape(s1), false) // Comparing a Square with a Rect of the same size t.checkExpect(s1.sameShape(r1), false) t.checkExpect(r1.sameShape(s1), false)
To design a better mechanism for checking sameness...we have actually already written nearly all the helpers we need. We just need to use them more cleverly!
12.1 Sameness for unions: Successful attempt using double-dispatch
// In Circle: public boolean sameShape(IShape that) { /* * Fields: * this.x, this.y, this.radius * * Methods: * this.sameShape(IShape) -> boolean * this.sameCircle(Circle) -> boolean * * Methods on fields: * * Fields of parameters: * * Methods on parameters: * that.sameShape(IShape) -> boolean */ ??? }
interface IShape { boolean sameShape(IShape that); boolean sameCircle(Circle that); boolean sameRect(Rect that); boolean sameSquare(Square that); }
Do Now!
Now the Circle class needs implementations for sameRect and sameSquare, the Rect class is missing sameCircle and sameSquare, and Square is missing sameCircle and sameRect. Implement them —they should be very short!
// In Circle: public boolean sameShape(IShape that) { /* * Fields: * this.x, this.y, this.radius * * Methods: * this.sameShape(IShape) -> boolean * this.sameCircle(Circle) -> boolean * this.sameRect(Rect) -> boolean * this.sameSquare(Square) -> boolean * * Methods on fields: * * Fields of parameters: * * Methods on parameters: * that.sameShape(IShape) -> boolean * that.sameCircle(Circle) -> boolean * that.sameRect(Rect) -> boolean * that.sameSquare(Square) -> boolean */ ??? }
Do Now!
Looking at the types for this and that, which method in the template is most likely to be useful?
We’d be going around in circles...
// In Circle: public boolean sameShape(IShape that) { return that.sameCircle(this); }
c1 is a Circle, so we invoke the sameShape method defined in Circle, inside of which this is c1 and that is c3.
This method immediately invokes sameCircle on that, and passes it this. Because that is c3, and c3 is a Circle, we invoke the sameCircle method defined in the Circle class, inside of which this will be c3 and that will be c1 —
they’ve swapped roles. Inside sameCircle, this is now c3 and that is now c1, and we can easily compare their fields. The fields are all equal, so we return true.
c1 is a Circle, so we invoke the sameShape method defined in Circle, inside of which this is c1 and that is c2.
This method immediately invokes sameCircle on that, and passes it this. Because that is c2, and c2 is a Circle, we invoke the sameCircle method defined in the Circle class, inside of which this will be c2 and that will be c1 —
they’ve swapped roles. Inside sameCircle, this is now c2 and that is now c1, and we can easily compare their fields. The fields are not equal, so we return false.
c1 is a Circle, so we invoke the sameShape method defined in Circle, inside of which this is c1 and that is r1.
This method immediately invokes sameCircle on that, and passes it this. Because that is r1, and r1 is a Rect, we invoke the sameCircle method defined in the Rect class, inside of which this will be r1 and that will be c1 —
they’ve swapped roles. ...
// In Rect public boolean sameCircle(Circle that) { return false; }
// In Circle public boolean sameRect(Rect that) { return false; } public boolean sameSquare(Square that) { return false; }
// In Rect public boolean sameCircle(Circle that) { return false; } public boolean sameSquare(Square that) { return false; }
// In Square public boolean sameCircle(Circle that) { return false; } public boolean sameRect(Rect that) { return false; }
The sameCircle method in the Rect class always returns false: Circles and Rects are never the same.
Do Now!
Do this yourself, following the pattern of the step-by-step walkthroughs above.
s1 is a Square, so we invoke the sameShape method defined in Square, inside of which this is s1 and that is r1.
This method immediately invokes sameSquare on that, and passes it this. Because that is r1, and r1 is a Rect, we invoke the sameSquare method defined in the Rect class, inside of which this will be r1 and that will be s1 —
they’ve swapped roles. The sameSquare method in the Rect class always returns false: Squares and Rects are never the same.
r1 is a Rect, so we invoke the sameShape method defined in Rect, inside of which this is r1 and that is s1.
This method immediately invokes sameRect on that, and passes it this. Because that is s1, and s1 is a Square, we invoke the sameRect method defined in the Square class, inside of which this will be s1 and that will be r1 —
they’ve swapped roles. The sameRect method in the Square class always returns false: Rects and Squares are never the same.
12.2 Summarizing the double-dispatch pattern for sameness testing
interface IFoo { boolean sameFoo(IFoo that); boolean sameX(X that); boolean sameY(Y that); boolean sameZ(Z that); }
// In X: public boolean sameFoo(IFoo that) { return that.sameX(this); } public boolean sameX(X that) { ... compares two X values ... }
// In X: public boolean sameY(Y that) { return false; } public boolean sameZ(Z that) { return false; }
How does this all work? The key observation is that when we want to compare two IFoo values f1 and f2 for sameness, we need to determine which kind of IFoo f1 is, and also which kind of IFoo f2 is. We get the answer to the first part automatically: by invoking a method on f1, dynamic dispatch will automatically route us to the appropriate method for the actual type of f1. Then, instead of trying to cobble together some form of type testing to figure out the answer for f2, we just invoke a method on it, using dispatch again to figure out the answer there. The trick lies in using our helper methods cleverly: by having sameFoo invoke sameX (in the example above), we are essentially answering the question “is f1 the same IFoo as f2?” by rebounding the question: “well we’ve figured out that f1 is an X, so is f2 the same X as f1?”
Exercise
Look back at Lecture 7: Accumulator methods, continued. Where did we use the double-dispatch pattern to answer a similar kind of question, that had to distinguish between different variants of a union data type?
Do Now!
Confirm that all of the properties we want out of a sameness operation are satisfied by our implementation. Notice in particular that totality is easy to confirm: there are no exceptions possible in this code!
Exercise
Implement a sameness operator for two IAT ancestry trees.
12.3 Cleaning up the code
abstract class AFoo implements IFoo { public boolean sameX(X that) { return false; } public boolean sameY(Y that) { return false; } public boolean sameZ(Z that) { return false; } } class X extends AFoo { public boolean sameFoo(IFoo that) { return that.sameX(this); } public boolean sameX(X that) { ... compares two X values ... } } class Y extends AFoo { public boolean sameFoo(IFoo that) { return that.sameY(this); } public boolean sameY(Y that) { ... compares two Y values ... } } class Z extends AFoo { public boolean sameFoo(IFoo that) { return that.sameZ(this); } public boolean sameZ(Z that) { ... compares two Z values ... } }
Exercise
Implement an AShape class, and abstract all the default cases you can. Be careful with the Square class!
12.4 Adding more variants
interface IShape { boolean sameShape(IShape that); boolean sameCircle(Circle that); boolean sameRect(Rect that); boolean sameSquare(Square that); // NEW: boolean sameCombo(Combo that); } // in AShape: public boolean sameCombo(Combo that) { return false; }
// in Combo: public boolean sameShape(IShape that) { return that.sameCombo(this); } public boolean sameCombo(Combo that) { ... }
// In Combo public boolean sameCombo(Combo that) { return this.left.sameShape(that.left) && this.right.sameShape(that.right); }