Pulp, un environnement d'exécution multivitaminé

... vers lequel compiler vos micro-langages

a marqué ce sujet comme résolu.

Voilà, c'est précisément parce qu'il faut faire ce style de choix que j'ai gardé le sujet pour plus tard (ou maintenant si vous préférez). Perso je ne vois pas du tout l'avantage de faire une VM si c'est pour manipuler des blobs non-typés et faire des castings dangereux comme ceux du C. Ça veut dire multiplier les opcodes et saturer le vocabulaire du langage avec des instructions typées qui sont responsables de donner du sens aux données et ça me rappelle beaucoup trop Perl pour me plaire.

Mais il y a peut-être des avantages à cette approche que je ne mesure pas.

+0 -0

Je pense qu’on est tous d’accord pour préférer que les constantes soient typées et qu’un même opcode puisse avoir un comportement différent sur des données de types différents, si cela fait sens (les opérations numériques, par exemple).

Là où l’on achoppe, c’est qu’à mon avis, POP_JMP_IF_TRUE Int(…) Addr(…) devrait renvoyer une erreur et non faire la conversion d’un Int vers un Bool de manière implicite. À mon sens, le pis aller serait que cette opération soit précédée d’un INTO_BOOL Int(…), et la bonne solution serait que convertir un Int en Bool soit tout simplement impossible, de la même manière qu’on ne convertit pas un Fruit en Voiture.

Si vraiment le langage de niveau supérieur veut autoriser une fonction qui renvoie une chaîne de caractères à être interprétée comme un vrai/faux, c’est à lui de gérer ça, pas à la machine virtuelle. ^^

+0 -0

En fait, qu'une fonction ait une valeur booléenne a du sens en Python parce que le test sert à savoir si l'objet qu'on a récupéré dans une variable est None ou non. On peut prendre une approche tangente :

  • données typées,
  • opcodes de conversion explicites, du moins sur le type de destination (TO_BOOL, TO_INT, etc.), ce qui veut dire que ces opcodes plantent si une conversion n'est pas possible mais se démerdent pour les réaliser en fonction du type d'entrée.
  • Rajout d'un opcode IS_NIL et d'un singleton NIL.
  • Tous les autres opcodes (hors opérateurs algébriques) sont fortement typés.

Pour moi c'est véritablement à la VM d'avoir la souplesse nécessaire, parce que c'est d'elle que vient le dynamisme des langages. On ne peut pas renvoyer la patate chaude en disant que c'est au langage de le gérer s'il veut être dynamique, c'est un oxymore : ça revient à lui demander d'être statique.

+0 -0

Désolé, je ne suis pas très familier de Python, du coup, j’ai du mal à voir comment tout cela se goupille. Dans ton fonctionnement, comment serait représenté en bytecode un truc comme ceci ?

1
2
3
4
5
if var1 < 42 || is_odd(var3) {
    
} else {
    
}

En fait, pour être tout à fait précis, je ne perçois pas l’intérêt de Nil. Pas comme un objet natif, en tout cas.

+0 -0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
LOAD var1
PUSH 42
LESSER_THAN
LOAD var3
LOAD is_odd
CALL  # <- syntaxe exacte à determiner
      # la valeur de retour est empilée
TO_BOOL
BOOL_OR
POP_JMP_IF_TRUE (:true_block)
# ... code du else ...
JMP (:endif)
# :true_block ... code du if ...
# :endif

J'ai utilisé des pseudo-labels ici. En vrai l'argument devrait être une adresse pour le pointeur d'instruction (sur 2 octets).

Pour le Nil ça me semble naturel que ce soit la valeur de retour des fonctions qui ne retournent rien, par exemple. Ou des variables non-initialisées. En Python il est convertible en bool et vaut False. Après honnêtement je ne sais pas si c'est mieux ou non de faire la distinction entre bool(a): et a is None nativement. Si on admet qu'une fonction peut retourner un entier ou rien du tout suivant le chemin qu'elle prend, ça me semble avoir du sens.

PPS : bien sûr on peut envisager transformer le BOOL_OR en un premier POP_JMP_IF_TRUE juste après le LESSER_THAN pour éviter d'appeller la fonction, mais c'est au langage de le décider à mon avis.

+0 -0

OK pour le code d’exemple, ça me paraît très bien.

En revanche, pour Nil, je n’en vois pas l’utilité. À mon avis :

  • une fonction qui ne retourne rien ne retourne rien, aucune valeur n’est mise sur la pile à l’issue du call ;
  • une variable non initialisée n’existe pas. D’ailleurs, le bytecode tel qu’il existe utilise le sommet de la pile pour donner une valeur à la variable lors d’un let, il n’est pas possible d’ajouter une variable à l’environnement sans lui donner une valeur, et je trouve ça très sain.

EDIT suite à ton EDIT : c’est vraiment une question de culture, mais je préfère qu’une fonction qui peut renvoyer quelque chose mais pas nécessairement renvoie un type Option, lequel n’a pas besoin d’être défini nativement.

+0 -0

EDIT suite à ton EDIT : c’est vraiment une question de culture, mais je préfère qu’une fonction qui peut renvoyer quelque chose mais pas nécessairement renvoie un type Option, lequel n’a pas besoin d’être défini nativement.

Oui c'est comme ça que font Rust et Haskell, contrairement à Python, Perl, Ruby, et probablement Lua et Javascript.

Posons le problème différemment : quelle syntaxe aura l'opcode RETURN ? Faut-il prévoir un RETURN_VOID si la fonction ne retourne rien ? Si oui cela veut dire que le bytecode autorise une fonction à empiler quelque chose ou pas durant un appel de fonction, et ce n'est pas déterministe (une même fonction peut faire l'un ou l'autre suivant le chemin qu'elle prend, puisqu'on peut en écrire le bytecode).

En disant que seul RETURN existe et qu'il prend obligatoirement un argument quitte à ce que celui-ci soit NIL, tu rends la VM naturellement plus robuste.

+0 -0

Si oui cela veut dire que le bytecode autorise une fonction à empiler quelque chose ou pas durant un appel de fonction, et ce n'est pas déterministe (une même fonction peut faire l'un ou l'autre suivant le chemin qu'elle prend, puisqu'on peut en écrire le bytecode).

Je ne vois pas le problème. Un langage au typage dynamique autorise qu’une fonction puisse retourner un Int ou un String selon son chemin d’exécution, et tu sembles vouloir que la machine virtuelle dispose de cette souplesse. Du coup, en quoi est-ce différent qu’elle puisse ne rien renvoyer du tout sur certains chemins ?

Ce qui nous amène à ça.

Posons le problème différemment : quelle syntaxe aura l'opcode RETURN ? Faut-il prévoir un RETURN_VOID si la fonction ne retourne rien ?

Là, c’est l’habitué de la programmation assembleur qui parle. En assembleur Intel, il existe une unique instruction ret qui se contente de sauter à l’endroit d’où la fonction a été appelée. C’est à la fonction elle-même qu’il appartient d’avoir mis sa ou ses valeurs de retour sur la pile avant de faire le saut. De même que, dans le fonctionnement actuel du bytecode, c’est à la fonction de se débrouiller si elle veut empiler/dépiler un nouvel environnement pour avoir des variables locales.

Ce qui du coup, règle la question du retour vide : on saute simplement sans rien avoir mis sur la pile.

+0 -0

Ah, c'est aussi parce que tu considères une pile unique…

Il va falloir que je prenne le temps de formaliser ce que j'ai en tête, parce que de mon point de vue on ne peut pas se contenter de faire comme en assembleur. A fortiori si la VM a une vocation pédagogique, on ne veut pas laisser la possibilité à une fonction de polluer la pile du code appelant, ni à un opcode de laisser la pile dans un état inconnu, ça implique notamment d'avoir une pile qui soit conceptuellement vide au début de chaque fonction, et automatiquement vidée (pourquoi pas avec un warning) lors du retour de la fonction.

Après il me semble évident que ça ne coûte rien à la VM de proposer nativement des valeurs et des types comme Nil, les booléens, ou même les tables de hachage puisque c'est exactement ce que sont les environnements. Après libre au langage client de les utiliser ou non, mais ça me semble dommage de forcer les gens à réinventer la roue quand on peut leur fournir ce style de commodités pour pas un rond.

+1 -0

Je pense que je commence à piger. Finalement, c’est l’environnement qui permet à l’appelant et l’appelé de communiquer, l’appelé allant piocher dans les niveaux supérieurs de l’environnement s’il a besoin de données venant de l’appelant.

Mais dans ce cas, pourquoi ne pas faire au plus simple et appliquer le même procédé à la valeur de retour ? L’appelé pourrait ne pas supprimer son environnement avant de faire un ret, permettant ainsi à l’appelant d’y récupérer les variables qui l’intéressent. Et ça ajouterait quelque chose d’assez unique, la possibilité de réellement renvoyer plusieurs valeurs à la fois, sans avoir à les cacher dans un tuple.

Et une fonction qui ne renvoie rien retournerait simplement un environnement vide, ou plus exactement un environnement dans lequel la variable voulue n’existerait pas.

Et du coup (je réfléchis en direct ^^ ), plutôt qu’une valeur Nil transversale à tous les types et un opcode is_nil, on aurait juste un opcode exists(arg) qui renverrait True si la variable arg existe dans l’environnement, et False sinon.

Je pense que c’est plus simple à mettre en place, pour des résultats globalement identiques.

Après il me semble évident que ça ne coûte rien à la VM de proposer nativement des valeurs et des types comme Nil, les booléens, ou même les tables de hachage puisque c'est exactement ce que sont les environnements. Après libre au langage client de les utiliser ou non, mais ça me semble dommage de forcer les gens à réinventer la roue quand on peut leur fournir ce style de commodités pour pas un rond.

Mouais. Le pas un rond me semble discutable, mais inutile d’ergoter là-dessus, on verra bien quand il faudra l’implémenter. :)

+0 -0

J'ai commencé de faire un petit truc de mon côté en C++, pas du tout compatible parce que je n'ai pas envie de me casser les pieds avec du code de lecture de fichier, ni de conformité à une spec pour le moment.

Par contre j'ai réfléchi à quelques questions de typage et je suis parti sur un système qui comprend pour le moment 4 types: null, bool, number et string; La VM utilise une pile qui stocke des boost::variant<std::nullptr_t, bool, double, std::wstring> (plus exactement, une classe dérivée). JE ne sais pas si c'est la meilleure des idées mais on verra si ça tient la route. Probablement que par la suite il faudrait se pencher sur un type tableau, un type table de hachage, un type fonction et un type objet, à voir selon l'envie, la difficulté d'implémentation, l'aboutissement de nos réflexions ici, et la motivation.

En tout cas personnellement, je vois bien un bytecode dans lequel le même opcode ADD permet d'additionner deux nombres, ou concaténer deux chaînes, ou concaténer un nombre et une chaîne, selon les arguments qui se trouve à ce moment-là au sommet de la pile. Ca me paraît assez naturel que ce soit la VM qui vérifie et qui, s'il le faut / si on souhaite donner cette sémantique-là au langage, lance une erreur si on essaie d'additionner un nombre et un bool. Ca me paraît beaucoup plus compliqué voire impossible de le faire en amont lors de la compilation, en tout cas dans la perspective de construire un langage duck typing qui ressemblerait un peu à python. Après on peut ne pas vouloir faire du duck typing mais c'est ce que je crois avoir sous-entendu depuis le début de ce topic.

Faire un opcode INT_ADD, puis FLOAT_ADD, puis STRING_ADD, puis après ARRAY_ADD, OBJECT_ADD, XXX_ADD, YYY_ADD, et ça fois le nombre d'opérateurs prévus, ça va juste mener à une explosion du nombre d'opcodes tellement que ça ne sera plus gérable. Pour gagner quoi ? pas grand chose au final; parce qu'avant d'exécuter FLOAT_ADD il faudra quand même vérifier si les deux éléments au sommet de la pile sont bien de type float. ON verra si on arrive à garder un opcode sur un seul octet.

Pour l'instruction RETURN, j'aimerais bien m'approcher de lua. EN lua, on peut returner aucune, une seule, ou plusieurs valeurs. D'ailleurs bien vu, je n'avais pas du tout pensé au fait que pour être bien safe, la pile propre à chaque fonction doit faire partie de l'environnement; ou en tout cas avoir un mécanisme qui empêche qu'une fonction ponctionne sur l'état de la fonction appelante.

C'est bien, ça dessine un peu le mécanisme d'appel de fonction.

Je réfléchis un peu en direct mais si on a ce code :

1
2
3
4
def func (a, b, c):
    return a+4, b+5, c+6

func(1, 2, 3)

Ca pourrait ressembler à :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Code appelant
LOAD func
PUSH 1
PUSH 2
PUSH 3
CALL 3 # 4 paramètres
# Transfert des arguments sur une nouvelle pile ou bien déplacement d'un registre genre ebp/esp et création d'un nouvel environnement
STORE c
STORE b
STORE a
LOAD a
PUSH 4
ADD
LOAD b
PUSH 5
ADD
LOAD c
PUSH 6
ADD
RETURN
# A ce stade on a 3 valeurs de retour sur la pile
# A réfléchir si on veut en ajouter un quatrième qui indique le nombre d'arguments précédents, ou bien si on veut un autre moyen de savoir combien de valeurs ont été retournés

Par contre pour les sauts, comment ça se passe ? Doit-on indiquer des adresses relatives, p.ex. avance de 42 octets, ou bien des adresses absolues, p.ex. saute à l'offset 42 depuis le début du code de la fonction, ou bien faut-il prévoir potentiellement les deux ? OU alors faut-il compter en nombre d'instructions plutôt qu'en octets ? Cette dernière possibilité me paraît limitative mais elle est peut-être intéressante ?

+0 -0

J'ai lu un peu en diagonale la discussion sur les sauts, mais voilà mon grain de sel : l'intérêt pratique d'une machine virtuelle, c'est d'avoir une représentation intermédiaire raisonnable entre un langage de haut niveau (mettons zlang) et le langage machine (ou les langages machines, d'où l'argument un peu fumeux de la portabilité de java). Il faut donc que ce soit suffisamment haut niveau pour que ce soit une cible de compilation agréable, mais suffisamment proche de ce qui se fait dans le silicium pour proposer des performances qu'on espère largement meilleures que celles d'une interprétation immédiate du programme initial.

Partant de là, un système de types très strict n'est pas indispensable (et je dis ça alors que je considère le contraire pour un langage de programmation).

Première digression : le principal intérêt d'un typage fort est que si le langage intermédiaire (ou la VM) est bien intégré à la compilation d'un langage de haut niveau, le typage du langage intermédiaire apporte un confort d'implémentation dudit compilateur : « ma passe de transformation est bien typée, donc j'ai certaines garanties sur sa correction ».

Ça a par contre un inconvénient certain, qui n'est pas tellement pertinent pour un langage de haut niveau : si je veux cibler le langage de Pulp avec un compilateur que j'écris pour un certain langage haut niveau, et que le langage de Pulp est fortement typé (comprendre « avec un système de types expressifs », pas « comme Python »), je dois non seulement générer du code correct, mais en plus je dois moralement convaincre le typeur de Pulp que ce code est correct. La deuxième partie peut poser des problèmes : il peut arriver qu'un système de types très expressif dans mon langage d'entrée m'assure que le code généré est le bon, mais que le typeur de Pulp ne soit pas prévu pour accepter ce genre de construction. C'est par exemple le cas de Coq : la génération vers OCaml utilise plein de Obj.magic : 'a -> 'b pour forcer le typeur d'OCaml à accepter des programmes qui sont prouvés corrects mais qu'il n'est pas assez puissant pour valider.

Ceci étant dit, j'ai certes lu un peu rapidement, mais je ne pense pas que ce soit le genre de problèmes que vous puissiez rencontrer. J'ai l'impression que le typage que vous envisagiez est assez rudimentaire, et c'était donc plus parce que je pense que c'est une remarque intéressante (il y a un article pas très vieux sur linuxfr où bluestorm parle de ça, pour ceux qui l'ont connu, je peux le retrouver si ça vous amuse - sinon vous pouvez probablement chercher « Malfunction »).

Par contre, le problème des performances se pose réellement. Qu'on se mette bien d'accord : the first rule of optimisation is "don't", et la course aux micro-optimisations et aux benchmarks ridicules à la « if ça prend 1ms de moins que switch » n'est ni intéressante, ni productive. Pour autant, je pense que dans le cadre de l'implémentation d'une VM, c'est un point qui mérite d'être pris en compte pour la conception des instructions (parce que c'est justement l'intérêt de la chose).

Je pense précisément aux différences entre entiers et booléens. Dans un langage haut-niveau, mon avis est qu'il doit s'agir de deux types différents et incompatibles, parce qu'ils remplissent des rôles qui n'ont rien à voir. Il faut donc une fonction de conversion du genre is_not_zero pour utiliser un entier tel quel dans un if. De l'autre côté, votre processeur ne fait pas cette différence : tout est entier, et les booléens ne sont que des entiers comme les autres avec des opérations qui ont une sémantique qui fait qu'on a l'impression que c'est vrai ou faux.

Pour une VM, je pense que vous n'avez pas besoin de faire cette différence : c'est à l'implémentation du langage source de s'assurer qu'il ne fait pas n'importe quoi en générant du code. C'est un choix parfaitement raisonnable d'oublier complètement les booléens et d'avoir des instructions de saut qui manipulent des entiers, parce qu'on peut considérer que les booléens du langage source sont eux-mêmes compilés vers des entiers une fois qu'on a vérifié qu'ils étaient utilisés correctement, et que c'est à la charge du compilateur de faire attention à ne pas mélanger n'importe comment ces entiers là avec les autres.

Concrètement, ça veut dire que la fonction int_to_bool du langage source est en fait, sous le capot, une simple indication au typeur pour le convaincre que le programme est correct, qui est traduite par l'identité une fois le programme compilé. Ainsi, le langage source profite d'un typage fort (et d'une implémentation micro-légèrement plus lente parce qu'il faut prendre le temps de ne pas traduire cette fonction), et la VM profite d'une implémentation plus efficace (pas d'appel de fonction inutile, et comme de toute façon son langage n'est pas à destination du programmeur, on n'a pas besoin de lui fournir ce genre de garanties).

Voilà pour ma contribution à ce débat. Comme je l'ai dit, j'ai lu un peu vite ce que vous avez écrit, et il est très possible que je dise ici des choses dont vous avez déjà parlé. Mon but ici n'est pas de vous dire « faites comme ça, c'est le meilleur choix » (je ne connais pas assez ce domaine pour en être sûr), mais pour vous inviter à réfléchir un peu en détail sur ce point avant de faire un choix par défaut. J'ai l'impression que certaines idées ou propositions de spécifications font comme s'il s'agissait d'un langage à destination du programmeur, et je pense que c'est un piège.

PS : je ne sais pas si c'est très clair dans mon message, mais mon avis n'est certainement pas de dire « à mort les types, tout est entier ». C'est totalement pertinent d'avoir un minimum de types différents dans le langage intermédiaire, qui dépendront probablement du langage source auquel vous voulez faciliter la vie (par exemple, des closures pour le fonctionnel, ou des objets). Il faut simplement faire attention à ne pas aller trop loin : tout ce qui est facilement gérable par un compilateur en amont peut être oublié.

PS2 : désolé pour le pavé. TL;DR : un système de types très expressif, c'est super pour un langage de programmation, mais c'est moins indispensable pour un langage cible et ça a un gros risque de compliquer le travail du compilateur.

+4 -0

On peut prendre la question à rebours: ça sert à quoi de faire une VM qui ne manipule que des entiers ?

Si c'est pour écrire du code C ou très proche du C qui ne fait aucune vérification, alors perso je ne vois pas trop l'intérêt de ce projet. A ce moment-là, mieux vaut se concentrer sur le compilateur et viser une VM existante; celle de lua pour rester simple, ou celles de python ou peut-être Java pour les plus fous; ou pourquoi pas le code intermédiaire de klang. Parce qu'à ce moment-là avec notre VM qui ne manipule que des entiers on n'apporte pas grand chose à mon avis, à part un format de bytecode commun qui ne sera peut-être au final pas plus simple que le format de bytecode de python ou de Java; en se mettant en bonus les barrières des dits langages, ce qui empêche toute possibilité de s'essayer à des concepts inédits non présents dans ceux-ci.

Évidemment les performances ne seront pas les mêmes; mais à mon avis, pour un toy project, on s'en fout. On n'a jamais dit qu'il fallait produire un langage rapide; de toute façon on n'a aucune chance de rivaliser avec ce qui existe. Si on commence comme ça, le simple fait de vouloir faire une VM en python ou dans un autre langage très haut niveau est déjà une hérésie en fait.

Je crois que le moment pour se poser toutes ces questions là, ça sera quand on aura atteint un stade où on s'attaquera à du JIT. Là faut être rapide, faire du C et de l'asm. Pas avant. D'ici là on a encore bien des choses à débattre, à faire mûrir et à implémenter.

+1 -0

@QuentinC pour répondre à ta question, je ne vois pas trop l'intérêt de sauts relatifs dans l'immédiat.

À moins d'une très bonne raison, j'ai bien envie de ne faire que des sauts vers un offset absolu dans le code du segment.

Si on commence comme ça, le simple fait de vouloir faire une VM en python ou dans un autre langage très haut niveau est déjà une hérésie en fait.

Va dire ça à Pypy. ;)

Sur le reste je pense qu'on est tous plus ou moins d'accord. J'avais d'ailleurs imaginé plus ou moins la même chose pour les appels et retours de fonction que l'exemple de Quentin :

Pour appeler une fonction à N arguments : on empile N valeurs, et on appelle CALL N avec N >= 0.

Pour retourner N valeurs, on peut imaginer empiler N valeurs et on appelle RETURN N avec N >= 1. Dans l'idée je préfère n'empiler qu'une seule valeur et fixer ce N à 1, de telle manière que l'on soit sûrs que CALL dépile les arguments et n'empile qu'une seule valeur en lisant le code, quitte à dépaqueter ensuite le résultat.

Mon principal soucis, c'est que les opcodes consomment et empilent un nombre déterministe de valeurs : en lisant un opcode et son argument, on doit le savoir au premier coup d'oeil. Dans cette optique : CALL N dépile N valeurs et en empile une seule. RETURN dépile une seule valeur, mais on peut imaginer PACK N qui dépile N valeurs et empile le tuple ainsi que UNPACK N qui dépile un tuple, le dépaquette et empile les N valeurs du n-uplet avec une erreur si le tuple n'a pas exactement N éléments.

+0 -0

@QuentinC pour répondre à ta question, je ne vois pas trop l'intérêt de sauts relatifs dans l'immédiat. À moins d'une très bonne raison, j'ai bien envie de ne faire que des sauts vers un offset absolu dans le code du segment.

Les sauts relatifs permettent d'écrire du bytecode plus facilement pour un humain, c'est utile pour tester. Après dès le moment où on a un compilateur c'est vrai que l'utilité est discutable, moi non plus je ne vois pas immédiatement quel en serait l'avantage, à part peut-être une opérande plus courte.

Argument vendu et il n'y a pas grand chose d'autre à ajouter aux sauts à mon avis, la responsabilité de transformer les constructions classiques if/else, switch, for, foreach, while, break/continue, etc. proposées par le langage est donnée au compilateur; il serait même libre de proposer un bon vieux goto. A ce stade ça fait juste deux opcodes, un saut conditionel et un saut inconditionnel.

Va dire ça à Pypy.

Tu es en train de me dire que pypy a été écrit en python et pas en C ?

Mon principal soucis, c'est que les opcodes consomment et empilent un nombre déterministe de valeurs : en lisant un opcode et son argument, on doit le savoir au premier coup d'oeil.

Quel problème il y a à admettre un argument pour l'opcode RETURN ?

Dans mon exemple j'en ai pas mis parce que je ne l'ai pas jugé nécessaire, pensant que le code appelant, lui, sait combien d'arguments il doit dépiler. Mais je me suis trompé, j'aurais dû; sinon on ne peut pas avoir des retours multiples et que le nombre de valeurs retournées puisse dépendre du chemin exécuté.

Avec ce changement je ne vois pas où ça pose problème.

Le gros inconvénient que je vois dans ta proposition c'est que dès le début il va falloir prévoir un type tuple, tableau, liste ou similaire. C'est quelque chose d'important bien sûr, mais je voyais ça pour beaucoup plus tard.

Dans un premier temps il y a sûrement moyen d'implémenter CALL sans nécessairement que ni le type tuple/tableau/liste, ni le type fonction en tant qu'objet de premier ordre n'existent. Peut-être ni même le type string si on se contente d'appeler le segment n°X.

Ou alors avant de se lancer sur les fonctions, on devrait d'abord avoir un débat sur les types avant. ON a plein de possibilités, et on n'est pas obligé de tout implémenter tout de suite non plus :

  • Un type null/None/NIL ou pas ?
  • Un type undefined différent de null comme en JS ? (j'ai toujours trouvé ça ridicule mais il y a sûrement une bonne raison pour que JS soit ainsi)
  • Un type booléen ou une simple convention 0=false/1=true ?
  • Un type unique number, un seule type number mais avec deux repréesentations internes (int64 et double) interchangeables ou pas, ou bien des types de nombres multiples comme en C/C++/C#/Java ?
  • Un type string avec représentation interne binaire/agnostique, UTF-8 ou UTF-16 ?
  • Un type fonction
  • Un ou plusieurs types tuple/liste/tableau
  • Un type table de hachage / dictionnaire / mapping, qui pourra peut-être faire office de type objet par la suite, ou pas
  • Un ou plusieurs types bonus si on est encore motivé et si c'est pertinent: buffer, range, iterator, generator/coroutine…

Plus toutes les questions de conversions/cohercission/transtypage implicites ou explicites souhaitées ou souhaitables.

+0 -0

Je réponds à un peu tout le monde à la fois.

Objectifs

À titre personnel, je ne recherche pas une quelconque forme de performances dans cette machine virtuelle. Bien au contraire, mon code est bardé de vérifications dans tous les sens dont au moins la moitié ne sont pas entièrement nécessaires et pourraient être supprimées pour améliorer les performances.

Mais justement, ce n’est pas ce que je recherche. Je cherche au contraire à ce que, autant que possible, la moindre erreur dans le bytecode soit détectée et signalée avec un message d’erreur clair. L’arrière-pensée étant qu’ainsi, ceux qui chercheront à écrire des compilateurs vers Pulp, notamment pour Acid ou Seventh, pourront tester beaucoup plus facilement si le résultat de leur compilation est correcte et déterminer ce qui ne va pas le cas échéant.

Sauts

Concernant les sauts, vu qu’on cherche à introduire une forme de sécurité dans le bytecode, je pense qu’il est inconcevable que l’adresse de destination du code représente un octet donné : il doit impérativement représenter une opération, sous peine de trucs vraiment, vraiment dégueu.

À partir de là, j’ai tendance à préférer les sauts absolus. Mais c’est vraiment une affaire de goût. Une histoire de ne pas vouloir gérer le cas d’un saut relatif de décalage supérieur au nombre total d’instructions du segment…

Quant à la représentation exacte d’un tel saut, voici ma proposition de solution.

  • On introduit un nouveau type de constante Addr(label, offset). L’argument label, sur deux octets, est une référence à la table des symboles, et offset, sur deux octets aussi, est le numéro de l’instruction à laquelle on veut sauter dans le segment.
  • Il faudra naturellement s’assurer qu’il n’y ait pas plusieurs objets Addr utilisant le même label.
  • Un saut prend comme argument une telle constante.

La question est un peu plus complexe pour les sauts d’un segment à un autre, mais ça peut attendre une version postérieure du bytecode. Mon impression générale est qu’il vaut mieux éviter d’autoriser les sauts à un offset donné d’un autre segment : celui-ci peut appartenir à une bibliothèque tierce, dont l’organisation interne va très certainement changer à chaque mise à jour. Je verrais plutôt un appel à identifiant-textuel-du-segmentlabel-dans-le-segment.

Fonctions

Pour les passages d’argument entre fonction appelée et fonction appelante, je reviens sur mon idée de tout à l’heure, à savoir utiliser uniquement l’environnement pour communiquer. Voici comment je vois les choses.

1) Dans la fonction appelante, les arguments destinés à la fonction appelée sont empilés à l’envers. Par exemple, pour le func(1, 2, 3) de QuentinC, on aurait tout d’abord ceci.

1
2
3
4
5
PUSH 3
PUSH 2
PUSH 1

La pile : |3 – 2 – 1

2) On pousse ensuite sur la pile une valeur de type Addr telle que définie plus haut (on verra plus tard pour l’appel à une fonction anonyme) correspondant à l’emplacement du code de la fonction appelée.

1
2
3
PUSH Addr(func, 0x4279)

La pile : |3 – 2 – 1 – Addr(func, 0x4279)

3) Vient ensuite l’instruction CALL n. Celle-ci prend en argument le nombre d’arguments adresse non comprise à envoyer à la fonction appelée. Elle a plusieurs effets.

  • Un nouveau scope est empilé par dessus les autres.
  • L’adresse de l’instruction suivant l’instruction CALL (si elle existe) est placée dans une variable réservée appelée par exemple .ret.
  • L’adresse de destination est dépilée et gardée dans un coin.
  • Les arguments sont dépilés et placés dans des variables de noms réservés .1, .2, .3
  • Une pile vierge est créée pour la fonction appelée, qui n’a donc aucun accès à la pile de la fonction appelante.
  • L’exécution saute à l’adresse de destination.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
CALL 3

La pile appelante : |
La pile appelée   : |

Le scope en haut de l’environnement :
.ret → Addr(label, offset)
.1   → 1
.2   → 2
.3   → 3

4) La fonction appelée fait tout ce qui lui plaît, sans toucher à la pile de la fonction appelante mais en ayant accès aux données transmises. Les valeurs de retour sont placées dans l’ordre dans .1, .2, .3, etc. Il existe une instruction unset(label) permettant de supprimer une variable de l’environnement (laquelle sera certainement utile ailleurs, en plus).

5) La fonction appelée lance une instruction RET, laquelle fait ce qui suit.

  • Le scope supérieur n’est pas enlevé de la pile.
  • La pile de la fonction appelée est supprimée.
  • L’exécution saute à l’adresse contenue dans .ret.

6) La fonction appelante fait ce qu’elle veut des valeurs de retour, et dépile le scope supérieur lorsqu’elle estime ne plus en avoir besoin.

+0 -0

Quel problème il y a à admettre un argument pour l'opcode RETURN ?

Il n'y a aucun problème à admettre un argument, le problème est du côté du CALL, si le RETURN devient capable d'empiler un nombre de valeurs arbitraires, alors l'appel à CALL n'est plus déterministe : le nombre de valeurs qu'il empile dépend du nombre de valeurs retourné par la fonction appelée. C'est un coup à se péter les dents parce qu'on ne peut plus du tout prévoir localement le contenu de la stack. Par ailleurs, les fonctions à retours multiples sont quand même loin d'être classiques. Si on décide de ne pas gérer PACK et UNPACK au départ, on s'affranchit du problème de tuples, mais le système est dès le départ compatible avec des fonctions d'une arité arbitraire et à valeur de retour unique.

@Dominus : pour moi une fonction aurait dû être un objet ayant son propre segment de code que l'on peut pousser tel quel sur la stack, plutôt qu'une adresse dans le bytecode courant.

Tel que je le vois, on pousse une fonction sur la pile, on appelle CALL avec le nombre d'arguments qui va bien, l'interpréteur :

  • dépile les N arguments,
  • crée une nouvelle frame avec un nouveau pointeur d'instruction (qui pointe sur le début du bytecode de la fonction appelée) et une nouvelle pile de valeurs toute fraîche,
  • empile un nouveau scope dans l'environnement rétablit l'environnement de la fonction (ce qui permet de gérer immédiatement les closures). Ça veut dire qu'une fonction doit avoir une référence sur l'environnement dans lequel elle est déclarée, c'est à dire une copie de la pile des scopes au moment où elle est créée.
  • poursuit l'exécution sur cette frame.

Lors de l'appel à RETURN, l'interpréteur :

  • dépile une valeur de la pile de la fonction appelée,
  • puis il dépile un scope de l'environnement,
  • dépile la frame de la fonction appelée, donc reprend celle de la fonction appelante,
  • empile la valeur de retour sur la pile de la fonction appelante,
  • rétablit l'environnement de la frame appelante,
  • reprend l'exécution de la fonction appelante juste après l'appel à CALL.

En somme, ça demande d'avoir une frame qui pointe sur :

  • une pile de valeurs dédiée,
  • un pointeur d'instruction dédié,
  • son environnement, qui référence son propre scope et tous les scopes englobants.
+0 -0

De ce que je comprends de ta proposition, j’aurais plusieurs choses à discuter.

Déjà, simple détail : si je parle de faire un call au sein d’un même segment, c’est parce que j’ai fait une proposition formelle pour une manière de gérer les sauts au sein d’un même segment et pas encore pour les sauts entre segments. Il est évident que call utilisé sur un objet FarAddr ou quelle que soit sa forme permettrait d’appeler une fonction d’un autre segment.

Ensuite, je n’avais pas pensé au cas des variables statiques à une fonction, qui te pousse à conserver en mémoire le scope de toutes les fonctions pour les rétablir en cas de second appel. Je ne suis pas nécessairement persuadé qu’il s’agisse de la solution la plus judicieuse.

Dans un langage de haut niveau comme Rust, ce type de variable aura la durée de vie 'static qui les assimile à des constantes : les chaînes de caractères natives (les &str) sont des références 'static, par exemple.

En assembleur x86/x64, c’est le segment de données utilisé qui va faire la différence. Généralement, à côté du segment .text qui contient le code, on trouve un segment .rodata pour les données qui peuvent être lues mais pas écrites, un segment .data pour les données qui sont initialisées au début du programme mais peuvent changer de valeur en cours de route, et un segment .bss pour les données dont la taille est connue à la compilation, mais dont la valeur ne sera définie qu’à l’exécution (par exemple, un tampon passé à fread()).

Dans le bytecode actuel, la table des constantes d’un segment de code correspond au segment .rodata. Une solution beaucoup plus simple que ce que tu proposes serait d’introduire une table des variables statiques, ayant exactement le même fonctionnement, mais pouvant être écrites, qui correspondrait au segment .data.

À partir de là, le reste de ta proposition consiste à généraliser un fonctionnement de fonction anonyme à toutes les fonctions. Est-il pertinent de généraliser une telle complexité, sachant que les situations dans un code où l’on retourne une fonction anonyme créée pour l’occasion (le seul cas où, a priori, un tel fonctionnement soit indispensable) sont foutrement rares ?

Je n’en ai pas l’impression, et j’aimerais bien lire un argumentaire.

Me vient une dernière remarque, plus générale : un segment de code intègre dans sa définition les données de taille fixe dont il a besoin pour fonctionner. S’il a besoin de données de taille non fixe, il faudra nécessairement passer par de l’allocation dynamique à l’exécution. Dans ces conditions, quel type de segment autre que des segments de code peut-il bien exister ? Je veux dire par là, la section type de l’en-tête de segment pourra-t-elle prendre une autre valeur que CodeSegment, et si oui, laquelle ? Si non, pourquoi la conserver ?

+0 -0

https://fr.m.wikipedia.org/wiki/Fermeture_(informatique)

Ça se voit certes moins dans un langage comme haskell où on cache la mutabilité sous le tapis, mais dans un langage comme OCaml, c'est très répandu comme pattern (l'exemple typique, c'est générer des id entiers uniques).

Après, gérer les fermetures ou pas, c'est un choix à faire. C'est incontestablement plus compliqué, mais ça permet d'écrire des choses rigolotes. Si vous voulez faire une bonne cible pour un langage fonctionnel, c'est probablement un choix à considérer sérieusement.

Une petite question: et si 1 segment de code très exactement = 1 fonction, ni plus, ni moins ?

Quelqu'un proposait plus tôt de nommer les segments. Ca suffirait pour s'y retrouver et que ce soit assez pratique non ?

ET pour permettre les closures on n'aurait juste besoin d'indiquer la fonction parente d'une façon ou d'une autre. Le segment qui a NULL comme parent est la fonction de base à exécuter d'office au démarrage/chargement du module.

Qu'Est-ce que vous en pensez ?

+1 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte