11 juillet 2023

Décorateurs et transferts, call/apply

JavaScript offre une flexibilité exceptionnelle dans le traitement des fonctions. Elles peuvent être échangées, utilisés comme objets, et maintenant nous allons voir comment transférer les appels entre eux et les décorer.

Cache transparent

Disons que nous avons une fonction slow(x) qui nécessite beaucoup de ressources processeur, mais ses résultats sont stables. En d’autres termes, pour le même x, le résultat est toujours le même.

Si la fonction est appelée souvent, nous voudrons peut-être mettre en mémoire cache (mémoriser) les résultats pour éviter de passer plus de temps sur les re-calculs.

Mais au lieu d’ajouter cette fonctionnalité à slow(), nous allons créer une fonction wrapper qui ajoute la mise en cache. Comme nous le verrons, cela présente de nombreux avantages.

Voici le code, et les explications suivent :

function slow(x) {
  // il peut y avoir un travail gourmand en ressources processeur ici
  alert(`Called with ${x}`);
  return x;
}

function cachingDecorator(func) {
  let cache = new Map();

  return function(x) {
    if (cache.has(x)) {    // s'il y a une telle clé dans le cache
      return cache.get(x); // lire le résultat
    }

    let result = func(x);  // sinon appeler func

    cache.set(x, result);  // et mettre le résultat en cache
    return result;
  };
}

slow = cachingDecorator(slow);

alert( slow(1) ); // slow(1) est mis en cache et le résultat est renvoyé
alert( "Again: " + slow(1) ); // le résultat slow(1) est retourné à partir du cache

alert( slow(2) ); // slow(2) est mis en cache et le résultat est renvoyé
alert( "Again: " + slow(2) ); // le résultat slow(2) est retourné à partir du cache

Dans le code ci-dessus, cachingDecorator est un décorateur : une fonction spéciale qui prend une autre fonction et modifie son comportement.

L’idée est que nous pouvons appeler cachingDecorator pour n’importe quelle fonction, ce qui renverra le wrapper de mise en cache. C’est formidable, car nous pouvons avoir de nombreuses fonctions qui pourraient utiliser une telle fonctionnalité, et tout ce que nous avons à faire est de leur appliquer cachingDecorator.

En séparant la mise en cache du code de la fonction principale, nous simplifions également le code principal.

Le résultat de cachingDecorator(func) est un “wrapper” : function(x) qui “encapsule” l’appel de func(x) dans la logique de mise en cache :

Depuis un code extérieur, la fonction encapsulée slow fait toujours la même chose. Un comportement de mise en cache vient d’être ajouté à son comportement.

Pour résumer, il y a plusieurs avantages à utiliser un cachingDecorator distinct au lieu de modifier le code de slow lui-même :

  • Le cachingDecorator est réutilisable. Nous pouvons l’appliquer à une autre fonction.
  • La logique de mise en cache est séparée, elle n’a pas augmenté la complexité de slow lui-même (s’il en existait).
  • Nous pouvons combiner plusieurs décorateurs si nécessaire (d’autres décorateurs suivront).

Utilisation de “func.call” pour le contexte

Le décorateur de mise en cache mentionné ci-dessus n’est pas adapté pour travailler avec des méthodes d’objet.

Par exemple, dans le code ci-dessous worker.slow() cesse de fonctionner après la décoration :

// on ajoutera une fonctionalité de cache à worker.slow
let worker = {
  someMethod() {
    return 1;
  },

  slow(x) {
    // tâche lourde et effrayante pour le CPU ici
    alert("Called with " + x);
    return x * this.someMethod(); // (*)
  }
};

// même code que précédemment
function cachingDecorator(func) {
  let cache = new Map();
  return function(x) {
    if (cache.has(x)) {
      return cache.get(x);
    }
    let result = func(x); // (**)
    cache.set(x, result);
    return result;
  };
}

alert( worker.slow(1) ); // la méthode originale fonctionne

worker.slow = cachingDecorator(worker.slow); // ajoute la mise en cache

alert( worker.slow(2) ); // Whoops! Error: Cannot read property 'someMethod' of undefined

L’erreur se produit dans la ligne (*) qui tente d’accéder à this.someMethod et échoue. Pouvez-vous voir pourquoi ?

La raison en est que le wrapper appelle la fonction d’origine sous la forme func(x) dans la ligne (**). Et, lorsqu’elle est appelée comme ça, la fonction obtient this = undefined.

Nous observerions un symptôme similaire si nous essayions d’executer :

let func = worker.slow;
func(2);

Ainsi, le wrapper passe l’appel à la méthode d’origine, mais sans le contexte this. D’où l’erreur.

Réparons-le.

Il existe une méthode de fonction intégrée spéciale func.call(context, …args) qui permet d’appeler explicitement une fonction en définissant this.

La syntaxe est la suivante :

func.call(context, arg1, arg2, ...)

Il exécute func en fournissant this comme le premier argument et les suivants en tant qu’arguments.

Pour le dire simplement, ces deux appels font presque la même chose :

func(1, 2, 3);
func.call(obj, 1, 2, 3)

Ils appellent tous les deux func avec les arguments 1, 2 et 3. La seule différence est que func.call définit également this sur obj.

Par exemple, dans le code ci-dessous, nous appelons sayHi dans le contexte de différents objets : sayHi.call(user) exécute sayHi fournissant this = user, et la ligne suivante définit this = admin :

function sayHi() {
  alert(this.name);
}

let user = { name: "John" };
let admin = { name: "Admin" };

// utilisons call pour passer différents objets en tant que "this"
sayHi.call( user ); // John
sayHi.call( admin ); // Admin

Et ici, nous utilisons call pour appeler say avec le contexte et la phrase donnés :

function say(phrase) {
  alert(this.name + ': ' + phrase);
}

let user = { name: "John" };

// user devient this, et "Hello" devient le premier argument
say.call( user, "Hello" ); // John: Hello

Dans notre cas, nous pouvons utiliser call dans le wrapper pour passer le contexte à la fonction d’origine :

let worker = {
  someMethod() {
    return 1;
  },

  slow(x) {
    alert("Called with " + x);
    return x * this.someMethod(); // (*)
  }
};

function cachingDecorator(func) {
  let cache = new Map();
  return function(x) {
    if (cache.has(x)) {
      return cache.get(x);
    }
    let result = func.call(this, x); // "this" est passé correctement maintenant
    cache.set(x, result);
    return result;
  };
}

worker.slow = cachingDecorator(worker.slow); // ajoute la mise en cache

alert( worker.slow(2) ); // ça fonctione
alert( worker.slow(2) ); // ça fonctionne, n'appelle pas l'original (mis en cache)

Maintenant, tout va bien.

Pour que tout soit clair, voyons plus en détail comment this est passé :

  1. Après la décoration, worker.slow est désormais le wrapper function(x) {...}.
  2. Ainsi, lorsque worker.slow(2) est exécuté, le wrapper obtient 2 en argument et this = worker (c’est l’objet avant le point).
  3. Dans le wrapper, en supposant que le résultat ne soit pas encore mis en cache, func.call(this, x) passe le this (= worker) actuel et l’argument actuel (= 2) à la méthode d’origine.

Passer plusieurs arguments

Rendons maintenant cachingDecorator encore plus universel. Jusqu’à présent, il ne travaillait qu’avec des fonctions à un seul argument.

Maintenant, comment mettre en cache la méthode multi-argument worker.slow ?

let worker = {
  slow(min, max) {
    return min + max; // la tâche est supposée lourde
  }
};

// devrait se rappeler des appels au mêmes arguments
worker.slow = cachingDecorator(worker.slow);

Auparavant, pour un seul argument, x, nous pouvions simplement cache.set(x, result) pour enregistrer le résultat et cache.get(x) pour le récupérer. Mais maintenant, nous devons nous rappeler le résultat pour une combinaison d’arguments (min, max). Le Map natif prend une valeur unique en tant que clé.

Il y a beaucoup de solutions possibles :

  1. Mettre en œuvre une nouvelle structure de données similaire à Map (ou utiliser une par une tierce partie) plus polyvalent et permetant l’utilisation de plusieurs clés.
  2. Utilisez des maps imbriquées : cache.set(min) sera un Map qui stocke la paire (max, result). Donc, nous pouvons obtenir result avec cache.get (min).get(max).
  3. Joignez deux valeurs en une. Dans notre cas particulier, nous pouvons simplement utiliser la chaîne "min, max" comme clé pour Map. Pour plus de flexibilité, nous pouvons permettre de fournir une fonction de hachage au décorateur, qui sait créer une valeur parmi plusieurs.

Pour de nombreuses applications pratiques, la 3ème variante est suffisante, nous allons donc nous y tenir.

Nous devons également transmettre non seulement x, mais tous les arguments dans func.call. Rappelons que dans une function() on peut obtenir un pseudo-tableau de ses arguments comme arguments, donc func.call(this, x) doit être remplacé par func.call(this, ...arguments).

Voici un cachingDecorator plus puissant :

let worker = {
  slow(min, max) {
    alert(`Called with ${min},${max}`);
    return min + max;
  }
};

function cachingDecorator(func, hash) {
  let cache = new Map();
  return function() {
    let key = hash(arguments); // (*)
    if (cache.has(key)) {
      return cache.get(key);
    }

    let result = func.call(this, ...arguments); // (**)

    cache.set(key, result);
    return result;
  };
}

function hash(args) {
  return args[0] + ',' + args[1];
}

worker.slow = cachingDecorator(worker.slow, hash);

alert( worker.slow(3, 5) ); // ça marche
alert( "Again " + worker.slow(3, 5) ); // pareil (mis en cache)

Maintenant, cela fonctionne avec n’importe quel nombre d’arguments (bien que la fonction de hachage doive également être ajustée pour permettre n’importe quel nombre d’arguments. Une façon intéressante de gérer cela sera traitée ci-dessous).

Il y a deux changements :

  • Dans la ligne (*), il appelle hash pour créer une clé unique à partir de arguments. Ici, nous utilisons une simple fonction “d’assemblage” qui transforme les arguments (3, 5) en la clé "3,5". Les cas plus complexes peuvent nécessiter d’autres fonctions de hachage.
  • Ensuite (**) utilise func.call(this, ...arguments) pour transmettre le contexte et tous les arguments obtenus par le wrapper (pas seulement le premier) à la fonction d’origine.

func.apply

Instead of func.call(this, ...arguments) we could use func.apply(this, arguments).

The syntax of built-in method func.apply is:

func.apply(context, args)

It runs the func setting this=context and using an array-like object args as the list of arguments.

The only syntax difference between call and apply is that call expects a list of arguments, while apply takes an array-like object with them.

So these two calls are almost equivalent:

func.call(context, ...args);
func.apply(context, args);

They perform the same call of func with given context and arguments.

There’s only a subtle difference regarding args:

  • The spread syntax ... allows to pass iterable args as the list to call.
  • The apply accepts only array-like args.

…And for objects that are both iterable and array-like, such as a real array, we can use any of them, but apply will probably be faster, because most JavaScript engines internally optimize it better.

Passing all arguments along with the context to another function is called call forwarding.

That’s the simplest form of it:

let wrapper = function() {
  return func.apply(this, arguments);
};

When an external code calls such wrapper, it is indistinguishable from the call of the original function func.

Emprunter une méthode

Maintenant, apportons une autre amélioration mineure à la fonction de hachage :

function hash(args) {
  return args[0] + ',' + args[1];
}

Pour l’instant, cela ne fonctionne que sur deux arguments. Ce serait mieux s’il pouvait coller un nombre quelconque de args.

La solution naturelle serait d’utiliser la méthode arr.join :

function hash(args) {
  return args.join();
}

… Malheureusement, ça ne marchera pas. Parce que nous appelons hash(arguments) et l’objet arguments est à la fois itérable et semblable à un tableau, mais pas un vrai tableau.

Donc, appeler join échouerait, comme on peut le voir ci-dessous :

function hash() {
  alert( arguments.join() ); // Error: arguments.join is not a function
}

hash(1, 2);

Néanmoins, il existe un moyen simple d’utiliser join :

function hash() {
  alert( [].join.call(arguments) ); // 1,2
}

hash(1, 2);

L’astuce s’appelle method borrowing (empruntage de méthode).

Nous prenons (empruntons) la méthode join d’un tableau régulier ([].join) et utilisons [].join.call pour l’exécuter dans le contexte des arguments.

Pourquoi ça marche ?

C’est parce que l’algorithme interne de la méthode native arr.join(glue) est très simple.

Tiré de la spécification presque “tel quel” :

  1. Soit glue le premier argument ou, s’il n’ya pas d’argument, une virgule ",".
  2. Soit result une chaîne de caractères vide.
  3. Ajoutez this[0] à result.
  4. Ajoutez glue et this[1].
  5. Ajoutez glue et this[2].
  6. … Faites-le jusqu’à ce que this.length éléments soient collés.
  7. Retournez result.

Donc, techniquement, cela prend this et associe this[0], this[1]… etc. Il est intentionnellement écrit de manière à permettre à tout type de tableau this (ce n’est pas une coïncidence, de nombreuses méthodes suivent cette pratique). C’est pourquoi cela fonctionne aussi avec this = arguments.

Décorateurs et propriétés fonctionnelles

Il est généralement prudent de remplacer une fonction ou une méthode par une fonction décorée, à une exception près. Si la fonction d’origine comportait des propriétés, telles que func.calledCount ou autre, la fonction décorée ne les fournira pas. Parce que c’est un wrapper. Il faut donc faire attention si on les utilise.

Dans l’exemple ci-dessus, si la fonction slow avait des propriétés, alors cachingDecorator(slow) est un wrapper sans elles.

Certains décorateurs peuvent fournir leurs propres propriétés. Par exemple un décorateur peut compter le nombre de fois où une fonction a été appelée et combien de temps cela a pris, et exposer ces informations via les propriétés du wrapper.

Il existe un moyen de créer des décorateurs qui conservent l’accès aux propriétés de la fonction, mais cela nécessite l’utilisation d’un objet Proxy spécial pour envelopper une fonction. Nous en discuterons plus tard dans l’article Proxy et Reflect.

Résumé

Decorator est un wrapper autour d’une fonction qui modifie son comportement. Le travail principal est toujours effectué par la fonction.

Les décorateurs peuvent être considérés comme des “caractéristiques” ou des “aspects” pouvant être ajoutés à une fonction. Nous pouvons en ajouter un ou en ajouter plusieurs. Et tout ça sans changer son code !

Pour implémenter cachingDecorator, nous avons étudié les méthodes :

Le renvoi d’appel, call forwarding, est généralement effectué avec apply :

let wrapper = function() {
  return original.apply(this, arguments);
};

Nous avons également vu un exemple d’empruntage de méthode, method borrowing, lorsque nous prenons une méthode à partir d’un objet et que nous l’appelons dans le contexte d’un autre objet. Il est assez courant de prendre des méthodes de tableau et de les appliquer à arguments. L’alternative consiste à utiliser l’objet de paramètres du reste qui est un vrai tableau.

Il y a beaucoup de décorateurs dans la nature. Vérifiez si vous les avez bien obtenus en résolvant les tâches de ce chapitre.

Exercices

importance: 5

Créez un décorateur spy(func) qui devrait renvoyer un wrapper qui enregistre tous les appels à la fonction dans sa propriété calls.

Chaque appel est enregistré sous la forme d’un tableau d’arguments.

Par exemple:

function work(a, b) {
  alert( a + b ); // work est une fonction ou une méthode arbitraire
}

work = spy(work);

work(1, 2); // 3
work(4, 5); // 9

for (let args of work.calls) {
  alert( 'call:' + args.join() ); // "call:1,2", "call:4,5"
}

P.S. Ce décorateur est parfois utile pour les tests unitaires. Sa forme avancée est sinon.spy dans la bibliothèque Sinon.JS.

Open a sandbox with tests.

Le wrapper renvoyé par spy(f) doit stocker tous les arguments, puis utiliser f.apply pour transférer l’appel.

function spy(func) {

  function wrapper(...args) {
    // using ...args instead of arguments to store "real" array in wrapper.calls
    wrapper.calls.push(args);
    return func.apply(this, args);
  }

  wrapper.calls = [];

  return wrapper;
}

Ouvrez la solution avec des tests dans une sandbox.

importance: 5

Créez un décorateur delay(f, ms) qui retarde chaque appel de f de ms millisecondes.

Par exemple:

function f(x) {
  alert(x);
}

// créer des wrappers
let f1000 = delay(f, 1000);
let f1500 = delay(f, 1500);

f1000("test"); // montre "test" après 1000ms
f1500("test"); // montre "test" après 1500ms

En d’autres termes, delay(f, ms) renvoie une variante “retardée de ms” de f.

Dans le code ci-dessus, f est une fonction d’un seul argument, mais votre solution doit transmettre tous les arguments et le contexte this.

Open a sandbox with tests.

La solution:

function delay(f, ms) {

  return function() {
    setTimeout(() => f.apply(this, arguments), ms);
  };

}

let f1000 = delay(alert, 1000);

f1000("test"); // montre "test" après 1000ms

Veuillez noter comment une fonction fléchée est utilisée ici. Comme nous le savons, les fonctions fléchées ne possèdent pas leurs propres this et arguments, aussi f.apply(this, arguments) prend this et arguments du wrapper.

Si nous passons une fonction régulière, setTimeout l’appellera sans arguments et this = window (en supposant que nous sommes dans le navigateur).

Nous pouvons toujours passer le bon this en utilisant une variable intermédiaire, mais c’est un peu plus lourd:

function delay(f, ms) {

  return function(...args) {
    let savedThis = this; // stocker "this" dans une variable intermédiaire
    setTimeout(function() {
      f.apply(savedThis, args); // utilisez-le ici
    }, ms);
  };

}

Ouvrez la solution avec des tests dans une sandbox.

importance: 5

Le résultat du décorateur debounce(f, ms) est un wrapper qui suspend les appels à f jusqu’à ce qu’il y ait ms millisecondes d’inactivité (pas d’appels, “période de cooldown”), puis invoque f une fois avec les derniers arguments.

En d’autres termes, debounce est comme une secrétaire qui accepte les “appels téléphoniques”, et attend jusqu’à ce qu’il y ait des millisecondes de silence. Et alors seulement, elle transfère les dernières informations d’appel au “boss” (appellez le “f” réel).

Par exemple, nous avons eu une fonction f et l’avons remplacée par f = debounce(f, 1000).

Ensuite, si la fonction encapsulée est appelée à 0ms, 200ms et 500ms, et qu’il n’y a aucun appel, alors le f actuel ne sera appelé qu’une seule fois, à 1500 ms. Autrement dit: après la période de temps de recharge de 1000 ms à partir du dernier appel.

… Et il récupérera les arguments du tout dernier appel, les autres appels sont ignorés.

Voici le code pour cela (utilise le décorateur debounce de la librairie Lodash):

let f = _.debounce(alert, 1000);

f("a");
setTimeout( () => f("b"), 200);
setTimeout( () => f("c"), 500);
// la fonction debounce attend 1000ms après le dernier appel puis exécute : alert("c")

Maintenant, un exemple pratique. Disons que l’utilisateur tape quelque chose et que nous aimerions envoyer une requête au serveur lorsque l’entrée est terminée.

Il ne sert à rien d’envoyer la requête pour chaque caractère saisi. Au lieu de cela, nous aimerions attendre, puis traiter l’ensemble du résultat.

Dans un navigateur Web, nous pouvons configurer un gestionnaire d’événements – une fonction qui est appelée à chaque modification d’un champ de saisie. Normalement, un gestionnaire d’événements est appelé très souvent, pour chaque touche tapée. Mais si on le debounce de 1000ms, il ne sera appelé qu’une seule fois, après 1000ms après la dernière entrée.

Dans cet exemple en live, le gestionnaire place le résultat dans une case ci-dessous, essayez-le :

Vous voyez ? La deuxième entrée appelle la fonction “debounced”, donc son contenu est traité après 1000ms à partir de la dernière entrée.

Donc, debounce est un excellent moyen de traiter une séquence d’événements: que ce soit une séquence de touches, de mouvements de souris ou autre.

Il attend le temps donné après le dernier appel, puis exécute sa fonction, qui peut traiter le résultat.

La tâche est d’implémenter le décorateur debounce.

Indice : ce ne sont que quelques lignes si vous y réfléchissez :)

Open a sandbox with tests.

function debounce(func, ms) {
  let timeout;
  return function() {
    clearTimeout(timeout);
    timeout = setTimeout(() => func.apply(this, arguments), ms);
  };
}

Un appel à debounce renvoie un wrapper. Lorsqu’il est appelé, il planifie l’appel de la fonction d’origine après ms donné et annule le délai d’expiration précédent.

Ouvrez la solution avec des tests dans une sandbox.

importance: 5

Créez un décorateur “d’accélération” throttle(f, ms) – qui retourne un wrapper.

Lorsqu’il est appelé plusieurs fois, il passe l’appel à f au maximum une fois par ms millisecondes.

La différence avec debounce est que c’est un décorateur complètement différent :

  • debounce exécute la fonction une fois après la période de “cooldown”. Bon pour traiter le résultat final.
  • throttle ne l’exécute pas plus souvent que le temps donné en ms. Bon pour les mises à jour régulières qui ne devraient pas être très fréquentes.

En d’autres termes, throttle est comme une secrétaire qui accepte les appels téléphoniques, mais qui dérange le patron (appellez le f réel) pas plus d’une fois par ms millisecondes.

Examinons l’application réelle pour mieux comprendre cette exigence et voir d’où elle vient.

Par exemple, nous voulons suivre les mouvements de la souris.

Dans le navigateur, nous pouvons configurer une fonction à exécuter à chaque mouvement de la souris et obtenir l’emplacement du pointeur à mesure qu’il se déplace. Pendant une utilisation active de la souris, cette fonction est généralement utilisée très souvent et peut atteindre 100 fois par seconde (toutes les 10 ms). Nous aimerions mettre à jour certaines informations sur la page Web lorsque le pointeur se déplace.

… Mais la mise à jour de la fonction update() est trop lourde pour tous les micro-mouvements. Il est également inutile de mettre à jour plus d’une fois toutes les 100 ms.

Nous allons donc l’envelopper dans le décorateur : utilisez throttle(update, 100) comme fonction à exécuter à chaque déplacement de souris à la place de update() d’origine. Le décorateur sera appelé souvent, mais update() sera appelé au maximum une fois toutes les 100 ms.

Visuellement, cela ressemblera à ceci:

  1. Pour le premier mouvement de souris, la variante décorée passe l’appel à update. Cela est important, l’utilisateur voit notre réaction à leur mouvement immédiatement.
  2. Puis, alors que la souris continue d’avancer, il ne se passe plus rien jusqu’à 100ms. La variante décorée ignore les appels.
  3. À la fin de 100ms – une autre update se produit avec les dernières coordonnées.
  4. Enfin, la souris s’arrête quelque part. La variante décorée attend que 100ms expire, puis lance update avec les dernières coordonnées. Donc, peut-être le plus important, les coordonnées finales de la souris sont traitées.

Un exemple de code:

function f(a) {
  console.log(a);
}

// f1000 passe les appels à f au maximum une fois toutes les 1000 ms
let f1000 = throttle(f, 1000);

f1000(1); // montre 1
f1000(2); // (étranglement, 1000ms pas encore écoulée)
f1000(3); // (étranglement, 1000ms pas encore écoulée)

// quand 1000ms expirent...
// ...sort 3, la valeur intermédiaire 2 a été ignorée

P.S. Les arguments et le contexte this transmis à f1000 doivent être transmis à f d’origine.

Open a sandbox with tests.

function throttle(func, ms) {

  let isThrottled = false,
    savedArgs,
    savedThis;

  function wrapper() {

    if (isThrottled) { // (2)
      savedArgs = arguments;
      savedThis = this;
      return;
    }
    isThrottled = true;

    func.apply(this, arguments); // (1)

    setTimeout(function() {
      isThrottled = false; // (3)
      if (savedArgs) {
        wrapper.apply(savedThis, savedArgs);
        savedArgs = savedThis = null;
      }
    }, ms);
  }

  return wrapper;
}

Un appel à throttle(func, ms) retourne wrapper.

  1. Lors du premier appel, le wrapper exécute simplement func et définit l’état de charge (isThrottled = true).
  2. Dans cet état, tous les appels sont mémorisés dans savedArgs/savedThis. Veuillez noter que le contexte et les arguments sont d’égale importance et doivent être mémorisés. Nous avons besoin d’eux simultanément pour reproduire l’appel.
  3. Après ms millisecondes, setTimeout se déclenche. L’état de charge est supprimé (isThrottled = false), et si nous avions ignoré les appels, alors wrapper est exécuté avec les derniers arguments et contextes mémorisés.

La 3ème étape n’exécute pas func, mais wrapper, car nous devons non seulement exécuter func, mais encore une fois entrer dans l’état de charge et configurer le délai d’expiration pour le réinitialiser.

Ouvrez la solution avec des tests dans une sandbox.

Carte du tutoriel