domingo, 24 de dezembro de 2006

Aprofundando os conceitos de Design by Contract

No último post sobre DbC, descrevi seus conceitos básicos e algumas considerações necessárias para sua aplicação. Farei agora um pequeno aprofundamento, descrevendo alguns aspectos importantes para sua aplicação prática.

 

Resumo rápido

  • Um contrato aplicado à escrita de código compreende na verificação de certas condições que devem ser verdadeiras durante a execução do programa. Se não forem, significa que o programa violou o contrato definido e conseqüentemente possui bugs.
  • Geralmente as linguagens de programação possuem uma rotina (uma função, um método ou mesmo uma macro) chamada Assert, que podemos utilizar para verificar se os contratos estão sendo cumpridos.

Contratos geralmente possuem:

  • Pré-condições: Condições esperadas; Valores ou estados previstos de serem recebidos;
  • Pós-condições: Resultados esperados; Valores ou estados previstos de serem resultantes;
  • Invariáveis: Condições que permeiam (estão presentes em) todos os contratos e que são imutáveis.

Sob os aspectos de implementação, da escrita de código, podemos dizer que as pré-condições tipicamente validam os parâmetros de entrada de uma rotina; as pós-condições validam os estados e valores resultantes da rotina; e as invariáveis, validam estados que devem permanecer verdadeiros, imutáveis, antes das pré-condições e após as pós-condições.

As invariáveis geralmente são utilizadas em classes, de forma a garantir que todas as suas instâncias sempre possuam determinados estados.

 

Mais sobre Assert

Por Assert interromper a execução do programa, gerando uma descrição da falha no cumprimento da condição, a utilizamos somente em modo de depuração (debug mode). No modo de depuração efetuamos todos os testes no programa em busca de falhas, até que o software seja considerado suficientemente estável para ser liberado. Neste ponto, compilamos o software em modo de liberação (release node) fazendo com que os Asserts sejam desabilitados. Desta forma, o programa não termina ao entrar num estado inválido, apesar de poder funcionar de maneira imprevisível.

Logo, a qualidade dos testes efetuados em modo de depuração será a garantia de que o software não está operando fora do esperado em modo de liberação.

Exemplo (C++):

void Funcionario::DefinirDiaDoPagamento(
  const int dia)

{
        // Contrato: O dia do pagamento deve ser
        // sempre igual, independente do mes em
        // que e' pago.
        
        // Logo, considerando que ha' um mes que
        // possui 28 dias (fevereiro) de quatro em
        // quatro anos, o dia do pagamento deve
        // variar entre 1 e 28, para poder ser
        // SEMPRE igual.

 
        #ifdef _DEBUG

        assert( ( dia >= 1 ) && ( dia <= 28 ) );
        #endif
 
        
_diaDoPagamento = dia;
}

Com a ajuda do exemplo, podemos notar alguns aspectos:

  • O método não trata o valor do dia fornecido. Pelo contrário, ele estabelece um contrato (com uma pré-condição) de que o valor do dia já esteja tratado (validado) e dentro de limites esperados.
  • O contrato só será validado em modo de depuração.
  • Ao retirar a pré-condição, o funcionamento do método continua o mesmo.

(Observação: No exemplo, a classe Funcionario poderia ter uma invariável que substituiria sua pré-condição. Assim, seria garantido que _diaDoPagamento sempre estaria na a faixa de valores desejada, independente do método que operasse sobre ele.)

 

Removabilidade de Contratos

Os contratos devem sempre poder ser removidos sem que o funcionamento do programa mude. Isso porque se o contrato fosse parte da lógica do programa, ele deveria estar sujeito a garantia de qualidade. Então teríamos de ter outros contratos para validar esses contratos, e assim por diante, o que é notadamente inviável e incorreto. Também porque devemos poder alterar a forma como eles são implementados ou removê-los por eficiência, sem que tenhamos que alterar outras partes do programa.

 

Herança, Polimorfismo e Contratos

Ao implementarmos herança em uma classe, trazemos os contratos da classe pai para a classe filha. A classe filha então pode fortalecer ou enfraquecer os contratos:

  • Fortalecer um contrato significa manter as cláusulas do contrato antigo e ainda adicionar novas cláusulas ao mesmo;
  • Enfraquecer um contrato significa não cumprir as cláusulas do contrato antigo sob certas condições;

Em termos de implementação, veremos que:

  • Podemos usar AND para fortalecer um contrato; e
  • Podemos usar OR para enfraquecer um contrato;

Exemplo (C++):

      Contrato original:

   // Pressao deve ser menor que 120
   assert( pressao <= 120 );

      Contrato fortalecido:

   // Pressao deve ser menor que 120 e
   // Temperatura deve ser menor que 400
   assert( ( pressao <= 120 )
     && ( temperatura <= 400 ) );

      Contrato enfraquecido:

   // Pressao deve ser menor que 120 ou
   // Temperatura deve ser menor que 400

   assert( ( pressao <= 120 )
     || ( temperatura <= 400 ) );

 

Fica evidenciado, então, como podemos fortalecer ou enfraquecer contratos. Mas quando devemos fortalecer ou enfraquecer um contrato ?

Para responder a esta questão, vamos refletir sobre seu impacto sobre o comportamento da classe filha.

Suponha que uma classe A possua um método X. Sua classe filha, B, precisa reimplementar X para que X se comporte conforme suas necessidades (polimorfismo). Se o método X de B fortalece as pré-condições de X de A, o risco de uma chamada correta para X de B não ser correta para X de A aumenta. Em outras palavras, como o X de B adiciona cláusulas ao contrato que existe em X de A, pode ser que uma destas cláusulas não seja cumprida ao chamar X de A. Ou mais resumido, o que é válido para B pode não ser para A.

Ainda, se o método X de B enfraquece uma pós-condição do método X de A, um resultado esperado para X de A pode não ser esperado para X de B, levando ao risco de haver problemas no entendimento do valor resultante de X de B.

Logo, concluímos que fortalecer uma pré-condição de um método em uma classe filha pode acarretar em um aumento do risco para o mesmo método da classe pai. E se enfraquecermos uma pós-condição de um método na classe filha, aumentamos o risco do método da classe filha.

Com isso, respondemos nossa pergunta e formamos duas regras:

  • Pré-condições não devem ser fortalecidas;
  • Pós-condições não devem ser enfraquecidas;

E quanto às invariáveis ?

Como uma instância de uma classe filha pode ser considerada uma instância de suas classes pai, podemos dizer que as regras (invariáveis) aplicadas para a classe filha devem servir também para as classes pai. Logo, só é permitido fortalecer as invariáveis das classes pai, o que nos leva a uma terceira regra:

  • Invariáveis não podem ser enfraquecidas;

Essas regras levam a um melhor entendimento dos contratos e das suas relações. Com elas podemos aplicar os contratos com mais segurança e garantir que a aplicação de herança e polimorfismo não resultará numa violação de algum contrato estabelecido.

 

Confiar no código escrito ?

O uso de Assert como garantia de contrato implica que o programa termine caso se encontrado uma violação no contrato (uma falha). Isso é bom, pois obriga-nos a consertar o software e não ignorar a falha, para que seja corrigida imediatamente e que a versão de liberação não contenha a mesma. Como vimos, na versão de liberação a falha não é mais tratada, já que confiamos nos testes efetuados na versão de depuração.

Como podemos então garantir ou ao menos melhorar as chances de não haver uma violação de contrato, ou mesmo outras falhas no software ?

Podemos introduzir algumas ações:

A aplicação destas ações maximiza (e muito) a chance de nunca ocorrer uma violação de contrato no software e ele funcionar conforme o previsto, mesmo que os contratos não cubram todas as falhas. Sim, o estabelecimento contrato ajuda na identificação de falhas. Mas não, ele sozinho não cobre todas as possíveis falhas.

Isso que acabei de citar leva a uma implicação:

A ausência de evidência de um erro não é a evidência da ausência de um erro.

Em outras palavras, só porque você não achou um erro não que dizer que ele não exista.

 

Conclusão

Vimos contratos mais a fundo e estabelecemos algumas regras importantes para a sua aplicação. Observamos que o uso de contratos auxilia a escrever código com menos falhas, mas que sozinho não pode oferecer a garantia de que falhas não ocorrerão.

 

Em um próximo post estarei mostrando exemplos de DbC na prática, em linguagens que suportam e que não o suportam nativamente. Até breve.

 

Nenhum comentário: