Uma prova é muito mais difícil no mundo da OOP por causa de efeitos colaterais, herança irrestrita e null
ser membro de todos os tipos. A maioria das provas se baseia em um princípio de indução para mostrar que você cobriu todas as possibilidades, e todas essas três coisas tornam isso mais difícil de provar.
Digamos que estamos implementando árvores binárias que contêm valores inteiros (para manter a sintaxe mais simples, não trarei programação genérica para isso, embora isso não mude nada.) No ML padrão, eu definiria isso como esta:
datatype tree = Empty | Node of (tree * int * tree)
Isso introduz um novo tipo chamado tree
cujos valores podem vir exatamente em duas variedades (ou classes, que não devem ser confundidas com o conceito OOP de uma classe) - um Empty
valor que não carrega informações e Node
valores que carregam uma tripla cuja primeira e última elementos são se tree
cujo elemento do meio é um int
. A aproximação mais próxima a esta declaração no OOP seria assim:
public class Tree {
private Tree() {} // Prevent external subclassing
public static final class Empty extends Tree {}
public static final class Node extends Tree {
public final Tree leftChild;
public final int value;
public final Tree rightChild;
public Node(Tree leftChild, int value, Tree rightChild) {
this.leftChild = leftChild;
this.value = value;
this.rightChild = rightChild;
}
}
}
Com a ressalva de que variáveis do tipo Árvore nunca podem ser null
.
Agora vamos escrever uma função para calcular a altura (ou profundidade) da árvore e supor que temos acesso a uma max
função que retorna o maior de dois números:
fun height(Empty) =
0
| height(Node (leftChild, value, rightChild)) =
1 + max( height(leftChild), height(rightChild) )
Nós definimos a height
função de casos - não há uma definição para Empty
árvores e uma definição para Node
árvores. O compilador sabe quantas classes de árvores existem e emitirá um aviso se você não definir os dois casos. A expressão Node (leftChild, value, rightChild)
na assinatura da função liga os valores da 3-tupla às variáveis leftChild
, value
e, rightChild
respectivamente, para que possa consultá-las na definição da função. É semelhante a ter declarado variáveis locais como este em uma linguagem OOP:
Tree leftChild = tuple.getFirst();
int value = tuple.getSecond();
Tree rightChild = tuple.getThird();
Como podemos provar que implementamos height
corretamente? Podemos usar a indução estrutural , que consiste em: 1. Prove que height
está correto no (s) caso (s) base (s) do nosso tree
tipo ( Empty
) 2. Supondo que as chamadas recursivas height
estejam corretas, prove que está correto no (s) caso (s) height
não básico (s) ) (quando a árvore é realmente a Node
).
Para a etapa 1, podemos ver que a função sempre retorna 0 quando o argumento é uma Empty
árvore. Isso está correto por definição da altura de uma árvore.
Para a etapa 2, a função retorna 1 + max( height(leftChild), height(rightChild) )
. Supondo que as chamadas recursivas realmente retornem a altura das crianças, podemos ver que isso também está correto.
E isso completa a prova. Os passos 1 e 2 combinados esgotam todas as possibilidades. Observe, no entanto, que não temos mutação, nulos e existem exatamente duas variedades de árvores. Tire essas três condições e a prova rapidamente se torna mais complicada, se não impraticável.
EDIT: Como essa resposta chegou ao topo, gostaria de adicionar um exemplo menos trivial de uma prova e cobrir a indução estrutural um pouco mais completamente. Acima, provamos que, se height
retorna , seu valor de retorno está correto. Porém, não provamos que ele sempre retorna um valor. Também podemos usar a indução estrutural para provar isso (ou qualquer outra propriedade). Novamente, durante a etapa 2, podemos assumir que a propriedade retém as chamadas recursivas, desde que todas as chamadas recursivas operem em um filho direto do árvore.
Uma função pode falhar ao retornar um valor em duas situações: se lança uma exceção e se faz um loop para sempre. Primeiro, vamos provar que, se nenhuma exceção for lançada, a função será encerrada:
Prove que (se nenhuma exceção for lançada) a função será encerrada para os casos base ( Empty
). Como retornamos 0 incondicionalmente, ele termina.
Prove que a função termina nos casos não básicos ( Node
). Há três chamadas de função aqui: +
, max
, e height
. Sabemos disso +
e max
terminamos porque fazem parte da biblioteca padrão da linguagem e são definidos dessa maneira. Como mencionado anteriormente, podemos assumir que a propriedade que estamos tentando provar é verdadeira em chamadas recursivas, desde que operem em subárvores imediatas; portanto, as chamadas height
também serão encerradas.
Isso conclui a prova. Observe que você não poderá provar a rescisão com um teste de unidade. Agora tudo o que resta é mostrar que height
não gera exceções.
- Prove que
height
não lança exceções no caso base ( Empty
). Retornar 0 não pode gerar uma exceção, por isso terminamos.
- Prove que
height
não gera exceção no caso não básico ( Node
). Suponha mais uma vez que sabemos +
e max
não lançamos exceções. E a indução estrutural nos permite assumir que as chamadas recursivas também não serão lançadas (porque operam nos filhos imediatos da árvore). Mas espere! Esta função é recursiva, mas não recursiva de cauda . Nós poderíamos explodir a pilha! Nossa tentativa de prova descobriu um bug. Podemos corrigi-lo mudando height
para ser recursivo da cauda .
Espero que isso mostre que as provas não precisam ser assustadoras ou complicadas. De fato, sempre que você escreve código, constrói informalmente uma prova em sua mente (caso contrário, não se convenceria de que acabou de implementar a função.) Ao evitar mutações nulas, desnecessárias e herança irrestrita, você pode provar que sua intuição é corrija com bastante facilidade. Essas restrições não são tão severas quanto você imagina:
null
é uma falha de linguagem e acabar com ela é incondicionalmente boa.
- Às vezes, a mutação é inevitável e necessária, mas é necessária com muito menos frequência do que você imagina - especialmente quando você tem estruturas de dados persistentes.
- Quanto a ter um número finito de classes (no sentido funcional) / subclasses (no sentido OOP) versus um número ilimitado delas, esse é um assunto grande demais para uma única resposta . Basta dizer que existe uma troca de design por aí - probabilidade de correção versus flexibilidade de extensão.