Che cos'è l'ottimizzazione delle chiamate di coda?


818

Molto semplicemente, cos'è l'ottimizzazione della coda?

Più specificamente, quali sono alcuni piccoli frammenti di codice in cui potrebbe essere applicato e dove no, con una spiegazione del perché?


10
TCO trasforma una chiamata di funzione in posizione di coda in un goto, un salto.
Will Ness,

8
Questa domanda fu posta completamente 8 anni prima di quella;)
majelbstoat il

Risposte:


756

L'ottimizzazione delle chiamate di coda è quella in cui è possibile evitare di allocare un nuovo frame di stack per una funzione perché la funzione di chiamata restituirà semplicemente il valore che ottiene dalla funzione chiamata. L'uso più comune è la ricorsione della coda, in cui una funzione ricorsiva scritta per sfruttare l'ottimizzazione della coda può utilizzare uno spazio di stack costante.

Scheme è uno dei pochi linguaggi di programmazione che garantisce nelle specifiche che qualsiasi implementazione deve fornire questa ottimizzazione (anche JavaScript, a partire da ES6) , quindi ecco due esempi della funzione fattoriale in Scheme:

(define (fact x)
  (if (= x 0) 1
      (* x (fact (- x 1)))))

(define (fact x)
  (define (fact-tail x accum)
    (if (= x 0) accum
        (fact-tail (- x 1) (* x accum))))
  (fact-tail x 1))

La prima funzione non è ricorsiva di coda perché quando viene effettuata la chiamata ricorsiva, la funzione deve tenere traccia della moltiplicazione che deve fare con il risultato dopo il ritorno della chiamata. Come tale, lo stack si presenta come segue:

(fact 3)
(* 3 (fact 2))
(* 3 (* 2 (fact 1)))
(* 3 (* 2 (* 1 (fact 0))))
(* 3 (* 2 (* 1 1)))
(* 3 (* 2 1))
(* 3 2)
6

Al contrario, la traccia dello stack per il fattoriale ricorsivo della coda appare come segue:

(fact 3)
(fact-tail 3 1)
(fact-tail 2 3)
(fact-tail 1 6)
(fact-tail 0 6)
6

Come puoi vedere, dobbiamo solo tenere traccia della stessa quantità di dati per ogni chiamata alla coda dei fatti perché stiamo semplicemente restituendo il valore che otteniamo fino in cima. Ciò significa che anche se dovessi chiamare (fatto 1000000), avrei bisogno solo della stessa quantità di spazio (fatto 3). Questo non è il caso del fatto non ricorsivo della coda e poiché valori così grandi possono causare un overflow dello stack.


99
Se vuoi saperne di più su questo, ti suggerisco di leggere il primo capitolo di Struttura e interpretazione dei programmi per computer.
Kyle Cronin,

3
Ottima risposta, spiegata perfettamente.
Giona il

15
A rigor di termini, l'ottimizzazione delle chiamate di coda non sostituisce necessariamente il frame dello stack del chiamante con i callees ma, piuttosto, assicura che un numero illimitato di chiamate nella posizione di coda richieda solo una quantità limitata di spazio. Vedi l'articolo di Will Clinger "Corretta ricorsione della coda ed efficienza dello spazio": cesura17.net/~will/Professional/Research/Papers/tail.pdf
Jon Harrop

3
È solo un modo per scrivere funzioni ricorsive in uno spazio costante? Perché non hai potuto ottenere gli stessi risultati usando un approccio iterativo?
dclowd9901,

5
@ dclowd9901, TCO ti consente di preferire uno stile funzionale piuttosto che un ciclo iterativo. Puoi preferire lo stile imperativo. Molti linguaggi (Java, Python) non forniscono TCO, quindi devi sapere che una chiamata funzionale costa memoria ... e lo stile imperativo è preferito.
mcoolive,

553

Vediamo un semplice esempio: la funzione fattoriale implementata in C.

Iniziamo con l'ovvia definizione ricorsiva

unsigned fac(unsigned n)
{
    if (n < 2) return 1;
    return n * fac(n - 1);
}

Una funzione termina con una chiamata di coda se l'ultima operazione prima che la funzione ritorni è un'altra chiamata di funzione. Se questa chiamata richiama la stessa funzione, è ricorsiva alla coda.

Anche se fac()a prima vista sembra ricorsivo alla coda, non è come realmente accade

unsigned fac(unsigned n)
{
    if (n < 2) return 1;
    unsigned acc = fac(n - 1);
    return n * acc;
}

cioè l'ultima operazione è la moltiplicazione e non la chiamata di funzione.

Tuttavia, è possibile riscrivere fac()in modo ricorsivo della coda passando il valore accumulato nella catena di chiamate come argomento aggiuntivo e passando di nuovo solo il risultato finale come valore di ritorno:

unsigned fac(unsigned n)
{
    return fac_tailrec(1, n);
}

unsigned fac_tailrec(unsigned acc, unsigned n)
{
    if (n < 2) return acc;
    return fac_tailrec(n * acc, n - 1);
}

Ora, perché è utile? Poiché torniamo immediatamente dopo la chiamata di coda, possiamo scartare lo stackframe precedente prima di richiamare la funzione in posizione di coda o, in caso di funzioni ricorsive, riutilizzare lo stackframe così com'è.

L'ottimizzazione del tail-call trasforma il nostro codice ricorsivo in

unsigned fac_tailrec(unsigned acc, unsigned n)
{
TOP:
    if (n < 2) return acc;
    acc = n * acc;
    n = n - 1;
    goto TOP;
}

Questo può essere integrato fac()e arriviamo a

unsigned fac(unsigned n)
{
    unsigned acc = 1;

TOP:
    if (n < 2) return acc;
    acc = n * acc;
    n = n - 1;
    goto TOP;
}

che equivale a

unsigned fac(unsigned n)
{
    unsigned acc = 1;

    for (; n > 1; --n)
        acc *= n;

    return acc;
}

Come possiamo vedere qui, un ottimizzatore sufficientemente avanzato può sostituire la ricorsione della coda con l'iterazione, che è molto più efficiente poiché si evita l'overhead delle chiamate di funzione e si utilizza solo una quantità costante di spazio dello stack.


puoi spiegare cosa significa esattamente uno stackframe? C'è una differenza tra lo stack di chiamate e lo stackframe?
Shasak,

11
@Kasahs: uno stack frame è la parte dello stack di chiamate che "appartiene" a una determinata funzione (attiva); cf en.wikipedia.org/wiki/Call_stack#Structure
Christoph

1
Ho appena avuto un'epifania abbastanza intensa dopo aver letto questo post dopo aver letto 2ality.com/2015/06/tail-call-optimization.html
agm1984,

199

Il TCO (Tail Call Optimization) è il processo mediante il quale un compilatore intelligente può effettuare una chiamata a una funzione e non occupare spazio di stack aggiuntivo. L' unica situazione in cui ciò accade è se l'ultima istruzione eseguita in una funzione f è una chiamata a una funzione g (Nota: g può essere f ). La chiave qui è che f non ha più bisogno di spazio per lo stack: chiama semplicemente g e quindi restituisce qualunque g restituisca. In questo caso si può fare l'ottimizzazione che g viene eseguito e restituisce qualsiasi valore che avrebbe alla cosa che ha chiamato f.

Questa ottimizzazione può far sì che le chiamate ricorsive occupino spazio nello stack costante, anziché esplodere.

Esempio: questa funzione fattoriale non è TCOptimizable:

def fact(n):
    if n == 0:
        return 1
    return n * fact(n-1)

Questa funzione fa cose oltre a chiamare un'altra funzione nella sua dichiarazione di ritorno.

Questa funzione di seguito è TCOptimizable:

def fact_h(n, acc):
    if n == 0:
        return acc
    return fact_h(n-1, acc*n)

def fact(n):
    return fact_h(n, 1)

Questo perché l'ultima cosa che accade in una di queste funzioni è chiamare un'altra funzione.


3
L'intera funzione g può essere una cosa un po 'confusa, ma capisco cosa intendi, e gli esempi hanno chiarito le cose. Molte grazie!
majelbstoat,

10
Ottimo esempio che illustra il concetto. Basta tenere conto del fatto che la lingua scelta deve implementare l'eliminazione della coda o l'ottimizzazione della coda. Nell'esempio, scritto in Python, se si inserisce un valore di 1000 si ottiene un "RuntimeError: superata la profondità massima di ricorsione" perché l'implementazione predefinita di Python non supporta l'eliminazione della ricorsione della coda. Vedi un post dello stesso Guido che spiega perché: neopythonic.blogspot.pt/2009/04/tail-recursion-elimination.html .
rmcc,

"L' unica situazione" è un po 'troppo assoluta; c'è anche TRMC , almeno in teoria, che ottimizzerebbe (cons a (foo b))o (+ c (bar d))in posizione di coda allo stesso modo.
Will Ness,

Mi è piaciuto il tuo approccio feg meglio della risposta accettata, forse perché sono una persona matematica.
Nithin,

Penso che intendi TCOptimized. Dire che non è TCOptimizable afferma che non può mai essere ottimizzato (quando in effetti può farlo)
Jacques Mathieu,

65

Probabilmente la migliore descrizione di alto livello che ho trovato per le chiamate di coda, le chiamate di coda ricorsive e l'ottimizzazione delle chiamate di coda è il post sul blog

"Che diamine è: una chiamata in coda"

di Dan Sugalski. Sull'ottimizzazione delle chiamate di coda scrive:

Considera, per un momento, questa semplice funzione:

sub foo (int a) {
  a += 15;
  return bar(a);
}

Quindi, cosa puoi fare, o meglio, il tuo compilatore di lingue? Bene, ciò che può fare è trasformare il codice del modulo return somefunc();nella sequenza di basso livello pop stack frame; goto somefunc();. Nel nostro esempio, ciò significa che prima di chiamare bar, si foopulisce e quindi, anziché chiamare barcome subroutine, eseguiamo un'operazione di basso livello gotoall'inizio di bar. FooSi è già ripulito dallo stack, quindi quando barinizia sembra che chiunque fooabbia chiamato abbia davvero chiamato bar, e quando barrestituisce il suo valore, lo restituisce direttamente a chiunque abbia chiamato foo, piuttosto che restituirlo al fooquale lo restituirebbe al suo chiamante.

E sulla ricorsione della coda:

La ricorsione della coda si verifica se una funzione, come ultima operazione, restituisce il risultato della chiamata stessa . La ricorsione della coda è più facile da gestire perché, piuttosto che dover saltare all'inizio di una funzione casuale da qualche parte, devi solo tornare all'inizio di te stesso, che è una cosa dannatamente semplice da fare.

In modo che questo:

sub foo (int a, int b) {
  if (b == 1) {
    return a;
  } else {
    return foo(a*a + a, b - 1);
  }

si trasforma tranquillamente in:

sub foo (int a, int b) {
  label:
    if (b == 1) {
      return a;
    } else {
      a = a*a + a;
      b = b - 1;
      goto label;
   }

Quello che mi piace di questa descrizione è quanto sia facile e conciso capire chi proviene da un background linguistico imperativo (C, C ++, Java)


4
404 Errore. Tuttavia, è ancora disponibile su archive.org: web.archive.org/web/20111030134120/http://www.sidhe.org/~dan/…
Tommy

Non l'ho capito, la foofunzione di coda iniziale non è ottimizzata? Sta solo chiamando una funzione come ultimo passo e sta semplicemente restituendo quel valore, giusto?
SexyBeast

1
@ TryinHard forse non è quello che avevi in ​​mente, ma l'ho aggiornato per dare un'idea di cosa si tratta. Spiacenti, non ripeterò l'intero articolo!
entro il

2
Grazie, questo è più semplice e comprensibile dell'esempio di schema più votato (per non parlare del fatto che Scheme non è un linguaggio comune che la maggior parte degli sviluppatori capisce)
Sevin7,

2
Come qualcuno che si tuffa raramente in linguaggi funzionali, è gratificante vedere una spiegazione nel "mio dialetto". C'è una (comprensibile) tendenza per i programmatori funzionali ad evangelizzare nella loro lingua preferita, ma provengo dal mondo imperativo e trovo molto più facile avvolgere la mia testa attorno a una risposta come questa.
James Beninger,

15

Nota innanzitutto che non tutte le lingue lo supportano.

Il TCO si applica a un caso speciale di ricorsione. L'essenza di ciò è che se l'ultima cosa che fai in una funzione è chiamare se stessa (ad es. Si sta chiamando dalla posizione "coda"), questo può essere ottimizzato dal compilatore per agire come iterazione invece che ricorsione standard.

Si vede, normalmente durante la ricorsione, il runtime deve tenere traccia di tutte le chiamate ricorsive, in modo che quando si ritorna può riprendere alla chiamata precedente e così via. (Prova a scrivere manualmente il risultato di una chiamata ricorsiva per avere un'idea visiva di come funziona.) Tenere traccia di tutte le chiamate occupa spazio, il che diventa significativo quando la funzione si chiama molto. Ma con TCO, si può semplicemente dire "torna all'inizio, solo che questa volta cambia i valori dei parametri con questi nuovi". Può farlo perché nulla dopo la chiamata ricorsiva si riferisce a quei valori.


3
Le chiamate di coda possono applicarsi anche a funzioni non ricorsive. Qualsiasi funzione il cui ultimo calcolo prima di ritornare è una chiamata a un'altra funzione può usare una coda.
Brian,

Non necessariamente vero in base alla lingua per lingua: il compilatore C # a 64 bit può inserire codici di coda mentre la versione a 32 bit non lo farà; e F # rilascerà la build, ma il debug di F # non lo farà di default.
Steve Gilham,

3
"Il TCO si applica a un caso speciale di ricorsione". Temo che sia completamente sbagliato. Le chiamate di coda si applicano a qualsiasi chiamata in posizione di coda. Comunemente discusso nel contesto della ricorsione, ma in realtà non ha nulla a che fare specificamente con la ricorsione.
Jon Harrop,

@Brian, guarda il link @btiernay fornito sopra. La foochiamata di coda del metodo iniziale non è ottimizzata?
SexyBeast

13

Esempio eseguibile minimo GCC con analisi di disassemblaggio x86

Vediamo come GCC può eseguire automaticamente le ottimizzazioni delle chiamate di coda per noi osservando l'assembly generato.

Questo servirà come esempio estremamente concreto di ciò che è stato menzionato in altre risposte come https://stackoverflow.com/a/9814654/895245 che l'ottimizzazione può convertire le chiamate di funzioni ricorsive in un ciclo.

Questo a sua volta consente di risparmiare memoria e migliorare le prestazioni, poiché gli accessi alla memoria sono spesso la cosa principale che rallenta i programmi al giorno d'oggi .

Come input, offriamo a GCC un fattoriale basato su stack ingenuo non ottimizzato:

tail_call.c

#include <stdio.h>
#include <stdlib.h>

unsigned factorial(unsigned n) {
    if (n == 1) {
        return 1;
    }
    return n * factorial(n - 1);
}

int main(int argc, char **argv) {
    int input;
    if (argc > 1) {
        input = strtoul(argv[1], NULL, 0);
    } else {
        input = 5;
    }
    printf("%u\n", factorial(input));
    return EXIT_SUCCESS;
}

GitHub a monte .

Compilare e disassemblare:

gcc -O1 -foptimize-sibling-calls -ggdb3 -std=c99 -Wall -Wextra -Wpedantic \
  -o tail_call.out tail_call.c
objdump -d tail_call.out

dov'è -foptimize-sibling-callsil nome di generalizzazione delle chiamate di coda secondo man gcc:

   -foptimize-sibling-calls
       Optimize sibling and tail recursive calls.

       Enabled at levels -O2, -O3, -Os.

come menzionato in: Come posso verificare se gcc sta eseguendo l'ottimizzazione della ricorsione della coda?

Scelgo -O1perché:

  • l'ottimizzazione non è terminata -O0 . Sospetto che ciò avvenga perché mancano trasformazioni intermedie richieste.
  • -O3 produce un codice ungodly efficiente che non sarebbe molto educativo, sebbene sia anche ottimizzato per la coda.

Smontaggio con -fno-optimize-sibling-calls:

0000000000001145 <factorial>:
    1145:       89 f8                   mov    %edi,%eax
    1147:       83 ff 01                cmp    $0x1,%edi
    114a:       74 10                   je     115c <factorial+0x17>
    114c:       53                      push   %rbx
    114d:       89 fb                   mov    %edi,%ebx
    114f:       8d 7f ff                lea    -0x1(%rdi),%edi
    1152:       e8 ee ff ff ff          callq  1145 <factorial>
    1157:       0f af c3                imul   %ebx,%eax
    115a:       5b                      pop    %rbx
    115b:       c3                      retq
    115c:       c3                      retq

Con -foptimize-sibling-calls:

0000000000001145 <factorial>:
    1145:       b8 01 00 00 00          mov    $0x1,%eax
    114a:       83 ff 01                cmp    $0x1,%edi
    114d:       74 0e                   je     115d <factorial+0x18>
    114f:       8d 57 ff                lea    -0x1(%rdi),%edx
    1152:       0f af c7                imul   %edi,%eax
    1155:       89 d7                   mov    %edx,%edi
    1157:       83 fa 01                cmp    $0x1,%edx
    115a:       75 f3                   jne    114f <factorial+0xa>
    115c:       c3                      retq
    115d:       89 f8                   mov    %edi,%eax
    115f:       c3                      retq

La differenza chiave tra i due è che:

  • gli -fno-optimize-sibling-callsusi callq, che è la tipica chiamata di funzione non ottimizzata.

    Questa istruzione invia l'indirizzo di ritorno allo stack, aumentandolo quindi.

    Inoltre, questa versione fa anche push %rbx, che spinge %rbxallo stack .

    GCC lo fa perché memorizza edi, che è il primo argomento della funzione ( n) in ebx, quindi chiama factorial.

    GCC deve farlo perché si sta preparando per un'altra chiamata factorial, che utilizzerà il nuovoedi == n-1 .

    Sceglie ebxperché questo registro viene salvato dalla chiamata: quali registri vengono conservati attraverso una chiamata di funzione x86-64 di linux in modo che la chiamata secondaria factorialnon lo cambi e perda n.

  • il -foptimize-sibling-callsnon usa alcuna istruzione che spinga nello stack: fa solo gotosalti dentro factorialcon le istruzioni jee jne.

    Pertanto, questa versione equivale a un ciclo while, senza alcuna chiamata di funzione. L'uso dello stack è costante.

Testato in Ubuntu 18.10, GCC 8.2.


6

Guarda qui:

http://tratt.net/laurie/tech_articles/articles/tail_call_optimization

Come probabilmente saprai, le chiamate di funzione ricorsive possono causare il caos in una pila; è facile esaurire rapidamente lo spazio dello stack. L'ottimizzazione delle chiamate di coda è il modo in cui è possibile creare un algoritmo di stile ricorsivo che utilizza uno spazio di stack costante, quindi non cresce e cresce e si ottengono errori di stack.


3
  1. Dovremmo assicurarci che non ci siano dichiarazioni di goto nella funzione stessa. La cura della chiamata funzione è l'ultima cosa nella funzione di chiamata.

  2. Le ricorsioni su larga scala possono utilizzare questo per le ottimizzazioni, ma su piccola scala, l'overhead delle istruzioni per rendere la chiamata di funzione una chiamata di coda riduce lo scopo effettivo.

  3. Il TCO potrebbe causare una funzione sempre attiva:

    void eternity()
    {
        eternity();
    }
    

3 non è stato ancora ottimizzato. Questa è la rappresentazione non ottimizzata che il compilatore trasforma in codice iterativo che utilizza lo spazio dello stack costante anziché il codice ricorsivo. Il TCO non è la causa dell'utilizzo dello schema di ricorsione errato per una struttura di dati.
nomen

"Il TCO non è la causa dell'utilizzo di uno schema di ricorsione errato per una struttura di dati" Si prega di elaborare come questo è rilevante per il caso dato. L'esempio sopra indica solo un esempio dei frame assegnati allo stack di chiamate con e senza TCO.
grillSandwich

Hai scelto di utilizzare la ricorsione infondata per traverse (). Non aveva nulla a che fare con TCO. l'eternità sembra essere la posizione di coda, ma la posizione di coda non è necessaria: void eternity () {eternity (); Uscita(); }
nomen

Mentre ci siamo, cos'è una "ricorsione su larga scala"? Perché dovremmo evitare i goto nella funzione? Ciò non è né necessario né sufficiente per consentire il TCO. E quale istruzione ambientale? L'intero punto di TCO è che il compilatore sostituisce la chiamata di funzione in posizione di coda con un goto.
nomen,

Il TCO riguarda l'ottimizzazione dello spazio utilizzato nello stack di chiamate. Per ricorsione su larga scala, mi riferisco alle dimensioni del frame. Ogni volta che si verifica una ricorsione, se ho bisogno di assegnare un frame enorme su stack di chiamate sopra la funzione di chiamata, il TCO sarebbe più utile e mi permetterebbe più livelli di ricorsione. Ma nel caso in cui le dimensioni del mio frame siano inferiori, posso fare a meno del TCO e comunque far funzionare bene il mio programma (non sto parlando di ricorsione infinita qui). Se si rimane con goto nella funzione, la chiamata "tail" non è in realtà call tail e TCO non è applicabile.
grillSandwich

3

L'approccio della funzione ricorsiva ha un problema. Costruisce uno stack di chiamate di dimensioni O (n), il che rende la nostra memoria totale costata O (n). Ciò lo rende vulnerabile a un errore di overflow dello stack, in cui lo stack di chiamate diventa troppo grande e si esaurisce lo spazio.

Schema di ottimizzazione delle chiamate di coda (TCO). Dove può ottimizzare le funzioni ricorsive per evitare di accumulare uno stack di chiamate elevato e quindi risparmiare il costo della memoria.

Ci sono molte lingue che fanno TCO come (JavaScript, Ruby e poche C) mentre Python e Java non fanno TCO.

Il linguaggio JavaScript ha confermato utilizzando :) http://2ality.com/2015/06/tail-call-optimization.html


0

In un linguaggio funzionale, l'ottimizzazione della chiamata di coda è come se una chiamata di funzione potesse restituire un'espressione parzialmente valutata come risultato, che sarebbe quindi valutata dal chiamante.

f x = g x

f 6 si riduce a g 6. Quindi, se l'implementazione potrebbe restituire g 6 come risultato, e quindi chiamare quell'espressione, salverebbe uno stack frame.

Anche

f x = if c x then g x else h x.

Riduce a f 6 a g 6 o h 6. Quindi, se l'implementazione valuta c 6 e trova che è vera, può ridurre,

if true then g x else h x ---> g x

f x ---> h x

Un semplice interprete di ottimizzazione delle chiamate non di coda potrebbe assomigliare a questo,

class simple_expresion
{
    ...
public:
    virtual ximple_value *DoEvaluate() const = 0;
};

class simple_value
{
    ...
};

class simple_function : public simple_expresion
{
    ...
private:
    simple_expresion *m_Function;
    simple_expresion *m_Parameter;

public:
    virtual simple_value *DoEvaluate() const
    {
        vector<simple_expresion *> parameterList;
        parameterList->push_back(m_Parameter);
        return m_Function->Call(parameterList);
    }
};

class simple_if : public simple_function
{
private:
    simple_expresion *m_Condition;
    simple_expresion *m_Positive;
    simple_expresion *m_Negative;

public:
    simple_value *DoEvaluate() const
    {
        if (m_Condition.DoEvaluate()->IsTrue())
        {
            return m_Positive.DoEvaluate();
        }
        else
        {
            return m_Negative.DoEvaluate();
        }
    }
}

Un interprete di ottimizzazione delle chiamate di coda potrebbe assomigliare a questo,

class tco_expresion
{
    ...
public:
    virtual tco_expresion *DoEvaluate() const = 0;
    virtual bool IsValue()
    {
        return false;
    }
};

class tco_value
{
    ...
public:
    virtual bool IsValue()
    {
        return true;
    }
};

class tco_function : public tco_expresion
{
    ...
private:
    tco_expresion *m_Function;
    tco_expresion *m_Parameter;

public:
    virtual tco_expression *DoEvaluate() const
    {
        vector< tco_expression *> parameterList;
        tco_expression *function = const_cast<SNI_Function *>(this);
        while (!function->IsValue())
        {
            function = function->DoCall(parameterList);
        }
        return function;
    }

    tco_expresion *DoCall(vector<tco_expresion *> &p_ParameterList)
    {
        p_ParameterList.push_back(m_Parameter);
        return m_Function;
    }
};

class tco_if : public tco_function
{
private:
    tco_expresion *m_Condition;
    tco_expresion *m_Positive;
    tco_expresion *m_Negative;

    tco_expresion *DoEvaluate() const
    {
        if (m_Condition.DoEvaluate()->IsTrue())
        {
            return m_Positive;
        }
        else
        {
            return m_Negative;
        }
    }
}
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.