Reading 12: Defining ADTs with Interfaces, Generics, Enums, and Functions
TypeScript Tutor exercises
Keep making progress on TypeScript by completing this category in the TypeScript Tutor:
Software in 6.031
Objectives
Today’s class is about various ways to implement abstract data types, including:
- interfaces: separating the interface of an ADT from its implementation;
- generic types: defining a family of ADTs using generic type parameters;
- enumerations: defining an ADT with a small finite set of values;
- global functions operating on an opaque type: rare in TypeScript but common in non-object-oriented languages.
We also discuss subtyping as a relationship between two types determined by their specs, and distinguish it from subclassing, which involves reps as well.
After today’s class, you should be able to:
- define ADTs using classes, interfaces, generics, and enumerations
- determine whether one type is a subtype of another
Interfaces
TypeScript’s interface
is a useful language mechanism for expressing an abstract data type.
An interface in TypeScript is a list of method signatures without method bodies.
A class implements an interface if it declares the interface in its implements
clause, and provides method bodies for all of the interface’s methods.
So one way to define an abstract data type in TypeScript is as an interface.
The type’s implementation is then a class implementing that interface.
One advantage of this approach is that the interface specifies the contract for the client and nothing more. The interface is all a client programmer needs to read to understand the ADT. The client can’t create inadvertent dependencies on the ADT’s rep, because fields can’t be put in an interface at all. The implementation is kept well and truly separated, in a different class altogether.
Another advantage is that multiple different representations of the abstract data type can coexist in the same program, as different classes implementing the interface.
When an abstract data type is represented just as a single class, without an interface, it’s harder to have multiple representations.
In the MyString
example from Abstract Data Types, MyString
was a single class.
We explored two different representations for MyString
, but we couldn’t have both representations for the ADT in the same program.
TypeScript’s static type checking allows the compiler to catch many mistakes in implementing an ADT’s contract. For instance, it is a compile-time error to omit one of the required methods, or to give a method the wrong return type. Unfortunately, the compiler doesn’t check for us that the code adheres to the specs of those methods that are written in documentation comments.
About interfaces in TypeScript
Syntactically, a TypeScript interface
contains the spec of an ADT, namely its public method signatures and public instance methods.
Each instance method signature ends with a semicolon.
An interface does not include information about the rep, so it should not declare any instance variables or include instance method bodies.
Because an interface has no rep, TypeScript doesn’t allow it to be instantiated with new
.
The expression new ArrayLike<number>()
is a static error.
To make an instance of an ArrayLike
type, you need to instead instantiate a class that provides a rep for it, like new Array<number>()
.
reading exercises
Consider this TypeScript interface and TypeScript class, which are intended to represent a curve in the 2D plane:
/** Represents an immutable curve in the plane. */
interface Curve {
/** make a one-point curve */
/* A */ constructor(x:number, y:number);
/** @returns true if the point (x,y) lies on this curve */
contains(x:number, y:number):boolean;
/** @returns a curve formed by connecting this with that */
/* B */ join(that:Curve):ArrayCurve;
}
/** Implementation of Curve. */
class ArrayCurve implements Curve {
/** make a one-point curve */
public constructor(x:number, y:number) { ... }
/** @returns a curve formed by connecting this with that */
public join(that:Curve):ArrayCurve { ... }
/** extend this curve with a segment to the point (x,y) */
public add(x:number, y:number) { ... }
}
The line labeled A
is a problem because TypeScript interfaces can’t have constructors.
(missing explanation)
The line labeled B
is a problem because Curve
mentions ArrayCurve
, but ArrayCurve
also mentions Curve
, which is circular.
(missing explanation)
The line labeled B
is a problem because it isn’t representation-independent.
(missing explanation)
ArrayCurve
doesn’t correctly implement Curve
because it’s missing the contains()
method.
(missing explanation)
ArrayCurve
doesn’t correctly implement Curve
because it includes a method that Curve
doesn’t have.
(missing explanation)
ArrayCurve
doesn’t correctly implement Curve
because ArrayCurve
is mutable while Curve
is immutable.
(missing explanation)
Subtypes
Recall that a type is a set of values, with associated operations.
For example, the TypeScript ArrayLike
type is defined by an interface, which has the operations length
and [...]
indexing.
If we think about all possible ArrayLike
values, none of them are actually objects of type ArrayLike
: we cannot create instances of an interface.
Instead, those values might be string
objects, or Array
objects, or objects of any class that provides the operations expected by ArrayLike
(namely length
and [...]
indexing).
A subtype is simply a subset of the supertype: string
and Array
are subtypes of ArrayLike
.
“B is a subtype of A” means “every B is an A.” In terms of specifications: “every B satisfies the specification for A.”
That means B is only a subtype of A if B’s specification is at least as strong as A’s specification. When we declare a class that implements an interface, the TypeScript compiler enforces part of this requirement automatically: for example, it ensures that every method in A appears in B, with a compatible type signature. Class B cannot implement interface A without implementing all of the operations declared in A.
But the compiler cannot check that we haven’t weakened the specification in other ways: strengthening the precondition on some inputs to a method, weakening a postcondition, weakening a guarantee that the interface abstract type advertises to clients. If you declare a subtype in TypeScript — implementing an interface is our current focus — then you must ensure that the subtype’s spec is at least as strong as the supertype’s.
How to declare subtypes in TypeScript
To declare that a class B is a subtype of an interface A, use implements
:
class MyArray<T> implements ArrayLike<T> {
...
}
This declaration requires MyArray
to implement (provide method bodies for) all the operations found in ArrayLike
, with specs at least as strong as the specs in ArrayLike
.
TypeScript statically checks the types, but a human programmer must check the rest of the spec.
You can also declare that an interface is a subtype of another interface, using extends
:
interface SortedArrayLike<T> extends ArrayLike<T> {
...
}
Because SortedArrayLike
is also an interface, it doesn’t provide any implementations of ArrayLike
operations.
But it may strengthen the spec of the type, by strengthening existing ArrayLike
operations or adding new operations.
In TypeScript, there is one more way for a type B to be a subtype of another type A: structural subtyping.
With structural subtyping, B doesn’t have to mention A in its declaration (no B implements A
or B extends A
).
But if B provides at least all the operations required by A – the same public methods and public instance variables, with compatible types – then TypeScript will consider B a subtype of A.
Structural subtyping is related to the duck typing used by dynamic languages like Python and JavaScript, which is inspired by the saying, “if it looks like a duck and quacks like a duck, it’s probably a duck.”
By contrast, languages like Java and C++ use nominal subtyping, where the compiler recognizes a subtyping relationship only if it has been explicitly declared by the programmer, using the equivalent of implements
or extends
.
reading exercises
Let’s define an interface for rectangles:
/** An immutable rectangle. */
interface ImmutableRectangle {
/** @returns the width of this rectangle */
getWidth():number;
/** @returns the height of this rectangle */
getHeight():number;
}
We would like to say that every square is a rectangle:
/** An immutable square. */
class ImmutableSquare implements ImmutableRectangle {
private readonly side:number;
/** Make a new side x side square. */
public constructor(side:number) { this.side = side; }
/** @return the width of this square */
public getWidth():number { return this.side; }
/** @return the height of this square */
public getHeight():number { return this.side; }
}
Does ImmutableSquare.getWidth()
satisfy the spec of ImmutableRectangle.getWidth()
?
Does ImmutableSquare.getHeight()
satisfy the spec of ImmutableRectangle.getHeight()
?
Does the whole ImmutableSquare
spec satisfy the ImmutableRectangle
spec?
(missing explanation)
Now let’s look at mutable versions of rectangles and squares.
/** A mutable rectangle. */
interface MutableRectangle {
// ... same methods as ImmutableRectangle above ...
/** Set this rectangle's dimensions to width x height. */
setSize(width:number, height:number);
}
Surely every square is still a rectangle?
/** A mutable square. */
class MutableSquare implements MutableRectangle /* hopefully? */ {
private side:number;
// ... same constructor and methods as ImmutableSquare above ...
// TODO implement setSize(..)
}
For each possible MutableSquare.setSize(..)
spec below, is it a valid strengthening of MutableRectangle.setSize()
?
/**
* Set this square's dimensions to width x height.
* Requires width = height.
*/
public setSize(width:number, height:number) { ... }
(missing explanation)
/**
* Set this square's dimensions to width x height.
* @throws Error if width != height
*/
public setSize(width:number, height:number) { ... }
(missing explanation)
/**
* If width = height, set this square's dimensions to width x height.
* Otherwise, new dimensions are unspecified.
*/
public setSize(width:number, height:number) { ... }
(missing explanation)
/**
* Set this square's dimensions to side x side.
*/
public setSize(side:number) { ... }
(missing explanation)
Example: MyString
Let’s revisit MyString
.
Using an interface instead of a class for the ADT, we can support multiple implementations:
/**
* MyString represents an immutable sequence of characters.
*/
interface MyString {
// We'll skip this creator operation for now
// /**
// * @param s
// * @returns MyString representing the sequence of characters in s
// */
// public constructor(s:string) { ... }
/**
* @returns number of characters in this string
*/
length():number;
/**
* @param i character position (requires 0 <= i < string length)
* @returns character at position i
*/
charAt(i:number):string;
/**
* Get the substring between start (inclusive) and end (exclusive).
* @param start starting index
* @param end ending index. Requires 0 <= start <= end <= string length.
* @returns string consisting of charAt(start)...charAt(end-1)
*/
substring(start:number, end:number):MyString;
}
Here’s our first implementation:
class SimpleMyString implements MyString {
private a: Uint16Array;
public constructor(s: string) {
this.a = new Uint16Array(s.length);
for (let i = 0; i < s.length; ++i) {
this.a[i] = s.charCodeAt(i);
}
}
public length(): number {
return this.a.length;
}
public charAt(i: number): string {
return String.fromCharCode(this.a[i]);
}
public substring(start: number, end: number): MyString {
const that = new MyString(""); // make a temporarily-empty MyString object
that.a = this.a.slice(start, end); // ... and immediately replace its array
return that;
}
}
And here’s the optimized implementation:
class FastMyString implements MyString {
private a: Uint16Array;
private start:number;
private end:number;
public constructor(s: string) {
this.a = new Uint16Array(s.length);
for (let i = 0; i < s.length; ++i) {
this.a[i] = s.charCodeAt(i);
}
this.start = 0;
this.end = this.a.length;
return s;
}
public length(): number {
return this.end - this.start;
}
public charAt(i: number): string {
return String.fromCharCode(this.a[this.start + i]);
}
public substring(start: number, end: number): MyString {
const that = new MyString(""); // make a temporarily-empty MyString object
that.a = this.a; // ... and immediately replace its instance variables
that.start = this.start + start;
that.end = this.start + end;
return that;
}
}
How will clients use this ADT? Here’s an example:
const s:MyString = new FastMyString("good morning");
console.log("The first character is: " + s.charAt(0));
Unfortunately, this pattern breaks the abstraction barrier we’ve worked so hard to build between the abstract type and its concrete representations. Clients must know the name of the concrete representation class. Because an interface in TypeScript has no constructor, a client must directly call one of the concrete class constructors. The spec of that constructor won’t appear anywhere in the interface, so there’s no static guarantee that different implementations will even provide the same constructors.
Instead of using a constructor, then, we can implement the creator operation as a factory function:
/**
* @param s
* @returns MyString representing the sequence of characters in s
*/
function makeMyString(s:string):MyString {
return new FastMyString(s);
}
Now a client can use the ADT without breaking the abstraction barrier:
const s:MyString = makeMyString("good morning");
console.log("The first character is: " + s.charAt(0));
Hiding the implementation completely is a tradeoff, because sometimes the client wants a choice of implementations with different characteristics.
reading exercises
Let’s review the code for FastMyString
.
Which of these are useful criticisms:
The abstraction function should be documented
(missing explanation)
The representation invariant should be documented
(missing explanation)
The rep fields should be readonly
so they cannot be reassigned
(missing explanation)
The charAt
specification should not expose that the rep contains individual characters
(missing explanation)
The charAt
implementation should behave more helpfully when i
is greater than the length of the string
(missing explanation)
Why interfaces?
Interfaces are used frequently in real code. Not every class is associated with an interface, but there are a few good reasons to bring an interface into the picture.
- Documentation for both the compiler and for humans. Not only does an interface help the compiler catch ADT implementation bugs, but it is also much more useful for a human to read than the code for a concrete implementation. Such an implementation intersperses ADT-level types and specs with implementation details.
- Allowing performance trade-offs. Different implementations of the ADT can provide methods with very different performance characteristics. Different applications may work better with different choices, but we would like to code these applications in a way that is representation-independent. From a correctness standpoint, it should be possible to drop in any new implementation of a key ADT with simple, localized code changes.
- Methods with intentionally underdetermined specifications. An ADT for finite sets could leave unspecified the element order one gets when converting to an array. Some implementations might use slower method implementations that manage to keep the set representation in some sorted order, allowing quick conversion to a sorted array. Other implementations might make many methods faster by not bothering to support conversion to sorted arrays.
- Multiple views of one class. A TypeScript class may implement multiple interfaces. For instance, a user interface widget displaying a drop-down list is natural to view as both a widget and a list. The class for this widget could implement both interfaces. In other words, we don’t always implement an ADT multiple times just because we are choosing different data structures. Those implementations may be various different sorts of objects that can be seen as special cases of the ADT.
- More and less trustworthy implementations. Another reason to implement an interface more than once might be that it is easy to build a simple implementation that you believe is correct, while you can work harder to build a fancier version that is more likely to contain bugs. You can choose implementations for applications based on how bad it would be to get bitten by a bug.
reading exercises
Suppose you have an abstract data type for rational numbers, which is currently represented as a TypeScript class:
class Rational {
...
}
You decide to change Rational
to a TypeScript interface instead, along with an implementation class called IntFraction
:
interface Rational {
...
}
class IntFraction implements Rational {
...
}
For each piece of code below, taken from your original Rational
class, first identify the role it plays in the ADT. Then decide where it should go in your new ADT design: in the interface Rational
, in the implementation class IntFraction
, or both?
Subclassing
Implementing an interface is one way to make a subtype of another type. Another technique, which you may have seen before, is subclassing. Subclassing means defining a class as an extension, or subclass, of another class, so that:
- the subclass automatically inherits the instance methods of its superclass, including their method bodies
- the subclass can override any of those inherited method bodies by substituting its own method body
- the subclass also inherits the fields of its superclass
- the subclass can also add new instance methods and fields for its own purposes
The boldfaced parts in the list above show how subclassing differs from implementing an interface. An interface has neither method bodies nor fields, so it’s not possible to inherit them when you implement an interface.
Subclassing is a common feature of object-oriented programming languages.
You may have already encountered it in Python, where you can write class SpottedTurtle(Turtle): ...
to define a new class SpottedTurtle
as a subclass of the class Turtle
.
TypeScript provides the same effect with the syntax class SpottedTurtle extends Turtle { ... }
.
Just like implementing an interface, subclassing should imply subtyping.
If SpottedTurtle
is a subclass of Turtle
, then SpottedTurtle
needs to have at least as strong a spec as Turtle
, because TypeScript will allow SpottedTurtle
objects to be used anywhere that a Turtle
might be expected.
But unlike implementing an interface, a subclass inherits not only the spec of its superclass but also its rep – the fields that implement it. At first this seems like a great idea. Reusing implementation ought to make our code more DRY, and more ready for change.
But years of experience by API designers have revealed subtle challenges to making subclassing safe from bugs and ready for change. You can read more if you’re interested, but essentially, inheriting the rep means:
- rep exposure between the superclass and all its subclasses
- rep dependence between superclass and subclasses
- superclass and subclass can inadvertently break each other’s rep invariants
Designing for safe subclassing means that the superclass must now offer two contracts: one for interaction with clients, and one for subclasses. These issues simply do not arise with interfaces.
Overriding and dynamic dispatch
In one respect, however, we can’t get away from thinking about subclassing when we write classes in TypeScript.
Every TypeScript class is automatically a subclass of Object
, and automatically inherits methods like toString()
.
We often need to override this inherited implementation, replacing it with our own implementation, in order for our class to behave correctly.
To illustrate, let’s look at a simpler case: overriding the toString
method for our FastMyString
class.
The default toString()
provided by Object
is not particularly useful for debugging:
const fms:FastMyString = new FastMyString("hello");
fms.toString() → "[object Object]" // not useful!
When a method has multiple implementations, such as when a class overrides a method like toString()
that already has an implementation in its superclass Object
, then TypeScript has to determine which of the implementations to use when the method is called.
This process is called dispatching to the method, and TypeScript’s rule is dynamic dispatch – it uses the implementation appropriate to the dynamic type of the object, not the static type of the reference that points to the object.
Let’s make this better by overriding toString()
:
class FastMyString {
...
public toString():string {
let s = "";
for (let i = 0; i < this.length(); ++i) {
s += this.charAt(i);
}
return s;
}
}
const fms:FastMyString = new FastMyString("hello");
fms.toString() → "hello"
In this example, FastMyString
is the type of both the variable fms
and the object it points to.
But they don’t have to have to be the same type.
The variable can be a supertype, while the object itself is from a subtype.
We have already done this when we used a variable of type MyString
to hold a reference to FastMyString
.
But because Object
is a supertype of all classes in TypeScript, we can also do this:
const obj:Object = new FastMyString("hello");
obj.toString() → ?
But now there seems to be an ambiguity.
Object
has its own implementation of toString()
.
When we call obj.toString()
, does it call the original Object
implementation, or the overridden FastMyString
implementation?
Do we get "[object Object]"
or "hello"
?
The dynamic dispatch rule says that the type of the object at runtime determines the method implementation to call.
This type is not known until runtime, hence the word “dynamic.”
The type of the obj
variable, by contrast, is known at compile time.
Using dynamic dispatch, here is what we get:
const obj:Object = new FastMyString("hello");
obj.toString() → "hello"
…the same as we would get from fms.toString()
.
The effect of the dynamic dispatch rule is that it doesn’t matter what type of reference you have pointing to an object, it will always use the object’s type for method dispatch.
reading exercises
let arr:ReadonlyArray<string> = ["abc"];
let obj:Object = arr;
/* next expression here */
What does each of the following do, if substituted for /* next expression here */
in the code above?
arr = obj;
(missing explanation)
arr = obj as ReadonlyArray<String>;
(missing explanation)
(missing explanation)
(missing explanation)
Generic types
A useful feature of the TypeScript type system, and other modern statically-typed languages too, is a generic type: a type whose specification is in terms of a placeholder type to be filled in later.
The collection types are good examples of this.
Set<T>
is the ADT of finite sets of elements of some other type T
.
Instead of writing separate specifications and implementations for Set<String>
, Set<Integer>
, and so on, we can design one interface Set<T>
, which represents an entire family of set ADTs.
Here is a simplified version of the Set
interface, MySet
:
/**
* A mutable set.
* @param <T> type of elements in the set
*/
interface MySet<T> {
// example observer operations
/**
* Get size of the set.
* @returns the number of elements in this set
*/
size(): number;
/**
* Test for membership.
* @param e an element
* @return true iff this set contains e
*/
has(e:T): boolean;
// example mutator operations
/**
* Modifies this set by adding e to the set.
* @param e element to add
*/
add(e:T): void;
/**
* Modifies this set by removing e, if found.
* If e is not found in the set, has no effect.
* @param e element to remove
*/
delete(e:T): void;
}
Notice how the specs are in terms of our abstract notion of a set; it would be malformed to mention the details of any particular implementation of sets with particular private fields.
These specs should apply to any valid implementation of the MySet
ADT.
We will also want a creator operation:
// example creator operation
/**
* Make an empty set.
* @param <U> type of elements in the set
* @returns a new set instance, initially empty
*/
function makeMySet<U>():MySet<U> { ... }
The make
operation is implemented as a factory function.
Clients will write code like:
const strings:MySet<string> = makeMySet();
and the compiler will infer that the new MySet
should be a set of string
objects.
Note that a TypeScript syntax requirement arises in the method signature above: a function like makeMySet
needs to independently declare its type parameter before the parameter list, here shown as function makeMySet<U>
.
We called the type parameter U
instead of T
just to highlight that it’s an arbitrary name, just like the name of a value parameter.
The instance methods above don’t have this extra <...>
declaration because they always use the type parameter declared by the enclosing class or interface, interface MySet<T>
.)
Implementing generic interfaces
Suppose we want to implement the generic MySet<T>
interface above.
We can either write a non-generic implementation that replaces T
with a specific type, or a generic implementation that keeps the placeholder.
Generic interface, non-generic implementation.
Let’s implement MySet<T>
for a particular type T
.
We’ll implement a set of digits:
class DigitSet implements MySet<number>
Wherever the interface mentions placeholder type T
, the DigitSet
implementations replace T
with number
.
For example:
Note that this representation for DigitSet
is not suited for representing sets of arbitrary-type elements.
Its rep cannot represent a MySet<string>
without careful work to define a new rep invariant and abstraction function that can handle arbitrary strings.
Generic interface, generic implementation.
We can also implement the generic MySet<T>
interface without picking a type for T
.
In that case, we write our code blind to the actual type that clients will choose for T
.
|
|
A generic implementation can only rely on details of a placeholder type T
that are explicitly included in the generic interface specification.
HashSet
can’t call methods on its elements that are only declared for some specific type like String
, because T
might be any type.
reading exercises
Consider again our MySet
interface:
interface MySet<T> {
contains(e:T): boolean;
// ...
}
What is wrong (if anything) with the following pieces of code that try to create new ADTs based on MySet
?
/** Represents a set of integers. */
/*1*/ class IntSet implements MySet<number> {
/*2*/ public contains(e:T):boolean { ... }
// ...
}
(missing explanation)
/** Represents a set that can grow but never shrink. */
/*1*/ interface GrowingSet<Z> extends MySet<T> { {
/*2*/ contains(z:Z):boolean;
// ...
}
(missing explanation)
/** Represents a set whose for-iteration returns the elements in the order they were added. */
/*1*/ class OrderedSet<T> implements MySet<T> {
/*2*/ public contains(e:T):boolean { ... }
// ...
}
(missing explanation)
Consider another possible operation on our MySet
interface:
interface MySet<T> {
/**
* Picks an element from the set.
* @return an element in the set
* @throws NoSuchElementError if set is empty
*/
pick():T;
// ...
}
This operation is underdetermined – it may have many possible legal return values for a particular set.
Suppose SimpleSet
implements MySet
, and wants to write its own spec for pick
:
class SimpleSet<T> implements MySet<T> {
private readonly elementList:Array<T> = [];
// ...
/**
* <new spec here>
*/
public pick():T {
...
}
}
(missing explanation)
(missing explanation)
(missing explanation)
Enumerations
Sometimes an ADT has a small, finite set of immutable values, such as:
- months of the year: January, February, …
- days of the week: Monday, Tuesday, …
- compass points: north, south, east, west
- line caps in a line drawing: butt, round, square
A type like this may be used as part of a more complex type (like DateTime
or Latitude
), or as a parameter that changes the behavior of a method (like drawLine
).
When the set of values is small and finite, it makes sense to define all the values as named constants, called an enumeration.
TypeScript has the enum
construct to make this convenient:
enum Month { JANUARY, FEBRUARY, MARCH, ..., DECEMBER };
This enum
defines a new type name, Month
, in the same way that class
and interface
define new type names.
It also defines a set of named values, which we write in all-caps because they are effectively public static readonly
constants.
So you can now write:
const thisMonth = Month.MARCH;
This idea is called an enumeration because you are explicitly listing the elements of the set, and TypeScript is assigning numbers to them as their default rep values.
In the simplest use case for enumerations, the only operation you need is testing equality between values:
if (day === Day.SATURDAY || day === Day.SUNDAY) {
console.log("It's the weekend");
}
In that sense, using an enumeration can feel like you’re using primitive number
constants.
TypeScript even supports using them in switch
statements (which otherwise only allow primitive number types and their wrappers, and string
, but not other objects):
switch (direction) {
case Direction.NORTH: return "polar bears";
case Direction.SOUTH: return "penguins";
case Direction.EAST: return "elephants";
case Direction.WEST: return "llamas";
}
But unlike number
values, enumerations have more static checking:
const firstMonth: Month = Day.MONDAY; // static error: MONDAY has type Day, not type Month
reading exercises
Consider these three alternative ways to name the semester you’re registering for:
startRegistrationFor("Fall", 2023);
const FALL = "Fall";
...
startRegistrationFor(FALL, 2023);
enum Semester { IAP, SPRING, SUMMER, FALL };
...
startRegistrationFor(Semester.FALL, 2023);
Which of the following are correctly-stated advantages or disadvantages of each approach?
(missing explanation)
(missing explanation)
(missing explanation)
(missing explanation)
(missing explanation)
Getters and setters
We often have observer operations that take no arguments, like this one for our MySet
interface:
interface MySet<T> {
/**
* Get size of the set.
* @returns the number of elements in this set
*/
size(): number;
// ...
}
What if we instead want to represent this observer operation (syntactically) as if it were an instance variable?
Then clients can omit the parentheses and use s.size
on an variable s
of type MySet
, just like we use Set.size
or Array.length
for those built-in types.
Declaring this observer property in the interface is straightforward:
interface MySet<T> {
/**
* the number of elements in this set
*/
readonly size: number;
// ...
}
(Note the important readonly
– we don’t want clients to try to resize the set by assigning to this property.)
But doesn’t this sacrifice representation independence?
Doesn’t it constrain the representation we choose for MySet
, by forcing us to have a public size
instance variable in our rep?
In languages like TypeScript that support getter methods, the answer is no.
Using the keyword get
, we can define a getter method that is called whenever a client reads the size
instance variable:
class DigitSet implements MySet<number> {
public get size(): number {
let count = 0;
for (const found of this.digitInSet) {
if (found) ++count;
}
this.checkRep();
return count;
}
// ...
}
But a different implementation of MySet
might choose to represent size
directly as an instance variable:
class HashSet<T> implements MySet<T> {
public readonly size;
// ...
}
The important thing is that DigitSet
and HashSet
can decide whether the observer is implemented by a getter function or a variable, without affecting clients.
From the client’s point of view, size
just looks like an instance variable.
For a public instance variable that is intended to look reassignable, we can define both a getter method (observer) and a setter method (mutator). The setter method can do whatever is necessary to make the rest of the rep consistent with the reassigned value – and even throw an exception if the client has assigned a bad value.
ADTs in non-OOP languages
One more way to define an abstract data type is simply as a group of globally-accessible functions that operate on an opaque data type. This pattern is rarely seen in object-oriented languages like TypeScript and Python, but often appears in older programming languages like C. Here is an example of some file I/O in C:
FILE* f = fopen("out.txt", "w"); // open a file for writing
fputs("hello", f); // write to the file
fclose(f); // close the file
In this code, the abstract data type is FILE
, representing an open file.
The functions fopen
, fputs
, and fclose
are operations of the type, taking a FILE
as an argument or returning it as a return value.
fputs
expects the file as its second argument, oddly enough.
Because C is not object-oriented, there is no notion of classes, methods, fields, or even private
, but representation independence is still achieved.
The client has no way to look inside a FILE
value.
The only way to use a FILE
is by passing it to operations provided by the type.
One key takeaway from this is that the notion of an abstract data type does not depend on language features like classes, or interfaces, or public
/private
access control.
Data abstraction is a powerful design pattern that is ubiquitous in software engineering.
ADTs in TypeScript
We’ve now completed our TypeScript toolbox of ADT concepts from the first ADTs reading:
Class |
|||
Constructor |
|||
Instance method |
|||
Static method |
|||
Instance method |
|||
Static method |
|||
Instance method |
|||
Static method |
|||
Representation |
Summary
TypeScript interfaces help us formalize the idea of an abstract data type as a set of operations that must be supported by a type.
Safe from bugs. An ADT is defined by its operations, and interfaces do just that. When clients use an interface type, static checking ensures that they only use methods defined by the interface. If the implementation class exposes other methods — or worse, has visible representation — the client can’t accidentally see or depend on them. When we have multiple implementations of a data type, interfaces provide static checking of the method signatures.
Easy to understand. Clients and maintainers know exactly where to look for the specification of the ADT. Since the interface doesn’t contain fields or implementations of instance methods, it’s easier to keep details of the implementation out of the specifications.
Ready for change. We can easily add new implementations of a type by adding classes that implement an interface. If we avoid constructors in favor of factory functions, clients will only see the interface. That means we can switch which implementation class clients are using without changing their code at all.
TypeScript enumerations allow defining an ADT with a small finite set of immutable values. Compared to the old-fashioned alternatives of special integer values or special strings, enumerations help make our code:
Safe from bugs. Static checking ensures that clients can’t use values outside the finite set, or confuse two different enumerated types.
Easy to understand. Named constants are not as magical as integer literals, and named types explain themselves better than
number
orstring
.Ready for change. The code that uses a particular enumeration is easy to find by searching for the enumeration’s type name, so that it can be reviewed and changed when the enumeration changes. (IDE refactoring tools can even do some of these changes automatically.) Code that depends on a particular set of string or integer constants is much harder to distinguish from code that uses strings and integers for other purposes.