Nella prima parte di questa serie abbiamo esaminato una panoramica concettuale della specifica Abra, che descrive il modo in cui il flusso di dati viene creato all’interno del modello computazionale Qubic. Nella seconda parte abbiamo usato la specifica Abra per iniziare ad implementare Qupla, un linguaggio di programmazione di livello superiore per Qubic. Abbiamo introdotto le entità di base di Qupla (vettori trit, look-up tables e costanti) e come mappano su Abra. Questa terza parte introdurrà come possiamo usare funzioni ed espressioni per creare programmi Qupla.

Funzioni ed espressioni di Qupla

Le entità di base che compongono un programma Qupla sono i vettori trit (definiti dall’utente), le costanti e le look-up tables (LUT). Queste entità saranno usate come componenti in espressioni che descrivono come trasformare i valori in input in valori in output, facendo elaborare i valori in input dai costrutti di programmazione di Qupla. Le espressioni a loro volta possono essere raggruppate in funzioni per realizzare compiti più complessi.

Funzioni

Una funzione Qupla è un gruppo nominativo di espressioni che descrivono come trasformare i dati di input in dati di output. Una funzione prenderà uno o più vettori trit come parametri, e restituisce sempre un vettore trit che tiene il valore del risultato della trasformazione. Le funzioni di Qupla, come la maggior parte dei linguaggi funzionali, sono funzioni pure.

Una funzione pura è una funzione che definisce una relazione unica tra i valori di input ed il valore restituito. Ciò significa che richiamando più volte una funzione con gli stessi valori di input, essa restituisce sempre lo stesso valore. Non ci sono effetti collaterali che potrebbero indurre la funzione a restituire valori diversi per gli stessi valori di input. L’eliminazione di tali effetti collaterali rende molto più facile comprendere e prevedere il comportamento di una funzione e la ragione sulla correttezza della funzione.

Un ulteriore vantaggio delle funzioni pure è che qualsiasi delle loro espressioni, indipendenti dai dati all’interno della funzione, possono essere eseguite in qualsiasi ordine, o anche in parallelo. Ciò dà al compilatore la libertà di riordinare o combinare il calcolo di queste espressioni indipendenti. Quando si compila per FPGA, la capacità di eseguire tutte queste espressioni indipendenti in parallelo si tradurrà in un’esecuzione altamente efficiente del programma.

Esprimere lo stato della funzione

L’opposto di una funzione pura, naturalmente, sarebbe una funzione che non necessariamente restituisce lo stesso valore quando la invoca più volte con gli stessi valori di input. Tale funzione può avere una o più variabili di stato interne che possono influenzare il valore restituito dalla funzione in modo diverso per ogni invocazione. A causa dei potenziali effetti collaterali che le variabili di stato possono causare, è molto più difficile ragionare sulla correttezza di una funzione così impura.

Le funzioni impure hanno un proprio ruolo nella programmazione, perché è spesso importante esprimere lo stato di un programma. Prendiamo ad esempio un negozio di chiavi/valori, che memorizza un valore sotto una certa chiave. La funzione di recupero utilizza la chiave per recuperare il valore memorizzato. Si noti che poiché è possibile sovrascrivere il valore memorizzato sotto una chiave con qualsiasi nuovo valore, ed il valore restituito dalla funzione di recupero restituirà sempre l’ultimo valore memorizzato, il valore restituito della funzione di recupero può differire per lo stesso valore di input della chiave.

Qupla, attraverso Abra, si occupa di variabili di stato della funzione con una modalità fortemente specifica per alleviare questo problema. Quando viene chiamata una funzione, il valore della variabile di stato al momento della chiamata è ‘fisso’ per la durata della chiamata di funzione. Ciò significa che qualsiasi espressione che utilizza la variabile di stato in qualsiasi punto della funzione userà lo stesso valore, anche quando qualche espressione all’interno della funzione esprime modifiche alla variabile di stato. Questo cambiamento avrà effetto sulla variabile di stato solo dopo che il valore di ritorno della funzione è stato calcolato.

In questo modo, le variabili di stato appaiono come valori di input nascosti alla funzione e, dato lo stesso insieme di valori dei parametri di input e valori delle variabili di stato, il valore restituito della funzione sarà sempre lo stesso. Anche i nuovi valori per le variabili di stato saranno sempre gli stessi, data la stessa combinazione di valori di input e di stato. Dal punto di vista della funzione può essere vista di nuovo come pura, il che significa che può essere ragionata e riorganizzata come qualsiasi funzione pura senza stato.

Espressioni

Le espressioni di Qupla sono costituite da un numero molto limitato di operazioni di base. Queste operazioni possono essere combinate in modi diversi per esprimere l’intento dell’espressione. Ci sono solo cinque operazioni di base in Qupla dal punto di vista del programmatore:

  • Call, che chiama una funzione predefinita con un dato numero di parametri di input e valuta il valore di ritorno associato a quei valori di input
  • Look-up, che cerca una data combinazione di trits di input in una LUT specifica e valuta il corrispondente valore di LUT output
  • Slice, che valuta una porzione di sotto-vettore presa da un dato vettore trit di input ad un dato offset e dimensione all’interno di quel vettore trit
  • Concatenate, che valuta ad un singolo vettore trit composto dalla concatenazione di più vettori di trit in input
  • Merge, che valuta al singolo vettore di trit non-null trit da diversi vettori di ingresso di uguali dimensioni

Si noti come non ci siano le solite operazioni aritmetiche, logiche o condizionali in Qupla. Tali funzionalità possono essere create, naturalmente, ma sono tutte espresse in termini delle cinque operazioni di cui sopra utilizzando funzioni e/o LUT che si combinano per implementare la funzionalità desiderata. Discuteremo ciascuna di queste operazioni in dettaglio più avanti in un articolo separato, ma prima discuteremo come viene definita una funzione in Qupla, in modo da poter mostrare come le operazioni possono essere utilizzate come parte di espressioni all’interno delle funzioni.

Dichiarazioni di funzione

Le funzioni di Qupla – proprio come nella maggior parte dei linguaggi di programmazione – sono costituite da due parti: la firma della funzione e il corpo della funzione. La firma della funzione descrive il nome della funzione, i parametri esatti con cui la funzione si aspetta di essere chiamata e descrive anche esattamente il tipo di ritorno della funzione. Inoltre, è possibile che una funzione abbia degli specificatori di tipo.

La firma della funzione è come una specifica contrattuale per la funzione. Fornisce a chiunque voglia chiamare la funzione con informazioni sufficienti per farlo senza dover conoscere il suo funzionamento interno. Vediamo un esempio:

func Int square (Int value) {
  return multiply(value, value)
}

Sulla prima riga si vede la firma della funzione. La parola chiave func indica che quella che segue è una dichiarazione di funzione. Poi vediamo che la funzione restituisce un vettore trit di dimensioni Int. Successivamente il nome della funzione è dichiarato come square, seguito da una singola dichiarazione di parametro di funzione all’interno delle parentesi. In questo caso, il parametro di funzione è un valore di dimensioni Int vettore trit chiamato value. Qupla si aspetta che i valori dei parametri corrispondano esattamente alla dimensione del parametro dichiarato. L’unica eccezione è quando un valore costante viene passato come parametro. I valori costanti possono essere più piccoli del parametro e saranno silenziosamente riempiti con zeri della dimensione corretta.

Tra le parentesi graffe si vede il corpo della funzione. Questa contiene un’unica espressione che sarà valutata dalla funzione quando viene chiamata, sostituendo i nomi dei parametri con i valori passati per questi parametri. La direttiva return indica che il risultato della successiva espressione dopo la valutazione viene restituito alla funzione chiamante.

In questo caso vediamo che l’espressione calcola il quadrato del valore che è stato passato moltiplicando il valore per se stesso. Questo viene fatto chiamando un’altra funzione chiamata multiply che gestisce la moltiplicazione di due valori. Si noti che poiché Qupla non ha operatori aritmetici come quelli che si trovano in altri linguaggi informatici, qualsiasi aritmetica verrà eseguita richiamando funzioni aritmetiche predefinite.

Si noti anche che per far funzionare correttamente la firma della funzione per la funzione multiply dovrà essere simile a questa:

func Int multiply (Int lhs, Int rhs)

Questa firma della funzione dichiara due parametri tra le parentesi tonde, separati da una virgola. Per ora ignoriamo l’implementazione della funzione moltiplicatore. Puoi scoprire esattamente come viene effettuata la moltiplicazione in Qupla esaminando il codice di moltiplicazione nella libreria standard che forniamo con Qupla.

Specificatori del tipo di funzione

Una cosa di cui non abbiamo ancora discusso è il suddetto tipo di funzione specifica. Ecco perché ne abbiamo bisogno e cosa fa. Immaginate che abbiamo le seguenti due funzioni:

func Int square (Int value) {
  return multiply(value, value)
}

func Huge square (Huge value) {
  return multiply(value, value)
}

Notate come l’unica differenza tra i due è la dimensione del vettore trit su cui operano? Il primo opera sul vettore 27-trit Int, e l’altro sul vettore 81-trit Huge. Questo è qualcosa che incontreremo spesso in Qupla: funzioni che sono sposate ad una certa dimensione del vettore trit. Dato che i vettori trit hanno sempre una dimensione fissa, non è facile creare un’unica funzione in grado di gestire diversi vettori trit di dimensioni diverse. Di solito significa che dovremo creare una funzione per ogni diversa dimensione di trit vettore che vogliamo supportare. Ma in questo caso, le linee di cui sopra causeranno un errore di nome duplicato della funzione perché Qupla non sarà in grado di determinare quale sia l’uso previsto quando qualcuno chiama square(100) per esempio.

Naturalmente sarebbe possibile cambiare i nomi delle funzioni in qualcosa di simile:

func Int squareInt (Int value) {
  return multiplyInt(value, value)
}

func Huge squareHuge (Huge value) {
  return multiplyHuge(value, value)
}

Ora si vede come distinguere tra funzioni che si riferiscono a tipi diversi aggiungendo il nome del tipo al nome della funzione. Ma ci sono alcuni problemi con questo approccio. Significa che dovrete scrivere ogni singola funzione che dipende da un certo tipo che volete usare separatamente. In molti casi il codice di tali funzioni è praticamente identico, come sopra, ma dovrete modificare ogni istanza del tipo a quella corretta. Il che può essere soggetto ad errori e richiedere molto tempo. Ed immaginate di voler cambiare il nome di un tipo che usate, dovreste cambiare il nome di ogni istanza in cui quel tipo è usato per riflettere il nuovo nome.

Inoltre, ci possono essere diversi tipi che hanno la stessa dimensione che potrebbero essere serviti dalla stessa funzione. Immaginate di definire Int27 per essere della stessa dimensione di Int. In questo caso si potrebbe riutilizzare le funzioni definite per il tipo Int, ma si dovrebbe creare versioni per Int27 che chiamano le versioni Int, o trovare uno schema differente di denominazione. Sembra tutto un po’ troppo artificioso.

Questo è il motivo per cui possiamo dichiarare gli specificatori di tipo associati ad una funzione. I type specifiers sono usati solo per distinguere tra funzioni simili che hanno incarnazioni per tipi diversi. Altrimenti i type specifiers possono essere omessi. E si scrive così:

func Int square<Int> (Int value) {
  return multiply<Int>(value, value)
}

func Huge square<Huge> (Huge value) {
  return multiply<Huge>(value, value)
}

Si vede come invece di concatenare il nome del tipo al nome della funzione ora si aggiunge un modificatore tra parentesi angolari? I nomi delle funzioni sono sempre gli stessi, ma ora possiamo facilmente cambiare il nome del tipo senza dover cambiare i nomi delle funzioni. Possiamo anche riutilizzare una funzione per un tipo della stessa dimensione. multiply<Int>(value, value) chiamerà la stessa funzione multiply come multiply<Int27>(value, value).

Qupla creerà, dietro le quinte, un nome di funzione modificato aggiungendo uno speciale carattere separatore e la dimensione del tipo al nome per ogni tipo specifico. Qupla non permette al programmatore di creare nomi di funzioni che contengono il carattere speciale del separatore, quindi è impossibile creare accidentalmente un nome come quello. Si noti che nei nostri esempi abbiamo usato un solo specificatore di tipo, ma ci sono casi in cui sono necessari più di uno e Qupla lo supporta.

Naturalmente, gli specificatori di tipo non risolvono ancora il codice ripetitivo per ogni dimensione di tipo trit vettoriale. Qupla ha un altro trucco per affrontare questa situazione: il template.

Template

Nell’esempio precedente, abbiamo creato due funzioni quadrate che differiscono solo per la dimensione del vettore trit. Utilizzando i template possiamo dichiarare tale funzione in un’unica posizione come segue:

template square<T> {
  func T square (T value) {
    return multiply<T>(value, value)
  }
}

La parola chiave template indica che ciò che segue è una dichiarazione di template. Il template ha un nome, in questo caso square (proprio come la funzione), che può essere usato per istanziare funzioni per diversi tipi. La T tra parentesi angolari direttamente dopo il nome del template è chiamata segnaposto del tipo. Come potete vedere abbiamo inserito la dichiarazione di funzione all’interno del template e sostituito il tipo Int originale ovunque sia stato usato con il segnaposto T.

Ora possiamo istanziare manualmente funzioni specifiche in modo esplicito come segue:

use square<Int>
use square<Huge>

La parola chiave use indica che ciò che segue è una dichiarazione di istanziazione del template. Istanzia un template per uno specifico insieme di tipi. In questo caso istanziamo due funzioni square, complete della loro implementazione, dal template square. Il risultato finale è come se avessimo digitato le dichiarazioni di funzione separate per square<Int> e square<Huge> proprio come abbiamo fatto qualche paragrafo prima.

L’uso dei template ci permette di definire una libreria standard di funzionalità per il linguaggio Qupla che funziona sui tipi predefiniti, discussa nella parte precedente di questa serie. L’abbiamo utilizzata per implementare un assortimento di funzioni aritmetiche generiche che possono lavorare su quasi tutte le dimensioni del vettore trit e le abbiamo predefinite per i comodi vettori trit Tryte, Tiny, Int e Huge. Eventuali funzioni aggiuntive necessarie per dimensioni del vettore trit non ancora definite possono spesso essere semplicemente istanziate in base alle necessità con una specifica dichiarazione use.

Istanziazione automatica dei modelli

A quanto pare, l’istanziazione esplicita dei template attraverso l’istruzione use può diventare molto noiosa quando altri template sono referenziati. Può causare la necessità di una cascata di istruzioni use. Quindi Qupla tenta di farlo per voi. Ogni volta che si utilizza una funzione che ha un tipo specifico, Qupla controllerà la lista dei template per vedere se uno di essi definisce quella funzione e istanzierà automaticamente il template se necessario. Lo farà in modo ricorsivo, in modo che tutte i template di funzioni che vengono utilizzate all’interno della funzione istanziata saranno automaticamente istanziati.

Questo significa che, nella maggior parte dei casi, la dichiarazione esplicita use non è più necessaria. Si usa solo quando si vuole essere assolutamente sicuri che un template sia stato esplicitamente istanziato, per esempio quando si crea un modulo di funzioni di libreria.

Corpo delle funzioni

Negli esempi precedenti il corpo della funzione è stato specificato come espressione di ritorno singolo. In pratica, solo le funzioni più semplici saranno specificate da una singola espressione di ritorno. La maggior parte delle funzioni avrà bisogno di più di un’espressione per ottenere il risultato corretto. Specialmente quando la logica decisionale entra nel mix.

Per ora manterremo le cose semplici e useremo solo espressioni che usano chiamate di funzione. Assumiamo anche che ogni funzione opera sul tipo Int, così non è necessario fornire alcun tipo di specificatori. In questo modo evitiamo la complessità aggiuntiva di costrutti diversi. Guardate la seguente funzione che implementa il noto algoritmo di Pitagora. Calcola c dato a e b utilizzando la formula a * a + b * b = c * c. Possiamo scrivere questo come segue:

func Int pythagoras (Int a, Int b) {
  return squareRoot(add(multiply(a, a), multiply(b, b)))
}

Tuttavia, esprimerlo in una singola riga di codice diventa rapidamente difficile da leggere, soprattutto perché l’ordine di chiamata delle funzioni va dall’interno all’esterno. Per un essere umano non è di facile lettura. Per questo motivo miglioreremo la leggibilità separando le chiamate di funzione ed assegnando i loro valori a variabili temporanee, in questo modo:

func Int pythagoras (Int a, Int b) {
  aSquared = multiply(a, a)
  bSquared = multiply(b, b)
  sum = add(aSquared, bSquared)
  return squareRoot(sum)
}

Si noti che le variabili di funzione temporanea in Qupla hanno una proprietà importante. Seguono il modulo Static Single Assignment (SSA). SSA richiede che ogni variabile sia assegnata esattamente una volta, e che ogni variabile sia definita prima di quel singolo utilizzo. L’utilità primaria di SSA deriva dal modo in cui semplifica e migliora simultaneamente i risultati di diverse ottimizzazioni del compilatore. L’SSA è anche un elemento importante nell’implementazione del flusso di dati in Qupla.

Qupla applica l’SSA facendo assegnare ad ogni espressione una nuova variabile implicitamente dichiarata. La dimensione del vettore trit della variabile sarà sempre uguale alla dimensione del risultato dell’espressione assegnata. Inoltre, ogni variabile o nome di parametro deve essere unico all’interno della funzione.

Variabili di stato della funzione

C’è un’eccezione alla regola di dichiarazione delle variabili implicite e cioè quando una funzione dichiara una o più variabili di statos. Le variabili di stato sono sempre dichiarate prima all’interno del corpo della funzione, prima di qualsiasi espressione di assegnazione, e sono sempre implicitamente inizializzate a zero. Prendiamo l’esempio seguente:

func Int runningTotal (Int value) {
  state Int sum
  newSum = add(sum, value)
  sum = newSum
  return newSum
}

Questa funzione dichiara una variabile state denominata sum di dimensione Int, e la usa per calcolare una somma totale corrente dei valori passati in ogni chiamata successiva e restituisce il totale corrente.

Si può vedere che la dichiarazione e l’assegnazione alla variabile di stato sum sono separate. Tuttavia, questo segue ancora SSA in quanto la dichiarazione precede l’assegnazione, e la variabile di stato può essere assegnata solo una volta nel corpo della funzione. La dimensione dell’espressione assegnata deve corrispondere alla dimensione dichiarata nella dichiarazione della variabile di stato.

Ricordate anche dalla nostra precedente spiegazione che, ovunque venga utilizzata la variabile di stato nel corpo della funzione, utilizzerà il valore che aveva all’inizio della chiamata di funzione. E anche se alla variabile di stato viene necessariamente assegnata un’espressione prima della dichiarazione return, tale valore sarà attualizzato nella variabile di stato solo dopo che la dichiarazione return è stata eseguita.

Una particolarità delle variabili di stato è che i loro trit non possono contenere valori null. Questo ha a che fare con la loro implementazione su dispositivi FPGA. Il loro valore è mantenuto dai registri alla fine della circuiteria LUT. E poiché null significa “nessun valore”, è impossibile per questi registri mantenere questo valore. Quello che succede quando si tenta di scrivere un vettore trit che contiene trit null in una variabile di stato è che i trit che non sono null vengono memorizzati nelle loro controparti delle variabili di stato, ma i trit che sono null non causeranno alcuna modifica al valore delle loro controparti delle variabili di stato. Nel caso estremo in cui l’intero vettore trit è null mentre si tenta di memorizzarlo in una variabile di stato, quella variabile di stato rimarrà completamente invariata.

Conclusione

Le funzioni di Qupla sono costituite da una o più espressioni. Le funzioni possono essere analizzate come funzioni pure anche quando hanno uno stato ad esse associato. Funzioni simili che operano su tipi diversi possono essere distinte utilizzando un type specifier, ed i template eliminano la necessità di clonare funzioni per ognuno di questi diversi tipi.

Nella prossima parte di questa serie esploreremo le operazioni di base che possono essere utilizzate nelle espressioni di Qupla.


Il testo originale in lingua inglese si trova qui: https://blog.iota.org/explaining-the-qubic-computation-model-part-3-d862429f7fb8


Per ulteriori informazioni in italiano o tedesco trovate i miei contatti a questa pagina.
Se avete trovato utile la mia libera traduzione, accetto volentieri delle donazioni 😉

IOTA:
QOQJDKYIZYKWASNNILZHDCTWDM9RZXZV9DUJFDRFWKRYPRMTYPEXDIVMHVRCNXRSBIJCJYMJ9EZ9USHHWKEVEOSOZB
BTC:
1BFgqtMC2nfRxPRge5Db3gkYK7kDwWRF79

Non garantisco nulla e mi libero da ogni responsabilità.