Recursão não estrutural em Coq
Published:
Introdução
Um problema comum a pessoas que começam a utilizar um assistente de provas, como Coq, é como definir funções que não usam recursão estrutural. Linguagens que suportam tipos dependentes usam verificadores de totalidade para exigir que todas as computações terminam e são bem definidas, garantindo assim a consistência lógica do sistema.
Motivação
Mas, porquê tal restrição é importante? Fica fácil entender o motivo ao considerarmos que Coq (ou outro assistente de provas) aceitasse a seguinte definição:
Fixpoint loop (x : nat) : False := loop x.
Com isso, a expressão loop 0
possui o tipo False
e, portanto, pode ser utilizada para deduzir qualquer teorema usando a tática contradiction
.
Nesse post vou apresentar algumas técnicas para permitir a definição de funções que utilizam recursão não estrutural em Coq. Para isso, utilizarei a seguinte definição:
Fixpoint merge (xs ys : list nat) : list nat :=
match xs, ys with
| nil , ys => ys
| xs , nil => xs
| (x :: xs) , (y :: ys) =>
match le_gt_dec x y with
| left _ _ => x :: (merge xs (y :: ys))
| right _ _ => y :: (merge (x :: xs) ys)
end
end.
que faz a intercalação de duas listas de números naturais. Tal função é utilizada como componente do algoritmo mergesort. Apesar desta função terminar de maneira óbvia, Coq apresenta a seguinte mensagem de erro:
Error: Cannot guess decreasing argument of fix.
que é exibida sempre que uma função não é aceita pelo verificador de terminação.
Uso de “combustível”
Uma técnica simples para definir funções recursivas é utilizar um parâmetro numérico que limita o número máximo de chamadas recursivas realizadas pelo algoritmo. Essa técnica é conhecida como ``fuel-based recursion’’, uma vez que a função termina sua execução quando o combustível acaba ou quando o cálculo realizado pela função é finalizado.
A seguir apresentamos a definição de merge
, utilizando essa técnica.
Fixpoint merge_fuel (xs ys : list nat)(fuel : nat) : option (list nat) :=
match fuel with
| O => None
| S fuel' =>
match xs, ys with
| nil , ys => Some ys
| xs , nil => Some xs
| (x :: xs) , (y :: ys) =>
match le_gt_dec x y with
| left _ _ =>
match merge_fuel xs (y :: ys) fuel' with
| Some r => Some (x :: r)
| None => None
end
| right _ _ =>
match merge_fuel (x :: xs) ys fuel' with
| Some r => Some (y :: r)
| None => None
end
end
end
end.
Note que o resultado de merge_fuel
usa o tipo option
, retornando o valor None
, sempre que a função extrapola o número máximo de chamadas recursivas especificadas pelo parâmetro fuel
.
Apesar de simples definição, essa técnica possui alguns inconvenientes: 1) Nem sempre é fácil determinar o quanto de “combustível” é necessário para se calcular o resultado completo de uma função. Para o exemplo de merge
, basta fornermos como entrada o valor length xs + length ys
, visto que essa função executa um número de passos proporcional ao tamanho das duas listas. 2) Outro inconveniente é que o parâmetro fuel
é preservado durante a extração de código para Haskell e OCaml. Tal parâmetro não possui nenhum valor computacional e não deveria estar presente no código dessa função.
O ideal seria o uso de uma técnica que permita que o código extraído seja o mais próximo possível do que desenvolveríamos em linguagens como Haskell ou OCaml. A próxima técnica atende parcialmente esse requisito.
Uso de relações bem formadas.
Seja R uma relação binária sobre um conjunto A. Dizemos que R é uma relação bem formada se todo subconjunto X de A possui um elemento minimal com respeito a R.
Outra maneira de caracterizar a boa formação de uma relação é em termos de uma noção chamada acessibilidade. Dizemos que um elemento x é acessível com respeito a uma relação R se: 1) x é minimal com respeito a R; ou 2) todo elemento y menor que x com respeito a R, R y x, é acessível. A acessibilidade é facilmente definida em Coq da seguinte maneira:
Inductive Acc (A:Type) (R:A->A->Prop) (x:A) : Prop :=
Acc_intro :
(forall y : A, R y x -> Acc R y) -> Acc R x.
Finalmente, dizemos que uma relação R é bem formada se todos os elementos de um conjunto A são acessíveis com respeito a R.
Definition well_founded (A:Type) (R:A->A->Prop) :=
forall a, Acc R a.
Com isso, podemos provar que a relação < (lt
), sobre os números naturais, é bem formada. Os teoremas seguintes provam esse resultado.
Theorem lt_Acc : forall n : nat, Acc lt n.
Proof.
induction n.
split ; intros p H ; inversion H.
split.
intros y H0.
case (le_lt_or_eq _ _ H0) ; intros.
+
eapply Acc_inv ; eauto ; try omega.
+
injection H ; intros ; subst ; eauto.
Qed.
Theorem ltnat_wf : well_founded lt.
Proof.
exact lt_Acc.
Qed.
Porém, construir relações bem formadas não é uma tarefa simples. A biblioteca padrão de Coq possui diversos combinadores para criar novas relações a partir de relações existentes. Sabe-se que relações bem formadas são fechadas sobre fechamento transitivo, imagem inversa entre outras operações.
Recursão sobre relações bem formadas
O uso da relação de acessibilidade fornece uma maneira prática de garantir que uma definição recursiva sempre termina. De forma simples, a idéia é implementar a função por recursão sobre uma prova de acessibilidade. Porém, como já visto em posts anteriores, o casamento de padrão envolvendo tipos dependentes exige diversas anotações tornando-o verboso e, em muitas situações, complexo. Dessa forma, a biblioteca de Coq, fornece diversas funções que encapsulam o processo de recursão sobre uma prova de acessibilidade entre valores de um tipo que possui uma relação bem formada. Uma delas é a função well-founded-induction
, cujo tipo é apresentado abaixo:
well_founded_induction
: forall (A : Type)
(R : A -> A -> Prop),
well_founded R ->
forall (P : A -> Set),
(forall x : A,
(forall y : A, R y x -> P y) -> P x) ->
forall a : A, P a
Apesar de parecer complicada, o tipo dessa função é bem simples de ser entendido:
O primeiro argumento é
A : Set
, que representa o tipo dos valores de entrada para a função.O segundo argumento,
R : A -> A -> Prop
, é uma relação binária sobreA
.O terceiro argumento é uma prova de que
R
é uma relação bem formada.O quarto argumento é uma função,
P : A -> Set
, utilizado para descrever o tipo do resultado. Isso permite usarmoswell-founded-induction
para definir funções cujo resultado é um tipo dependente.O quinto argumento é uma função que recebe dois argumentos:
a) O primeiro é um argumento
x : A
que denota o valor de entrada para a função que estamos definindo.b) O segundo é uma função de dois parâmetros: o primeiro é um valor
y : A
que deve ser um predecessor dex
emR
, isto é deve ser possível provarR y x
. O valor de retorno dessa função possui tipoP y
. Essa função é utilizada para representar as chamadas recursivas da função que estamos definindo. Note que a provaR y x
especifica que só podemos fazer chamadas recursivas sobre elementosy
“menores” quex
emR
. Uma vez queR
é bem formada, não existem cadeias decrescentes infinitas e isso é suficiente para garantir a terminação da função que estamos definindo.
A maior dificuldade é o que deve ser fornecido como o 5o argumento de well-founded-induction
. De maneira simples, deve ser uma função que permite chamadas recursivas somente a termos que são menores que o parâmetro de entrada de acordo com a relação R utilizada. No caso da função merge
, utilizaremos funcionalidades da biblioteca de Coq para construir a relação e sua prova de boa formação.
Primeiramente, definimos o seguinte tipo para representar entradas para a função merge
Definition merge_input := sigT (fun _ : list nat => list nat).
O tipo sigT
é a representação do produto dependente em Coq. Logo, podemos entender o tipo merge_input
como um par de listas de números naturais. Utilizamos o tipo sigT
ao invés de pares porquê o tipo de well-founded-induction
retorna uma função de tipo dependente. As definições seguintes são usadas para construir valores e obter componentes do tipo merge_input
.
Definition mk_input (xs ys : list nat) : merge_input := existT _ xs ys.
Definition list1 (inp : merge_input) : list nat :=
let (xs,_) := inp in xs.
Definition list2 (inp : merge_input) : list nat :=
let (_,ys) := inp in ys.
Definimos a relação de ordem entre entradas para o algoritmo merge
da seguinte maneira: Sejam xs,ys, xs’ e ys’ listas quaisquer. Dizemos que (xs,ys) < (xs’,ys’) se (xs < xs’) \/ ((xs = xs’) /\ ys < ys’). Porém, isso corresponde exatamente a noção de ordem lexicográfica entre pares de valores. É fácil demonstrar que dadas duas relações bem formadas a ordem lexicográfica de pares de valores dessas relações é também uma relação bem formada. A demonstração desse fato está presente na biblioteca padrão de Coq. Para utilizarmos essa demonstração precisamos primeiro construir a ordem lexicográfica usando a função lexprod
, que recebe como parâmetros os tipos de cada componente do par e a relação de ordem de cada um destes tipos.
Definition merge_input_lt : merge_input -> merge_input -> Prop
:= lexprod (list nat)
(fun _ => list nat)
(fun (xs xs' : list nat) => length xs < length xs')
(fun _ (ys ys' : list nat) => length ys < length ys').
De posse da ordem lexicográfica, podemos construir sua prova de boa formação usando a função wf_lexprod
, que recebe como parâmetros o tipo de cada componente, suas respectivas ordenadações e provas de boa formação produzindo, como resultado, a prova de que a ordem lexicográfica é também bem formada.
Definition merge_input_lt_wf : well_founded merge_input_lt
:= @wf_lexprod (list nat)
(fun _ => list nat)
(fun (xs xs' : list nat) => length xs < length xs')
(fun _ (ys ys' : list nat) => length ys < length ys')
(well_founded_ltof (list nat) (@length nat))
(fun _ => well_founded_ltof (list nat) (@length nat)).
A seguir, definimos dois lemmas que mostram que os valores passados para as chamadas recursivas de merge
são menores de acordo com a ordem definida.
Lemma mergeF_obligation1
: forall x y xs ys, merge_input_lt (mk_input xs (y :: ys))
(existT _ (x :: xs) (y :: ys)).
Proof.
intros ; apply left_lex ; simpl ; try omega.
Qed.
Lemma mergeF_obligation2
: forall x y xs ys, merge_input_lt (mk_input (x :: xs) ys)
(existT _ (x :: xs) (y :: ys)).
Proof.
intros ; apply right_lex ; simpl ; try omega.
Qed.
Agora, definimos o corpo da função merge
, que recebe como parâmetro, uma função que permite a recursão sobre valores que são menores de acordo com a ordem definida.
Definition mergeF
: forall inp : merge_input,
(forall inp' : merge_input, merge_input_lt inp' inp -> list nat) -> list nat.
refine (fun inp canRec =>
match inp as inp' return inp = inp' -> list nat with
| existT _ nil ys => fun _ => ys
| existT _ xs nil => fun _ => xs
| existT _ (x :: xs) (y :: ys) => fun _ =>
if le_gt_dec x y then x :: canRec (mk_input xs (y :: ys)) _
else y :: canRec (mk_input (x :: xs) ys) _
end (eq_refl inp)) ; subst ; auto.
Defined.
Finalmente, podemos definir a função merge
utilizando well-founded-induction
da seguinte maneira:
Definition merge_wf (xs ys : list nat) : list nat :=
well_founded_induction merge_input_lt_wf (fun _ => list nat) mergeF (mk_input xs ys).
O uso de relações bem formadas permite definir funções com padrões de recursão quaisquer, desde que seja possível demonstrar que em todas as chamadas recursivas, o valor utilizado nessas chamadas é menor que o passado como entrada para a função em questão.
Recursão sobre um predicado de domínio
Na seção anterior descrevemos como utilizar recursão sobre provas de boa formação de uma relação para definir funções recursivas. Outra possibilidade é utilizar um predicado que descreva quais valores pertencem ao domínio da função a ser definida. Dessa forma, realizamos a recursão sobre a estrutura desse predicado. Usualmente, o predicado de domínio de uma função descreve o formato dos parâmetros para os casos base e chamadas recursivas de maneira a caracterizar o grafo de chamadas da função definida.
O predicado merge_acc
define o domínio da função merge
. Note que cada constructor do predicado, caracteriza uma das equações da definição da função.
Inductive merge_acc : list nat -> list nat -> Prop :=
| Merge1 : forall xs, merge_acc xs nil
| Merge2 : forall ys, merge_acc nil ys
| Merge3 : forall x y xs ys,
x <= y -> merge_acc xs (y :: ys) -> merge_acc (x :: xs) (y :: ys)
| Merge4 : forall x y xs ys,
x > y -> merge_acc (x :: xs) ys -> merge_acc (x :: xs) (y :: ys).
De posse desse predicado, definimos dois lemmas que serão utilizados para permitir as chamadas recursivas a merge
utilizando sub-termos desse predicado.
Ltac inv H := inversion H ; subst ; clear H.
Lemma merge_acc_inv3
: forall xs ys x y xxs yys,
xxs = x :: xs -> yys = y :: ys ->
merge_acc xxs yys ->
x <= y ->
merge_acc xs yys.
Proof.
intros xs ys x y xxs yys eqx eqy H Hle;
subst; inv H ; eauto ; try omega.
Defined.
Lemma merge_acc_inv4
: forall xs ys x y xxs yys,
xxs = x :: xs -> yys = y :: ys ->
merge_acc xxs yys ->
x > y ->
merge_acc xxs ys.
Proof.
intros xs ys x y xxs yys eqx eqy H Hxy ;
subst ; inv H ; eauto ; try omega.
Defined.
Usando esses lemas, podemos definir a função por recursão sob provas de merge_acc
como se segue:
Fixpoint merge_bc1
(xs ys : list nat)(H : merge_acc xs ys) {struct H}: list nat :=
(match xs as us, ys as vs return xs = us -> ys = vs -> list nat with
| nil, ys => fun _ _ => ys
| xs , nil => fun _ _ => xs
| (x :: xs) , (y :: ys) =>
match le_gt_dec x y with
| left _ h1 => fun eqx eqy =>
let H' := merge_acc_inv3 _ _ x y _ _ eqx eqy H h1
in x :: merge_bc1 _ _ H'
| right _ h2 => fun eqx eqy =>
let H' := merge_acc_inv4 _ _ x y _ _ eqx eqy H h2
in y :: merge_bc1 _ _ H'
end
end) eq_refl eq_refl.
Note que a definição anterior precisa de uma prova de que o predicado é verdadeiro para as listas fornecidas como parâmetro. Para criação dessa demonstração, devemos provar que o predicado merge_acc
é demonstrável para quaisquer listas xs
e ys
. A prova é como se segue.
Definition merge_acc_intro : forall xs ys, merge_acc xs ys.
induction xs as [| x xs IHxs]; destruct ys as [| y ys] ;
try solve [econstructor ; eauto].
+
destruct (le_gt_dec x y) ; eauto.
induction ys as [| y' ys' IHys'].
*
apply Merge4 ; eauto.
*
apply Merge4.
assumption.
inv IHys'. try omega.
destruct (le_gt_dec x y') ; try omega ; auto.
Defined.
De posse da prova de que o predicado é verdadeiro para quaisquer listas xs e ys e da função merge_bc1
, podemos criar a função de intercalação de maneira direta.
Definition merge_bc (xs ys : list nat) : list nat.
apply (merge_bc1 xs ys).
apply merge_acc_intro.
Defined.
Conclusão
Nesse post apresentamos três técnicas para definição de funções que não utilizam recursão estrutural em Coq. Utilizamos um exemplo clássico — a rotina de intercalação de listas do algoritmo merge sort — para apresentar de maneira suscinta cada uma das técnicas. Explicações mais detalhadas sobre cada uma destas técnicas podem ser encontradas nos livros Certified Programming with Dependent Types e Interactive Theorem Proving and Program Developement.
O código completo deste post está disponível no seguinte gist.