Cet article a été écrit avant l'introduction de l'opérateur ** en Elixir qui
aurait grandement simplifié la secode partie !
Le puzzle d'aujourd'hui sur Advent Of Code demande d'implémenter un simple calculateur qui lit des expressions mathématiques telles et en donne le résultat. Par exemple:
# Exemple 1
1 + 2 * 3 + 4 * 5 + 6
# Exemple 2
1 + (2 * 3) + (4 * (5 + 6))
Mais, afin que cela ait un quelconque intérêt, les règles suivantes s'appliquent :
1 + 2 * 3 soit exécuté comme 1 + (2 * 3),
ce qui donne 7. Dans cet exercice, cela sera plutôt interprété comme
(1 + 2) * 3, donc 9.1 + (2 * 3) alors on doit bien obtenir 7.+ devient prioritaire par rapport à *.4 * 5 + 6 est donc
interprété comme 4 * (5 + 6).Sans ces règles, il suffirait d'utilser la fonction eval() en JavaScript, PHP,
Ruby ou un équivalent dans d'autres langages. Avec ces règles, il est nécessaire
de parser l'expression méthématique puis d'implémenter un interpréteur.
Advent Of Code nous donne un fichier contenant plusieurs calculs comme ceux présentés ci-dessus. L'exercice consiste à tous les exécuter puis à faire la somme de tous les résultats. D'abord avec les règles de la partie 1, puis avec les règles de la seconde partie.
Le fichier contient environ 380 lignes afin que tout être humain normalement constitué n'envisage jamais de le faire à la main par pure flemme, et également afin de s'assurer que la solution soit suffisament rapide.
Dans le cadre d'Advent Of Code, la « solution » désigne non pas le nombre final à trouver mais le code qui permet de le trouver. Chaque joueur reçoit un fichier "input" différent, l'important est de trouver le code qui permet de trouver la bonne réponse pour un input donné.
# Extrait de mon fichier d'input
6 + 9 * (6 + 7 + (3 + 9))
(6 * 7) + 2
9 + (4 * (4 * 2 + 6 + 9) + 2 + 4 * 5 + 9)
2 * (8 + 6 * 2 * 6 + 2 * (6 + 5 + 9 + 6)) * 2 * 6
(5 * 7 * 4) * 3 * (3 * 6)
Voici une bonne solution qui implémente un parser et un interpréteur dont on peut configurer la précédence des opérateurs.
Voici maintenant une solution en Ruby que j'ai trouvée sur reddit :
# Part 1 in ruby
class Integer
def -(other)
self * other
end
end
total = 0
File.open('day18_input.txt', 'r').each do |line|
total += eval(line.tr('*', '-'))
end
puts total
# Part 2 in ruby
class Integer
def **(other)
self + other
end
end
total = 0
File.open('day18_input.txt', 'r').each do |line|
total += eval(line.gsub(/+/, '**'))
end
puts total
L'idée du hack que l'on voit ici est d'utiliser le parseur natif du langage
pour faire tout le travail. On ne peut pas contrôler la précédence des
opérateurs au niveau du langage, car celle-ci est considérée au moment ou le
code source est transformé en abstract syntax tree. L'astuce consiste donc à
réimplémenter un opérateur qui a la précédence souhaitée, directement sur la
classe Integer, en lui faisant faire l'opération que l'on veut, puis faire la
même transformation sur le texte en input.
Evil, non ? C'est absolument dégueulasse en termes de bonnes pratiques, nous sommes bien d'accord. Mais le problème posé par Advent Of Code demande simplement d'implémenter l'addition et la multiplication avec une précédence spécifique, et ce code répond parfaitement au problème.
Pour la première partie de l'exercice, on souhaite que l'addition et la
multiplication soient traitées avec la même précédence. Il faut donc remplacer
l'opérateur *, prioritaire par rapport à +, par un opérateur de même
précédence que +.
L'auteur·e de ce code à donc choisi la soustraction, -. Il/elle remplace donc
le caractère * par - dans l'input :
line.tr('*', '-')
Pour que cela fonctionne, il faut quand même qu'une multiplication soit faite,
et non une soustraction, donc on surcharge l'opérateur - pour la classe
Integer, en effectuant la multiplication à la place :
class Integer
def -(other)
self * other
end
end
Enfin il ne reste qu'à lire chaque ligne du fichier, appeler eval() et
faire la somme des résultats.
Pour la seconde partie, on ne veut pas une précédence neutre entre + et *.
On souhaite l'inverser. On remplace donc l'opérateur + par un opérateur encore
plus prioritaire que *, l'opérateur ** (exponent).
Ces exercices « pour le fun » sont vraiment les seuls cas au monde où il est acceptable de surcharger un opérateur de base sur les nombres entiers – à mon sens. À moins que la fin du monde approche et que vous n'ayez que quelques minutes pour implémenter une solution d'urgence qui va éviter une guerre nucléaire… D'autant plus qu'en Ruby, la rédéfinition de l'opérateur vaut pour le runtime entier, pas simplement pour le scope courant (mais je peux me tromper, je connais très mal ce langage). C'est donc une pratique innomable à laquelle ce code à recours ici. Mais pour Advent Of Code, tout est permis.
Voyons maintenant comment on peut faire la même chose avec Elixir.
Pour la première partie il est possible de faire quelque chose d'équivalent. Il
nous faut un contexte d'exécution pour surcharger l'opérateur - et le
transformer en multiplication, donc nous définissons un module.
Dans ce module nous importons explicitement Kernel (qui est normalement
importé d'office) pour omettre d'importer la fonction Kernel.-/2, ce qui nous
permet de définir cet opérateur sans qu'il y ait clash.
defmodule NoPrecedence do
import Kernel, except: [-: 2]
def a - b, do: a * b
# ...
Ensuite nous définissons l'interprétation de la commande.
__ENV__ pour y ajouter
NoPrecedence.-/2 en tête de la liste des fonctions disponibles dans le
scope. Cela nous donne notre environnement d'exécution – et nous sommes bien
d'accord, un tel truc ne dois jamais partir en production !"*" par "-" dans la chaîne de caractères afin d'utiliser
notre opérateur - surchargé à la place de la multiplication.Code.eval_string/3
avec cette nouvelle chaîne, une liste vide comme bindings et notre
environnement 0) est le résultat de l'évaluation. # ...
def eval(expression) do
env = Map.update!(__ENV__, :functions, fn funs ->
[{NoPrecedence, -: 2} | funs]
end)
expression
|> String.replace("*", "-")
|> Code.eval_string([], env)
|> elem(0)
end
end
Il est enfin temps d'exécuter tout l'input avec cette nouvelle solution !
for line <- File.stream!("day-18.inp") do
NoPrecedence.eval(line)
end
|> Enum.sum()
|> IO.inspect(label: "part 1")
Et ça fonctionne ! J'ai testé avec mon input d'Advent Of Code et j'obtiens bien les résultats attendus.
Ça reste plus complexe et beaucoup plus long que son pendant Ruby, mais sans aucun impact sur le reste du runtime. Si par malheur il s'avère qu'un jour il ne reste que cette ultime solution pour résoudre un problème, on pourrait envisager un instant de la mettre en place. Reste à trouver quel genre de projet nous pousserait à faire une telle chose… je préfère ne pas y penser.
Les choses se corsent ! Il n'y a pas d'opérateur ** en Elixir, et aucun autre
opérateur left-to-right n'a de précédence supérieure à * et /.
Pour rappel, nous devons ici exécuter les + avant les *, c'est à dire
l'inverse de la règle mathématique standard.
La solution reste simple :
+ en / ;* en - ;/ et -.Ainsi 2 * 4 + 8 s'écrira 2 - 4 / 8, la précédence aura comme effet
d'interpréter ça comme 2 - (4 / 8), et notre surcharge effectuera finalement
2 * (4 + 8).
Hyper « simple » !
Voici le code intégral pour la partie 2. J'ai utilisé un for/reduce au lieu
d'un for classique histoire de réduire le nombre d'itérations du code (et
aussi réduire le nombre de lignes ! – espoir futile).
defmodule AdditionPrecedence do
import Kernel, except: [-: 2, /: 2]
def a / b, do: a + b
def a - b, do: a * b
def eval(expression) do
env = Map.update!(__ENV__, :functions, fn funs ->
[{AdditionPrecedence, -: 2}, {AdditionPrecedence, /: 2} | funs]
end)
expression
|> String.replace("+", "/")
|> String.replace("*", "-")
|> Code.eval_string([], env)
|> elem(0)
end
end
for line <- File.stream!("day-18.inp"), reduce: 0 do
sum -> sum + AdditionPrecedence.eval(line)
end
|> IO.inspect(label: "part 2")
Grâce à ce petit exercice j'ai pu comprendre un peu mieux le fonctionnement de
l'environnement lors de la compilation. L'implémentation n'a pas été immédiate ;
j'ai notamment du chercher pourquoi mes opérateurs surchargés n'étaient pas
trouvés (fonction :erl_eval.-/2 manquante).
Mais ça reste moins intéressant à faire qu'un vrai parseur. Ça sera pour une prochaine fois !