Li nesta pergunta que programadores funcionais tendem a usar provas matemáticas para garantir que seu programa esteja funcionando corretamente. Isso parece muito mais fácil e rápido do que o teste de unidade, mas vindo de uma experiência em OOP / Unit Testing, nunca vi isso acontecer.
Você pode me explicar e me dar um exemplo?
testing
functional-programming
leeand00
fonte
fonte
null
).Respostas:
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) - umEmpty
valor que não carrega informações eNode
valores que carregam uma tripla cuja primeira e última elementos são setree
cujo elemento do meio é umint
. A aproximação mais próxima a esta declaração no OOP seria assim: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:Nós definimos a
height
função de casos - não há uma definição paraEmpty
árvores e uma definição paraNode
árvores. O compilador sabe quantas classes de árvores existem e emitirá um aviso se você não definir os dois casos. A expressãoNode (leftChild, value, rightChild)
na assinatura da função liga os valores da 3-tupla às variáveisleftChild
,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:Como podemos provar que implementamos
height
corretamente? Podemos usar a indução estrutural , que consiste em: 1. Prove queheight
está correto no (s) caso (s) base (s) do nossotree
tipo (Empty
) 2. Supondo que as chamadas recursivasheight
estejam corretas, prove que está correto no (s) caso (s)height
não básico (s) ) (quando a árvore é realmente aNode
).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
, eheight
. Sabemos disso+
emax
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 chamadasheight
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.height
não lança exceções no caso base (Empty
). Retornar 0 não pode gerar uma exceção, por isso terminamos.height
não gera exceção no caso não básico (Node
). Suponha mais uma vez que sabemos+
emax
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 mudandoheight
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.fonte
É muito mais fácil argumentar sobre código quando tudo é imutável . Como resultado, os loops são mais frequentemente gravados como recursão. Em geral, é mais fácil verificar a exatidão de uma solução recursiva. Freqüentemente, essa solução também será lida de maneira muito semelhante a uma definição matemática do problema.
No entanto, há muito pouca motivação para realizar uma prova formal real de correção na maioria dos casos. As provas são difíceis, demoram muito tempo (humanas) e têm um ROI baixo.
Algumas linguagens funcionais (especialmente da família ML) têm sistemas de tipos extremamente expressivos que podem oferecer garantias muito mais completas que um sistema do tipo C (mas algumas idéias como genéricas também se tornaram comuns nas linguagens comuns). Quando um programa passa em uma verificação de tipo, esse é um tipo de prova automatizada. Em alguns casos, este será capaz de detectar alguns erros (por exemplo, esquecer o caso base em uma recursão, ou esquecendo-se de lidar com certos casos em um jogo padrão).
Por outro lado, estes sistemas do tipo tem que ser mantido muito limitado, a fim de mantê-los decidable . Assim, em certo sentido, nós ganhamos garantias estáticos, dando-se flexibilidade - e estas restrições são uma razão pela qual complicado trabalhos acadêmicos ao longo das linhas de “ A solução monádico a um problema resolvido, em Haskell ” existir.
Gosto de línguas muito liberais e muito restritas, e ambas têm suas respectivas dificuldades. Mas não é o caso de um ser "melhor", cada um é apenas mais conveniente para um tipo diferente de tarefa.
Depois, é preciso salientar que provas e testes de unidade não são intercambiáveis. Ambos nos permitem colocar limites à exatidão do programa:
O teste coloca um limite superior na correção: se um teste falhar, o programa está incorreto, se nenhum teste falhar, estamos certos de que o programa tratará dos casos testados, mas ainda poderá haver erros não descobertos.
As provas colocam um limite mais baixo na correção: pode ser impossível provar certas propriedades. Por exemplo, pode ser fácil provar que uma função sempre retorna um número (é o que os sistemas de tipos fazem). Mas pode ser impossível provar que o número sempre será
< 10
.fonte
Uma palavra de aviso pode estar em ordem aqui:
Embora seja geralmente verdade o que os outros escrevem aqui - em resumo, que sistemas de tipo avançado, imutabilidade e transparência referencial contribuem muito para a correção - não é o caso de que o teste não seja realizado no mundo funcional. Pelo contrário !
Isso ocorre porque temos ferramentas como o Quickcheck, que geram casos de teste automática e aleatoriamente. Você apenas declara as leis que uma função deve obedecer e, em seguida, a verificação rápida verifica essas leis em centenas de casos de teste aleatórios.
Veja bem, esse é um nível um pouco mais alto do que as verificações triviais de igualdade em alguns casos de teste.
Aqui está um exemplo de uma implementação de uma árvore AVL:
A segunda lei (ou propriedade) que podemos ler da seguinte maneira: Para todas as árvores arbitrárias
t
, o seguinte vale: set
não estiver vazio, para todas as chavesk
dessa árvore, ela manterá a procurak
na árvore resultante da exclusãok
det
, o resultado seráNothing
(o que indica: não encontrado).Isso verifica a funcionalidade adequada para exclusão de uma chave existente. Quais leis devem governar a exclusão de uma chave inexistente? Certamente queremos que a árvore resultante seja igual à que excluímos. Podemos expressar isso facilmente:
Dessa forma, testar é realmente divertido. Além disso, depois de aprender a ler as propriedades de verificação rápida, elas servem como uma especificação testável por máquina .
fonte
Não entendo exatamente o que a resposta vinculada significa "alcançar a modularidade por meio de leis matemáticas", mas acho que tenho uma idéia do que se entende.
Confira o Functor :
Não vem com casos de teste, mas com algumas leis que devem ser cumpridas.
Agora, digamos que você implemente
Functor
( fonte ):O problema é verificar se sua implementação atende às leis. Como você faz isso?
Uma abordagem é escrever casos de teste. A limitação fundamental dessa abordagem é que você está verificando o comportamento em um número finito de casos (boa sorte testando exaustivamente uma função com 8 parâmetros!) E, portanto, passar nos testes não pode garantir nada, exceto que os testes passam.
Outra abordagem é usar o raciocínio matemático, ou seja, uma prova, com base na definição real (em vez de no comportamento em um número limitado de casos). A idéia aqui é que uma prova matemática pode ser mais eficaz; no entanto, isso depende de quão favorável é o seu programa à prova matemática.
Não posso guiá-lo através de uma prova formal real de que a
Functor
instância acima satisfaz as leis, mas tentarei fornecer um esboço de como a prova pode parecer:fmap id = id
Nothing
fmap id Nothing
=Nothing
pela parte 1 da implementaçãoid Nothing
=Nothing
pela definição deid
Just x
fmap id (Just x)
=Just (id x)
=Just x
pela parte 2 da implementação, depois pela definição deid
fmap (p . q) = (fmap p) . (fmap q)
Nothing
fmap (p . q) Nothing
=Nothing
pela parte 1(fmap p) . (fmap q) $ Nothing
=(fmap p) $ Nothing
=Nothing
por duas aplicações da parte 1Just x
fmap (p . q) (Just x)
=Just ((p . q) x)
=Just (p (q x))
pela parte 2, depois pela definição de.
(fmap p) . (fmap q) $ (Just x)
=(fmap p) $ (Just (q x))
=Just (p (q x))
por duas aplicações da parte doisfonte
"Cuidado com os bugs no código acima; eu apenas provei que está correto, não tentei." - Donald Knuth
Em um mundo perfeito, os programadores são perfeitos e não cometem erros, então não há bugs.
Em um mundo perfeito, cientistas da computação e matemáticos também são perfeitos, e também não cometem erros.
Mas não vivemos em um mundo perfeito. Portanto, não podemos confiar nos programadores para cometer erros. Mas não podemos assumir que qualquer cientista da computação que forneça uma prova matemática de que um programa esteja correto não cometeu nenhum erro nessa prova. Portanto, eu não prestaria atenção a ninguém tentando provar que seu código funciona. Escreva testes de unidade e mostre-me que o código se comporta de acordo com as especificações. Qualquer outra coisa não vai me convencer de nada.
fonte