Problem overview
A drawing app keeps rectangles, squares and triangles in one list. You write a common type, Shape, and three classes that extend it: Rectangle(width, height), Square(side) and Triangle(a, b, c). Every shape answers two questions: name() and perimeter(). A plain Shape answers "Shape" and 0; each subclass overrides both methods with its own rule:
- Rectangle:
2 * (width + height); - Square:
4 * side; - Triangle:
a + b + c.
Then two plain functions take a Shape[]. totalPerimeter(shapes) adds up all the perimeters, and largestShape(shapes) returns the name of the shape with the largest perimeter, the earliest one on a tie. For Rectangle(3, 4), Square(5) and Triangle(3, 4, 5) the perimeters are 14, 20 and 12, so the total is 46 and the largest is "Square".
Approach: ask the object, don't check its kind
The two functions never test what kind of shape they hold. They call shape.perimeter() and shape.name() and let each object run its own version. That is polymorphism: one call, many behaviours. The payoff is that a new shape class, such as a circle added later, works with totalPerimeter and largestShape without changing either function.
Both functions look at each shape once, so each takes O(n) time for n shapes and no extra space beyond a few variables.
Step by step
- The base class.
Shape.name()returns"Shape"andShape.perimeter()returns0. Every subclass overrides these, so the defaults are never printed. They give the common type its two methods, and that is why the functions can call them on any shape. - Fields. Each subclass declares the numbers it needs as fields:
widthandheight, orside, ora,bandc. Without them the object couldn't remember its sizes untilperimeter()is called. - Constructors. A constructor of a subclass calls
super()first, which runsShape's constructor. Only after that can it usethis, so the field assignments come next. The parameters share their names with the fields, andthis.picks the field. - The overrides.
name()returns the class's own name, spelled exactly with its capital letter.perimeter()applies the class's formula to the stored fields. totalPerimeter. Start alet total = 0, loop withfor...of, addshape.perimeter()tototaland return it.largestShape. Start withlargestas the first shape, loop over all of them, and replacelargestonly when a shape's perimeter is strictly greater. Returnlargest.name().
The solution in TypeScript
class Shape {
name(): string {
return "Shape";
}
perimeter(): number {
return 0;
}
}
class Rectangle extends Shape {
width: number;
height: number;
constructor(width: number, height: number) {
super();
this.width = width;
this.height = height;
}
name(): string {
return "Rectangle";
}
perimeter(): number {
return 2 * (this.width + this.height);
}
}
class Square extends Shape {
side: number;
constructor(side: number) {
super();
this.side = side;
}
name(): string {
return "Square";
}
perimeter(): number {
return 4 * this.side;
}
}
class Triangle extends Shape {
a: number;
b: number;
c: number;
constructor(a: number, b: number, c: number) {
super();
this.a = a;
this.b = b;
this.c = c;
}
name(): string {
return "Triangle";
}
perimeter(): number {
return this.a + this.b + this.c;
}
}
function totalPerimeter(shapes: Shape[]): number {
let total = 0;
for (const shape of shapes) {
total += shape.perimeter();
}
return total;
}
function largestShape(shapes: Shape[]): string {
let largest = shapes[0];
for (const shape of shapes) {
if (shape.perimeter() > largest.perimeter()) {
largest = shape;
}
}
return largest.name();
}
TypeScript details worth knowing:
- Field declarations. In this form, a field is declared in the class body (
width: number;) and assigned in the constructor. Without the declaration,this.width = widthis a compiler error:Property 'width' does not exist on type 'Rectangle'. super()beforethis. In a derived class, touchingthisbefore callingsuper()is a compiler error: it reports that'super' must be called before accessing 'this' in the constructor of a derived class.- The variable's type.
let largest = shapes[0]giveslargestthe typeShape, so it can later hold aRectangle, aSquareor aTriangle. - Types are erased. The parameter type
Shape[]only lets the compiler check the calls. At run time each element is the real object, and JavaScript runs the method of its own class.
Seeing it run
Build the three lists from the examples and call both functions:
const a = [new Rectangle(3, 4), new Square(5), new Triangle(3, 4, 5)];
console.log(totalPerimeter(a), largestShape(a));
const b = [new Rectangle(2, 4), new Square(3), new Triangle(4, 4, 4)];
console.log(totalPerimeter(b), largestShape(b));
const c = [new Square(1), new Rectangle(1, 1), new Triangle(2, 2, 3)];
console.log(totalPerimeter(c), largestShape(c));
console.log(new Shape().name(), new Shape().perimeter());
46 Square
36 Rectangle
15 Triangle
Shape 0
In the second list all three perimeters are 12, so the first shape, the rectangle, wins the tie. In the third list a 1 by 1 rectangle has the same sides as the square before it, but it is still a Rectangle: each object answers name() for its own class.
To see that the functions need no changes for a new kind of shape, add a class of your own in a separate file:
class Circle extends Shape {
constructor(public r: number) { super(); }
name() { return "Circle"; }
perimeter() { return Math.round(2 * 3 * this.r); }
}
const d: Shape[] = [new Square(2), new Circle(5)];
console.log(totalPerimeter(d), largestShape(d));
38 Circle
The circle's perimeter here is a rough 30 (a stand-in with 3 for pi), and totalPerimeter adds it to the square's 8 without knowing it is a circle.
Edge cases and common mistakes
- A tie. Using
>=instead of>lets the later shape replace the earlier one, so example 2 would answer"Triangle"instead of"Rectangle". Only a strictly larger perimeter replaces the current largest. - A single shape. The list is never empty, so
shapes[0]exists. With one shape the loop compares it with itself and changes nothing. - Checking the kind. Writing
if (shape instanceof Square)inside the functions works for today's shapes but breaks the idea: every new class would need another branch. Call the methods and stop there. - Forgetting
super(). A constructor in a derived class that never callssuper()is a compile-time error, and one that usesthisfirst is also rejected. - Dropping the factor of two. A rectangle has two sides of each size:
2 * (width + height), notwidth + height. - Mixing up the constructor's parameters. A triangle takes three sides in the order
a, b, c. The perimeter is a sum, so the order doesn't change the result, but storing the same parameter twice does: check that each of the three fields gets its own value. - Name spelling. The names are printed as they are, so
"square"and"Square"are different outputs.
