Princípio de substituição de Liskov

Uma das regras que ajudam a nortear o design de objetos é o Princípio de Substituição de Liskov. Esse preceito foi definido inicialmente por Barbara Liskov em 1987 mas ganhou maior divulgação ao compor o grupo de regras chamado SOLID exposto por Robert C. Martin no livro Agile Software Development: Principles, Patterns, and Practices.

Este princípio diz que se q(x) é uma propriedade demonstrável dos objetos x de tipo T. Então q(y) deve ser verdadeiro para objetos y de tipo S onde S é um subtipo de T. Um pouco confuso na primeira vez que se lê, mas que na prática atesta que eu posso substituir uma instância de uma classe por outra instância que seja de uma subclasse da primeira, sem que isso altere o comportamento do sistema.

Diagrama de classes mostrando uma subclasse sendo substituída como dependência de uma classe

Vamos usar um exemplo para poder explicar melhor. Veja a listagem de código abaixo. Ela simula um sistema de cálculo de imposto para venda de veículos. A classe Carro contém um método chamado CalcularValorImposto que recebe o valor do veículo e quando acionado faz todo o cálculo dos impostos devidos (ICMS, IPI, etc.), armazenando o resultado em propriedades do objeto.

IMPORTANTE: os cálculos aqui estão bem simplificados, na vida real a tributação é bem mais complexa...

Um desenvolvedor pode então criar a classe Taxi, herdando de Carro, para fazer o cálculo de imposto na venda de veículos para transporte comercial de passageiros, aproveitando assim outros métodos que a classe Carro tenha e que sejam comum para o sistema (o que não deveria ser o fator para a decisão em se utilizar herança). Taxistas têm uma série de isenções de impostos para fomentar sua profissão. Este desenvolvedor então sobrescreve o método CalcularValorImposto zerando o valor dos impostos que o taxista é desobrigado a pagar.

A princípio não existem problemas nessa implementação pois eu consigo fazer o cálculo do imposto para compra de carros e de taxis chamando o método CalcularValorImposto da respectiva instância. Mas há um problema. Veja que no nosso cliente, que no código de exemplo é uma classe de teste de unidade, quando fazemos a substituição da dependência Carro no método DeveCalcularValorImposto por uma instância de Taxi, a funcionalidade quebra.

Este exemplo de código, neste contexto, não atende o princípio de Liskov. A pós condição na chamada do método CalcularValorImposto quando é feita na classe Carro é ter todos os valores de impostos calculados (as propriedades ICMS, IPI, PIS e COFINS). Já a pós condição na chamada do mesmo método para a classe Taxi é ter algum dos valores dos impostos calculados dada a isenção. Note que a pós condição da classe Taxi é mais "fraca", menos restritiva. Ter uma pós condição mais fraca na subclasse é um dos indícios de violação do princípio de Liskov. Outro sinal seria se as pré condições da classe filha fossem mais restritivas.

using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace LiskovNok
{
    public class Carro
    {
        protected const double ALIQUOTA_ICMS = 12;
        protected const double ALIQUOTA_IPI = 18;
        protected const double ALIQUOTA_PIS = 1.65;
        protected const double ALIQUOTA_COFINS = 7.6;
        public double ICMS { get; set; }
        public double IPI { get; set; }
        public double PIS { get; set; }
        public double COFINS { get; set; }
        public virtual void CalcularValorImposto(double valorBruto)
        {
            ICMS = valorBruto * ALIQUOTA_ICMS / 100;
            IPI = valorBruto * ALIQUOTA_IPI / 100;
            PIS = valorBruto * ALIQUOTA_PIS / 100;
            COFINS = valorBruto * ALIQUOTA_COFINS / 100;
        }

        public double Imposto
        {
            get { return ICMS + IPI + PIS + COFINS; }
        }
    }
    
    public class Taxi : Carro
    {
        public override void CalcularValorImposto(double valorBruto)
        {
            base.CalcularValorImposto(valorBruto);
            ICMS = 0;
            PIS = 0;
        }
    }

    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void DeveCalcularValorImpostoCarro()
        {
            DeveCalcularValorImposto(new Carro());
        }

        [TestMethod]
        public void DeveCalcularValorImpostoTaxi()
        {
            DeveCalcularValorImposto(new Taxi());
        }

        private void DeveCalcularValorImposto(Carro sut)
        {
            sut.CalcularValorImposto(45000);
            Assert.AreEqual(17662.5, sut.Imposto);
        }
    }
}


Agora vamos ver um exemplo onde a regra de Liskov é atendida. Vou mostrar ainda utilizando as classes Carro e Taxi mas com um contexto diferente.

Na listagem de código seguinte, vemos um sistema que faz o cálculo de estimativa de distância entre dois pontos no mapa para um sistema estilo Waze. A classe Carro faz isso através do método CalcularEstimativas onde recebe dois pontos (objetos da estrutura Coordenada que traz a latitude e longitude) e faz a estimativa. Por simplicidade, no nosso exemplo, eu fiz o cálculo traçando uma linha reta e usando o teorema de Pitágoras, mas sabemos que na vida real um carro percorre ruas e avenidas fugindo do trânsito, então um sistema real a estimativa de distância seria mais apurada.

Já quando adicionamos a classe Taxi neste sistema, além da distância também calculamos a estimativa de custo que esse trajeto trará para o usuário. Fazemos isso no mesmo método CalcularEstimativas. Novamente por simplicidade eu considerei como resultado da conta um valor inicial da bandeirada de R$ 4,50 mais R$ 2,75 de cada unidade de distância percorrida.

Vejam o que acontece no cliente, representado aqui pelo método DeveCalcularEstimativaDistancia. Não importa se eu passo um objeto do tipo Carro ou Taxi, a funcionalidade não vai quebrar! Neste caso, a pós condição da classe Taxi não se enfraqueceu. A classe Carro calcula a distância e a classe Taxi calcula a distância e o preço, ou seja, ela faz uma coisa a mais. A substituição de objetos ocorre sem problemas aqui, demonstrando que este exemplo agora atende o princípio de Liskov.

using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace LiskovOk
{
    public struct Coordenada
    {
        public double Latitude;
        public double Longitude;
        public Coordenada(double latitude, double longitude)
        {
            Latitude = latitude;
            Longitude = longitude;
        }
    }

    public class Carro
    {
        public double Distancia { get; set; }
        public virtual void CalcularEstimativas(Coordenada pontoInicial, Coordenada pontoFinal)
        {
            double cateto1 = pontoFinal.Latitude - pontoInicial.Latitude;
            double cateto1AoQuadrado = Math.Pow(cateto1, 2);
            double cateto2 = pontoFinal.Longitude - pontoInicial.Longitude;
            double cateto2AoQuadrado = Math.Pow(cateto2, 2);
            Distancia = Math.Sqrt(cateto1AoQuadrado + cateto2AoQuadrado);
        }
    }
    
    public class Taxi : Carro
    {
        public double Valor { get; set; }
        public override void CalcularEstimativas(Coordenada pontoInicial, Coordenada pontoFinal)
        {
            base.CalcularEstimativas(pontoInicial, pontoFinal);
            Valor = 4.5 + Distancia * 2.75;
        }
    }

    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void DeveCalcularEstimativaDistanciaComCarro()
        {
            DeveCalcularEstimativaDistancia(new Carro());
        }

        [TestMethod]
        public void DeveCalcularEstimativaDistanciaComTaxi()
        {
            DeveCalcularEstimativaDistancia(new Taxi());
        }

        private void DeveCalcularEstimativaDistancia(Carro sut)
        {
            sut.CalcularEstimativas(new Coordenada(0, 0), new Coordenada(12, 9));
            Assert.AreEqual(15, sut.Distancia);
        }
    }
}


A regra de Liskov é importante pois ela serve para confirmar se estamos fazendo um uso correto da herança de classes, sempre olhando o contexto geral da aplicação. Se não atendermos este princípio, pode ter certeza que alguma besteira está sendo feita. Herdar uma classe somente para reaproveitar códigos utilitários seria um exemplo de erro cometido.

Toda herança depende de um contexto. No primeiro exemplo, o contexto não permite que se tenha subclasses visto que são cálculos totalmente diferentes, não estão relacionados.

Lembre-se sempre de ponderar bem quando se está usando herança: se as pré condições não estão sendo fortificadas; se as pós condições não estão sendo enfraquecidas. Só dessa maneira podemos deixar o sistema melhor desenhado, o que reflete melhor o negócio que ele atende e facilita as manutenções futuras.

Mais para frente eu pretendo comentar de outros princípios do SOLID. Até a próxima!

Comentários

Postagens mais visitadas deste blog

Trocando configurações padrão do Live TIM

Uma proposta de Clean Architecure com Modelo de Atores

Testes automatizados em sistemas autenticados com certificados digitais, usando Selenium e PhantomJS