Úvod do funkcionálneho programovania v Rocq a prvé dôkazy

Ciele

  1. spustiť Rocq, pracovať v RocqIDE a rozlíšiť term Galliny, príkaz Vernacular a taktiku;
  2. pomocou Definition , fun , Check , Compute a Print definovať a skúmať funkcie a ich typy;
  3. definovať induktívny typ, spracovať ho pomocou match , napísať jednoduchý Fixpoint a vysvetliť úplnosť vzorov a kontrolu terminácie.
  4. sledovať dôkazový stav a zostaviť jednoduché dôkazy pomocou základných taktík.

Postup

Krok 1

Rocq ako nástroj teórie typov

Rocq v predmete Teória typov používame na štúdium vzťahu medzi typovaním, výpočtom a dokazovaním. V tomto cvičení definujeme dátové typy a funkcie, preskúmame typy termov a vyhodnotíme ich aplikácie. Na tieto základy nadviaže formulácia tvrdení a konštrukcia formálnych dôkazov.

Rocq je interaktívny dokazovací systém. Jeho jadro vychádza z Calculus of Inductive Constructions (CIC), teórie typov spájajúcej závislé funkčné typy, induktívne definície a konštruktívne dokazovanie. V Rocqu možno definovať matematické objekty, programy, tvrdenia aj dôkazy. Správnosť formálneho dôkazu napokon kontroluje malé dôveryhodné jadro.

Rocq obsahuje čistý funkcionálny jazyk Gallina, ktorý používame na zápis termov, funkcií, typov a tvrdení. Keď napríklad definujeme funkciu, systém overí, že jej telo má deklarovaný typ. Neskôr budeme rovnakú myšlienku typovej kontroly používať pri dôkazoch.

Poznámka

V softvérovom inžinierstve Rocq umožňuje overovať presne formulované vlastnosti programov. Príkladom je CompCert, prekladač jazyka C, pri ktorom boli v Rocqu formálne overené dôležité vlastnosti prekladu. Formálna verifikácia sa vždy vzťahuje na konkrétnu špecifikáciu a model; sama osebe nie je zárukou bezchybnosti ľubovoľného softvéru.

Vrstvy zápisu v Rocqu

Pri práci s Rocqom sa stretávame s viacerými vrstvami zápisu:

  1. Gallina je jazyk termov. Termom môže byť číslo, funkcia, aplikácia funkcie alebo výraz match; zapisujeme ním aj matematické tvrdenia.
  2. Vernacular obsahuje príkazy, ktoré menia globálne prostredie alebo sa ho dopytujú. Patria sem Definition, Fixpoint, Inductive, Check, Compute, Print, Lemma a Theorem. Príkaz sa končí bodkou; term môže byť jeho súčasťou.
  3. Taktické jazyky, najmä Ltac a Ltac2, slúžia na interaktívne vytváranie dôkazov. Ich kroky spracúvajú aktuálne dôkazové ciele.

Napríklad v príkaze Check 3. je Check príkaz Vernacular a 3 je term Galliny. Rovnako Definition double (n : nat) : nat := n + n. je príkaz, v ktorom je n + n telom funkcie v Galline.

Poznámka

Nasledujúcu ukážku dôkazu možno vykonať v Rocqu. Taktiky intros, induction, simpl a rewrite budeme podrobne vysvetľovať až v ďalšom cvičení. Teraz si stačí všimnúť hranicu medzi tvrdením a jeho dôkazom.

Lemma plus_0_r : forall n : nat, n + 0 = n.
Proof.
  intros n.
  induction n as [| n IHn].
  - reflexivity.
  - simpl. rewrite IHn. reflexivity.
Qed.

Inštalácia Rocqu

Postup inštalácie pre svoj operačný systém nájdete na oficiálnej stránke Rocqu. Nainštalujte Rocq a pracovné prostredie, v ktorom budete otvárať súbory s príponou .v.

Použiť možno viacero prostredí:

  • RocqIDE poskytuje editor, zobrazenie cieľov a správ i interaktívne krokovanie.
  • Visual Studio Code s rozšírením VsRocq poskytuje podporu Rocqu; podľa spôsobu inštalácie môže byť potrebný aj zodpovedajúci jazykový server.
  • Emacs s rozšírením Proof General je ďalšia možnosť interaktívnej práce.

V rámci tohto cvičenia budeme primárne používať RocqIDE, aby sme mohli sledovať rovnaké okná a postupy. Príklady v súboroch .v možno vykonať aj v inom podporovanom prostredí.

Ovládanie RocqIDE

RocqIDE má editor zdrojového súboru, okno aktuálnych cieľov Goals, okno správ Messages a ovládací panel. V editore píšeme definície, výpočty aj dôkazy. V prvej lekcii bude väčšina príkazov definíciami a dopytmi; okno Goals bude dôležité najmä pri neskoršom dokazovaní.

  1. Editor je hlavné textové okno. Príkazy v ňom možno postupne odosielať systému. Na obrázku je editor vľavo.

Editor zdrojového súboru v RocqIDE
Obr. 1: Editor zdrojového súboru v RocqIDE

  1. Goals zobrazuje aktuálny dôkazový cieľ a prípadné podciele. Ak práve nedefinujeme dôkaz, nemusí obsahovať nič, čo by bolo potrebné riešiť.

Okno Goals v RocqIDE
Obr. 2: Okno Goals v RocqIDE

  1. Messages zobrazuje výsledky príkazov Check, Print a Compute, ako aj upozornenia a chybové hlásenia.

Okno Messages v RocqIDE
Obr. 3: Okno Messages v RocqIDE

Ovládací panel umožňuje vykonať ďalší príkaz (Next), vrátiť sa o krok (Previous), vrátiť spracovanie na začiatok (Reset) alebo skontrolovať zvyšok súboru (Run to end). Krokovanie sa vzťahuje na príkazy v súbore, nielen na dôkazové taktiky. Klávesové skratky sa môžu líšiť podľa verzie RocqIDE a operačného systému; aktuálne priradenie si overte v ponuke aplikácie.

Prvý term, typ a výpočet

Do nového súboru .v vložte tieto príkazy a vykonávajte ich po jednom:

Check 3.
Compute 2 + 3.

Definition double (n : nat) : nat :=
  n + n.

Check double.
Compute double 4.
Print double.

Výstupy príkazov v poradí, v akom sa vykonajú:

3 : nat
= 5
: nat
double : nat -> nat
= 8
: nat

Skrátený výstup Print double.:

double = fun n : nat => n + n
     : nat -> nat

Ďalšie pomocné riadky výpisu Print sa môžu medzi verziami líšiť.

Číslo 3 je term typu nat, čo zistí Check 3.. Compute 2 + 3. vyhodnotí term a zobrazí výsledok 5. Príkaz Definition zavedie meno double pre funkciu, ktorá svoj vstup sčíta so sebou. Check double. vypíše funkčný typ nat -> nat; Compute double 4. vypíše 8. Print double. zobrazí uloženú definíciu a jej typ.

Výsledky príkazov sa zobrazujú v Messages. Check zisťuje typ, Compute vykonáva výpočet a Print ukazuje definíciu. Funkčný typ nat -> nat hovorí, že double prijíma prirodzené číslo a vracia prirodzené číslo.

Krok 2

Termy a funkcie v Galline

Funkcionálne programovanie stavia výpočty na tvorbe a aplikácii funkcií. Gallina je čistý funkcionálny jazyk používaný v Rocqu na zápis termov, funkcií, dátových typov a matematických objektov. Pri štúdiu teórie typov nám umožní sledovať, aký typ má každý term a ako sa jeho vyhodnotenie viaže na typové pravidlá.

  • Nemennosť: Definované meno označuje term; jeho hodnotu nemeníme priradením ako pri meniteľnej premennej. Nový lokálny názov možno zaviesť pomocou let.
  • Čisté funkcie: Výsledok funkcie závisí od vstupov a jej vyhodnotenie nemení vonkajší stav programu.
  • Funkcie ako hodnoty: Funkcie v Galline môžu byť argumentmi iných funkcií aj ich výsledkami. Toto je jedna z vlastností funkcionálneho programovania, nie jeho jediná definícia.

Komentáre

Komentár je uzatvorený medzi (* a *). Môže zaberať jeden riadok alebo viac riadkov; samostatný symbol pre jednoriadkový komentár sa nepoužíva. Napríklad:

(* Nasledujúci príkaz vypočíta súčet. *)
Compute 1 + 1. (* Výsledok je 2. *)

Definície a základné príkazy Vernacular

Príkaz Definition zavedie globálne meno pre term. Nasledujúca definícia nepredstavuje priradenie do meniteľnej programovej premennej:

Definition x : nat := 10.
Check x.
Print x.
Compute x + 2.

Výstup Check x. a Compute x + 2.:

x : nat
= 12
: nat

Skrátený výstup Print x.:

x = 10
  : nat

Meno x označuje term 10. Check x. zistí typ nat, Print x. vypíše definíciu a Compute x + 2. vypočíta 12. Ak je typ z termu jednoznačný, Rocq ho často dokáže odvodiť; explicitná anotácia : nat je pri výklade užitočná.

Rovnaký príkaz môže definovať funkciu:

Definition increment (n : nat) : nat := n + 1.
Check increment.
Print increment.
Compute increment 4.

Výstup Check increment. a Compute increment 4.:

increment : nat -> nat
= 5
: nat

Skrátený výstup Print increment.:

increment = fun n : nat => n + 1
          : nat -> nat

Funkcia increment má typ nat -> nat. V zobrazení Print ju možno vidieť aj ako anonymnú funkciu fun n : nat => n + 1; presný formát ďalších riadkov výpisu sa môže medzi verziami Rocqu líšiť.

Ďalšie dopytovacie príkazy

Pri prvých príkladoch používajte najmä Check, Compute a Print. Na skúmanie existujúcich objektov sú užitočné aj tieto príkazy:

About Nat.add.
Search Nat.add.
Locate "+".
Print Scope nat_scope.

Skrátený výstup:

Nat.add : nat -> nat -> nat
Notation "x + y" := (Nat.add x y) : nat_scope

Search môže nájsť viac objektov a úplný výpis závisí od aktuálne načítaných knižníc. Uvedená notácia je podstatná časť výstupu Locate "+". a Print Scope nat_scope..

About poskytuje informácie o objekte, Search vyhľadáva podľa zadaného termu alebo tvaru a Locate zisťuje, ku ktorým definíciám sa môže viazať notácia +. Symbol + môže v rôznych kontextoch označovať rôzne operácie. Rozsah notácie (scope) pomáha určiť jeho interpretáciu; Print Scope nat_scope. ukáže notácie v rozsahu prirodzených čísel. Vlastné notácie zavedie samostatná časť nižšie.

Základné typy a hierarchia typov

Každý term má typ. Aj typy sú v Rocqu termami: napríklad true má typ bool a samotný bool má typ Set. Nasledujúca tabuľka odlišuje dátové typy od univerz, do ktorých tieto typy patria.

Typ alebo univerzum Kde patrí Význam Príklady
Set Type univerzum malých dátových typov nat, bool, list nat
Prop Type univerzum logických tvrdení True, False, n = 0
Typeᵢ Typeᵢ₊₁ univerzum na úrovni i typy nižších úrovní
bool Set pravdivostné hodnoty true, false
nat Set prirodzené čísla O, S O
unit Set typ s jedinou hodnotou tt
Empty_set Set prázdny dátový typ nemá konštruktor
list nat Set zoznam prirodzených čísel nil, cons 1 nil

Zápis $Type_i : Type_{i+1}$ vyjadruje hierarchiu univerz: univerzum na úrovni i patrí do vyššieho univerza. Je to matematický opis, nie doslovná syntax príkazu Rocqu. Pri dopyte Check Type. sa môže zobraziť Type : Type, pretože Rocq v bežnom výstupe konkrétne úrovne skrýva. Neznamená to, že ide o ten istý typ na tej istej úrovni.

Check Set.
Check Prop.
Check Type.

Typický výstup:

Set : Type
Prop : Type
Type : Type

Pri základných dátových typoch a ich hodnotách:

Check bool.
Check true.
Check false.
Check nat.
Check O.
Check (S O).
Check unit.
Check tt.
Check Empty_set.

Výstup (Rocq môže zobrazovať O ako 0 a S O ako 1):

bool : Set
true : bool
false : bool
nat : Set
0 : nat
1 : nat
unit : Set
tt : unit
Empty_set : Set

Typ list očakáva parameter, ktorý určí typ prvkov zoznamu. Po dosadení nat dostaneme dátový typ list nat:

Check list.
Check (list nat).
Check (nil : list nat).
Check (cons 1 nil).

Výstup:

list : Type -> Type
list nat : Set
nil : list nat
(1 :: nil)%list : list nat

Zápis cons 1 nil môže Rocq vo výstupe zobraziť pomocou notácie ::. Napokon príklady tvrdení:

Check True.
Check False.
Check (3 = 4).
Check (forall n, n + 0 = n).

Výstup:

True : Prop
False : Prop
3 = 4 : Prop
forall n : nat, n + 0 = n : Prop

True a False sú tvrdenia, nie booleovské hodnoty true a false. Tvrdenie 3 = 4 je dobre typované ako objekt v Prop, hoci nie je pravdivé.

Anonymná funkcia a súvis s λ-kalkulom

Gallina zapisuje anonymnú funkciu v tvare fun x => t. Ide o obdobu λ-abstrakcie $\lambda x.t$ z prednášky o λ-kalkule. Identickú funkciu na prirodzených číslach môžeme definovať takto:

Definition id_nat : nat -> nat :=
  fun n => n.
Check id_nat.
Compute id_nat 3.

Výstup:

id_nat : nat -> nat
= 3
: nat

Term fun n => n zodpovedá $\lambda n.n$. Anotácia nat -> nat určuje, že vstup aj výstup majú typ nat. Pri aplikácii id_nat 3 sa argument dosadí za parameter funkcie.

Funkcie vyššieho rádu a čiastočná aplikácia

Funkcia je hodnotou, a preto môže prijímať inú funkciu alebo ju vrátiť ako výsledok. Funkcie viacerých argumentov sa v Galline zapisujú ako postupnosť funkcií jedného argumentu. Preto aj aplikácia len na časť argumentov môže vrátiť funkciu.

Definition plus (a b : nat) : nat := a + b.
Definition plus2 := plus 2.
Check plus.
Check plus2.
Compute plus2 3.

Výstup:

plus : nat -> nat -> nat
plus2 : nat -> nat
= 5
: nat

Check plus. zobrazí nat -> nat -> nat. Šípka funkčného typu sa združuje sprava, takže ide o nat -> (nat -> nat): po prijatí prvého čísla zostáva funkcia z nat do nat. Výraz plus 2 má preto typ nat -> nat; Compute plus2 3. vráti 5. Tento spôsob reprezentácie viacargumentových funkcií sa nazýva currying.

Funkcia vyššieho rádu môže prijať funkciu ako argument:

Definition apply_twice (f : nat -> nat) (n : nat) : nat :=
  f (f n).
Check apply_twice.
Compute apply_twice increment 3.

Výstup:

apply_twice : (nat -> nat) -> nat -> nat
= 5
: nat

Tu je prvým argumentom f funkcia nat -> nat; výsledok posledného výpočtu je 5.

Lokálne meno pomocou let ... in

Konštrukcia let zavedie lokálne meno iba v rámci daného termu. Nejde o meniteľnú premennú ani o novú globálnu definíciu:

Definition add_xy : nat :=
  let a := 10 in
  let b := 20 in
  a + b.
Compute add_xy.

Výstup:

= 30
: nat

Výsledkom je 30. Mená a a b nie sú po skončení definície dostupné v globálnom prostredí.

Krok 3

Induktívne typy, analýza prípadov a rekurzia

Doteraz sme používali štandardné typy nat a bool. Teraz uvidíme, ako konštruktory vytvárajú hodnoty induktívneho typu, ako ich rozlišuje match a prečo pri rekurzii sledujeme štruktúru vstupu.

Induktívny typ a jeho konštruktory

Príkaz Inductive zavádza nový typ a jeho konštruktory. Jednoduchý nerekurzívny príklad predstavujú dni v týždni:

Inductive day : Type :=
| Monday
| Tuesday
| Wednesday
| Thursday
| Friday
| Saturday
| Sunday.
Check day.
Check Monday.

day je typ. Monday až Sunday sú jeho konštruktory; každý z nich vytvorí jednu hodnotu typu day. Konštruktory iných induktívnych typov môžu prijímať argumenty. V tomto príklade ich nemajú.

Všeobecný zápis má tvar Inductive MenoTypu : Type := ..., kde za := nasledujú konkrétne konštruktory a ich typy. Tri bodky tu vyjadrujú opis formátu, nie spustiteľný príkaz Rocqu. Typ konštruktorov závisí od definovaného typu; preto sa učíme prednostne z úplných príkladov.

Type označuje univerzum typov. Pri logických tvrdeniach sa neskôr stretneme aj s Prop. Podrobné rozlíšenie univerzov teraz nepotrebujeme.

Analýza podľa konštruktora: match

Výraz match vyberie vetvu podľa konštruktora spracúvanej hodnoty. V prípade typu day môžeme definovať nasledujúci kalendárny deň:

Definition next_day (d : day) : day :=
  match d with
  | Monday    => Tuesday
  | Tuesday   => Wednesday
  | Wednesday => Thursday
  | Thursday  => Friday
  | Friday    => Saturday
  | Saturday  => Sunday
  | Sunday    => Monday
  end.
Compute next_day Friday.

Výstup:

= Saturday
: day

Výsledok next_day Friday je Saturday. Názov next_day zodpovedá tomu, že príklad počíta aj s víkendom.

Pri štandardnom type bool sú konštruktory true a false. Obe možnosti možno rozlíšiť priamo:

Print bool.
Definition negb_ok (b : bool) : bool :=
  match b with
  | true => false
  | false => true
  end.
Compute negb_ok true.

Výstup:

= false
: bool

Podobne pri type nat symbol 0 reprezentuje konštruktor O a vzor _ znamená „ľubovoľný iný prípad“:

Definition is_zero (n : nat) : bool :=
  match n with
  | 0 => true
  | _ => false
  end.
Compute is_zero 0.
Compute is_zero 3.

Výstupy:

= true
: bool
= false
: bool

Podčiarkovník _ nepomenuje zachytenú hodnotu, preto je vhodný vtedy, keď ju vetva nepotrebuje. V druhej vetve is_zero pokrýva všetky nenulové prirodzené čísla.

Úplnosť analýzy prípadov

Rocq vyžaduje, aby match pokryl všetky možné konštruktory spracúvaného typu. Nasledujúci príkaz ukazuje zámerne neúspešný pokus; predpona Fail potvrdí, že vnútornú definíciu Rocq odmietol, a umožní pokračovať vo vykonávaní súboru:

Fail Definition negb_bad (b : bool) : bool :=
  match b with
  | true => false
  end.

Typ bool má aj konštruktor false, pre ktorý chýba vetva. Opravená definícia negb_ok vyššie pokrýva oba prípady. Toto je problém úplnosti vzorov; nesúvisí s termináciou rekurzie.

Bežné programovacie jazyky ako OCaml alebo Haskell môžu neúplný pattern matching akceptovať, často s upozornením. Ak počas behu nastane nepokrytý prípad, výpočet môže skončiť chybou. Rocq takúto definíciu funkcie neprijme.

Prirodzené čísla ako rekurzívny induktívny typ

Základnú myšlienku štandardnej definície nat možno znázorniť takto:

Inductive nat : Type :=
| O
| S (n : nat).

Tento zápis je zjednodušená ukážka, ktorú teraz nevykonávame: štandardný typ nat je už v prostredí zavedený. Konštruktor O predstavuje nulu, S n nasledovníka čísla n. Na rozdiel od day sa nat vyskytuje v type argumentu vlastného konštruktora S, preto je rekurzívny. Jeho skutočnú definíciu si môžete pozrieť príkazmi:

Print nat.
Check O.
Check S.
Compute S (S O).

Štruktúra O alebo S n nám dáva dve vetvy pre match a zároveň spôsob, ako pri rekurzii prejsť k menšiemu podtermu n.

Rekurzívne funkcie pomocou Fixpoint

Rekurzívna funkcia sa definuje príkazom Fixpoint. Pre faktoriál platí:

$$ \mathit{factorial}(n)= \begin{cases} 1, & n=0,\\ n\cdot\mathit{factorial}(n-1), & n>0. \end{cases} $$

Pri prirodzenom čísle tvaru S k platí n = S k. Rekurzívne volanie vykonáme na podterme k:

Fixpoint factorial (n : nat) : nat :=
  match n with
  | 0 => 1
  | S k => n * factorial k
  end.
Check factorial.
Compute factorial 5.

Výstup:

factorial : nat -> nat
= 120
: nat

Check factorial. vráti nat -> nat a Compute factorial 5. vráti 120. V druhej vetve je k štrukturálne menší podterm pôvodného argumentu S k; preto Rocq túto rekurziu akceptuje. Samotný fakt, že v tele vidíme menšie číslo, nestačí: kontrola sleduje vhodný štrukturálny vzťah medzi argumentmi.

Kontrola terminácie

Gallina povoľuje rekurzívnu definíciu iba vtedy, keď Rocq dokáže overiť jej termináciu. Bežnú neobmedzenú všeobecnú rekurziu preto priamo nepovoľuje. Nasledujúce volanie používa ten istý argument n, takže nespĺňa požiadavku štrukturálneho zmenšovania:

Fail Fixpoint loop (n : nat) : nat :=
  loop n.

Fail opäť označuje zámerne odmietnutý pokus. Kontrastuje s factorial, kde volanie pracuje s podtermom k. Kontrola terminácie súvisí s logickými vlastnosťami systému a s výpočtami, ktoré Rocq používa pri kontrole typov. Zložitejšie spôsoby definovania terminujúcich funkcií budeme študovať neskôr.

Moduly a vlastný typ nat

Poznámka

Modul nám umožní definovať vlastnú verziu nat, plus a mult bez zámeny so štandardnými názvami.

Modul vytvorí priestor mien. Objekt definovaný v module možno zvonka pomenovať kvalifikovaným názvom, napríklad NatPlayground.plus. Vlastnú verziu prirodzených čísel preto uzavrieme do modulu:

Module NatPlayground.

Inductive nat : Type :=
| O
| S (n : nat).

Fixpoint plus (n m : nat) : nat :=
  match n with
  | O => m
  | S k => S (plus k m)
  end.

Fixpoint mult (n m : nat) : nat :=
  match n with
  | O => O
  | S k => plus m (mult k m)
  end.

Compute plus (S O) (S O).
Compute mult (S (S O)) (S (S O)).

End NatPlayground.

Výstupy oboch výpočtov v module zobrazujú hodnoty zostavené z jeho vlastných konštruktorov:

= S (S O)
: nat
= S (S (S (S O)))
: nat

Vnútri modulu sa meno nat vzťahuje na NatPlayground.nat; pri výpise po uzavretí modulu Rocq môže použiť kvalifikované názvy.

V definícii plus sa prvý argument postupne mení z S k na k. V definícii mult sa ten istý menší podterm používa pri rekurzívnom volaní mult k m. V oboch prípadoch ide o štrukturálnu rekurziu. Po uzavretí modulu sú vlastné objekty dostupné napríklad takto:

Check NatPlayground.nat.
Check NatPlayground.plus.
Compute NatPlayground.plus
  (NatPlayground.S NatPlayground.O)
  (NatPlayground.S NatPlayground.O).

Výstup posledného výpočtu:

= NatPlayground.S (NatPlayground.S NatPlayground.O)
: NatPlayground.nat

Typy NatPlayground.nat a štandardný nat sú rôzne. Modul bráni kolízii mien pri písaní príkladov; pri odkazoch na objekty používame ich kvalifikované názvy. Nie je presné povedať, že vlastná definícia automaticky zmení definíciu štandardnej knižnice.

Notácie a rozsahy notácií

Notácia nemení význam funkcie; poskytuje alternatívny syntaktický zápis termu. Napríklad 3 + 4 je zápis aplikácie funkcie sčítania na dva argumenty:

Compute Nat.add 3 4.
Compute 3 + 4.
Locate "+".

Rocq môže viazať ten istý symbol na rôzne funkcie podľa rozsahu notácie (scope). Notácia pre prirodzené čísla je v nat_scope; medzi ďalšie používané rozsahy patria bool_scope, list_scope a type_scope. Niektoré rozsahy sa otvárajú podľa očakávaného typu, iné môžeme vybrať explicitne. Príkaz Locate ukazuje možné interpretácie symbolu a Print Scope nat_scope. vypíše notácie daného rozsahu.

Vlastnú notáciu môžeme bezpečne zaviesť v novom rozsahu. Nasledujúci samostatný modul pokračuje v práci s vlastným typom nat:

Module NatNotation.
Import NatPlayground.

Declare Scope playground_nat_scope.
Delimit Scope playground_nat_scope with pnat.
Notation "a + b" := (plus a b)
  (at level 50, left associativity) : playground_nat_scope.
Notation "a * b" := (mult a b)
  (at level 40, left associativity) : playground_nat_scope.

Compute ((S O) + (S O))%pnat.
Print Scope playground_nat_scope.

End NatNotation.

Parameter at level určuje prioritu: v bežnom rozsahu úrovní má nižšie číslo vyššiu prioritu, takže násobenie na úrovni 40 sa zoskupí skôr než sčítanie na úrovni 50. left associativity určuje zoskupovanie operátorov rovnakej priority zľava. Sufix %pnat vyberie vlastný rozsah; tým sa vyhneme zámene so štandardným + a *.

Polymorfizmus a implicitné argumenty

id_nat funguje iba na hodnotách typu nat. Polymorfná identická funkcia prijíma typ A a hodnotu tohto typu; výsledok má opäť typ A:

Definition id_poly (A : Type) (value : A) : A := value.
Definition id_poly2 : forall A : Type, A -> A :=
  fun A value => value.
Check id_poly.
Compute id_poly nat 3.
Compute id_poly _ 3.

Výstup Check id_poly. a oboch výpočtov:

id_poly : forall A : Type, A -> A
= 3
: nat
= 3
: nat

Argument A určuje typ ďalšieho argumentu. V poslednom výpočte podčiarkovník žiada Rocq, aby A odvodil z typu hodnoty 3. Zápis forall A : Type, A -> A vyjadruje, že funkcia funguje pre každý typ A.

Ak parameter uzavrieme do zložených zátvoriek, stane sa implicitným argumentom. Bežne ho potom pri aplikácii vynechávame:

Definition id_poly3 {A : Type} (value : A) : A := value.
Compute id_poly3 3.
Compute @id_poly3 nat 3.
Compute id_poly3 (A := nat) 3.

Všetky tri výpočty vrátia:

= 3
: nat

Symbol @ vypne v danom použití implicitnosť, takže typ nat zapíšeme explicitne. Pomenovaný argument (A := nat) je druhý spôsob, ako typ uviesť bez zmeny ostatných argumentov. Tieto možnosti sa budú hodiť pri typovo všeobecných definíciách v ďalších lekciách.

Vzájomná rekurzia

Dve funkcie sa môžu volať navzájom. Príkaz with spája ich definície do jednej vzájomne rekurzívnej skupiny. Nasledujúce funkcie zisťujú paritu čísla:

Fixpoint is_even (n : nat) : bool :=
  match n with
  | 0 => true
  | S k => is_odd k
  end
with is_odd (n : nat) : bool :=
  match n with
  | 0 => false
  | S k => is_even k
  end.

Compute is_even 4.
Compute is_odd 5.

Výstupy oboch volaní:

= true
: bool
= true
: bool

Každé rekurzívne volanie pracuje s podtermom k čísla S k. Rocq teda môže overiť termináciu celej skupiny.

Extrakcia kódu

Z výpočtového obsahu definovaného v Galline možno generovať kód pre bežné funkcionálne jazyky, napríklad OCaml. Pri faktoriáli stačí načítať podporu extrakcie a určiť výstupný súbor:

From Corelib Require Extraction.
Extraction Language OCaml.
Extraction "factorial.ml" factorial.

Posledný príkaz vytvorí súbor factorial.ml v pracovnom adresári procesu Rocq. Extrakcia je samostatný ďalší krok po definovaní a typovej kontrole funkcie; výsledný súbor je vhodné osobitne skompilovať a otestovať. Rocq podporuje aj extrakciu do ďalších funkcionálnych jazykov.

Programovanie v Rocqu

Poznámka

Pracujte v jednom súbore .v a príkazy vykonávajte postupne. Vlastné typy definujte v moduloch, aby sa nemiešali so štandardnými typmi Rocqu.

Poznámka

Užitočné sú oficiálna dokumentácia Rocqu, starší prehľad syntaxe a prehľad taktík. Staršie pomôcky používajú názov Coq a môžu opisovať odlišnú verziu syntaxe.

Úloha 3.1

Definujte modul cvicenieBool na prácu s vlastným typom bool.

Úloha 3.2

V module definujte vlastný induktívny typ bool s konštruktormi pre pravdu a nepravdu. Pomocou Check zistite typ oboch konštruktorov.

Úloha 3.3

Pomocou match definujte funkcie nad vlastným typom bool s týmito názvami:

  • negb – negáciu,
  • andb – konjunkciu,
  • orb – disjunkciu,
  • implb – implikáciu.

Úloha 3.4

Pomocou príkazov Check, Print a About vypíšte informácie o svojich definíciách. Porovnajte, čo zobrazí každý z príkazov.

Úloha 3.5

Príkazom Compute otestujte obe hodnoty negácie a vybrané dvojice vstupov ostatných funkcií. Aspoň jednu binárnu funkciu otestujte na všetkých štyroch dvojiciach hodnôt.

Úloha 3.6

Zaveďte vo vlastnom rozsahu notácie ! pre negáciu, &&& pre konjunkciu, ||| pre disjunkciu a ---> pre implikáciu. V Rocqu 9.2 tieto symboly fungujú pri deklarácii notácií vo vlastnom rozsahu. Nastavte priority v tomto poradí od najvyššej po najnižšiu: negácia, konjunkcia, disjunkcia, implikácia. Implikáciu nastavte ako pravostrannú, teda a ---> b ---> c sa má čítať a ---> (b ---> c). Pomocou Locate overte interpretáciu symbolov.

Úloha 3.7

Pomocou Compute otestujte výrazy s novými operátormi. Výsledky porovnajte s výpočtom, v ktorom použijete priamo názvy funkcií.

Úloha 3.8

Overte prioritu a asociativitu nasledujúcimi výpočtami po otvorení vlastného rozsahu notácií:

Compute (! false &&& false).                (* false *)
Compute (true ||| true &&& false).          (* true *)
Compute (false &&& true ---> false).        (* true *)
Compute (false ---> true ---> false).       (* true *)

Prvý výraz má význam (! false) &&& false, druhý true ||| (true &&& false) a tretí (false &&& true) ---> false. Posledný výraz musí mať význam false ---> (true ---> false); pri ľavostrannom zoskupení by bol výsledok false. Každý výsledok porovnajte aj s úplne uzátvorkovaným volaním funkcií.

Úloha 3.9

Ukončite modul cvicenieBool a overte, že k jeho objektom viete pristupovať kvalifikovanými názvami.

Úloha 3.10

Preskúmajte štandardný typ nat a funkcie nad ním. Použite Print nat., Check O., Check S. a About Nat.add.. Porovnajte štandardné definície s vlastným typom nat z modulu NatPlayground.

Úloha 3.11

V module NatPlayground definujte funkcie successor, pred a iszero. Vlastné hodnoty 0, 1 a 3 zostavte z konštruktorov O a S a použite ich pri testoch. Predchodca nuly má byť nula, teda pred O = O.

Úloha 3.12

V module NatPlayground implementujte pomocou Fixpoint funkcie plus_rec a times_rec podľa rovníc:

  • plus_rec O n = n,
  • plus_rec (S m) n = S (plus_rec m n),
  • times_rec O n = O,
  • times_rec (S m) n = plus_rec n (times_rec m n).

Rekurzia v oboch funkciách prebieha podľa prvého argumentu. Pri každom rekurzívnom volaní určte, ako sa tento argument štrukturálne zmenšuje.

Poznámka

Compute iba vypíše výsledok výpočtu. Neoveruje automaticky, či je výsledok očakávaný; porovnajte ho s vlastným výpočtom.

Poznámka

Pri vlastnom type nat nemožno bez konverzie používať číselné literály ani štandardné operácie pre štandardný nat. Testovacie hodnoty zostavujte z konštruktorov O a S príslušného modulu.

Úloha 3.13

Vyskúšajte extrakciu vlastnej terminujúcej funkcie do OCaml a prezrite si vygenerovaný súbor.

Krok 4

II. časť: Prvé dôkazy

V prvej časti sme skúmali termy a ich typy. Teraz budeme dokazovať jednoduché tvrdenia a sledovať, ako taktiky menia dôkazový stav. Zobrazené stavy zodpovedajú Rocqu 9.2; pre čitateľnosť vynechávame iba rámec okna a hlásenie o počte cieľov. Odlišná verzia môže niektoré termy zobraziť inou notáciou.

Dôkazový stav

Cieľ (goal) je tvrdenie, ktoré treba dokázať. Nad vodorovnou čiarou je lokálny kontext: premenné s typmi a hypotézy, ktoré možno použiť. Pod čiarou je aktuálny cieľ. Napríklad:

n : nat
============================
n = n

Taktika transformuje dôkazový stav. Môže zaviesť premennú alebo hypotézu do kontextu, zjednodušiť cieľ, rozdeliť ho na podciele alebo ho uzavrieť. Proof. začína taktický dôkaz; Qed. ho uzavrie až vtedy, keď nezostáva žiadny podcieľ.

Zavedenie premennej: intro

Theorem refl_nat : forall n : nat, n = n.
Proof.
  (* ============================
     forall n : nat, n = n *)
  intro n.
  (* n : nat
     ============================
     n = n *)
  reflexivity.
Qed.

Na začiatku je kontext prázdny. Taktika intro n. presunie univerzálne kvantifikovanú premennú do lokálneho kontextu; oba stavy sú uvedené priamo v komentároch dôkazu.

Taktika reflexivity. dokáže rovnosť termu so sebou samým. Po jej vykonaní nezostáva žiadny podcieľ (No more goals), preto možno použiť Qed.. Meno možno aj vynechať; Rocq ho vtedy zvolí sám:

Theorem refl_nat' : forall n : nat, n = n.
Proof.
  intro.
  reflexivity.
Qed.

Zavedenie viacerých objektov a použitie hypotézy

Theorem intros_example :
  forall (n m : nat),
    n + m = m + n ->
    n + m = m + n.
Proof.
  intros n m H.
  (* n, m : nat
     H : n + m = m + n
     ============================
     n + m = m + n *)
  exact H.
Qed.

Príkaz intros n m H. tu vykoná tri zavedenia, ekvivalentne intro n. intro m. intro H.. Výsledný stav je v komentári. Hypotéza H má presne typ aktuálneho cieľa. Taktika exact H. preto cieľ uzavrie; nezostáva žiadny podcieľ.

Výpočet v cieli: simpl a reflexivity

Theorem dva_plus_tri : 2 + 3 = 5.
Proof.
  (* ============================
     2 + 3 = 5 *)
  simpl.
  (* ============================
     5 = 5 *)
  reflexivity.
Qed.

Komentáre zachytávajú cieľ pred simpl a po ňom. simpl vykoná redukciu výrazu 2 + 3. reflexivity potom uzavrie rovnosť. Pri tomto konkrétnom cieli možno simpl vynechať, pretože reflexivity využíva aj konverziu termov:

Theorem dva_plus_tri' : 2 + 3 = 5.
Proof.
  reflexivity.
Qed.

Sčítanie prirodzených čísel je definované rekurziou podľa prvého argumentu. Preto sa výraz 0 + n zjednoduší aj pri neznámom n:

Theorem zero_plus_n : forall n : nat, 0 + n = n.
Proof.
  intro n.
  (* n : nat
     ============================
     0 + n = n *)
  simpl.
  (* n : nat
     ============================
     n = n *)
  reflexivity.
Qed.

Komentáre ukazujú, že po intro n. je cieľ 0 + n = n a po simpl. sa zmení na n = n.

Pri n + 0 je prvý argument neznámy: Rocq nemôže iba výpočtom rozhodnúť, či ide o O alebo S k. Samotný simpl preto všeobecne nepremení cieľ n + 0 = n na reflexívnu rovnosť. Dôkaz takéhoto tvrdenia bude neskôr motivovať indukciu.

Prepísanie cieľa: rewrite

Rovnosť z kontextu možno použiť na nahradenie výrazu v cieli:

Theorem example_rewrite :
  forall n m : nat,
    n = m ->
    n + 1 = m + 1.
Proof.
  intros n m H.
  (* n, m : nat
     H : n = m
     ============================
     n + 1 = m + 1 *)
  rewrite H.
  (* n, m : nat
     H : n = m
     ============================
     m + 1 = m + 1 *)
  reflexivity.
Qed.

Komentáre ukazujú, ako rewrite H. prepíše výskyt n v cieli na m; hypotéza zostáva v kontexte.

rewrite H používa rovnosť zľava doprava. Opačný smer zapisujeme rewrite <- H:

Theorem example_rewrite_backwards :
  forall n m : nat,
    n = m ->
    m + 1 = n + 1.
Proof.
  intros n m H.
  rewrite <- H.
  (* n, m : nat
     H : n = m
     ============================
     n + 1 = n + 1 *)
  reflexivity.
Qed.

Po spätnom prepísaní má cieľ tvar uvedený v komentári dôkazu.

Prepísať možno aj pomocou už dokázanej lemy:

Theorem plus_O_n : forall n : nat, 0 + n = n.
Proof.
  intro n.
  simpl.
  reflexivity.
Qed.

Theorem example_rewrite_with_theorem :
  forall n : nat, 0 + n + 1 = n + 1.
Proof.
  intro n.
  (* n : nat
     ============================
     0 + n + 1 = n + 1 *)
  rewrite plus_O_n.
  (* n : nat
     ============================
     n + 1 = n + 1 *)
  reflexivity.
Qed.

Rocq v tomto prípade odvodí argument lemy z výskytu 0 + n v cieli; stav pred prepísaním aj po ňom je v komentári. Smer prepísania má zásadný vplyv na podobu cieľa:

Theorem example_rewrite_chain :
  forall n,
    n = n + 0 ->
    n + 1 = (n + 0) + 1.
Proof.
  intros n H.
  (* n : nat
     H : n = n + 0
     ============================
     n + 1 = n + 0 + 1 *)
  rewrite <- H.
  (* n : nat
     H : n = n + 0
     ============================
     n + 1 = n + 1 *)
  reflexivity.
Qed.

rewrite <- H. nahradí výskyt n + 0 na pravej strane za n, takže zostane n + 1 = n + 1. Oba stavy sú v komentároch. Pozrime sa aj na zámerne nedokončený variant:

Theorem example_rewrite_chain' :
  forall n,
    n = n + 0 ->
    n + 1 = (n + 0) + 1.
Proof.
  intros n H.
  rewrite H.
  (* n : nat
     H : n = n + 0
     ============================
     n + 0 + 1 = n + 0 + 0 + 1 *)
Admitted.

rewrite H. je syntakticky platný, ale ide nevhodným smerom: nahrádza n zložitejším n + 0. Výsledný stav je v komentári. Takto zvolený krok nevyriešil cieľ. Pre tento dôkaz je vhodnejší smer rewrite <- H..

Admitted: nedokončený dôkaz

Admitted. ukončí otvorený dôkaz bez vyriešenia zostávajúcich podcieľov. Rocq potom tvrdenie prijíma ako predpokladaný výsledok, ale nejde o overený dôkaz tohto tvrdenia: v dôkaze vzniká diera. Počas prípravy súboru to môže byť užitočné, vo finálnom riešení však treba Admitted. nahradiť dôkazom ukončeným Qed.. Úspešné spracovanie súboru .v s Admitted. neznamená, že všetky jeho tvrdenia majú dokončený formálny dôkaz.

Analýza prípadov: destruct

Nasledujúce tvrdenie hovorí, že dvojnásobná negácia booleovskej hodnoty vráti pôvodnú hodnotu:

Theorem example_destruct_bool :
  forall b : bool,
    negb (negb b) = b.
Proof.
  intros b.
  (* b : bool
     ============================
     negb (negb b) = b *)
  destruct b.
  (* 2 ciele:
     1. ============================
        negb (negb true) = true

     2. ============================
        negb (negb false) = false *)
  - reflexivity.
  - reflexivity.
Qed.

Komentáre ukazujú cieľ s b : bool aj dva podciele vytvorené taktikou destruct b.. V každej vetve sa po dosadení konkrétneho konštruktora obe negácie vypočítajú. reflexivity uzavrie najprv prvý, potom druhý cieľ. Zápis - pomáha oddeliť vetvy dôkazu.

Pri prirodzených číslach vzniknú prípady O a S n'. Symbol \/ je logická disjunkcia („alebo“):

Theorem example_destruct_nat :
  forall n : nat,
    n = 0 \/ n = S (Nat.pred n).
Proof.
  intros n.
  (* n : nat
     ============================
     n = 0 \/ n = S (Nat.pred n) *)
  destruct n as [| n'].
  (* 2 ciele:
     1. ============================
        0 = 0 \/ 0 = S (Nat.pred 0)

     2. n' : nat
        ============================
        S n' = 0 \/ S n' = S (Nat.pred (S n')) *)
  - left.
    (* ============================
       0 = 0 *)
    reflexivity.
  - right.
    (* n' : nat
       ============================
       S n' = S (Nat.pred (S n')) *)
    reflexivity.
Qed.

Komentáre zachytávajú oba prípady po destruct n as [| n'] aj účinok taktík left a right. Nat.pred (S n') sa podľa definície Nat.pred vypočíta na n', preto aj druhý cieľ uzavrie reflexivity. V syntaxi as [| n'] prvé, prázdne miesto patrí konštruktoru O bez argumentu. Meno n' za zvislou čiarou označuje argument konštruktora S.

Rozdelenie cieľa a vnorené vetvy

Taktika split rozdelí konjunkciu A /\ B na ciele A a B. Pri viacerých úrovniach vetvenia používame značky vetiev (bullets):

Theorem example_bullets :
  forall b1 b2 : bool,
    b1 = b1 /\ b2 = b2.
Proof.
  intros b1 b2.
  (* b1, b2 : bool
     ============================
     b1 = b1 /\ b2 = b2 *)
  split.
  (* 2 ciele:
     1. b1, b2 : bool
        ============================
        b1 = b1

     2. b1, b2 : bool
        ============================
        b2 = b2 *)
  - destruct b1.
    (* 2 ciele v prvej vetve:
       1. b2 : bool
          ============================
          true = true

       2. b2 : bool
          ============================
          false = false *)
    + reflexivity.
    + reflexivity.
  - destruct b2.
    + reflexivity.
    + reflexivity.
Qed.

Komentáre ukazujú stav po intros, podciele po split aj rozdelenie prvej vetvy po destruct b1. Značka - zaostrí prvú vetvu; druhý cieľ zostane zatiaľ nezaostrený. Pre vnorenú úroveň použijeme +. Po uzavretí oboch prípadov sa vrátime k druhému cieľu pôvodného split; ten riešime obdobne cez destruct b2.. Pri ďalšej úrovni vetvenia možno použiť *. Značky organizujú dôkaz, samy však nič nedokazujú.

Prehľad použitých taktík

Taktika Základný účinok
intro x presunie premennú alebo predpoklad do kontextu
intros vykoná viac zavedení naraz
exact H uzavrie cieľ termom presného typu, napríklad hypotézou H
simpl zjednoduší výrazy výpočtom
reflexivity dokáže rovnosť konvertibilných termov
rewrite H prepíše pomocou rovnosti zľava doprava
rewrite <- H prepíše sprava doľava
destruct x rozdelí dôkaz podľa konštruktorov hodnoty x
split rozdelí vhodný zložený cieľ, napríklad konjunkciu
left zvolí ľavú vetvu disjunkcie
right zvolí pravú vetvu disjunkcie

Tabuľka zachytáva účinok taktík použitých v tejto lekcii; nie je úplným prehľadom taktického jazyka.

Samostatné úlohy – Prvé dôkazy

Poznámka

V každej úlohe doplňte dôkaz medzi Proof. a Qed.. Zobrazené Admitted. je iba dočasná kostra: odstráňte ho a nahraďte úplným dôkazom. Používajte predovšetkým taktiky intro, intros, exact, simpl, reflexivity, rewrite a destruct vysvetlené vyššie.

Úloha 2.1 – Reflexivita pre bool

Dokážte, že každá hodnota typu bool je rovná sama sebe.

Theorem bool_self :
  forall b : bool, b = b.
Proof.
  (* TODO *)
Admitted.

Úloha 2.2 – Konkrétny výpočet

Dokážte, že 4 + 0 = 4.

Theorem four_plus_zero :
  4 + 0 = 4.
Proof.
  (* TODO *)
Admitted.

Úloha 2.3 – Použitie hypotézy

Zaveďte premenné a predpoklad do kontextu a použite daný predpoklad na uzavretie cieľa.

Theorem use_assumption :
  forall (n m : nat),
    n = m -> n = m.
Proof.
  (* TODO *)
Admitted.

Úloha 2.4 – Prepísanie rovnosťou

Dokážte tvrdenie pomocou predpokladu n = m.

Theorem plus_two :
  forall (n m : nat),
    n = m ->
    n + 2 = m + 2.
Proof.
  (* TODO *)
Admitted.

Úloha 2.5 – Prepísanie dokázanou lemou

Najprv dokážte add_zero_left. V dôkaze plus_zero_example potom explicitne použite rewrite add_zero_left.; samotný automatický výpočet nestačí.

Theorem add_zero_left :
  forall n : nat,
    0 + n = n.
Proof.
  (* TODO *)
Admitted.
Theorem plus_zero_example :
  forall n : nat,
    0 + n + 3 = n + 3.
Proof.
  (* TODO: použite rewrite add_zero_left. *)
Admitted.

Úloha 2.6 – Analýza prípadov pre bool

Nasledujúce príkazy ukazujú správanie štandardných booleovských funkcií:

From Stdlib Require Import Bool.Bool.
Compute (true && true).
Compute (true || true).
Compute (negb true).

Výstupy sú postupne true, true a false, vždy typu bool. Tvrdenie dokážte analýzou prípadov hodnoty b:

Theorem orb_true :
  forall b : bool,
    b || true = true.
Proof.
  (* TODO *)
Admitted.

Úloha 2.7 – Výpočet bez destruct

Dokážte tvrdenie výpočtom podľa definície sčítania. destruct nie je potrebný, pretože sčítanie nat je definované rekurziou podľa prvého argumentu.

Theorem one_plus_n :
  forall n : nat,
    1 + n = S n.
Proof.
  (* TODO *)
Admitted.

Úloha 2.8 – Tranzitivita rovnosti

Dokážte tvrdenie použitím zavedených hypotéz a prepísania.

Theorem trans_eq :
  forall n m k : nat,
    n = m ->
    m = k ->
    n = k.
Proof.
  (* TODO *)
Admitted.

Zhrnutie

Gallina umožňuje zapisovať termy, funkcie a induktívne typy; príkazy Vernacular, najmä Check, Compute a Print, skúmajú ich typy, výpočty a definície. Funkčné typy, anonymné funkcie, match a štrukturálna rekurzia ukazujú, ako spolu súvisia typovanie, výpočet a kontrola terminácie. Set, Prop a hierarchia Type odlišujú dátové typy, tvrdenia a univerzá.

Pri dokazovaní sledujeme lokálny kontext, aktuálny cieľ a podciele. intro a intros zavádzajú objekty do kontextu, exact používa term presného typu, simpl vykonáva redukciu, reflexivity uzatvára konvertibilnú rovnosť, rewrite prepíše výraz podľa rovnosti a destruct rozdelí dôkaz podľa konštruktorov. split, left a right pracujú so zloženými tvrdeniami; značky -, + a * organizujú vetvy dôkazu. V ďalšej prednáške na tieto základy nadviažeme pri zložitejších dôkazoch a indukcii.

Zdroje

  • The Rocq Prover: oficiálna dokumentácia.
  • Paulin-Mohring, Christine. „Introduction to the Calculus of Inductive Constructions.“ All about Proofs, Proofs for All 55 (2015).
  • Bertot, Yves, a Pierre Castéran. Interactive Theorem Proving and Program Development: Coq’Art: The Calculus of Inductive Constructions. Springer, 2013.
  • Bertot, Yves. „Coq in a Hurry.“ arXiv:cs/0603118 (2006).
  • Huet, Gérard, Gilles Kahn a Christine Paulin-Mohring. The Coq Proof Assistant: A Tutorial. Rapport Technique 178 (1997).
  • В. Н. Крупский, С. Л. Кузнецов. Практикум по математической логике в COQ. Московский государственный университет имени М. В. Ломоносова, 2013.