Blog-Archiv

Samstag, 19. Mai 2018

Don't Call Overridables From Constructor in TypeScript

Do not call overridable methods from constructor. This is a general risk-mitigation rule for most of today's object-oriented languages, among them Java and TypeScript. Not so widely known, but quite subtle and hard to understand.

In TypeScript, overridable methods are public or protected. In Java you could avoid the pitfall by making these methods final, but there is nothing like that in TypeScript. Now, what exactly is the pitfall?

Example

The problem occurs not always when you call an overridable method from constructor, just when that overridable uses instance fields of its own class that are expected to have been initialized to a certain value.

Following example classes should make this clear. They represent the idea of encapsulating a Name string. Let's assume that FirstName and LastName are both names, but with different semantics, one being a different default value. (I left out all other semantics, so the classes may not look really useful.)

Name.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
export abstract class Name
{
    private value: string;

    constructor() {
        this.value = this.getDefault();
    }

    /** Sub-classes must define a default. */
    protected abstract getDefault(): string;
    
    /** Expose the value readonly. */
    public getValue(): string {
        return this.value;
    }
}

The abstract super-class Name leaves it up to sub-classes to define a default-value for the encapsulated value string. It does so by declaring a protected abstract getDefault() method. Mind that TypeScript, like Java, requires a class to be abstract when it contains an abstract method, and sub-classes are forced to implement it, or be abstract again.

FirstName.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import { Name } from "./Name.js";

export class FirstName extends Name
{
    public readonly defaultValue: string = "(No Firstname)";

    protected getDefault(): string {
        return this.defaultValue;
    }
}

FirstName extends Name. The programmer decided to define the default in an instance field constant. Looks like nothing can break that code, right?

LastName.ts
Click to see LastName, which is exactly the same, just with a different default-name.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import { Name } from "./Name.js";

export class LastName extends Name
{
    public readonly defaultValue: string = "(No Lastname)";

    protected getDefault(): string {
        return this.defaultValue;
    }
}

Test

Finally here is a test for these quite simple looking classes. You can execute it using the HTML page that I introduced in a recent Blog. Script references are on bottom of the page.

test.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { FirstName } from "./FirstName.js";
import { LastName } from "./LastName.js";

declare function title(testTitle: string): void;
declare function assert(criterion: boolean, message: string): void;

title("Don't Call Overridables From Constructor");

const firstName: FirstName = new FirstName();
assert(
    firstName.getValue() === firstName.defaultValue,
    "firstName.getValue() is expected to be '"+firstName.defaultValue+"': '"+firstName.getValue()+"'");

const lastName: LastName = new LastName();
assert(
    lastName.getValue() === lastName.defaultValue,
    "lastName.getValue() is expected to be '"+lastName.defaultValue+"': '"+lastName.getValue()+"'");

This first imports the classes to test. Then it declares two external functions that are provided by test.html (thus it depends on its test-executor). It outputs a title, and then constructs FirstName and LastName objects, executing the same assertion on both: check that getValue() returns the correct default value.

Mind that, due to the imports, all TS files must be in the same directory. You can compile them by:

  tsc -t ES6 *.ts

Would you expect that the test succeeds?
When you load the test.html page into your browser, you will see this result:

Don't Call Overridables From Constructor

firstName.getValue() is expected to be '(No Firstname)': 'undefined'
lastName.getValue() is expected to be '(No Lastname)': 'undefined'

Both tests failed because the real value was undefined instead of the expected default name!

Executing an override before its owning object was initialized

The explanation of this pitfall is the object-initialization control flow.

  • A sub-class calls the constructor of its super-class before its own instance fields have been initialized. In other words, firstName.defaultValue is still undefined when the Name constructor starts to work.

  • The Name constructor calls the getDefault() method, which is overridden and thus control goes back to the FirstName object.

  • The getDefault() method in FirstName returns the value of the not-yet-initialized instance field this.defaultValue, which is undefined.

  • The value field in Name now has been set to undefined by the Name constructor. That was the pitfall.

  • As soon as the Name constructor has terminated, the object-initialization of FirstName gets started, before constructor execution. Now "(No Firstname)" is assigned to the instance field firstName.defaultValue, but too late for getting into super.value.

Conclusion

There are several ways to fix this. One is to provide the default value as static field. Another one is to hardcode the default inside the getDefault() override implementation. The basic problem is the initialization order of object instances. Constructors are not really object-oriented, they are a legacy from structured languages. Whatever is inside a constructor is hardly overridable.

In my next Blog I will show how this can be fixed without static fields or hardcoding values inside methods.




Keine Kommentare: