Générer des variantes de produit dans Ruby

Oct 31 2020

Sur la base des exemples de variantes, je dois obtenir toutes les combinaisons de toutes les variantes. Dans l'exemple, j'ai 3x3x2 = 18 variantes.

## SAMPLE VARIANTS
sizes = ['small', 'medium', 'large']
colors = ['red', 'green', 'blue']
materials = ['cotton', 'linen']

## ITERATE TO ALL VARIANTS
titles = []
sizes.each do |size|
  colors.each do |color|
    materials.each do |material|
      ## PUT THE VARIANT IN THE NEW ARRAY
      titles.push("#{size} - #{color} - #{material}")
    end
  end
end

puts titles.inspect

Est-ce que ma boucle imbriquée est préférée ou il existe une meilleure implémentation pour cela?

Réponses

4 JörgWMittag Oct 31 2020 at 17:22

Littéraux de chaîne gelés

Les structures de données immuables et le code purement fonctionnel sont toujours préférés, sauf si la mutabilité et les effets secondaires sont requis pour la clarté ou les performances. Dans Ruby, les chaînes sont toujours modifiables, mais il existe un commentaire magique que vous pouvez ajouter à vos fichiers (également disponible en tant qu'option de ligne de commande pour le moteur Ruby), qui rendra automatiquement toutes les chaînes littérales immuables:

# frozen_string_literal: true

Il est généralement préférable d'ajouter ce commentaire à tous vos fichiers.

Tableau de mots littéraux "pour cent"

Ruby a des littéraux de tableau spéciaux pour les tableaux de chaînes d'un seul mot qui peuvent rendre votre code plus facile à lire en réduisant la quantité de «fluff syntaxique» autour du contenu réel.

Le littéral commence par les sigils %wou %W(pensez "mot" ou "witespace-séparés"). %wse comporte comme une chaîne entre guillemets simples, c'est-à-dire n'effectue pas d'interpolation et ne prend en charge aucun caractère d'échappement autre que \'et \\. %Wse comporte comme une chaîne entre guillemets.

Ainsi, le début de votre script pourrait ressembler à ceci:

# frozen_string_literal: true

## SAMPLE VARIANTS
sizes = %w[small medium large]
colors = %w[red green blue]
materials = %w[cotton linen]

Comme pour tous les littéraux de pourcentage, vous pouvez choisir librement le délimiteur que vous souhaitez utiliser de telle sorte que le délimiteur n'apparaisse pas à l'intérieur du littéral. Par exemple, vous pouvez utiliser |comme délimiteur ,, @tout ce que vous voulez:

sizes = %w@small medium large@
colors = %w@red green blue@
materials = %w@cotton linen@

Le premier caractère après le wdétermine le délimiteur. Les délimiteurs sont disponibles en deux variantes: appariés et non appariés. Avec un délimiteur non apparié, le même caractère termine le littéral, comme dans le deuxième exemple. Avec un délimiteur apparié, le délimiteur de fermeture correspondant termine le littéral, par exemple lorsque vous commencez par <, vous fermez avec >, etc. Voir le premier exemple.

Peluchage

Vous devez exécuter une sorte d'analyseur linter ou statique sur votre code. Rubocop est populaire, mais il y en a d'autres.

Rubocop a pu détecter toutes les améliorations de style que j'ai signalées ci-dessus et a également été en mesure de corriger automatiquement toutes celles que j'ai répertoriées.

J'ai configuré mon éditeur de sorte qu'il exécute automatiquement Rubocop avec correction automatique dès que je clique sur "enregistrer".

Voici à quoi ressemble le résultat de la correction automatique:

# frozen_string_literal: true

## SAMPLE VARIANTS
sizes = %w[small medium large]
colors = %w[red green blue]
materials = %w[cotton linen]

## ITERATE TO ALL VARIANTS
titles = []
sizes.each do |size|
  colors.each do |color|
    materials.each do |material|
      ## PUT THE VARIANT IN THE NEW ARRAY
      titles.push("#{size} - #{color} - #{material}")
    end
  end
end

puts titles.inspect

puts foo.inspect

Kernel#pest la méthode de débogage préférée. Il fait la même chose, mais est plus idiomatique et est spécialement conçu pour un débogage rapide (d'où le nom à un caractère).

Ainsi, la dernière ligne peut simplement être

p titles

De plus, Kernel#putsrenvoie nil, mais Kernel#prenvoie son ou ses arguments, de sorte que vous pouvez rapidement le jeter dans une longue chaîne d'expressions sans changer le résultat.

Espace blanc vertical

Votre code peut utiliser des espaces blancs verticaux pour donner au code plus d'espace pour respirer. Je suggérerais au moins de séparer l'initialisation au début de la boucle:

titles = []

sizes.each do |size|
  colors.each do |color|
    materials.each do |material|
      ## PUT THE VARIANT IN THE NEW ARRAY
      titles.push("#{size} - #{color} - #{material}")
    end
  end
end

L'opérateur "pelle" <<

Array#pushn'est pas idiomatique. Plus précisément, il est seulement idiomatiques si vous utilisez le tableau comme une pile , alors vous utilisez Array#pushet Array#pop, puisque ce sont les noms standard pour les opérations de la pile.

La manière idiomatique d'ajouter quelque chose à quelque chose d'autre est l'opérateur de la pelle, dans ce cas Array#<<, donc cela devrait être

titles << "#{size} - #{color} - #{material}"

Itérateurs

Dans Ruby, il est idiomatique d'utiliser des itérateurs de haut niveau. Dans votre code, vous utilisez déjà des itérateurs au lieu de boucles, donc c'est bien. Cependant, eachc'est vraiment le niveau le plus bas de tous les itérateurs. C'est essentiellement équivalent à une FOREACH-OFboucle. Il n'a pas de sémantique de niveau supérieur et repose sur des mutations et des effets secondaires.

Chaque fois que vous avez le modèle «Initialiser un résultat, boucler sur une collection ajoutée au résultat, renvoyer le résultat», c'est un repli . Il existe deux implémentations de fold dans la bibliothèque de base Ruby, injectet each_with_object. injectest la plus fonctionnelle, each_with_objectest la plus impérative. Donc, pour l'instant, nous allons utiliser each_with_objectici, car le code est toujours assez impératif, et cela rend la relation entre l'ancien et le nouveau code plus claire.

En tant que transformation générale,

accumulator = some_initial_value

collection.each do |element|
  accumulator = do_something_with(accumulator, element)
end

devient

accumulator = collection.inject(some_initial_value) do |accumulator, element|
  do_something_with(accumulator, element)
end

ou

collection.each_with_object(some_initial_value) do |element, accumulator|
  do_something_with(accumulator, element)
end

Dans votre cas, cela ressemblerait à ceci:

titles = []

sizes.each do |size|
  colors.each do |color|
    materials.each do |material|
      ## PUT THE VARIANT IN THE NEW ARRAY
      titles << "#{size} - #{color} - #{material}"
    end
  end
end

devient

titles = []

sizes.each_with_object(titles) do |size, titles|
  colors.each_with_object(titles) do |color, titles|
    materials.each_with_object(titles) do |material, titles|
      ## PUT THE VARIANT IN THE NEW ARRAY
      titles << "#{size} - #{color} - #{material}"
    end
  end
end

Certes, cela ne nous achète pas grand-chose, bien au contraire. Cela commence à être légèrement différent lorsque nous passons à une version purement fonctionnelle sans effets secondaires ni mutation en utilisant Enumerable#inject:

titles = sizes.inject([]) do |acc, size|
  colors.inject(acc) do |acc, color|
    materials.inject(acc) do |acc, material|
      ## PUT THE VARIANT IN THE NEW ARRAY
      acc + ["#{size} - #{color} - #{material}"]
    end
  end
end

Linter, revisité

Rubocop se plaint en fait de mon utilisation de l' ombrage de l'extérieur accavec l'intérieur acc.

Je ne suis pas d'accord. N'ayez pas peur de désactiver ou de reconfigurer les règles de votre linter en fonction de votre style.

Cependant, notez que la programmation est un sport d'équipe. Si vous modifiez du code, adoptez le style existant. Si vous faites partie d'une équipe, adoptez le style de l'équipe. Si vous écrivez du code open source, adoptez le style du projet. Si vous démarrez votre propre projet, adoptez le style de la communauté (ne créez pas votre propre style pour votre projet, jusqu'à ce que votre projet soit suffisamment grand et réussi pour avoir sa propre communauté indépendante).

Excursion: généralité du pli ( inject/ each_with_object)

Quand j'ai écrit ci-dessus que vous pouvez réécrire cette itération avec injectou each_with_object, c'était en fait une déclaration tautologique. Je n'ai même pas eu besoin de lire le code pour faire cette déclaration.

Il s'avère que ce pli est "général". Chaque itération sur une collection peut être exprimée en utilisant fold . Cela signifie que si nous supprimions toutes les méthodes de Enumerable, sauf inject, nous pourrions ré-implémenter à Enumerablenouveau tout le module, en n'utilisant rien d'autre que inject. Tant que nous en avons inject, nous pouvons tout faire.

Itérateurs, prenez 2

Donc, ce que nous avons fait jusqu'à présent a été de remplacer l'itérateur de bas niveau par un itérateur de niveau supérieur.

Cependant, nous n'avons toujours pas terminé. Ce que nous faisons maintenant, c'est de prendre chacun des trois éléments de nos trois collections, de les concaténer et de les mettre dans une nouvelle collection. Donc, vraiment, ce que nous faisons est de transformer chaque élément (ou triple d'éléments), ou de "mapper" chaque élément sur un nouvel élément.

Cela s'appelle map et est également disponible en Ruby sous forme de Enumerable#map.

Donc, enfin, notre code ressemble à ceci:

titles = sizes.map do |size|
  colors.map do |color|
    materials.map do | material|
      "#{size} - #{color} - #{material}"
    end
  end
end

Ce résultat n'est en fait pas tout à fait exact: nous obtenons un tableau triplement imbriqué, car nous avons un triplement imbriqué Enumerable#map.

On pourrait Array#flattenle résultat, mais il y a une meilleure façon Enumerable#flat_map::

titles = sizes.flat_map do |size|
  colors.flat_map do |color|
    materials.map do | material|
      "#{size} - #{color} - #{material}"
    end
  end
end

Ce que nous avons fait ici, c'est de remplacer le pli général des itérateurs de haut niveau (qui peut tout faire ) par une carte d' itérateurs de haut niveau plus restreinte et plus spécialisée . En utilisant un itérateur plus spécialisé, nous sommes en mesure de mieux transmettre notre sémantique au lecteur. Au lieu de penser "Ok, donc ici nous avons un accumulateur, et un élément, et nous faisons quelque chose avec l'élément, puis nous l'ajoutons à l'accumulateur… ah, je vois, nous transformons chaque élément", le lecteur voit et sait instantanément que transforme les éléments.mapmap

Méthodes de tableau

Il n'y a pas grand-chose que nous pouvons améliorer dans le code en utilisant des itérateurs. Cependant, il existe de nombreuses autres méthodes à la fois dans le Enumerablemixin et dans la Arrayclasse .

Alors, prenons du recul et réfléchissons à ce que nous faisons réellement ici: nous construisons le produit cartésien des trois tableaux. Et peut-être pas surprenant, il existe déjà une méthode qui calcule un produit de tableaux, nommé de manière créative Array#product:

titles = sizes.product(colors, materials).map do |size, color, material|
  "#{size} - #{color} - #{material}"
end

Array#join

Comme dernière amélioration, regardons ce que fait le bloc: il "joint" les trois variantes ensemble. Et encore une fois, il existe déjà une méthode qui fait cela Array#join::

titles = sizes.product(colors, materials).map do |variant|
  variant.join(' - ')
end

Résultat final

Donc, à la fin, tout cela ressemble à ceci:

# frozen_string_literal: true

## SAMPLE VARIANTS
sizes = %w[small medium large]
colors = %w[red green blue]
materials = %w[cotton linen]

titles = sizes.product(colors, materials).map do |variant|
  variant.join(' - ')
end

p titles

Ce qui, je pense, est un code beau, facile à lire et à comprendre.