O Princípio da Substituição de Liskov ajuda a reduzir a fragilidade por meio da utilização de hierarquias de classe mais coerentes, ele diz o seguinte:
"if S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program".
Simplificando, seria algo como: Deve ser possível substituir uma subclasse por outra sem causar problemas, sem quebrar a expectativa imposta pelo programa.
Considere o código abaixo onde temos uma classe que é responsável por obter informações de uma conta por meio de uma API utilizando o padrão Gateway, que basicamente serve para encapsular o acesso a sistemas ou recursos externos. Por padrão, estamos utilizando a biblioteca Axios para interagir com o protocolo HTTP:
class AccountGateway {
async getAccountById (accountId: string) {
const response = await axios.get(`http://localhost:3000/accounts/${accountId}`);
return response.data;
}
}
Para tornar o código mais flexível, não ficando limitado apenas ao Axios, podemos criar a interface HttpClient, invertendo a dependência e permitindo que AccountGateway utilize qualquer tipo de biblioteca.
interface HttpClient {
get (url: string): Promise;
}
class AccountGateway {
constructor (readonly httpClient: HttpClient) {
}
async getAccountById (accountId: string) {
const response = await this.httpClient.get(`http://localhost:3000/accounts/${accountId}`);
return response.data;
}
}
Agora que desacoplamos a classe AccountGateway da biblioteca Axios podemos implementar a interface HttpClient de diferentes formas. Na primeira implementação vamos utilizar o próprio Axios por meio de um Adapter.
class AxiosAdapter implements HttpClient {
async get (url: string): Promise {
return axios.get(url);
}
}
Pelo princípio, existem 3 regras que devem ser seguidas pelas subclasses:
Vamos começar com a primeira regra: Pré-condições não devem ser fortalecidas na subclasse. Considere a implementação abaixo:
class ProductionAxiosAdapter implements HttpClient {
async get (url: string): Promise {
if (url.includes("localhost")) throw new Error("This implementation is production only");
return axios.get(url);
}
}
Essa subclasse quebra a regra já que ao invés de receber qualquer URL acaba fortalecendo e restringindo a pré-condição, rejeitando qualquer URL local e fazendo o programa não funcionar adequadamente nesse tipo de situação.
Nesse caso, talvez fosse o caso de criar hierarquias de classes independentes como LocalHttpClient e ProductionHttpClient, reforçando e deixando claro a intenção de cada uma, evitando que o programa crie expectativas frustradas.
Agora vamos abordar a segunda regra: Pós-condições não devem ser enfraquecidas na subclasse.
Repare que a o método getAccountById criou uma expectativa, ele espera que o retorno do método get da instância de HttpClient, não importa qual ela seja, retorne um objeto que dentro tem uma propriedade "data".
No momento em que implementamos uma variação do HttpClient, por exemplo, usando a biblioteca Fetch, corremos o risco de não retornar o mesmo objeto, da mesma forma.
class FetchAdapter implements HttpClient {
async get (url: string): Promise {
const response = await fetch(url);
return response.json();
}
}
Ao utilizar o AxiosAdapter, a expectativa do programa será atendida e tudo vai funcionar corretamente, no entanto, ao substituir por FetchAdapter, não temos o mesmo retorno, com a propriedade "data" dentro do objeto, o que fará o programa quebrar por não respeitar o Princípio da Substituição de Liskov.
A solução nesse caso é simples, é só retirar a expectativa da propriedade "data", que na verdade foi imposta pelo Axios, movendo-a para dentro do AxiosAdapter.
class AccountGateway {
constructor (readonly httpClient: HttpClient) {
}
async getAccountById (accountId: string) {
const response = await this.httpClient.get(`http://localhost:3000/accounts/${accountId}`);
return response;
}
}
class AxiosAdapter implements HttpClient {
async get (url: string): Promise {
const response = await axios.get(url);
return response.data;
}
}
Por fim: A invariância deve ser preservada na subclasse.
Muitas vezes a superclasse define um determinado comportamento que deve ser respeitado e não poderia ser violado pela subclasse. Abaixo temos um exemplo clássico, utilizado pela maior parte dos autores para explicar esse comportamento.
Podemos dizer que todo quadrado é um retângulo mas nem todo retângulo é um quadrado já que nem sempre os lados são iguais. Isso pode nos levar a implementar uma hierarquia de classes onde quadrado herda as características de retângulo:
class Rectangle {
private x: number;
private y: number;
constructor (x: number, y: number) {
this.x = x;
this.y = y;
}
setX (x: number) {
this.x = x;
}
setY (y: number) {
this.y = y;
}
getArea () {
return this.x * this.y;
}
}
class Square extends Rectangle {
setX (x: number) {
this.x = x;
this.y = y;
}
set Y (y: number) {
this.x = y;
this.y = y;
}
}
Na classe Rectangle a propriedade "x" é definida pelo construtor ou por "setX" e "y" também é definido pelo construtor ou por "setY". Na subclasse Square ao invocar o método "setX" a propriedade "y" é modificada, causando uma quebra na expectativa imposta pela superclasse Rectangle. Quem estava utilizando a classe Rectangle espera que ao invocar "setX" apenas "x" seja modificado e eventualmente caso a instância Square seja utilizada, o comportamento não iria fazer sentido.
A solução nesse caso é simples, criar hierarquias de classes independentes, apesar de na prática um quadrado ser um retângulo, isso não faz com que a classe Square herde de Rectangle, por isso é melhor que eles sigam por caminhos diferentes, ainda que exista duplicação de partes do código.
O Princípio da Substituição de Liskov é sobre não quebrar expectativas e permitir a substituição sem prejudicar o funcionamento do programa. Não se trata apenas de não retornar exceções, elas podem ser lançadas, desde que a expectativa do programa inclua o seu tratamento.