Revenons aux fonctions et étudions-les plus en profondeur.
Notre premier sujet sera la recursion.
Si vous nâêtes pas novice en programmation, cela vous est probablement familier et vous pouvez sauter ce chapitre.
La récursion est un modèle de programmation utile dans les situations où une tâche peut être naturellement divisée en plusieurs tâches du même type, mais plus simple. Ou lorsquâune tâche peut être simplifiée en une action facile plus une variante plus simple de la même tâche. Ou, comme nous le verrons bientôt, pour traiter certaines structures de données.
Lorsquâune fonction résout une tâche, elle peut appeler de nombreuses autres fonctions. Cela se produit partiellement lorsquâune fonction sâappelle elle-même. Cela sâappelle la récursion.
Deux façons de penser
Prenons quelque chose de simple pour commencer â écrivons une fonction pow(x, n) qui élève x à une puissance naturel de n. En dâautres termes, multiplie x par lui-même n fois.
pow(2, 2) = 4
pow(2, 3) = 8
pow(2, 4) = 16
Il y a deux façons de le mettre en Åuvre.
-
La pensée itérative: la boucle
for:function pow(x, n) { let result = 1; // multiplier le résultat par x n fois dans la boucle for (let i = 0; i < n; i++) { result *= x; } return result; } alert( pow(2, 3) ); // 8 -
La pensée récursive: simplifie la tâche et sâappele elle-même:
function pow(x, n) { if (n == 1) { return x; } else { return x * pow(x, n - 1); } } alert( pow(2, 3) ); // 8
Veuillez noter en quoi la variante récursive est fondamentalement différente.
Quand pow(x, n) est appelé, lâexécution se scinde en deux branches:
if n==1 = x
/
pow(x, n) =
\
else = x * pow(x, n - 1)
- Si
n == 1, alors tout est trivial. On lâappelle la base de la récursion, car elle produit immédiatement le résultat évident:pow(x, 1)équivaut Ãx. - Sinon, nous pouvons représenter
pow(x, n)commex * pow(x, n - 1). En maths, on écriraitxn = x * xn-1. Ceci sâappelle une étape récursive: nous transformons la tâche en une action plus simple (multiplication parx) et un appel plus simple de la même tâche (powavec le petitn). Les prochaines étapes le simplifient de plus en plus jusquâà ce quenatteigne1.
On peut aussi dire que pow sâappelle récursivement jusquâà ce que n == 1.
Par exemple, pour calculer pow(2, 4) la variante récursive effectue ces étapes:
pow(2, 4) = 2 * pow(2, 3)pow(2, 3) = 2 * pow(2, 2)pow(2, 2) = 2 * pow(2, 1)pow(2, 1) = 2
Ainsi, la récursion réduit un appel de fonction à un processus plus simple, puis â à un processus encore plus simple, etc. jusquâà ce que le résultat devienne évident.
Une solution récursive est généralement plus courte quâune solution itérative.
Ici, nous pouvons réécrire la même chose en utilisant lâopérateur conditionnel ? Au lieu de if pour rendre pow (x, n) plus concis et toujours très lisible:
function pow(x, n) {
return (n == 1) ? x : (x * pow(x, n - 1));
}
Le nombre maximal dâappels imbriqués (y compris le premier) est appelé la profondeur de récursivité. Dans notre cas, ce sera exactement n.
La profondeur maximale de récursion est limitée par le moteur JavaScript. Nous sommes sur quâil va jusquâà 10000, certains moteurs en autorisent plus, mais 100000 est probablement hors limite pour la majorité dâentre eux. Il existe des optimisations automatiques qui aident à atténuer ce problème (âoptimisation des appels de queueâ), mais elles ne sont pas encore prises en charge partout et ne fonctionnent que dans des cas simples.
Cela limite lâapplication de la récursion, mais cela reste très large. Il y a beaucoup de tâches pour lesquelles la pensée récursive donne un code plus simple et plus facile à gérer.
Le contexte dâexécution et la pile
Voyons maintenant comment fonctionnent les appels récursifs. Pour cela, nous allons regarder sous le capot des fonctions.
Les informations sur le processus dâexécution dâune fonction en cours dâexécution sont stockées dans son contexte dâexécution.
Le contexte dâexécution est une structure de données interne contenant des détails sur lâexécution dâune fonction: où le flux de contrôle est maintenant, les variables actuelles, la valeur de this (nous ne lâutilisons pas ici) et quelques autres détails internes.
Un appel de fonction est associé à exactement un contexte dâexécution.
Lorsquâune fonction effectue un appel imbriqué, les événements suivants se produisent:
- La fonction en cours est suspendue.
- Le contexte dâexécution qui lui est associé est mémorisé dans une structure de données spéciale appelée pile de contexte dâexécution.
- Lâappel imbriqué sâexécute.
- Une fois terminé, lâancien contexte dâexécution est extrait de la pile et la fonction externe reprend à partir de son point dâarrêt.
Voyons ce qui se passe pendant lâappel de pow(2, 3).
pow(2, 3)
Au début de lâappel de pow(2, 3) le contexte dâexécution stockera des variables: x = 2, n = 3, le flux dâexécution est à la ligne 1 de la fonction.
Nous pouvons lâesquisser comme:
- Context: { x: 2, n: 3, at line 1 } pow(2, 3)
Câest à ce moment que la fonction commence à sâexécuter. La conditionn == 1 est faux, donc le flux continue dans la deuxième branche de if:
function pow(x, n) {
if (n == 1) {
return x;
} else {
return x * pow(x, n - 1);
}
}
alert( pow(2, 3) );
Les variables sont les mêmes, mais la ligne change, le contexte est donc le suivant:
- Context: { x: 2, n: 3, at line 5 } pow(2, 3)
Pour calculer x * pow(x, n - 1), nous devons faire un sous-appel de pow avec de nouveaux arguments pow(2, 2).
pow(2, 2)
Pour effectuer un appel imbriqué, JavaScript se souvient du contexte dâexécution actuel dans le contexte dâexécution de la pile.
Ici, nous appelons la même fonction pow, mais cela nâa absolument aucune importance. Le processus est le même pour toutes les fonctions:
- Le contexte actuel est âmémoriséâ en haut de la pile.
- Le nouveau contexte est créé pour le sous-appel.
- Quand le sous-appel est fini â le contexte précédent est extrait de la pile et son exécution se poursuit.
Voici la pile de contexte lorsque nous sommes entrés dans le sous-appel pow(2, 2):
- Context: { x: 2, n: 2, at line 1 } pow(2, 2)
- Context: { x: 2, n: 3, at line 5 } pow(2, 3)
Le nouveau contexte dâexécution actuel est en haut (et en gras) et les contextes précédemment mémorisés sont en dessous.
Quand on termine le sous-appel â il est facile de reprendre le contexte précédent, car il conserve les deux variables et lâemplacement exact du code où il sâest arrêté.
Ici, dans lâimage, nous utilisons le mot âlineâ, comme dans notre exemple, il nây a quâun seul sous-appel en ligne, mais généralement une seule ligne de code peut contenir plusieurs sous-appels, comme pow(â¦) + pow(â¦) + somethingElse(â¦).
Il serait donc plus précis de dire que lâexécution reprend âimmédiatement après le sous-appelâ.
pow(2, 1)
Le processus se répète: un nouveau sous-appel est fait à la ligne 5, maintenant avec des arguments x=2, n=1.
Un nouveau contexte dâexécution est créé, le précédent est placé en haut de la pile:
- Context: { x: 2, n: 1, at line 1 } pow(2, 1)
- Context: { x: 2, n: 2, at line 5 } pow(2, 2)
- Context: { x: 2, n: 3, at line 5 } pow(2, 3)
Il y a 2 anciens contextes et 1 en cours dâexécution pour pow(2, 1).
La sortie
Pendant lâexécution de pow(2, 1), contrairement à avant, la condition n == 1 est la vérité, donc la première branche de if fonctionne:
function pow(x, n) {
if (n == 1) {
return x;
} else {
return x * pow(x, n - 1);
}
}
Il nây a plus dâappels imbriqués, donc la fonction se termine en renvoyant2.
Lorsque la fonction se termine, son contexte dâexécution nâest plus nécessaire, il est donc supprimé de la mémoire. La précédente est restaurée en haut de la pile:
- Context: { x: 2, n: 2, at line 5 } pow(2, 2)
- Context: { x: 2, n: 3, at line 5 } pow(2, 3)
Lâexécution de pow(2, 2) est repris. Il a le résultat du sous-appel pow(2, 1), de sorte quâil peut également terminer lâévaluation de x * pow(x, n - 1), retournant 4.
Ensuite, le contexte précédent est restauré:
- Context: { x: 2, n: 3, at line 5 } pow(2, 3)
Quand il se termine, nous avons un résultat de pow(2, 3) = 8.
La profondeur de récursion dans ce cas était: 3.
Comme nous pouvons le voir dans les illustrations ci-dessus, la profondeur de récursion est égale au nombre maximal de contextes dans la pile.
Notez les besoins en mémoire. Les contextes prennent de la mémoire. Dans notre cas, augmenter à la puissance de n nécessite en réalité de la mémoire pour les contextes n, pour toutes les valeurs inférieures de n.
Un algorithme basé sur des boucles est plus économe en mémoire:
function pow(x, n) {
let result = 1;
for (let i = 0; i < n; i++) {
result *= x;
}
return result;
}
Le pow itératif utilise un contexte unique qui change les processus i et result dans le processus. Ses besoins en mémoire sont faibles, fixes et ne dépendent pas de n.
Toute récursion peut être réécrite sous forme de boucle. La variante de boucle peut généralement être rendue plus efficace.
â¦Parfois, la réécriture nâest pas triviale, en particulier lorsque la fonction utilise différents sous-appels récursifs en fonction des conditions et fusionne leurs résultats ou lorsque la création de branche est plus complexe. Et lâoptimisation risque de ne pas être nécessaire et de ne pas valoir la peine.
La récursion peut donner un code plus court, plus facile à comprendre et à supporter. Les optimisations ne sont pas nécessaires à chaque endroit, nous avons surtout besoin dâun bon code, câest pourquoi il est utilisé.
Traversées récursives
Une autre grande application de la récursion est une traversée récursive.
Imaginez, nous avons une entreprise. La structure du personnel peut être présentée comme un objet:
let company = {
sales: [{
name: 'John',
salary: 1000
}, {
name: 'Alice',
salary: 1600
}],
development: {
sites: [{
name: 'Peter',
salary: 2000
}, {
name: 'Alex',
salary: 1800
}],
internals: [{
name: 'Jack',
salary: 1300
}]
}
};
En dâautres termes, une entreprise a des départements.
-
Un département peut avoir un tableau de personnel. Par exemple, le département des ventes compte 2 employés: John et Alice.
-
Ou bien un département peut être divisé en sous-départements, comme
developmenta deux branches:sitesetinternes. Chacun dâentre eux a son propre personnel. -
Il est également possible que lorsquâun sous-département sâagrandisse, il se divise en sous-départements (ou équipes).
Par exemple, le département
sitespeut être divisé en équipes pour les sitessiteAetsiteB. Et, potentiellement, ils peuvent être diviser encore plus. Ce nâest pas sur la photo, câest juste quelque chose quâont pourrait immaginer.
Maintenant, disons que nous voulons une fonction pour obtenir la somme de tous les salaires. Comment peut-on faire ça?
Une approche itérative nâest pas facile, car la structure nâest pas simple. La première idée peut être de créer une boucle for sur company avec une sous-boucle imbriqué sur les départements de premier niveau. Mais ensuite, nous avons besoin de plus de sous-boucles imbriquées pour parcourir le personnel des départements de second niveau, tels que les sites⦠Et puis une autre sous-boucle dans ceux des départements de 3ème niveau qui pourraient apparaître dans le futur ? Si nous mettons 3-4 sous-boucles imbriquées dans le code pour traverser un seul objet, cela devient plutôt moche.
Essayons la récursion.
Comme nous pouvons le constater, lorsque notre fonction demande à un département de faire la somme, il existe deux cas possibles:
- Sâil sâagit dâun âsimpleâ département avec un tableau de personnes, nous pouvons alors additionner les salaires en une simple boucle.
- Ou bien câest un objet avec
Nsous-départements â alors nous pouvons faire des appelsNrécursifs pour obtenir la somme de chaque sous-étape et combiner les résultats.
Le premier cas est la base de la récursivité, le cas trivial, lorsque nous obtenons un tableau.
Le 2ème cas où nous obtenons un objet est lâétape récursive. Une tâche complexe est divisée en sous-tâches pour les plus petits départements. Ils peuvent à leur tour se séparer à nouveau, mais tôt ou tard, la scission se terminera à (1).
Lâalgorithme est probablement encore plus facile à lire à partir du code:
let company = { // le même objet, compressé pour la brièveté
sales: [{name: 'John', salary: 1000}, {name: 'Alice', salary: 1600 }],
development: {
sites: [{name: 'Peter', salary: 2000}, {name: 'Alex', salary: 1800 }],
internals: [{name: 'Jack', salary: 1300}]
}
};
// La fonction pour faire le travail
function sumSalaries(department) {
if (Array.isArray(department)) { // case (1)
return department.reduce((prev, current) => prev + current.salary, 0); // additionne le tableau
} else { // case (2)
let sum = 0;
for (let subdep of Object.values(department)) {
sum += sumSalaries(subdep); // appel récursivement pour les sous-départements, additionnez les résultats
}
return sum;
}
}
alert(sumSalaries(company)); // 7700
Le code est court et facile à comprendre (tout va bien?). Câest le pouvoir de la récursion. Cela fonctionne également pour tous les niveaux dâimbrication de sous-départements.
Voici le schéma des appels:
On peut facilement voir le principe: pour un objet {...} les sous-appels sont faits, alors que les tableaux [...] sont les âfeuillesâ de lâarbre de récurrence, elles donnent un résultat immédiat.
Notez que le code utilise des fonctionnalités intelligentes que nous avons déjà abordées:
- La méthode
arr.reducea été expliquée dans le chapitre Méthodes de tableau pour obtenir la somme du tableau. - La boucle
for(val of Object.values(obj))itérer sur les valeurs dâobjet:Object.valuesretourne un tableau dâeux-mêmes.
Structures récursives
Une structure de données récursive (définie de manière récursive) est une structure qui se réplique par parties.
Nous venons de le voir dans lâexemple dâune structure dâentreprise ci-dessus.
Un département dâentreprise est:
- Soit un éventail de personnes.
- Ou un objet avec des départements.
Pour les développeurs Web, il existe des exemples bien mieux connus: les documents HTML et XML.
Dans le document HTML, une balise HTML peut contenir une liste de:
- Morceaux de texte.
- Commentaires HTML.
- Autres balises HTML (pouvant à leur tour contenir des morceaux de texte/commentaires ou dâautres balises, etc.).
Câest encore une définition récursive.
Pour une meilleure compréhension, nous allons couvrir une autre structure récursive nommée âListe chaînéeâ qui pourrait être une meilleure alternative aux tableaux dans certains cas.
Liste chaînée
Imaginez, nous voulons stocker une liste ordonnée dâobjets.
Le choix naturel serait un tableau:
let arr = [obj1, obj2, obj3];
⦠Mais il y a un problème avec les tableaux. Les opérations âdelete elementâ et âinsert elementâ sont coûteuses. Par exemple, lâopération arr.unshift(obj) doit renuméroter tous les éléments pour faire de la place pour un nouvel obj, et si le tableau est grand, cela prend du temps. Même chose avec arr.shift().
Les seules modifications structurelles ne nécessitant pas de renumérotation en masse sont celles qui fonctionnent avec la fin du tableau: arr.push/pop. Ainsi, un tableau peut être assez lent pour les grandes files dâattente, lorsque nous devons travailler avec sont début.
Alternativement, si nous avons vraiment besoin dâune insertion/suppression rapide, nous pouvons choisir une autre structure de données appelée la Liste chaînée.
Lâélémentde la liste liée est défini de manière récursive en tant quâobjet avec:
value.nextpropriété référençant le prochain élément de liste liée ounullsi câest la fin.
Par exemple:
let list = {
value: 1,
next: {
value: 2,
next: {
value: 3,
next: {
value: 4,
next: null
}
}
}
};
Représentation graphique de la liste:
An alternative code for creation:
let list = { value: 1 };
list.next = { value: 2 };
list.next.next = { value: 3 };
list.next.next.next = { value: 4 };
list.next.next.next.next = null;
Ici, nous pouvons voir encore plus clairement quâil y a plusieurs objets, chacun ayant les valeurs value et next pointant vers le voisin. La variable list est le premier objet de la chaîne. Par conséquent, en suivant les pointeurs next, nous pouvons atteindre nâimporte quel élément.
La liste peut être facilement divisée en plusieurs parties et ultérieurement réunie:
let secondList = list.next.next;
list.next.next = null;
Pour joindre:
list.next.next = secondList;
Et nous pouvons sûrement insérer ou retirer des objets nâimporte où.
Par exemple, pour ajouter une nouvelle valeur, nous devons mettre à jour la tête de la liste:
let list = { value: 1 };
list.next = { value: 2 };
list.next.next = { value: 3 };
list.next.next.next = { value: 4 };
// ajoute la nouvelle valeur à la liste
list = { value: "new item", next: list };
Pour supprimer une valeur du milieu, changez le next de la précédente:
list.next = list.next.next;
List.next a sauté 1 à la valeur 2. La valeur 1 est maintenant exclue de la chaîne. Si elle nâest pas stocké ailleurs, elle sera automatiquement supprimé de la mémoire.
Contrairement aux tableaux, il nây a pas de renumérotation en masse, nous pouvons facilement réorganiser les éléments.
Naturellement, les listes ne sont pas toujours meilleures que les tableaux. Sinon, tout le monde nâutiliserait que des listes.
Le principal inconvénient est que nous ne pouvons pas facilement accéder à un élément par son numéro. Dans un tableau simple: arr [n] est une référence directe. Mais dans la liste, nous devons commencer à partir du premier élément et aller next``N fois pour obtenir le Nième élément.
â¦Mais nous nâavons pas toujours besoin de telles opérations. Par exemple, quand on a besoin dâune file dâattente ou même dâun deque â la structure ordonnée qui doit permettre lâajout/suppression très rapide dâéléments des deux extrémités, mais lâaccès au milieu nâest pas nécessaire.
Les listes peuvent être améliorées:
- Nous pouvons ajouter la propriété
preven plus denextpour référencer lâélément précédent, pour revenir facilement. - Nous pouvons également ajouter une variable nommée
tailfaisant référence au dernier élément de la liste (et la mettre à jour lors de lâajout/suppression dâéléments de la fin). - ⦠La structure de données peut varier en fonction de nos besoins.
Résumé
Terms:
- Recursion est un terme de programmation qui signifie qâune fonction sâappelle elle-même. Les fonctions récursives peuvent être utilisées pour résoudre des tâches de manière élégante.
Lorsquâune fonction sâappelle elle-même, cela sâappelle une étape de récursion. La base de la récursion est constituée par les arguments de la fonction qui rendent la tâche si simple que la fonction ne fait plus dâappels.
-
Une structure de données de Type récursif est une structure de données qui peut être définie à lâaide de elle-même.
Par exemple, la liste chaînée peut être définie comme une structure de données consistant en un objet référençant une liste (ou null).
list = { value, next -> list }Les arbres tels que lâarbre des éléments HTML ou lâarbre des départements de ce chapitre sont également naturellement récursifs: ils ont des branches et chaque branche peut avoir dâautres branches.
Des fonctions récursives peuvent être utilisées pour les parcourir, comme nous lâavons vu dans lâexemple
sumSalary.
Toute fonction récursive peut être réécrite en une fonction itérative. Et câest parfois nécessaire pour optimiser les choses. Mais pour de nombreuses tâches, une solution récursive est assez rapide et plus facile à écrire et à supporter.
Commentaires
<code>, pour plusieurs lignes â enveloppez-les avec la balise<pre>, pour plus de 10 lignes - utilisez une sandbox (plnkr, jsbin, codepenâ¦)