Cosa sono le continuazioni di Scala e perché usarle?


85

Ho appena finito di programmare in Scala e ho esaminato le modifiche tra Scala 2.7 e 2.8. Quello che sembra essere il più importante è il plug-in delle continuazioni, ma non capisco per cosa sia utile o come funzioni. Ho visto che è utile per l'I / O asincrono, ma non sono stato in grado di scoprire perché. Alcune delle risorse più popolari sull'argomento sono queste:

E questa domanda su Stack Overflow:

Sfortunatamente, nessuno di questi riferimenti cerca di definire a cosa servono le continuazioni o cosa dovrebbero fare le funzioni shift / reset, e non ho trovato alcun riferimento che lo faccia. Non sono stato in grado di indovinare come funzionano gli esempi negli articoli collegati (o cosa fanno), quindi un modo per aiutarmi potrebbe essere quello di passare riga per riga attraverso uno di quegli esempi. Anche questo semplice dal terzo articolo:

reset {
    ...
    shift { k: (Int=>Int) =>  // The continuation k will be the '_ + 1' below.
        k(7)
    } + 1
}
// Result: 8

Perché il risultato è 8? Questo probabilmente mi aiuterebbe a iniziare.


Risposte:


38

Il mio blog spiega cosa resete cosa shiftfare, quindi potresti rileggerlo.

Un'altra buona fonte, che indico anche nel mio blog, è la voce di Wikipedia sullo stile del passaggio di continuazione . Quello è di gran lunga il più chiaro sull'argomento, sebbene non utilizzi la sintassi Scala e la continuazione sia esplicitamente passata.

Il documento sulle continuazioni delimitate, a cui collego nel mio blog ma che sembra essersi rotto, fornisce molti esempi di utilizzo.

Ma penso che il miglior esempio del concetto di continuazioni delimitate sia Scala Swarm. In esso, la libreria interrompe l'esecuzione del codice a un certo punto e il calcolo rimanente diventa la continuazione. La libreria quindi fa qualcosa: in questo caso, trasferisce il calcolo su un altro host e restituisce il risultato (il valore della variabile a cui si è avuto accesso) al calcolo che è stato interrotto.

Ora, non si capisce anche il semplice esempio sulla pagina Scala, in modo da non leggere il mio blog. In esso mi interessa solo spiegare queste basi, del perché il risultato è 8.


Ho riletto il tuo post sul blog e questa volta l'ho mantenuto - penso di avere un'idea migliore di quello che sta succedendo. Non ho ottenuto molto dalla pagina di Wikipedia (conosco già le continuazioni Lisp) ma lo stile differito di ripristino / spostamento o qualunque cosa sia chiamato mi ha lasciato perplesso. Per gli impazienti (cioè me stesso) la tua descrizione era ok ma le persone dovranno assicurarsi di mantenerla fino al "Il risultato del reset è il risultato del codice all'interno di shift". paragrafo ... ero irrimediabilmente perso fino a quel punto, ma diventa più chiaro! Darò un'occhiata a Swarm perché sono ancora curioso di sapere a cosa serva. Grazie!
Dave

Sì, ci vuole tempo prima che le cose inizino ad avere un senso. Non sentivo di poter scappare rendendo la spiegazione più veloce.
Daniel C. Sobral

Tutto si è risolto per me quando sono arrivato alla conclusione che "reset delimita l'ambito della continuazione. (Cioè: le variabili e le dichiarazioni da includere).
JeffV

1
La tua spiegazione era prolissa e non arrivava all'essenza della comprensione. Gli esempi erano lunghi, non ho avuto abbastanza comprensione nei primi paragrafi per ispirarmi a leggere tutto. Quindi ho votato contro. SO mostra un messaggio dopo che ho votato, chiedendomi di aggiungere un commento, quindi sto rispettando. Mi scuso per la mia franchezza.
Shelby Moore III,

1
Ho scritto su questo blog concentrandomi sulla comprensione del flusso di controllo (senza discutere i dettagli dell'implementazione). whereenullpoints.com/2014/04/scala-continuations.html
Alexandros

31

Ho trovato le spiegazioni esistenti meno efficaci per spiegare il concetto di quanto spero. Spero che questo sia chiaro (e corretto). Non ho ancora usato le continuazioni.

Quando cfviene chiamata una funzione di continuazione :

  1. L'esecuzione salta il resto del shiftblocco e ricomincia alla fine di esso
    • il parametro passato cfè quello a cui il shiftblocco "valuta" man mano che l'esecuzione continua. questo può essere diverso per ogni chiamata acf
  2. L'esecuzione continua fino alla fine del resetblocco (o fino a una chiamata a resetse non c'è blocco)
    • il risultato del resetblocco (o il parametro a reset() se non c'è blocco) è ciò che cfritorna
  3. L'esecuzione continua dopo cffino alla fine del shiftblocco
  4. L'esecuzione salta fino alla fine del resetblocco (o una chiamata al ripristino?)

Quindi, in questo esempio, segui le lettere dalla A alla Z.

reset {
  // A
  shift { cf: (Int=>Int) =>
    // B
    val eleven = cf(10)
    // E
    println(eleven)
    val oneHundredOne = cf(100)
    // H
    println(oneHundredOne)
    oneHundredOne
  }
  // C execution continues here with the 10 as the context
  // F execution continues here with 100
  + 1
  // D 10.+(1) has been executed - 11 is returned from cf which gets assigned to eleven
  // G 100.+(1) has been executed and 101 is returned and assigned to oneHundredOne
}
// I

Questo stampa:

11
101

2
Ho ricevuto un errore che diceva "Impossibile calcolare il tipo per il risultato della funzione trasformata da CPS" quando ho provato a compilarlo .. Non sono sicuro di cosa sia né come risolverlo
Fabio Veronez

@Fabio Veronez Aggiungere una dichiarazione ritorno alla fine del turno: il cambiamento println(oneHundredOne) }, per esempio, println(oneHundredOne); oneHundredOne }.
folone

Bella spiegazione per una sintassi orribile. La dichiarazione della funzione di continuazione è stranamente staccata dal suo corpo. Sarei riluttante a condividere questo codice da grattacapi con altri.
joeytwiddle

Per evitare l' cannot compute type for CPS-transformed function resulterrore, +1seguire immediatamente dopo oneHundredOne}. I commenti che attualmente risiedono tra di loro rompono in qualche modo la grammatica.
LCN

9

Dato l'esempio canonico del documento di ricerca per le continuazioni delimitate di Scala, leggermente modificato in modo che l'input della funzione a cui shiftvenga dato il nome fe quindi non sia più anonimo.

def f(k: Int => Int): Int = k(k(k(7)))
reset(
  shift(f) + 1   // replace from here down with `f(k)` and move to `k`
) * 2

Il plugin Scala trasforma questo esempio in modo tale che il calcolo (all'interno dell'argomento di input di reset) a partire da ogni shiftinvocazione di resetvenga sostituito con la funzione (ad esempio f) input a shift.

Il calcolo sostituito viene spostato (cioè spostato) in una funzione k. La funzione finserisce la funzione k, dove k contiene il calcolo sostituito, kinput x: Inte il calcolo in ksostituisce shift(f)con x.

f(k) * 2
def k(x: Int): Int = x + 1

Che ha lo stesso effetto di:

k(k(k(7))) * 2
def k(x: Int): Int = x + 1

Si noti che il tipo Intdel parametro di input x(cioè la firma del tipo di k) è stata data dalla firma del tipo del parametro di input di f.

Un altro esempio preso in prestito con l'astrazione concettualmente equivalente, cioè readè l'input della funzione per shift:

def read(callback: Byte => Unit): Unit = myCallback = callback
reset {
  val byte = "byte"

  val byte1 = shift(read)   // replace from here with `read(callback)` and move to `callback`
  println(byte + "1 = " + byte1)
  val byte2 = shift(read)   // replace from here with `read(callback)` and move to `callback`
  println(byte + "2 = " + byte2)
}

Credo che questo sarebbe tradotto nell'equivalente logico di:

val byte = "byte"

read(callback)
def callback(x: Byte): Unit {
  val byte1 = x
  println(byte + "1 = " + byte1)
  read(callback2)
  def callback2(x: Byte): Unit {
    val byte2 = x
    println(byte + "2 = " + byte1)
  }
}

Spero che questo chiarisca l'astrazione comune coerente che è stata alquanto offuscata dalla precedente presentazione di questi due esempi. Ad esempio, il primo esempio canonico è stato presentato nel documento di ricerca come una funzione anonima, invece del mio nome f, quindi non è stato immediatamente chiaro ad alcuni lettori che fosse astrattamente analogo al secondo esempio presoread in prestito .

Continuazioni così delimitate creano l'illusione di un'inversione di controllo da "tu mi chiami dall'esterno di reset" a "io ti chiamo dentro reset".

Nota che il tipo restituito di fè, ma knon è, richiesto per essere lo stesso del tipo restituito di reset, ovvero fha la libertà di dichiarare qualsiasi tipo restituito kfintanto che frestituisce lo stesso tipo di reset. Idem per reade capture(vedi anche ENVsotto).


Le continuazioni delimitate non invertono implicitamente il controllo di stato, ad esempio reade callbacknon sono funzioni pure. Pertanto, il chiamante non può creare espressioni referenzialmente trasparenti e quindi non ha il controllo dichiarativo (noto anche come trasparente) sulla semantica imperativa prevista .

Possiamo ottenere esplicitamente funzioni pure con continuazioni delimitate.

def aread(env: ENV): Tuple2[Byte,ENV] {
  def read(callback: Tuple2[Byte,ENV] => ENV): ENV = env.myCallback(callback)
  shift(read)
}
def pure(val env: ENV): ENV {
  reset {
    val (byte1, env) = aread(env)
    val env = env.println("byte1 = " + byte1)
    val (byte2, env) = aread(env)
    val env = env.println("byte2 = " + byte2)
  }
}

Credo che questo sarebbe tradotto nell'equivalente logico di:

def read(callback: Tuple2[Byte,ENV] => ENV, env: ENV): ENV =
  env.myCallback(callback)
def pure(val env: ENV): ENV {
  read(callback,env)
  def callback(x: Tuple2[Byte,ENV]): ENV {
    val (byte1, env) = x
    val env = env.println("byte1 = " + byte1)
    read(callback2,env)
    def callback2(x: Tuple2[Byte,ENV]): ENV {
      val (byte2, env) = x
      val env = env.println("byte2 = " + byte2)
    }
  }
}

Questo sta diventando rumoroso, a causa dell'ambiente esplicito.

Nota tangenzialmente, Scala non ha l'inferenza di tipo globale di Haskell e quindi per quanto ne so non potrebbe supportare l'elevazione implicita a una monade di stato unit(come una possibile strategia per nascondere l'ambiente esplicito), perché l'inferenza di tipo globale (Hindley-Milner) di Haskell dipende dal non supportare l'ereditarietà virtuale multipla del diamante .


Sto proponendo che reset/ shiftvenga cambiato in delimit/ replace. E per convenzione, che fe readessere with, e ked callbackessere replaced, captured, continuation, o callback.
Shelby Moore III

con è una parola chiave. PS Alcuni dei tuoi ripristini hanno () che dovrebbe essere {} Comunque un ottimo articolo!
nafg

@nafg grazie, quindi ti propongo replacementinvece di with. Afaik, ()è consentito anche? Afaik, {}è "la sintassi leggera di Scala per le chiusure" , che nasconde una chiamata di funzione sottostante. Ad esempio, guarda come ho riscritto Daniel'ssequence (nota che il codice non è mai stato compilato o testato, quindi sentiti libero di correggermi).
Shelby Moore III

1
Un blocco, ovvero un'espressione contenente più istruzioni, richiede parentesi graffe.
nafg

@nafg, corretto. Gli afaik shift resetsono funzioni di libreria, non parole chiave. Pertanto {}o ()può essere utilizzato quando la funzione prevede un solo parametro . Scala ha per nome parametri (vedere la sezione "9.5 Controllo Astrazioni" di programmazione a Scala, 2 ° ed. Pag. 218), in cui, se il parametro è di tipo () => ...l' () =>può essere eliminato. Presumo Unite non per nome perché il blocco dovrebbe valutare prima di resetessere invocato, ma ho bisogno {}di più istruzioni. Il mio utilizzo per shiftè corretto, perché ovviamente introduce un tipo di funzione.
Shelby Moore III

8

La continuazione cattura lo stato di un calcolo, da richiamare in seguito.

Pensa al calcolo tra l'abbandono dell'espressione shift e il lasciare l'espressione reset come funzione. All'interno dell'espressione shift questa funzione è chiamata k, è la continuazione. Puoi passarlo in giro, invocarlo più tardi, anche più di una volta.

Penso che il valore restituito dall'espressione reset sia il valore dell'espressione all'interno dell'espressione shift dopo =>, ma su questo non ne sono abbastanza sicuro.

Quindi con le continuazioni puoi racchiudere un pezzo di codice piuttosto arbitrario e non locale in una funzione. Questo può essere utilizzato per implementare un flusso di controllo non standard, come il coroutining o il backtracking.

Quindi le continuazioni dovrebbero essere utilizzate a livello di sistema. Cospargerli attraverso il codice dell'applicazione sarebbe una ricetta sicura per gli incubi, molto peggio di quanto possa mai essere il peggior codice di spaghetti che usa goto.

Dichiarazione di non responsabilità: non ho una comprensione approfondita delle continuazioni in Scala, l'ho solo dedotto guardando gli esempi e conoscendo le continuazioni da Scheme.


5

Dal mio punto di vista, la migliore spiegazione è stata data qui: http://jim-mcbeath.blogspot.ru/2010/08/delimited-continuations.html

Uno degli esempi:

Per vedere il flusso di controllo un po 'più chiaramente, puoi eseguire questo snippet di codice:

reset {
    println("A")
    shift { k1: (Unit=>Unit) =>
        println("B")
        k1()
        println("C")
    }
    println("D")
    shift { k2: (Unit=>Unit) =>
        println("E")
        k2()
        println("F")
    }
    println("G")
}

Ecco l'output prodotto dal codice precedente:

A
B
D
E
G
F
C

1

Un altro articolo (più recente - maggio 2016) sulle continuazioni di Scala è:
" Time Travel in Scala: CPS in Scala (scala's continuation) " di Shivansh Srivastava ( shiv4nsh) .
Si riferisce anche a Jim McBeath 's articolo di cui Dmitry Bespalov ' s risposta .

Ma prima, descrive Continuations in questo modo:

Una continuazione è una rappresentazione astratta dello stato di controllo di un programma per computer .
Quindi ciò che in realtà significa è che si tratta di una struttura di dati che rappresenta il processo di calcolo in un dato punto dell'esecuzione del processo; la struttura dati creata è accessibile dal linguaggio di programmazione, invece di essere nascosta nell'ambiente di runtime.

Per spiegarlo ulteriormente possiamo avere uno degli esempi più classici,

Diciamo che sei in cucina davanti al frigorifero e pensi a un panino. Prendi una continuazione proprio lì e te la metti in tasca.
Quindi prendi del tacchino e del pane dal frigorifero e ti prepari un panino, che ora è seduto sul bancone.
Invochi il seguito in tasca, e ti ritrovi di nuovo davanti al frigorifero, a pensare a un panino. Ma per fortuna c'è un panino sul bancone e tutti i materiali usati per realizzarlo sono spariti. Quindi lo mangi. :-)

In questa descrizione, sandwichfa parte dei dati del programma (ad esempio, un oggetto sull'heap), e invece di chiamare una make sandwichroutine " " e poi tornare, la persona chiama una make sandwich with current continuationroutine " ", che crea il sandwich e poi continua dove l'esecuzione lasciato fuori.

Detto questo, come annunciato nell'aprile 2014 per Scala 2.11.0-RC1

Stiamo cercando manutentori che si occupino dei seguenti moduli: scala-swing , scala-continuations .
2.12 non li includerà se non viene trovato un nuovo manutentore .
Probabilmente continueremo a mantenere gli altri moduli (scala-xml, scala-parser-combinators), ma l'aiuto è ancora molto apprezzato.


0

Continuazioni di Scala tramite esempi significativi

Definiamo from0to10che esprime l'idea di iterazione da 0 a 10:

def from0to10() = shift { (cont: Int => Unit) =>
   for ( i <- 0 to 10 ) {
     cont(i)
   }
}

Adesso,

reset {
  val x = from0to10()
  print(s"$x ")
}
println()

stampe:

0 1 2 3 4 5 6 7 8 9 10 

Non abbiamo infatti bisogno di x:

reset {
  print(s"${from0to10()} ")
}
println()

stampa lo stesso risultato.

E

reset {
  print(s"(${from0to10()},${from0to10()}) ")
}
println()

stampa tutte le coppie:

(0,0) (0,1) (0,2) (0,3) (0,4) (0,5) (0,6) (0,7) (0,8) (0,9) (0,10) (1,0) (1,1) (1,2) (1,3) (1,4) (1,5) (1,6) (1,7) (1,8) (1,9) (1,10) (2,0) (2,1) (2,2) (2,3) (2,4) (2,5) (2,6) (2,7) (2,8) (2,9) (2,10) (3,0) (3,1) (3,2) (3,3) (3,4) (3,5) (3,6) (3,7) (3,8) (3,9) (3,10) (4,0) (4,1) (4,2) (4,3) (4,4) (4,5) (4,6) (4,7) (4,8) (4,9) (4,10) (5,0) (5,1) (5,2) (5,3) (5,4) (5,5) (5,6) (5,7) (5,8) (5,9) (5,10) (6,0) (6,1) (6,2) (6,3) (6,4) (6,5) (6,6) (6,7) (6,8) (6,9) (6,10) (7,0) (7,1) (7,2) (7,3) (7,4) (7,5) (7,6) (7,7) (7,8) (7,9) (7,10) (8,0) (8,1) (8,2) (8,3) (8,4) (8,5) (8,6) (8,7) (8,8) (8,9) (8,10) (9,0) (9,1) (9,2) (9,3) (9,4) (9,5) (9,6) (9,7) (9,8) (9,9) (9,10) (10,0) (10,1) (10,2) (10,3) (10,4) (10,5) (10,6) (10,7) (10,8) (10,9) (10,10) 

Ora, come funziona?

C'è il codice chiamato , from0to10e il codice di chiamata . In questo caso, è il blocco che segue reset. Uno dei parametri passati al codice chiamato è un indirizzo di ritorno che mostra quale parte del codice chiamante non è stata ancora eseguita (**). Quella parte del codice chiamante è la continuazione . Il codice chiamato può fare con quel parametro qualunque cosa decida di: passargli il controllo, o ignorarlo, o chiamarlo più volte. Qui from0to10chiama quella continuazione per ogni numero intero compreso tra 0 e 10.

def from0to10() = shift { (cont: Int => Unit) =>
   for ( i <- 0 to 10 ) {
     cont(i) // call the continuation
   }
}

Ma dove finisce il seguito? Questo è importante perché l'ultima returndai rendimenti di continuazione il controllo al codice chiamato, from0to10. In Scala, finisce dove finisce il resetblocco (*).

Ora, vediamo che la continuazione è dichiarata come cont: Int => Unit. Perché? Invociamo from0to10as val x = from0to10(), ed Intè il tipo di valore a cui va x. Unitsignifica che il blocco dopo non resetdeve restituire alcun valore (altrimenti ci sarà un errore di tipo). In generale, ci sono 4 tipi di firme: input di funzione, input di continuazione, risultato di continuazione, risultato di funzione. Tutti e quattro devono corrispondere al contesto di chiamata.

Sopra, abbiamo stampato coppie di valori. Stampiamo la tavola pitagorica. Ma come produciamo l'output \ndopo ogni riga?

La funzione backci consente di specificare cosa deve essere fatto quando il controllo ritorna, dalla continuazione al codice che lo ha chiamato.

def back(action: => Unit) = shift { (cont: Unit => Unit) =>
  cont()
  action
}

backprima chiama la sua continuazione, quindi esegue l' azione .

reset {
  val i = from0to10()
  back { println() }
  val j = from0to10
  print(f"${i*j}%4d ") // printf-like formatted i*j
}

Stampa:

   0    0    0    0    0    0    0    0    0    0    0 
   0    1    2    3    4    5    6    7    8    9   10 
   0    2    4    6    8   10   12   14   16   18   20 
   0    3    6    9   12   15   18   21   24   27   30 
   0    4    8   12   16   20   24   28   32   36   40 
   0    5   10   15   20   25   30   35   40   45   50 
   0    6   12   18   24   30   36   42   48   54   60 
   0    7   14   21   28   35   42   49   56   63   70 
   0    8   16   24   32   40   48   56   64   72   80 
   0    9   18   27   36   45   54   63   72   81   90 
   0   10   20   30   40   50   60   70   80   90  100 

Bene, ora è il momento di scioccare il cervello. Ci sono due invocazioni di from0to10. Qual è la continuazione del primo from0to10? Segue l'invocazione di from0to10nel codice binario , ma nel codice sorgente include anche l'istruzione di assegnazione val i =. Termina dove finisce il resetblocco, ma la fine del resetblocco non restituisce il controllo al primo from0to10. La fine del resetblocco restituisce il controllo al 2 ° from0to10, che a sua volta alla fine restituisce il controllo a back, ed è backche restituisce il controllo alla prima invocazione di from0to10. Quando il primo (sì! 1 °!) from0to10Esce, si esce dall'intero resetblocco.

Tale metodo di restituzione del controllo è chiamato backtracking , è una tecnica molto antica, conosciuta almeno dai tempi dei derivati ​​del Lisp orientati al Prolog e AI.

I nomi resete shiftsono nomi impropri. Questi nomi avrebbero dovuto essere lasciati meglio per le operazioni bit per bit. resetdefinisce i limiti di continuazione e shiftprende una continuazione dallo stack di chiamate.

Appunti)

(*) In Scala il seguito finisce dove finisce il resetblocco. Un altro approccio possibile sarebbe lasciarlo finire dove finisce la funzione.

(**) Uno dei parametri del codice chiamato è un indirizzo di ritorno che mostra quale parte del codice chiamante non è stata ancora eseguita. Bene, in Scala, per questo viene utilizzata una sequenza di indirizzi di ritorno. Quanti? Tutti gli indirizzi di ritorno inseriti nello stack di chiamate dall'ingresso nel resetblocco.


UPD Parte 2 Discarding Continuations: Filtering

def onEven(x:Int) = shift { (cont: Unit => Unit) =>
  if ((x&1)==0) {
    cont() // call continuation only for even numbers
  }
}
reset {
  back { println() }
  val x = from0to10()
  onEven(x)
  print(s"$x ")
}

Questo stampa:

0 2 4 6 8 10 

Consideriamo due importanti operazioni: scartare la continuation ( fail()) e passare il controllo ad essa ( succ()):

// fail: just discard the continuation, force control to return back
def fail() = shift { (cont: Unit => Unit) => }
// succ: does nothing (well, passes control to the continuation), but has a funny signature
def succ():Unit @cpsParam[Unit,Unit] = { }
// def succ() = shift { (cont: Unit => Unit) => cont() }

Entrambe le versioni di succ()(sopra) funzionano. Si scopre che shiftha una firma divertente e, sebbene succ()non faccia nulla, deve avere quella firma per l'equilibrio del tipo.

reset {
  back { println() }
  val x = from0to10()
  if ((x&1)==0) {
    succ()
  } else {
    fail()
  }
  print(s"$x ")
}

come previsto, viene stampato

0 2 4 6 8 10

All'interno di una funzione succ()non è necessario:

def onTrue(b:Boolean) = {
  if(!b) {
    fail()
  }
}
reset {
  back { println() }
  val x = from0to10()
  onTrue ((x&1)==0)
  print(s"$x ")
}

ancora una volta, stampa

0 2 4 6 8 10

Ora, definiamo onOdd()tramite onEven():

// negation: the hard way
class ControlTransferException extends Exception {}
def onOdd(x:Int) = shift { (cont: Unit => Unit) =>
  try {
    reset {
      onEven(x)
      throw new ControlTransferException() // return is not allowed here
    }
    cont()
  } catch {
    case e: ControlTransferException =>
    case t: Throwable => throw t
  }
}
reset {
  back { println() }
  val x = from0to10()
  onOdd(x)
  print(s"$x ")
}

Sopra, se xè pari, viene generata un'eccezione e la continuazione non viene chiamata; se xè dispari, l'eccezione non viene generata e viene chiamata la continuazione. Il codice sopra viene stampato:

1 3 5 7 9 
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.