Sobrecargas de construtor em TypeScript

Exploramos diversas formas de implementar múltiplos construtores em TypeScript.

Embora tecnicamente o TypeScript só permita uma implementação concreta de um construtor, podemos ter vários caminhos para a inicialização de objectos.

Considerem a seguinte interface:

interface Point {
    coordinates(): Iterable<number>;
}

Vamos fazer uma implementação concreta enquanto exploramos as seguintes alternativas:

  1. Assinaturas com múltiplos tipos.
  2. Interface à parte para argumentos.
  3. Métodos estáticos de fabrico.
  4. Classes proxy com diferentes construtores.
  5. Construtor único.
  6. Combinação de várias abordagens.
  7. Reformular o código.

1. Assinaturas com múltiplos tipos

class NDPoint implements Point {
    private values: number[];

    constructor()
    constructor(point: Point)
    constructor(x: number)
    constructor(x: number, y: number)
    constructor(x: number, y: number, z: number)
    constructor(...coordinates: number[])
    constructor(coordinates: number[])
    constructor(xOrPoint?: Point | number | number[], y?: number, z?: number) {
        if (typeof xOrPoint === 'undefined' || xOrPoint === null) {
            this.values = [];
        } else if (xOrPoint instanceof Array) {
            this.values = xOrPoint;
        } else if (typeof xOrPoint === 'number') {
            if (typeof y !== 'undefined') {
                if (typeof z !== 'undefined') {
                    this.values = [xOrPoint, y, z];
                } else {
                    this.values = [xOrPoint, y];
                }
            } else {
                this.values = [xOrPoint];
            }
        } else {
            this.values = [...xOrPoint.coordinates()];
        }
    }
    coordinates(): Iterable<number> {
        return this.values;
    }
}

Sei que o exemplo é um bocado convoluto e que as várias assinaturas podiam ser simplificadas, mas consideremos este exemplo para efeitos de discussão.

As primeiras declarações do construtor são apenas para nosso benefício. Apenas existem durante a fase de desenho e não podem ter nenhuma implementação. Apenas a última versão será realmente compilada.

Para criar novas instâncias:

new NDPoint();
new NDPoint(new NDPoint());
new NDPoint(10);
new NDPoint(10, 10);
new NDPoint(10, 10, 10);
new NDPoint(10, 10, 10, 10);
new NDPoint([10, 10, 10]);

2. Interface à parte para argumentos

Consideremos agora criar uma interface própria para os argumentos do construtor:

interface PointArguments {
    point?: Point;
    coordinates?: number[];
    x?: number;
    xy?: { x: number, y: number };
    xyz?: { x: number, y: number, z: number };
}

A nossa implementação fica assim:

class NDPoint implements Point {
    private values: number[];

    constructor(args?: PointArguments) {
        if (!args) {
            this.values = [];
        } else if (args.point) {
            this.values = [...args.point.coordinates()];
        } else if (args.coordinates) {
            this.values = args.coordinates;
        } else if (typeof args.x !== 'undefined') {
            this.values = [args.x];
        } else if (args.xy) {
            this.values = [args.xy.x, args.xy.y];
        } else if (args.xyz) {
            this.values = [args.xyz.x, args.xyz.y, args.xyz.z];
        } else {
            this.values = [];
        }
    }
    coordinates(): Iterable<number> {
        return this.values;
    }
}

Para criar novas instâncias:

new NDPoint();
new NDPoint({ point: new NDPoint() });
new NDPoint({ coordinates: [10, 10, 10] });
new NDPoint({ x: 10 });
new NDPoint({ xy: { x: 10, y: 10 } });
new NDPoint({ xyz: { x: 10, y: 10, z: 10 } });
// new NDPoint(10, 10, 10); // error, not possible

No entanto o que acontece se fizermos o seguinte?

// unclear what should happen here
new NDPoint({
    coordinates: [10, 10, 10],
    x: 20,
    xyz: { x: 30, y: 30, z: 30 }
});

3. Métodos estáticos de fabrico

class NDPoint implements Point {
    private values: number[];

    static fromVoid() {
        return new NDPoint([]);
    }
    static fromPoint(point: Point) {
        return new NDPoint([...point.coordinates()]);
    }
    static fromX(x: number) {
        return new NDPoint([x]);
    }
    static fromXY(x: number, y: number) {
        return new NDPoint([x, y]);
    }
    static fromXYZ(x: number, y: number, z: number) {
        return new NDPoint([x, y, z]);
    }
    static fromSpread(...coordinates: number[]) {
        return new NDPoint(coordinates);
    }
    constructor(coordinates: number[]) {
        this.values = coordinates;
    }
    coordinates(): Iterable<number> {
        return this.values;
    }
}

Para criar as instâncias:

NDPoint.fromVoid();
NDPoint.fromPoint(new NDPoint([]));
NDPoint.fromX(10);
NDPoint.fromXY(10, 10);
NDPoint.fromXYZ(10, 10, 10);
NDPoint.fromSpread(10, 10, 10, 10);
new NDPoint([10, 10, 10]);

4. Classes proxy com diferentes construtores

O que acontece se tivermos várias implementações em vez de apenas uma, cada uma com um construtor diferente?

class NDPoint implements Point {
    private values: number[];

    constructor(coordinates: number[]) {
        this.values = coordinates;
    }
    coordinates(): Iterable<number> {
        return this.values;
    }
}
class OneDPoint implements Point {
    private point: Point;

    constructor(x: number) {
        this.point = new NDPoint([x]);
    }
    coordinates(): Iterable<number> {
        return this.point.coordinates();
    }
}
class TwoDPoint implements Point {
    private point: Point;

    constructor(x: number, y: number) {
        this.point = new NDPoint([x, y]);
    }
    coordinates(): Iterable<number> {
        return this.point.coordinates();
    }
}
class ThreeDPoint implements Point {
    private point: Point;

    constructor(x: number, y: number, z: number) {
        this.point = new NDPoint([x, y, z]);
    }
    coordinates(): Iterable<number> {
        return this.point.coordinates();
    }
}
class PointFromSpread implements Point {
    private point: Point;

    constructor(...coordinates: number[]) {
        this.point = new NDPoint(coordinates);
    }
    coordinates(): Iterable<number> {
        return this.point.coordinates();
    }
}
class EmptyPoint implements Point {
    constructor() {
    }
    coordinates(): Iterable<number> {
        return [];
    }
}
class ClonedPoint implements Point {
    private point: Point;

    constructor(point: Point) {
        this.point = new NDPoint([...point.coordinates()]);
    }
    coordinates(): Iterable<number> {
        return this.point.coordinates();
    }
}

Para criar novas instâncias:

new EmptyPoint();
new ClonedPoint(new EmptyPoint());
new OneDPoint(10);
new TwoDPoint(10, 10);
new ThreeDPoint(10, 10, 10);
new PointFromSpread(10, 10, 10, 10);
new NDPoint([10, 10, 10]);

5. Construtor único

class NDPoint implements Point {
    private values: number[];

    constructor(coordinates: number[]) {
        this.values = coordinates;
    }
    coordinates(): Iterable<number> {
        return this.values;
    }
}

Para criar novas instâncias:

new NDPoint([]);
new NDPoint([...new NDPoint([]).coordinates()]);
new NDPoint([10]);
new NDPoint([10, 10]);
new NDPoint([10, 10, 10]);
new NDPoint([10, 10, 10, 10]);

6. Combinação de várias abordagens

class NDPoint implements Point {
    private values: number[];

    static from(coordinates: Iterable<number>) {
        return new NDPoint(...coordinates);
    }
    constructor(...coordinates: number[]) {
        this.values = coordinates;
    }
    coordinates(): Iterable<number> {
        return this.values;
    }
}

Para criar novas instâncias:

new NDPoint();
NDPoint.from(new NDPoint().coordinates());
new NDPoint(10);
new NDPoint(10, 10);
new NDPoint(10, 10, 10);
new NDPoint(10, 10, 10, 10);
NDPoint.from([10, 10, 10]);

7. Reformular o código

Outra abordagem é reformular o código para que a classe principal tenha apenas um único ponto de inicialização e criar classes proxy para casos extremos e classes utilitárias para transformação de argumentos.

class NDPoint implements Point {
    private values: Iterable<number>;

    constructor(coordinates: Iterable<number>) {
        this.values = coordinates;
    }
    coordinates(): Iterable<number> {
        return this.values;
    }
}
class EmptyPoint implements Point {
    coordinates(): Iterable<number> {
        return [];
    }
}
class IterableOf<T> implements Iterable<T> {
    private items: T[];

    constructor(...items: T[]) {
        this.items = items;
    }
    [Symbol.iterator](): Iterator<T> {
        return this.items.values();
    }
}

Para criar novas instâncias:

new EmptyPoint();
new NDPoint(new NDPoint([10, 10]).coordinates());
new NDPoint(new IterableOf(10));
new NDPoint(new IterableOf(10, 10));
new NDPoint(new IterableOf(10, 10, 10));
new NDPoint(new IterableOf(10, 10, 10, 10));
new NDPoint([10, 10, 10]);

Conclusão

Pessoalmente acho a sobrecarga de construtores um dos pontos fracos do TypeScript. Ou temos um construtor com muito código ou temos de recorrer a métodos estáticos ou classes adicionais que forneçam as diferentes opções de inicialização.

Não sei qual é a melhor abordagem. Não gosto de usar lógica no construtor nem de usar métodos estáticos, mas as alternativas também não são muito boas. Neste caso provavelmente escolheria a última opção.

A solução que escolherem será sempre um compromiso entre flexibilidade e código limpo.

O que acham? Qual é a vossa abordagem favorita?

Artigos relacionados