Court-circuiter plusieurs niveaux de boucles
Par Delicious Insights • Publié le 9 mai 2020

Cet article est également disponible en anglais.

Voici le sixième article de notre série quotidienne « 19 pépites de JS pur ». L’occasion de revenir sur une capacité très ancienne du langage, mais à utiliser avec beaucoup de dicernement : les instructions étiquettées.

Dans la série…

Extrait de la liste des articles :

  1. Array#splice
  2. Strings et Unicode
  3. Court-circuiter plusieurs niveaux de boucles (cet article)
  4. Inverser deux valeurs avec la déstructuration
  5. Retirer facilement les « valeurs vides » d’un tableau
  6. …au-delà, c’est la surprise ! (mais la liste est déjà calée)…

Ça fait un bail

Depuis JS 1.2 (1997), il est possible d’étiquetter (label) des instructions. C’est notamment le cas des boucles : for numérique, for…in, while et do…while (et depuis ES2015, for…of). L’idée est de permettre le court-circuit multi-niveaux, notamment lors de boucles imbriquées.

Il est naturellement possible de faire du code dégueulasse avec ça (façon goto, qui n’existe cependant pas en JS), mais on va se concentrer sur les usages orientés boucles.

(Tu trouveras des explications complémentaires sur la toujours excellente doc du MDN, notamment pour les blocs étiquettés, lorsque tu as plusieurs blocs successifs, ce qui rend return impraticable alors que tu veux néanmoins éviter d’enrober la suite d’un bloc dans un if.)

Court-circuiter avec break

Il est fréquent, lorsqu’on a des boucles imbriquées, de vouloir court-circuiter plusieurs niveaux de boucles d’un coup. Imaginons que tu cherches une valeur dans une matrice à 2 dimensions ; dès que tu l’as trouvée, tu vas vouloir sortir de la boucle interne (au sein d’une ligne) et de la boucle externe (qui passe d’une ligne à l’autre).

Si tu fais ça sans étiquettes et que tu as du code à exécuter qui suit tes boucles, ça peut être un peu pataud :

const matrix = [
  [1, 2, 3],
  [4, 5, 6],
  [7, 8, 9],
]
const value = 5

let foundAt = null
for (let row = 0, rows = matrix.length; row < rows; ++row) {
  for (let col = 0, cols = matrix[row].length; col < cols; ++col) {
    if (matrix[row][col] === value) {
      foundAt = [row, col]
      break
    }
  }
  // Tout pataud le test pour court-circuiter la boucle externe…
  if (foundAt) {
    break
  }
}

foundAt // => [1, 1]

Le secret (qui, comme tous les secrets, se découvre en lisant la doc, rogntudjuu !), c’est qu’on peut étiquetter les boucles, et utiliser n’importe quelle étiquette « en vigueur » comme opérande du break. L’étiquette est complètement libre, ça peut même être un identifiant existant (mais c’est pas hyper lisible). On utilise souvent outer ou top pour la boucle la plus externe. Pour le code précédent, ça donnerait par exemple :

// Ça, c’est l’étiquette
outer: for (let row = 0, rows = matrix.length; row < rows; ++row) {
  for (let col = 0, cols = matrix[row].length; col < cols; ++col) {
    if (matrix[row][col] === value) {
      foundAt = [row, col]
      break outer // Et là, je m’en sers
    }
  }
  // Tu pourrais avoir du code ici, il sauterait aussi…
}

Déjà mieux non ? Un exemple que j’aime bien, qu’on retrouve dans le MDN, concerne un tableau de prédicats (de tests vrai/faux, quoi) et une série de valeurs, et on cherche à déterminer si toutes les valeurs passent tous les tests. Il va de soi que dès qu’un test échoue, on peut lâcher l’affaire :

const values = [1, 2, 3, 4, 5, 6, 7, 8, 9]
const tests = [(n) => n >= 0, Number.isInteger, (n) => n < 5]

let allPassed = true
outer: for (const value of values) {
  for (const test of tests) {
    // À value === 5 et sur le dernier test, ça va échouer
    if (!test(value)) {
      allPassed = false
      break outer
    }
  }
}

Moi j’aime bien 😊

Court-circuiter avec continue

On peut évidemment faire pareil avec continue, pour ne pas simplement sauter au prochain tour de la boucle courante, mais au prochain tour d’une boucle englobante !

Si on varie l’exemple précédent, disons qu’on veut toutes les valeurs qui passent tous les tests. Dès qu’un test échoue, inutile de continuer sur la liste des tests pour la valeur courante, on peut passer directement à la valeur suivante, et redémarrer le parcours des tests pour celle-ci.

const values = [1, 2, 3, 4, 5, 6, 7, 8, 9]
const tests = [
  (n) => n % 2 === 0, // Multiple de 2
  (n) => n % 3 !== 0, // Pas multiple de 3
]

const passingValues = []
outer: for (const value of values) {
  for (const test of tests) {
    if (!test(value)) {
      continue outer
    }
  }
  passingValues.push(value)
}

passingValues // => [2, 4, 8]

C’est choupi.

Le piège des étiquettes cachées

Depuis l’apparition des fonctions fléchées avec ES2015, on constate une résurgence des étiquettes… mais pas volontaire !

Supposons que, pour des besoins de compatibilité avec une API tierce, tu veuilles transformer une liste de nombres en liste d’objets avec ce nombre comme propriété value. Tu pourrais être tenté·e de faire ça :

const values = [1, 2, 3, 4, 5, 6, 7, 8, 9]
values.map((n) => {
  value: n
})

Sauf que non. Tu te retrouves avec un tableau de 9 undefined. La classe à Dallas™. En fait, tu as bien pris l’habitude de la syntaxe concise pour les fonctions fléchées qui renvoient simplement une valeur (et c’est bien !), mais tu as négligé le double-sens des accolades.

// Tu crois avoir écrit l’équivalent de ceci :
values.map(function (n) {
  return { value: n }
})

// Alors qu’en fait tu as écrit ça :
values.map(function (n) {
  value: n // Oh, une étiquette !
})

Ta fonction de rappel évalue n, n’en fait rien et ne renvoie rien (donc renvoie undefined).

C’est un piège courant lorsqu’on fait des fonctions fléchées concises qui veulent renvoyer un littéral objet : il faut s’assurer que les accolades désignent un littéral objet. Ici, par défaut, elles désignent le bloc de fonction.

Pour que des accolades représentent un littéral objet, elles doivent apparaître là où la grammaire du langage exige une expression. Le moyen le plus simple de déclencher un tel contexte grammatical sans altérer la sémantique du code consiste à enrober par des parenthèses :

const values = [1, 2, 3, 4, 5, 6, 7, 8, 9]
values.map((n) => ({ value: n }))
// => [{ value: 1 }, { value: 2 }, …]

Dans un contexte contemporain, on trouve par exemple souvent ce genre de blague dans les sélecteurs / mapStateToProps avec Redux (ou d’autres bibliothèques de gestion d’état applicatif, qui ont le même type de besoin).

Préfère des fonctions et return

Pour finir, ce type de code est généalement peu lisible et risque de laisser un goût déplaisant dans la bouche… Dans la plupart des cas, préfère définir de petites fonctions utilitaires pour réaliser les parcours imbriqués, et utilise ce bon vieux return pour le court-circuit. L’exemple de break étiquetté peut être réécrit avantageusement comme suit :

function everythingPasses(values, tests) {
  for (const value of values) {
    for (const test of tests) {
      if (!test(value)) {
        return false
      }
    }
  }
  return true
}

Les boucles imbriquées étiquettées sont à préférer pour des raisons de performance pure, et encore, après avoir copieusement profilé le code pour vérifier que la perf posait un souci concret et qu’on y gagne réellement après coup. Ce qui est assez rare, dans les applis de tous les jours. On n’est pas tous en train de coder un moteur 3D qui veut garantir du 60fps en Full HD, ni des obsédés de la nanoseconde comme Lodash, hein…

Découvrez notre cours vidéo : JavaScript : this is it ! 🖥

Tout savoir sur le fonctionnement de this en JavaScript, des règles fondamentales aux ajustements des API, en passant par les fonctions fléchées, le binding et bien plus encore…