Previous Contents Next

Chapter 5   Un proof-checker per i file CIC della libreria

In questo capitolo descriveremo brevemente la progettazione e l'implementazione di un proof-checker per i file CIC della libreria di HELM. Entrambe non sono ancora state completate, in quanto non è stata ancora considerata la stratificazione degli universi, ovvero tutti i Type(i) sono rappresentati da Type1. Il proof-checker verrà completato quanto prima, probabilmente entro la fine del 2000.

Le motivazioni dello sviluppo del proof-checker sono molteplici:
  1. La scrittura del proof-checker ha permesso di determinare se tutta l'informazione degli oggetti CIC è stata esportata.
  2. Il proof-checker rappresenta la dimostrazione concreta della possibilità di reimplementare tutte le funzionalità dei moderni proof-assistant monolitici in maniera modulare, basandosi sul formato XML. In particolare, l'architettura e l'implementazione risultanti sono estremamente più semplici ed eleganti di quelle del proof-checker integrato in Coq. Inoltre, nonostante il codice non sia stato particolarmente ottimizzato e gli algoritmi adottati siano fra i più semplici noti, le prestazioni del proof-checker sono comparabili a quelle di Coq, se si escludono le rispettive fasi di parsing.
  3. A partire da una implementazione completa e funzionante del proof-checker, diventa possibile sperimentare modifiche al sistema logico, per esempio per affrontare il problema delle dimensioni delle prove.
  4. Alcune sottigliezze del sistema logico possono essere comprese solamente durante la progettazione e l'implementazione di un proof-checker, che assumono, quindi, importanti valenze didattiche.
Inoltre, il nostro proof-checker è il primo, a conoscenza degli autori, a gestire dinamicamente gli ambienti, utilizzando esclusivamente l'ordinamento parziale generato dalle dipendenze fra gli oggetti (vedi pagina 59).

Sono state prese due importanti decisioni preliminari alla progettazione. La prima è sull'obiettivo dello sviluppo, che è mirato all'ottenimento di un prototipo. Pertanto, la performance viene tenuta in secondo piano rispetto alla pulizia dell'architettura e alla semplicità dell'implementazione. Ciò nonostante, i risultati ottenuti sono stati decisamente buoni, anche sul piano delle prestazioni.

La seconda decisione riguarda la scelta del linguaggio di programmazione: l'intero proof-checker è sviluppato in OCaml. Il linguaggio si è già dimostrato ottimale nell'ambito dello sviluppo di type-checker. Infatti, i tipi di dati concreti di OCaml permettono una definizione compatta ed elegante della sintassi di CIC, mentre il paradigma funzionale e il garbage collector rendono concisa l'implementazione e permettono di definire funzioni aderendo strettamente alle regole di tipaggio date nel capitolo 2.1. Il fatto che anche Coq sia implementato in OCaml semplifica il riutilizzo del codice2. Inoltre, l'estrema concisione della sintassi di OCaml, dovuta principalmente alla type-inference, e la caratteristica del linguaggio di essere fortemente tipato rendono la produttività in fase di implementazione mediamente molto alta, con un numero quasi nullo di errori di tipo non logico. Il complesso sistema di moduli del linguaggio permette di sfruttare moderne tecniche di ingegneria del software usualmente applicabili quasi esclusivamente nell'ambito dei linguaggi ad oggetti. Infine, per quei compiti per i quali il paradigma funzionale non è maggiormente adatto o è poco conciso, è possibile utilizzare feature imperative e, quando il sistema dei moduli si rivela insufficiente, ricorrere agli oggetti, che possono essere utilizzati sia in maniera funzionale che imperativa.

Nelle successive due sezioni descriveremo brevemente rispettivamente la progettazione e l'implementazione del prototipo. Si assume non solo un'ottima comprensione dell'intera sezione 2.1 che descrive CIC e dei paragrafi 2.2.1, 2.2.2 e 2.2.4 che descrivono le estensioni di Coq, ma anche una buona conoscenza sull'implementazione di type-checker per l-calcoli basati sulla rappresentazione delle variabili con indici di DeBrujin.

5.1   Progettazione



Figura 5.1: Grafo delle dipendenze per i moduli dell'implementazione del typechecker
 
In figura 5.1 viene mostrato il grafo delle dipendenze dei moduli del typechecker, cosí come deciso in fase di progettazione. La buona norma di evitare dipendenze cicliche fra moduli non può essere violata in OCaml. Pertanto, già in fase di progettazione, è bene discriminare le dipendenze in statiche e dinamiche per eliminare i cicli. Una freccia solida da un modulo A verso un modulo B significa che A dipende staticamente, ovvero in fase di compilazione, da B. Una freccia tratteggiata da A verso B, invece, indica una dipendenza dinamica: a run-time il modulo B registrerà una o più sue funzioni presso il modulo A affinché questo le richiami3. Tutti i moduli racchiusi in un rettangolo tratteggiato dipendono direttamente da tutti i moduli che sono bersaglio di una freccia con sorgente il riquadro. Il simbolo formato con rettangoli sovrapposti indica un metamodulo, ovvero un modulo logico che è a sua volta decomposto in altri moduli logici. In particolare, il metamodulo getter è già stato descritto nel capitolo 4.1.1, dove in figura 4.1 sono state indicate le dipendenze fra i moduli logici che lo compongono.

Passiamo ora a descrivere individualmente i singoli (meta)moduli.

5.1.1   UriManager

È un sotto-modulo, nel senso dell'ereditarietà nei linguaggi ad oggetti, di quello descritto nel paragrafo 4.1.1; pertanto, una sua implementazione può sostituire quella dell'omonimo modulo nel metamodulo Getter. Lo scopo del modulo è fornire un tipo di dato astratto uri con le relative operazioni richieste durante l'implementazione del type-checker. La sua interfaccia, o signature, è:
type uri
val eq : uri -> uri -> bool
val uri_of_string : string -> uri
val string_of_uri : uri -> string  (* complete uri *)
val name_of_uri   : uri -> string  (* name only (without extension)*)
val buri_of_uri   : uri -> string  (* base uri only *)
val depth_of_uri  : uri -> int     (* length of the path *)

(* relative_depth curi uri cookingsno                            *)
(* is the number of times to cook uri to use it when the current *)
(* uri is curi cooked cookingsno times                           *)
val relative_depth : uri -> uri -> int -> int
I primi quattro elementi sono gli stessi già descritti nel paragrafo 4.1.1. Le successive tre funzioni sono di uso frequente. L'ultima è tecnica e verrà richiamata dal meccanismo di cottura: restituisce il numero di volte che un oggetto di cui viene specificato l'URI deve essere cucinato per essere utilizzato durante il type-checking di un altro oggetto di cui viene indicato l'URI e il numero di cotture subite. Con ``numero di cotture'', a differenza di quanto accade in Coq, non indichiamo il numero di ingredienti astratti, bensí  la differenza fra la lunghezza dell'URI dell'oggetto prima e dopo la cottura. Quest'uso è coerente con l'osservazione che un oggetto viene sempre cucinato rispetto a tutti o nessuno degli ingredienti definiti in una stessa sezione.

Raffinamento della specifica

Poiché gli URI vengono utilizzati pesantemente sia nella rappresentazione interna dei l-termini, sia durante il type-checking, si impongono, per motivi prestazionali, dei vincoli sia sulla complessità computazione delle funzioni, sia sull'occupazione in spazio della rappresentazione degli URI:
  1. Due URI identici devono condividere la stessa rappresentazione. In altre parole, la uri_of_string, se richiamata due volte su stringhe uguali, deve restituire in entrambi i casi la stessa (in senso fisico) struttura dati.
  2. Le funzioni eq, string_of_uri, name_of_uri, buri_of_uri e depth_of_uri devono appartenere a O(1).
  3. La funzione relative_depth deve appartenere a O(depth_of_uri(u)) dove u è l'URI dato in input.
Per ottenere complessità computazionali cosí basse diventa necessario ricorrere a una rappresentazione degli URI decisamente elaborata. Come conseguenza, è lecito aspettarsi un'alta complessità computazionale per la uri_of_string. Ciò nonostante, poiché capita frequentemente che, durante il proof-checking di un oggetto, si incontri lo stesso URI più volte, viene richiesto un ulteriore vincolo sulla complessità di tale funzione:
  1. La complessità computazionale media della depth_of_uri su un URI già incontrato deve essere al più O(log(n)) dove n è il numero di URI già incontrati.

5.1.2   Cic

Questo modulo, privo di funzioni, fornisce esclusivamente la rappresentazione concreta dei termini e degli oggetti di CIC. La sua signature viene interamente riportata, per comodità di consultazione, in appendice B.

Confrontando attentamente la rappresentazione XML in appendice A con quella OCaml e con la sintassi di CIC descritta nel paragrafo 2.1.1, si individuano facilmente le seguenti discrepanze4:
  1. Lo zucchero sintattico è scomparso, riavvicinandosi alla rappresentazione data nella descrizione della teoria. In particolare, i binder, che nella rappresentazione XML erano stati spostati sull'elemento target, sono tornati ad essere attributi di Lambda o Prod.
  2. Tutti gli URI, che in XML erano in parte assoluti e in parte relativi, hanno la stessa rappresentazione interna, determinata dal modulo UriManager.
  3. Tutti gli URI che si riferiscono ad oggetti cucinabili hanno sempre associato un indice, indicato in commento con cookingsno, che indica il livello di cottura voluto per l'oggetto. Questo deve essere calcolato durante il parsing e modificato ad ogni cottura dell'oggetto. Il motivo che ci ha spinto ad esplicitare l'indice, che poteva comunque essere calcolato al volo all'occorrenza, è dovuto alla complessità computazione dell'operazione, che, pur non essendo troppo elevata (vedi paragrafo 5.1.1), diventava influente per l'alto numero di applicazioni della funzione relative_depth che sarebbero state richieste.
  4. La rappresentazione interna degli ingredienti di un oggetto è una buona sintesi, in termini di efficienza, fra quella dei file XML e quella come lista di coppie. In pratica, è una lista di liste di URI avente un elemento per ogni token numerico presente nel file XML, ovvero per ogni livello di cottura; la lista elemento contiene tutti gli URI degli ingredienti per quel livello.
  5. La rappresentazione OCaml dei costruttori prevede un ulteriore parametro, assente sia nella teoria che nel DTD XML, che è un reference ad una lista di booleani, possibilmente non specificata: durante il parsing, la lista non viene specificata; durante il proof-checker del tipo induttivo del costruttore, vengono individuati i parametri in cui il costruttore è ricorsivo, nel senso del paragrafo 2.10, e l'informazione viene memorizzata nella lista di booleani per essere poi utilizzata più volte durante le verifiche delle condizioni GC e GD, con conseguente incremento delle prestazioni.

5.1.3   CicParser

Il compito di questo metamodulo è semplicemente quello, dato un file XML che descrive un oggetto CIC, di restituirne la rappresentazione interna. Pertanto, la sua signature è formata da una sola funzione:
val term_of_xml : string -> UriManager.uri -> Cic.obj
dove il primo parametro è il pathname del file XML e il secondo il suo URI, necessario per risolvere gli URI relativi durante il parsing.

Raffinando la progettazione, si è deciso di implementare il metamodulo utilizzando tre distinti moduli, chiamati CicParser, CicParser2, CicParser3. Il primo ha il compito di costruire l'albero di sintassi astratta associato al file XML5; il secondo si occupa della creazione, a partire da questo, della rappresentazione interna a livello degli oggetti; il terzo fa lo stesso, ma a livello dei termini. Nella sezione dedicata all'implementazione si mostrerà il grafo delle dipendenze fra i moduli e si accennerà brevemente alle signature degli ultimi due.

5.1.4   CicCache

Questo è sicuramente il modulo dall'interfaccia più complessa. I suoi compiti principali sono quello di fungere da cache per incrementare le prestazioni nell'accesso agli oggetti e quello di nascondere l'utilizzo del Getter e del CicParser ai moduli dei livelli soprastanti. Poiché il parsing dei file XML è un'operazione molto costosa in termini di tempo, il cache deve evitare il più possibile un cache miss, eliminando gli elementi esclusivamente quando strettamente necessario. Il parsing non è, però, la sola operazione critica compiuta sugli oggetti: la loro cottura, infatti, richiede la visita esaustiva dei loro termini e l'accesso a tutti gli oggetti in essi riferiti. Pertanto, dopo aver sviluppato un primo prototipo del proof-checker in cui la cottura veniva effettuata al volo, abbiamo deciso di mantenere in cache non solo la versione meno cucinata di un oggetto, ma anche tutte quelle parzialmente o totalmente cotte, ottenendo un sensibile incremento delle prestazioni. La decisione, però, introduce una inevitabile circolarità fra le operazioni di accesso al cache, cottura e type-checking: un oggetto può essere cucinato esclusivamente se ha già superato il proof-checking; per il suo type-checking e la cottura, però, è necessario utilizzare il cache per ottenere la definizione dell'oggetto e di quelli da questo riferiti. Il punto chiave che ha permesso di risolvere, in parte, la circolarità sono le seguenti osservazioni, derivate da una semplice analisi delle regole di tipaggio di CIC e da quelle di introduzione di oggetti nell'ambiente:
  1. Il grafo diretto delle dipendenze fra gli oggetti di CIC è aciclico6.
  2. Anche se la riduzione è definita per termini qualsiasi, ai fini del proof-checking può essere sempre richiamata esclusivamente su termini che abbiano già superato il type-checking.
Come conseguenza, è possibile definire il cache in modo che mantenga gli oggetti in una delle seguenti tre forme: non cucinata e non type-checked, congelata, cucinata a tutti i livelli possibili. Quando il termine viene richiesto per la prima volta, lo si scarica ricorrendo al getter e se ne ottiene la forma non cucinata e non type-checked utilizzando il parser. Per effettuarne il type-checking, si deve comunicarne l'intenzione al cache, che passerà l'oggetto alla forma congelata, ma, quando richiesto, continuerà a restituirlo nella forma precedente. Infine, la terminazione del type-checking deve essere comunicata al cache, che invoca il meccanismo di cottura per ottenere lo stesso oggetto cucinato ad ogni possibile livello, passandolo alla terza forma. Solo da questo momento in avanti sarà possibile accedere alle forme cucinate.

Il meccanismo appena descritto permette di risolvere elegantemente la dipendenza circolare fra il type-checker e il modulo responsabile della cottura e quella fra il type-checker e il cache, ma non elimina quello fra il modulo di cottura e il cache. Poiché il cache non dovrebbe avere, almeno intuitivamente, alcuna dipendenza dal meccanismo di cottura, che è strettamente dipendente dal sistema logico, diventa naturale mantenere la dipendenza statica fra il modulo di cottura e il cache, rendendo dinamica quella in senso opposto.

Considerate tutte le precedenti osservazioni, si è scelta la seguente signature per il modulo, dove il costruttore delle forme congelate viene tenuto nascosto grazie ai meccanismi di information hiding di OCaml:
exception CircularDependency of string;;

(* get_obj uri                                                   *)
(* returns the uncooked form of the cic object whose URI is uri. *)
(* If the term is not just in cache, then it is parsed via       *)
(* CicParser.term_of_xml from the file whose name is the result  *)
(* of Getter.get uri                                             *)
val get_obj : UriManager.uri -> Cic.obj

type type_checked_obj =
   CheckedObj of Cic.obj    (* cooked obj *)
 | UncheckedObj of Cic.obj  (* uncooked obj *)

(* is_type_checked uri cookingsno                               *)
(* returns (CheckedObj object) if the object has been           *)
(* type-checked otherwise it returns (UncheckedObj oject) and   *)
(* freeze the object for the type-checking                      *)
(* set_type_checking_info must be called to unfreeze the object *)
val is_type_checked : UriManager.uri -> int -> type_checked_obj

(* set_type_checking_info uri                                   *)
(* must be called once the type-checking of uri is finished     *)
(* The object whose URI is uri is unfreezed and cooked at every *)
(* possible level using the !cook_obj function                  *)
(* Therefore, is_type_checked will return a CheckedObj          *)
val set_type_checking_info : UriManager.uri -> unit

(* get_cooked_obj uri cookingsno *)
(* returns the object whose URI is uri cooked cookingsno levels *)
val get_cooked_obj : UriManager.uri -> int -> Cic.obj

val cook_obj :
 (Cic.obj -> UriManager.uri -> (int * Cic.obj) list) ref
Il significato di tutte le funzioni dovrebbe essere chiaro grazie ai commenti e alle spiegazioni precedenti. La cook_obj è la reference con la quale deve essere registrata la funzione di cottura che, dato un oggetto nella forma non cucinata e il suo URI, ritorna una lista di coppie livello di cottura---oggetto cucinato a quel livello.

5.1.5   CicCooking

La signature del modulo CicCooking è composta da una sola funzione che viene registrata presso il cache durante l'inizializzazione del modulo stesso:
val cook_obj : Cic.obj -> UriManager.uri -> (int * Cic.obj) list
La funzione deve semplicemente implementare fedelmente le regole di cottura date informalmente nel paragrafo 2.2.4.

5.1.6   CicSubstitution, CicReduction e CicTypeChecker

Questi moduli non hanno richiesto alcuno sforzo progettuale, in quanto sono assolutamente standard nella scrittura di type-checker per l-calcoli, a partire da quello tipato semplice. Chi conosce questi strumenti dovrebbe riconoscere immediatamente le funzioni descritte nelle signature dei tre moduli:

CicSubstitution:

val lift : int -> Cic.term -> Cic.term
(* subst t1 t2  substitutes t1 for the first free variable in t2 *)
val subst : Cic.term -> Cic.term -> Cic.term

CicReduction:

val whd : Cic.term -> Cic.term
val are_convertible : Cic.term -> Cic.term -> bool

CicTypeChecker:

exception NotWellTyped of string
val typecheck : UriManager.uri -> unit

5.2   Implementazione

In questa sezione si accennerà brevemente all'implementazione del proof-checker, descrivendo esclusivamente le scelte implementative non standard o quelle che hanno avuto grossi impatti dal punto di vista delle prestazioni. I moduli verranno descritti singolarmente, nello stesso ordine della precedente sezione. Infine, verranno riportate alcune statistiche.

5.2.1   UriManager

Sono state fornite tre differenti implementazioni, che utilizzano strutture dati di complessità crescente. All'aumento di complessità corrisponde, in teoria, un aumento del tempo richiesto per il primo accesso e una sua diminuzione per gli accessi successivi. Concretamente, però, nessuna delle tre soluzioni ha dato risultati superiori alle altre in tutti i casi: per teorie semplici, con pochi riferimenti fra oggetti, le soluzioni più semplici danno risultati lievemente migliori; per quelle complesse, si ha il fenomeno opposto. Effettuando statistiche sul proof-checking di tutta la libreria standard di Coq, che contiene due teorie complesse di notevoli dimensioni, quella dei numeri reali e quella della normalizzazione di espressioni su anelli, si è riscontrato un iniziale aumento delle prestazioni passando dall'implementazione più semplice a quella più complessa di circa il 15%, che si è poi ridotto notevolmente in seguito alle ottimizzazioni successivamente introdotte nella cicReduction. Pertanto, si è preferito mantenere esclusivamente l'implementazione più complessa, che ora descriveremo, che è anche l'unica che rispetta pienamente i vincoli sulla complessità computazionale introdotti in fase di progettazione nel paragrafo 5.1.1.

Ogni URI viene rappresentato internamente come un vettore contenente, in ordine lessicografico, tutti i suoi URI prefisso, seguiti dal nome dell'oggetto. Per esempio, l'URI ``cic:/a/b/c.con'' viene codificato con il vettore [| "cic:/a" ; "cic:/a/b" ; "cic:/a/b/c.con" ; "c" |]. Questa rappresentazione permette di ottenere sia la lunghezza di un URI che il nome dell'oggetto da esso riferito in O(1). Per implementare la uri_of_string, viene utilizzato un albero bilanciato di ricerca che implementa una funzione che mappa ogni URI in formato stringa nella rappresentazione interna: ad ogni invocazione della funzione, l'URI viene cercato nell'albero e restituito; solo se non presente, viene creata la sua rappresentazione interna, memorizzata nell'albero e restituita come valore di ritorno. In questo modo, come funzione eq per l'uguaglianza fra URI può essere utilizzata direttamente l'uguaglianza fisica di OCaml, che appartiene a O(1) in quanto si limita a confrontare l'indirizzo in memoria dei due valori. Lo stesso stratagemma viene utilizzato per normalizzare i prefissi utilizzati nella rappresentazione interna, ricorrendo ad un secondo albero bilanciato di ricerca. A parte l'ovvio risparmio in spazio, è in questo modo possibile implementare la relative_depth iterando in parallelo sulle rappresentazioni interne dei due URI e utilizzando l'uguaglianza fisica per confrontare fra loro i prefissi, riducendo la complessità computazionale di tutta l'operazione, che diventa lineare nella profondità dell'URI più breve. Come conseguenza negativa, il caso pessimo della creazione di un nuovo URI diventa O(n2 + log m), dove n è la lunghezza in caratteri dell'URI e m il numero di URI già incontrati. Un'analisi più approfondita basata su un modello plausibile per la località dei riferimenti nella libreria, però, rivela che è possibile stimare il costo ammortizzato in solo O(k/k - 1 + log m) dove k è il numero medio di oggetti contenuti non ricorsivamente in ogni sezione.

5.2.2   CicParser

Il metamodulo, come preannunciato, è stato implementato ricorrendo a tre moduli il grafo delle cui mutue dipendenze è riportato in figura 5.2.


Figura 5.2: Grafo delle dipendenze per i moduli dell'implementazione del CicParser
 
Il primo modulo contiene il codice necessario per interfacciarsi con Markup, un parser XML scritto in OCaml; una volta ottenuto l'albero di sintassi astratta da Markup, vengono richiamate le funzioni del secondo modulo. Il terzo modulo prende in input un albero di sintassi astratta di Markup relativo ad un termine e ne ritorna la rappresentazione interna, di tipo Cic.term. Il secondo, che richiama il terzo, fa lo stesso per gli oggetti. La dipendenza statica e quella dinamica fra il primo modulo e il terzo sono dovute a dettagli implementativi sull'utilizzo di Markup, che espone due interfacce, una ad oggetti e una funzionale pura; il secondo modulo ricorre a quella funzionale pura, il terzo a quella ad oggetti. I motivi della scelta dell'utilizzo di entrambi i tipi di interfacce sono dovuti al trade-off fra complessità computazionale e spaziale: l'interfaccia ad oggetti diminuisce la prima grazie all'uso del late binding, ma richiede la creazione di un nuovo oggetto per ogni nodo dell'albero di sintassi astratta; quella funzionale non deve istanziare oggetti, ma è costretta ad effettuare ripetuti e costosi confronti fra stringhe per discriminare i tag dei nodi dell'albero. L'alto numero di elementi XML per il markup a livello dei termini suggerisce l'adozione dell'interfaccia ad oggetti per minimizzare il tempo di parsing. Il basso numero di elementi per il livello degli oggetti determina un basso costo computazionale anche per l'interfaccia funzionale, che non è, però, soggetta ad ulteriore costo spaziale, rendendola preferibile.

5.2.3   CicCache

L'unica osservazione di rilievo è il fatto che, almeno in questa prima implementazione, si è deciso di non eliminare mai un oggetto dal cache. Infatti, l'occupazione complessiva di memoria del proof-checker dopo la verifica di tutti gli oggetti della libreria standard di Coq è ancora sufficientemente bassa da non giustificare lo sforzo implementativo.

5.2.4   CicCooking

Sull'implementazione del meccanismo di cottura, sono necessarie due osservazioni. La prima è legata alla differente rappresentazione interna dei tipi mutuamente (co)induttivi rispetto alla descrizione teorica. Come descritto nel paragrafo 2.2.2, le occorrenze dei tipi (co)induttivi all'interno dei tipi dei loro costruttori vengono rappresentate da indici di de Brujin. Tali indici, quindi, si riferiscono ad oggetti cucinabili presenti nell'ambiente, che possono avere ingredienti. Conseguentemente, l'implementazione naïf della cottura diventa complessa, con la necessità di mantenere un contesto di cottura in cui agli indici di de Brujin vengono associati gli ingredienti richiesti dalle ``entità'' a cui essi puntano. Per ovviare al problema, è possibile utilizzare un diverso formato interno in cui anche le occorrenze dei tipi (co)induttivi all'interno dei tipi dei rispettivi costruttori sono codificate come MutInd. Come conseguenza, però, si avrebbe un aumento della complessità del type-checking. La soluzione che abbiamo adottato consiste nell'utilizzare quest'ultima forma esclusivamente per i termini cotti.

La prima operazione compiuta dalla funzione di cottura, quindi, diventa il cambio di rappresentazione per il termine ancora non cucinato. Il fatto che sia possibile far coesistere due diverse rappresentazioni senza modificare le regole di tipaggio può, a prima vista, sembrare sorprendente. Valutando attentamente la forma della regola Ind data a pagina 35 nella codifica con indici di de Brujin, però, si nota che quello che viene fatto è semplicemente anticipare la sostituzione che andrebbe effettuata ogni volta che la regola viene applicata.

La seconda osservazione sul meccanismo di cottura è legata alla scelta, nella nostra rappresentazione interna, di associare ad ogni URI il numero di volte che l'oggetto puntato deve essere cucinato. Cosí facendo, un oggetto A che non dipenda da nessun ingrediente e l'oggetto A', ottenuto cucinando A di un livello, differiscono nella rappresentazione interna esclusivamente per tali indici. La conseguenza è la necessità di mantenere in cache copie praticamente identiche degli oggetti, con enorme spreco di memoria dovuto al fatto che, normalmente, un oggetto ha ingredienti solamente ad una piccola percentuale dei livelli possibili. Riguardando attentamente la definizione di cottura data nel paragrafo 2.2.4, è facile convincersi del seguente fatto: gli ingredienti di un oggetto sono contenuti nell'insieme degli ingredienti di ogni oggetto che da lui dipende. Come semplice corollario si ha che, inserendo in cache l'oggetto A dell'esempio precedente al posto di A', tutti gli oggetti che dipendono da A' supereranno il type-checking a patto che la regola di conversione ignori gli indici; questo è esattamente ciò che è stato implementato.

5.2.5   CicSubstitution, CicReduction

I moduli CicSubstitution e CicReduction sono stati implementati in maniera naïf, con sostituzioni esplicite. Questo ha permesso al codice di essere estremamente aderente alle definizioni formali, ma rende la whd, ovvero la funzione che restituisce la forma normale di testa debole di un termine, molto inefficiente: per esempio, per calcolare il fattoriale di 6, la nostra implementazione impiega circa 2 minuti contro il secondo richiesto da Coq. Lo scarso utilizzo della riduzione nelle dimostrazioni formali della libreria di Coq7, però, annulla l'inefficienza, permettendoci di ottenere buone prestazioni.

La prima implementazione fornita, a dire il vero, era estremamente poco soddisfacente dal punto di vista delle prestazioni. Un attento profiling ha permesso di individuare la sorgente delle inefficienze nell'implementazione della are_convertible, che decide la convertibilità di due termini. In particolare, la funzione veniva richiamata la maggior parte delle volte su termini identici, che venivano visitati interamente in maniera ricorsiva. Una piccola euristica che abbiamo introdotto è il confronto preliminare dei due termini, effettuato utilizzando l'uguaglianza polimorfa di OCaml, la cui implementazione è molto efficiente. I risultati dell'applicazione di questa semplice euristica sono stati stupefacenti: le prestazioni del proof-checking dell'intera libreria standard di Coq sono state incrementate di circa dieci volte.

5.2.6   CicTypeChecker

L'implementazione del type-checker è resa possibile dalla decidibilità delle regole di tipaggio ovvero, in ultima analisi, dalle proprietà di confluenza e normalizzazione forte di CIC. A causa della regola Conv, non è possibile implementare direttamente le regole di tipaggio di CIC come sono state date nel capitolo 2.1. In maniera del tutto standard, viene definita una funzione privata, chiamata type_of, che effettua contemporaneamente sia type-inference che type-checking, verificando tutte le regole diverse dalla Conv e da quelle di introduzione degli oggetti nell'ambiente. Una volta definita la type_of, la verifica di t:T si riduce a quella type_of(t) =bdi T, ovvero alla are_convertible(type_of(t),T)8. Le regole di introduzione degli oggetti nell'ambiente sono implementate seguendo fedelmente le regole date nel paragrafo 2.1.3, fermo restando il meccanismo appena descritto.

Quando, durante il type-checking di un oggetto, si incontra un riferimento ad un oggetto non ancora presente in forma cucinata in cache, si interrompe momentaneamente l'operazione sull'oggetto corrente e si entra in ricorsione su quello riferito, sfruttando i meccanismi messi a disposizione dal modulo CicCache e già descritti nel paragrafo 5.1.4.

5.2.7   Risultati ottenuti

L'implementazione complessiva del proof-checker ha richiesto circa 340 ore uomo; i tre moduli principali (CicSubstitution, CicReduction e CicTypeChecker) sono stati sviluppati lavorando sempre in coppia; i restanti, invece, singolarmente. Complessivamente sono state prodotte circa 3000 righe di codice OCaml, escludendo quelle del metamodulo Getter, ma includendo la nuova implementazione del modulo UriManager. Per effettuare il proof-checking dell'intera libreria standard di Coq vengono richiesti mediamente 5 minuti e 47 secondi, incluso il tempo di parsing che è nell'ordine dei 5 minuti; la prima implementazione richiedeva circa 50 minuti. Non è possibile avere una stima precisa del tempo impiegato da Coq, che è comunque sicuramente inferiore al minuto, parsing incluso9. I tempi, comunque, sono inevitabilmente destinati a salire con l'introduzione della gestione del livello degli universi; una prima stima, però, indica che l'incremento dovrebbe essere estremamente contenuto, non superando il minuto.


1
Come conseguenza immediata della regola Ax-Acc si ha Type:Type; in presenza di questa regola è ben nota (vedi [Coq86]) l'inconsistenza del sistema logico, nel quale è possibile codificare un paradosso analogo a quello di Russel.
2
Durante lo sviluppo non è stato in effetti possibile riusare nessuna parte del codice di Coq, principalmente a causa della rappresentazione interna di questo basata sulla codifica di CIC come istanza di un metalinguaggio.
3
Affinché B registri funzioni presso A è necessario che B dipenda staticamente da A.
4
Oltre a quella, già citata, sull'assenza della gestione dei livelli degli universi, che sono espliciti nella sintassi di CIC, impliciti nella rappresentazione XML, ma ancora assenti nella rappresentazione OCaml.
5
Una possibile rappresentazione dell'albero è il DOM, ovvero un API standard definito dal W3C. La natura tipicamente imperativa di questo, però, si armonizza male in un linguaggio con feature imperative, ma spirito funzionale quale OCaml. Pertanto, nessun parser XML per questo linguaggio lo implementa, preferendogli un'API proprietario dalle caratteristiche similari.
6
Almeno nel caso delle costanti, è facile convincersi dell'aciclicità: se questa venisse violata, esisterebbe una catena infinita di d-espansioni; conseguentemente, il sistema non sarebbe fortemente normalizzabile e, pertanto, nemmeno consistente. Nel caso dei tipi mutuamente (co)induttivi, invece, l'argomento è più complesso, ma semplicemente a causa della particolare formulazione di CIC proposta.
7
Esistono due posizioni filosofiche sul ruolo della riduzione nelle dimostrazioni formali. La prima cerca di evitarla il più possibile, in quanto essa nasconde le motivazioni della correttezza delle prove, che vengono basate esclusivamente sulla correttezza delle regole di riduzione, ovvero sulla metateoria. La seconda la accetta sostenendo che, una volta che ci si fidi della metateoria, le prove per riduzione non debbono essere più comprese. Un esempio può chiarire il punto: si voglia provare il teorema 2 + 3 = 3 + 2; una prima prova risulta immediata utilizzando la proprietà commutativa dell'addizione; un metodo alternativo è giustificare l'uguaglianza poiché entrambi i termini riducono a 5 e la riduzione preserva l'uguaglianza di Leibniz: in questo caso, quindi, la prova è immediata per la proprietà riflessiva dell'uguaglianza di Leibniz.
8
È in questo momento che viene utilizzata la regola Conv.
9
Non bisogna dimenticare che il tempo richiesto dal parsing di Coq è estremamente basso in quanto il formato dei file ``.vo'' è binario.

Previous Contents Next