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:
- Assinaturas com múltiplos tipos.
- Interface à parte para argumentos.
- Métodos estáticos de fabrico.
- Classes proxy com diferentes construtores.
- Construtor único.
- Combinação de várias abordagens.
- 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]);
Prós
- Fácil de ver as várias sobrecargas do construtor.
Contras
- Muita lógica no construtor.
- Verificação de tipos.
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 }
});
Prós
- Lógica no construtor ficou mais simples (apenas necessário verificar se cada membro está presente).
Contras
- Interface de argumentos ambígua (mais difícil de trabalhar com a classe para quem chama).
- Continua com lógica no construtor.
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]);
Prós
- Sem lógica no construtor.
Contras
- Uso de métodos estáticos.
- Quem chama tem de procurar os métodos estáticos para inicialização.
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]);
Prós
- Sem lógica no construtor.
- Sem uso de métodos estáticos.
Contras
- Muito verboso (muitas classes proxy).
- Difícil de descobrir as variações.
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]);
Prós
- Sem lógica no construtor.
- Sem uso de métodos estáticos.
- Implementação mais curta.
Contras
- Responsabilidade de transformar os argumentos para quem chama o construtor.
- Código duplicado para transformação de argumentos (e.g. repetir o mesmo código sempre que queremos clonar um ponto).
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]);
Prós
- Sem lógica no construtor.
Contras
- Uso de métodos estáticos.
- Pequeno fardo para descobrir os métodos estáticos e para transformar os argumentos.
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]);
Pros
- Sem lógica no construtor.
- Sem uso de métodos estáticos.
- Código mais reutilizável.
Cons
- Responsabilidade de descobrir como inicializar para quem chama.
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?