Créer son langage avec la DLR

   Auteur : Lionel Laské (llaske@c2s.fr)

Introduction
Présentation de la DLR
MyJScript encore
Contexte du langage
Créer un arbre syntaxique via la DLR
Générer l'arbre syntaxique depuis la grammaire
Génération des variables
Utilisation des variables
Type de données
Règles d'exécution de l'arbre syntaxique
La programmation objet en MyJScript
Manipulation des propriétés des objets
Construction et appel des méthondes
Contexte global et fonctions built-in
Interopérabilité avec la CLR
Interpréteur et ligne de commande
Interopérabilité avec d'autres langages de la DLR
Pour aller plus loin
Conclusion

Introduction

Dans une application, on a parfois besoin de mettre à la disposition de l'utilisateur un langage de paramétrage: par exemple pour offrir la possibilité de paramétrer les règles d'un moteur de règles  ou de modifier le détail des opérations à réaliser pour le calcul d'un champ. Pour cela le développeur doit  construire son interpréteur ou son compilateur et l'interfacer avec son code .NET. Il doit alors non seulement maîtriser les arcanes de l'analyse lexicale, de l'analyse syntaxique et de la compilation, mais aussi se plonger dans la génération de code pour exécuter les instructions de son nouveau langage.

La Dynamic Language Runtime (DLR) est une surcouche du .NET Framework 3.5 permettant de faciliter la création de langages dynamiques pour .NET. Cela peut être, comme décrit ci-dessus, un langage intégré à une application ou plus largement l'implémentation d'un nouveau langage pour .NET à l'image du IronPython ou du IronRuby que propose Microsoft.

Dans un précédent article ("La DLR: une autre CLR ?"), je présentais les objectifs et les principales fonctionnalités de la DLR. En résumé, le rôle de la DLR est de:

  • Faciliter l'implémentation d'un langage dynamique,
  • Permettre l'interopérabilité d'un langage dynamique avec la CLR,
  • Permettre l'interopérabilité entre les langages dynamiques de la DLR.

Ce précédent article utilisait une implémentation "maison" de la DLR, l'objectif de ce nouvel article est de détailler le mode de fonctionnement réel de la DLR en suivant pas à pas l'implémentation d'un nouveau langage pour la DLR.

Présentation de la DLR

La DLR se présente comme une DLL nommée "Microsoft.Scripting.dll". Actuellement, elle est distribuée  avec SilverLight et, en code source, avec les différents langages dynamiques proposés par Microsoft, notamment IronPython ou IronRuby. La DLR est distribuée sous licence Open Source de type Microsoft Public Licence.

La DLR définie le namespace "Microsoft.Scripting" et plusieurs sous-namespace dont les principaux que nous utiliserons ici :

  • Microsoft.Scripting.Ast pour les arbres syntaxique,
  • Microsoft.Scripting.Hosting pour l'hébergement de la DLR,
  • Microsoft.Script.Shell pour la construction d'un interpréteur ligne de commande.  

La DLR s'est construite (et continue à se construire) au fur et à mesure de l'avancement des implémentations des langages dynamiques de Microsoft. Le code source actuel montre d'ailleurs que l'organisation n'est pas complètement stabilisée à ce jour. La version décrite ici est la v1.0.0.1000 fournie avec IronPython 2.0 beta 1, le code décrit dans cet article sera néanmoins mis à jour lorsque la version définitive sera distribuée.

MyJScript encore

Pour illustrer cet article, nous allons encore une fois utiliser un langage dérivé du JavaScript, MyJScript. Le JavaScript est en effet assez intéressant car il est suffisamment éloigné du C# et VB.NET que nous connaissons et n'est pas trop complexe à implémenter si l'on ce limite à ses fonctionnalités importantes.  Les caractéristiques principales du MyJScript sont:

  • Syntaxe basée sur JavaScript: point-virgules, accolades,  mot-clés "var" et "function",
  • Gestion des types de base: entiers et chaînes de caractères sont supportés. Les deux représentation des chaînes sont autorisées (quotes et double-quotes) . Les méthodes et propriétés de base des chaînes sont supportés: length, substr, blink, ...
  • Déclaration des variables facultatives: les variables sont déclarées en utilisant le mot clé "var", une variable non déclarée est considérée globale,
  • Mélange d'instructions et de déclarations: le compilateur exécute les instructions dans l'ordre où il les rencontre. Lorsqu'on rencontre une déclaration de fonction, on la "stocke" en attendant son appel. A noter que les fonctions doivent être déclarées avant leur utilisation,
  • Gestions des objets: on peut créer des objets et appeler un constructeur avec le mot clé "new", on peut affecter ses membres (propriétés ou méthodes), on peut manipuler l'objet courant via le mot clé "this".  

Voilà déjà un objectif assez ambitieux pour étudier la DLR. A noter que l'implémentation proposée a été réalisée à titre d'exemple et ne se base par sur l'implémentation proposée par Microsoft du futur "Managed JScript".  

Pour illustrer mon propos, voici deux exemples simples d'utilisation de MyJScript:

    function fact(n) {
        if (n==0) return 1;
            return n*fact(n-1);
    }

    write("!4 = " + fact(4));

Dans ce premier exemple, on déclare une fonction factorielle et on l'appelle de manière récursive, on notera la conversion automatique d'entier en chaîne de caractères qui est réalisée pour permettre d'afficher un libellé puis sa valeur. Une telle conversion n'est pas autorisée en C#.

    function Cat(name) {
        this.name = name;
        this.miaow = function () { write(this.name+' said Miaow'); };
    }
    
    a = new Cat('Felix');
    a.miaow();
    Cat("this");
    this.miaow();

Dans ce deuxième exemple, on manipule les concepts objets de MyJScript. On créé un constructeur pour une classe "Cat" et on créé une instance de cet objet. Nous illustrons également l'utilisation de "this": soit en tant qu'instance actuelle dans la méthode "Cat", soit en tant que contexte global. A noter la double représentation des chaînes de caractères (simple quote ou double-quote).

Contexte du langage

Pour créer son langage pour la DLR, la première chose à réaliser est de décrire son "contexte". Ce contexte permet de définir les propriétés du langage (nom, identifiant, version, …) et de définir les points d'entrée utilisés par la DLR. Voici l'interface du contexte pour MyJScript, il doit s'agir d'une classe qui dérive de Microsoft.Scripting.LanguageContext:

    class MJSLanguageContext : LanguageContext
    {
        // Constructeur: initialise le "binder"
        public MJSLanguageContext(ScriptDomainManager);
        
        // Description du langage
        public override Guid LanguageGuid;
        public override string DisplayName;
        public override Version LanguageVersion;
        
        // Point d'entrée du parseur
        public override LambdaExpression ParseSourceCode(CompilerContext);
        
        // Recherche d'un symbole en global
        public override bool TryLookupGlobal(CodeContext, SymbolId, out object );
        
        // Personnalisation de la ligne de commande
        public override ServiceType GetService<ServiceType>(params object[]);
        public override string FormatException(Exception exception);
    }

Le constructeur est l'une des méthodes essentielles, il permet d'effectuer les initialisations globales du contexte. Dans notre cas nous le verrons, cela concerne l'affectation des fonctions initiales du contexte MyJScript ("write" notamment) et surtout l'affectation du "binder" que nous détaillerons plus tard et qui est le moteur de règle du langage.

L'autre méthode importante du contexte est la méthode ParseSourceCode. Voici son implémentation pour MyJScript.

        public override LambdaExpression ParseSourceCode(CompilerContext context)
        {
            // Call MyJScript parser
            Parser parser = new Parser(context.SourceUnit);
            parser.Parse();

            // Return the generated AST
            return parser.Result;
        }

C'est cette méthode qui est le réel point d'entrée pour interpréter du code de notre langage MyJScript. La méthode ParseSourceCode prend en paramètre un contexte qui contient le code à exécuter, elle doit retourner l'arbre syntaxique correspondant sous la forme d'une "Lambda Expression", qui sera alors exécuté par la DLR.

Créer un arbre syntaxique via la DLR

Un arbre syntaxique est une représentation hiérarchique d'un programme de notre langage. Chaque nœud de l'arbre représente un élément: instruction, opérateur ou valeur. Par exemple l'instruction:

    res = n * (n-1);

Peut se présenter comme ceci:

Le namespace "Microsoft.Scripting.Ast" propose des objets pour tous les différents éléments manipulables depuis un langage reposant sur la CLS (Common Language Specification). Ainsi pour construire l'instruction ci-dessus, on écrit le code C# suivant:

    Ast.Statement(span,
        Ast.Assign(
            res,
            Ast.Action.Operator(
                Operators.Multiply,
                typeof(object),
                Ast.Read(n),
                Ast.Action.Operator(
                    Operators.Subtract,
                    typeof(object),
                    Ast.Read(n),
                    Ast.Constant(1)
                )
            )
        )
    );

C'est évidement assez "verbeux" et pour ceux qui connaissent l'API CodeDOM, vous retrouverez pas mal de similitudes. Dans les deux cas il s'agit de représenter un programme mais les AST de la DLR se traduisent en code IL alors que l'API CodeDOM se traduit en du code source C# ou VB.NET.

Générer l'arbre syntaxique depuis la grammaire

Générer un arbre syntaxique à partir du texte d'un programme langage nécessite plusieurs étapes:

  • Analyse lexicale: c'est à dire reconnaître les mots dans le code source à compiler (par exemple pour le MyJScript: "var", "function", "if" ou "+"),
  • Analyse syntaxique: c'est à dire vérifier que les mots sont bien dans le bon ordre,
  • Construction de l'arbre syntaxique: construire en mémoire l'arbre lors de la reconnaissance de la grammaire.

J'ai détaillé les deux premières étapes dans un article précédent ("Ecrire un compilateur pour .NET"), nous ne rentrerons donc pas à nouveau dans le détail ici. L'implémentation de MyJScript sur la DLR ne modifie pas le code de ces étapes qui est généré par Jay, un dérivé de YACC. Voilà l'interface de notre parser:

    class Parser
    {
        public Parser (SourceUnit source)
        {
            ...
        }
        
        public bool Parse()
        {
            ...
        }
        
        public LambdaExpression Result
        {
            get { return generator.Result; }
        }
    }

L'objet parser s'initialise avec un code source représenté par un SourceUnit. Il s'agit d'un objet construit par la DLR qui représente des lignes de code du langage en provenance d'une ligne de commande ou un fichier. Il est d'ailleurs envisageable d'avoir un traitement différent selon le cas. Le parsing s'exécute par l'appel de la méthode Parse. La construction de l'arbre syntaxique s'effectue au fur et à mesure du parcours de la grammaire exécutée par la méthode Parse. Pour plus de clarté, nous l'avons isolé dans une classe de génération.

Prenons un exemple: la règle de construction du IF dans la grammaire du MyJScript. Voilà comment elle s'exprime en Jay/YACC:

    if_else: IF LPAR cond_expr RPAR block_or_statement ELSE block_or_statement 
            { $$ = generator.IfElse($3 as Expression, $5 as Expression, $7 as Expression); }
        | IF LPAR cond_expr RPAR block_or_statement 
            { $$ = generator.IfElse($3 as Expression, $5 as Expression, null); }

Voici le code de la méthode de génération du IF dans le générateur MyJScript pour la DLR:

        public Expression IfElse(Expression condition, Expression ifTrue, Expression ifFalse)
        {
            if (ifFalse == null)
                return Ast.IfThen(
                    Ast.Convert(condition, typeof(bool)),
                    ifTrue
                );

            return Ast.IfThenElse(
                Ast.Convert(condition, typeof(bool)),
                ifTrue,
                ifFalse
            );
        }

Vous remarquez qu'on se contente de construire les nœuds correspondants au IfThen ou au IfThenElse. On retrouve ce même type de construction pour les autres instructions du langage.

Génération des variables

Un zoom particulier doit être réalisé sur la création des variables avec la DLR. La portée des variables est  fortement dépendante du langage de programmation que l'on utilise. D'un langage à un autre, une variable peut être: globale, locale, être liée à une classe, à une instance, à un bloc, ...  Néanmoins la portée se détecte toujours au niveau de la grammaire. Ainsi par exemple en C#, lorsqu'on rencontre l'utilisation d'une variable dans une méthode, ce sont les règles de syntaxe qui permettent de la retrouver. On cherchera ainsi d'abord dans les variables du bloc, puis dans les paramètres, et enfin dans les variables d'instance (si on est dans une méthode d'instance) ou dans les variables de classe (si on est dans une méthode statique).

La DLR permet de déclarer des variables au niveau de l'objet LambdaBuilder. Ce nœud représente une LambdaExpression correspondant soit au code d'une fonction, soit à un bloc interne. La méthode LambdaBuilder.CreateLocalVariable permet de créer une variable pour le bloc actuel. La méthode LambdaBuilder.CreateParameter permet de créer un paramètre.

En MyJScript, une variable peut être déclarée localement à une fonction, être un paramètre ou, par défaut (car la déclaration est optionnelle) globale.  L'exemple de code suivant:

    a = 100;
    
    function foo(n) {
        var b = n + a;
        return b;
    }

Peut donc se représenter de manière simplifiée par la LambdaExpression:

Le code de notre compilateur MyJScript gère lui-même la portée des variables en utilisant des dictionnaires. Un dictionnaire pour les variables globales et un dictionnaire pour les variables locales. Pour les paramètres, ils sont créés directement lors de la déclaration. Pour illustrer ce mode de fonctionnement, voici le code appelé par le compilateur MyJScript lorsqu'il rencontre un début de fonction.

        public void BeginFunction(String name, List<String> arguments)
        {
            if (name != null && globalVariables.ContainsKey(name))
            {
                report.Error(…); // function déjà définie
                return;
            }

            previousMethod = currentMethod;
            currentMethod = Ast.Lambda(name ?? "<member function>", typeof(object));

            localVariables = new Dictionary<string, Variable>();

            currentMethod.CreateParameter(SymbolTable.StringToId("this"), typeof(object));           
            if (arguments.Count > 0)
            {
                foreach (string parameter in arguments)
                    currentMethod.CreateParameter(SymbolTable.StringToId(parameter), typeof(object));
            }
        }

Plusieurs choses sont à remarquer:

  • C'est le dictionnaire globalVariables qui stocke les variables globales. Lorsqu'une nouvelle fonction est déclarée, on s'assure d'abord qu'elle n'existe pas déjà en recherchant son nom dans les variables globales existantes. Et oui: une chose auquel il faut nous habituer avec la DLR: une fonction ce n'est qu'une variable que l'on peut appeler !
  • Le dictionnaire localVariables est initialisé lorsqu'on rencontre la déclaration de la fonction et pas au début de chaque bloc. En effet, en MyJScript (comme en JavaScript), il n'existe pas de déclaration locale à un bloc, on déclare toutes les variables dans le bloc de la fonction. Si nous étions en train d'implémenter un langage plus évolué (comme C#), l'implémentation serait différente et nous devrions gérer des variables locales à chaque bloc de code.
  • On constate que chaque paramètre est créé par un appel de LambdaBuilder.CreateParameter. Nous ajoutons systématiquement un premier paramètre "this" à toute les méthodes, nous y reviendrons plus tard.
  • Enfin, il faut noter que la DLR ne manipule pas directement des noms de variable mais des identifiants dans une table des symboles, d'où l'utilisation de la classe SymbolTable de la DLR et de la méthode SymbolTable.StringToId.

Utilisation des variables

Maintenant que nous avons compris comment nous stockions les variables, il est facile d'écrire le code qui va générer l'arbre pour récupérer la valeur d'une variable. Voici comment cela se passe en MyJScript:

        public Expression Variable(string name)
        {
            Variable variable = null;
            if (localVariables.ContainsKey(name))
                variable = localVariables[name]; // Variable locale

            else 
            {
                if (currentMethod != null)
                {
                    foreach (Variable param in currentMethod.Parameters)
                        if (SymbolTable.IdToString(param.Name).Equals(name))
                            variable = param;

                    if (variable != null)
                        return Ast.Read(variable); // Paramètre
                }

                if (variable == null)
                {
                    if (!globalVariables.ContainsKey(name))
                        return Ast.Read(SymbolTable.StringToId(name));  // Variable inconnue
                    variable = globalVariables[name]; // Variable globale
                }
            }

            return Ast.Read(variable);  // Variable connue
        }

Nous recherchons d'abord dans le dictionnaire des variables locales, puis dans les paramètres et enfin dans les variables globales. Dans ce dernier cas:

  • Si nous trouvons la variable, nous retournons un nœud Ast.Read référençant un objet Variable. On parle alors d'une BoundExpression car la variable est liée à l'expression de lecture et la DLR connait directement l'emplacement de la valeur de la variable.
  • Si nous ne trouvons pas la variable, nous retournons un nœud Ast.Read référençant un identifiant (le nom de la variable dans la table des symboles). On parle alors d'une UnboundExpression car la DLR devra s'assurer lors de l'exécution du code que cette variable existe et récupérer par elle-même sa valeur. Ce sera le cas notamment lorsque la variable est déclarée dans un autre langage de la DLR (voir plus loin)

Type de données

La représentation des données dans la DLR est l'un des choix de conception les plus important qui a guidé son implémentation. C'est ce que nous explique Jim Hugunin sur son blog. Le problème est simple: généralement chaque langage de programmation a sa propre représentation des types de données. Ainsi, les chaînes de caractères sont représentés par des String dans la CLR et peuvent être représentés par des PyString en Python ou par des MJSString en MyJScript (comme dans mon précédent article).

A chaque fois, ces classes implémentent des méthodes et des propriétés particulières sur les types de base, ce qui fait la richesse de chaque langage. Or, comment peut-on imaginer faire intéropérer les types de la CLR ou les types de différents langages de la DLR s'ils ne partagent pas les mêmes types de base ?

La DLR propose donc de n'utiliser que des types de base. Une String doit être représentée par un objet String de la CLR quelque soit le langage dans lequel elle est manipulée. Pour cela, la DLR repose sur les méthodes d'extension ajoutées au .NET 3.5. Pour MyJScript, nous voulons ajouter au String les méthodes blink, bold et substr du JavaScript. Voilà donc le code à écrire:

    [assembly: ExtensionType(typeof(string), typeof(MyJScript.Runtime.StringExtensions))]
    public static class StringExtensions
    {
        public static string blink(string @this)
        {
            StringBuilder res = new StringBuilder("<blink>");
            res.Append(@this);
            res.Append("</blink>");
            return res.ToString();
        }

        public static string bold(string @this)
        {
            StringBuilder res = new StringBuilder("<bold>");
            res.Append(@this);
            res.Append("</bold>");
            return res.ToString();
        }

        public static string substr(string @this, int index)
        {
            return @this.Substring(index);
        }

        public static string substr(string @this, int index, int length)
        {
            return @this.Substring(index, length);
        }
    }

Avec ces quelques lignes nous ajoutons quatre méthodes d'extension à la classe String, les String peuvent donc désormais se manipuler comme en JavaScript. A noter que les méthodes d'extensions, comme leur nom l'indique, ne permettent que de rajouter des méthodes et pas de propriétés. Pour ajouter des propriétés, il faudra passer par des règles comme nous le verrons plus loin.

Règles d'exécution de l'arbre syntaxique

Résumons ce que nous avons appris jusqu'à présent. Pour exécuter une instruction du MyJScript, par exemple, "1 + 1", il nous suffit de retourner dans le point d'entrée ParseSourceCode de notre contexte MJSLanguageContext, un arbre syntaxique correspondant à l'expression. Ici, nous allons simplement retourner:

    Ast.Action.Operator(
        Operators.Add,
        typeof(object),
        Ast.Constant(1),
        Ast.Constant(1)
    )

Comment fait-on ensuite pour exécuter ce code ? En fait, il n'y a rien à faire ! Lorsque la DLR reçoit un arbre syntaxique, elle le traduit en code IL et elle l'exécute. Dans notre cas, cela signifie qu'elle appelle l'addition de deux entiers.

Cela fonctionne car la DLR connait de manière standard les règles d'exécution pour de nombreux types de base dont les entiers, nous n'avons donc pas à écrire du code pour l'exécution.

Modifions maintenant notre exemple et essayons d'exécuter " 1 + 'A' ". Ce code est tout à fait valide en MyJScript puisque le MyJScript autorise comme le JavaScript les conversions implicites. Nous le traduisons par:

    Ast.Action.Operator(
        Operators.Add,
        typeof(object),
        Ast.Constant(1),
        Ast.Constant("A")
    )

Et bien sûr, là ça ne fonctionne pas, nous avons une erreur à l'exécution. La DLR ne sait pas ajouter une chaîne de caractères à un entier, elle ne connait pas cette règle, il nous faut donc lui apprendre.

Les règles à ajouter sont à implémenter dans l'objet Binder qui est construit à l'initialisation du contexte du langage. Voici une partie du code de la classe MJSBinder:

    public class MJSBinder : ActionBinder
    {
        public MJSBinder(CodeContext context) : base(context)
        {
        }

        protected override StandardRule<T> MakeRule<T>(CodeContext callerContext, DynamicAction action, object[] args)
        {
            if (operation.Operation == Operators.Add
              && args[0] is int
              && args[1] is string)
            {
                // Règle d'addition entier et chaînes du MyJScript
                return MakeAddStringRule<T>(callerContext, action, args);
            }

            // Laisser la DLR trouver une règle
            return base.MakeRule<T>(callerContext, action, args);
         }

    ...
    }

Le cœur de cet objet est la méthode MakeRule. Elle est appelée par la DLR chaque fois qu'elle se trouve devant une expression du langage qu'elle ne sait pas exécuter. La méthode doit retourner une règle c'est-à-dire une condition décrivant le cas d'utilisation de la règle et le code à exécuter. Dans les deux cas, cela va se faire par l'intermédiaire des AST que nous avons vu précédemment. Voici le code de la méthode MakeAddStringRule appelée ci-dessus:

        private StandardRule<T> MakeAddStringRule<T>(CodeContext callerContext, DynamicAction action, object[] args)
        {
            StandardRule<T> rule = new StandardRule<T>();

            // Equivalent à: (p0 is int) && (p1 is string)
            rule.Test = 
                Ast.AndAlso(
                    Ast.TypeIs(rule.Parameters[0], typeof(int)),
                    Ast.TypeIs(rule.Parameters[1], typeof(string))
                );

            // Equivalent à: string.Contat(p0.ToString(), p1)
            rule.Target =
                rule.MakeReturn(this,
                    Ast.Call(
                        typeof(string).GetMethod("Concat", new Type[] { typeof(string), typeof(string) }),
                        Ast.Call(
                            Ast.Convert(rule.Parameters[0], typeof(object)),
                            typeof(object).GetMethod("ToString", new Type[0])
                        ),
                        Ast.Convert(rule.Parameters[1], typeof(string))
                    );

          return rule;
      }

Notre règle est constituée d'un test et d'une cible. Le test (positionné dans la propriété StandardRule.Test) consiste à vérifier que le premier paramètre est un entier et le second est une chaîne. La cible (positionné dans la propriété StandardRule.Target) est l'arbre à exécuter pour obtenir le résultat de l'expression. Il s'agit de convertir le premier paramètre en chaîne de caractères et de le concaténer avec le deuxième paramètre.

Une fois que cette règle est construite, la DLR dispose désormais de toutes les informations pour exécuter notre expression "1 + 'A'". Mieux: si elle rencontre à nouveau ce cas de figure elle appliquera la même construction sans désormais faire appel à notre méthode MJSBinder.MakeRule.

Evidemment, il faut de la même manière construire toutes les règles du langage ce qui peut être un peu verbeux. Il peut également être assez complexe de construire un arbre lorsque la cible nécessite plusieurs opérations. Rien ne nous empêche alors d'appeler une fonction depuis notre arbre. Prenons par exemple l'expression MyJScript suivante:

    '10' < 2

Si on effectue une conversion du membre droit en chaîne de caractères, on va alors comparer deux chaînes de caractères et le chiffre 1 précédent le chiffre 2 dans l'ordre lexicographique, on obtiendra un résultat faux. Pour obtenir un résultat correct, il faut convertir le membre gauche en entier avant de faire la comparaison. C'est d'ailleurs ce que fait JavaScript. Le traitement nécessite néanmoins d'essayer la conversion et de faire une comparaison différente selon son résultat. C'est un peu "lourd" à exprimer dans un AST, on peut alors passer par une méthode. C'est la méthode "MJSLibrary.Compare" de notre langage qui effectue ce traitement, la règle correspondante est alors beaucoup plus simple:

       private StandardRule<T> MakeCompareStringRule<T>(CodeContext callerContext, DynamicAction action, object[] args)
        {
            StandardRule<T> rule = new StandardRule<T>();

            // Equivalent à: (p0 is string) && (p1 is int)
            rule.Test =
                Ast.AndAlso(
                   Ast.TypeIs(rule.Parameters[0], typeof(string)),
                   Ast.TypeIs(rule.Parameters[1], typeof(int))
                );

            // Equivalent à: MJSLibrary.CompareTo(p0, p1) < 0
            rule.Target =
                rule.MakeReturn(this,
                    Ast.LessThan(
                        Ast.Call(
                           typeof(MJSLibrary).GetMethod("CompareTo"),
                           rule.Parameters[0],
                           rule.Parameters[1]
                        ),
                    Ast.Constant(0)
                    );
 
          return rule;
      }

La programmation objet en MyJScript

Nous avons jusqu'à présent étudié uniquement la manipulation de types de données de base. Mais la DLR peut aussi être utilisée pour manipuler des instances d'objets d'un langage objet. Pour cela intéressons nous d'abord à la programmation objet en JavaScript.

En JavaScript, un objet est simplement un conteneur, plus précisément c'est un dictionnaire de couples: nom, valeur. Une bonne manière de le démontrer est d'observer sa représentation JSON (JavaScript Object Notation). Ainsi, par exemple l'objet "Cat" manipulé précédemment peut être déclaré par:

    function Cat(name) {
        this.name = name;
        this.miaow = function () { alert(this.name+' said Miaow'); };
    }

    x = new Cat('Felix');

ou en utilisant uniquement JSON:

    x = { "name":"Felix", "miaow": function() { alert(this.name+' said Miaow'); } }

Les deux représentations sont identiques et donnent le même résultat. En JavaScript, il n'y a donc pas de déclaration préalable d'une classe, de ses variables d'instances et de ses méthodes d'instance comme dans un langage statique. Un objet JavaScript se constitue au fur et à mesure qu'on affecte ses membres: variables ou méthodes. La syntaxe "new Constructor(…)" du JavaScript n'est en fait qu'un sucre syntaxique qui crée un objet vide puis appelle le constructeur. Ainsi, une troisième manière d'initialiser notre objet est:

    x = {};
    Cat.call(x, 'Felix');

Au passage, nous remarquons qu'une fonction JavaScript peut être manipulée comme une valeur. En fait, une fonction n'est qu'un objet particulier supportant une méthode spécifique: "call" qui prend en paramètre l'objet représentant this et les paramètres du constructeur. C'est d'ailleurs parce qu'une fonction est une valeur que nous pouvons affecter la méthode d'instance "miaow" dans notre classe.

Pour MyJScript nous allons réutiliser ce principe, voici une partie de l'implémentation de la classe MJSObject qui représente une instance d'objet en MyJScript:

    public class MJSObject
    {
        Dictionary<string, object> members;

        public MJSObject()
        {
            this.members = new Dictionary<string, object>();
        }

        public bool HasMember(string name)
        {
            return members.ContainsKey(name);
        }

        public virtual void Set(string name, object value)
        {
            if (members.ContainsKey(name))
                members.Remove(name);

            members.Add(name, value);
        }

        public virtual object Get(string name)
        {
            if (!members.ContainsKey(name))
                return null;

            return members[name];
        }
    }

La classe MyJScript est constituée d'un simple dictionnaire qui va stocker les valeurs de toutes les variables d'instance: propriétés ou méthodes (comme "name" ou "miaow" dans notre exemple).

Manipulation des propriétés des objets

Notre compilateur MyJScript supporte l'initialisation vide d'un objet, l'instruction:

    x = {};

Est ainsi traduite par le parseur MyJScript par l'arbre:

    Ast.Statement(span,
        Ast.Assign(
            x,
            Ast.New(
                typeof(MJSObject).GetConstructor(new Type[0])
            )
        )
   );

C'est-à-dire que nous affectons à la variable "x" le résultat de l'appel du constructeur de MJSObject sans paramètre.

Pour affecter ou récupérer des membres de notre nouvel objet, nous allons apprendre à la DLR les opérations nécessaires. Pour cela, nous ajoutons deux nouvelles règles à notre objet MJSBinder.

        private StandardRule<T> MakeSetMemberRule<T>(CodeContext callerContext, DynamicAction action, object[] args)
        {
            SetMemberAction setmember = (SetMemberAction)action;
            StandardRule<T> rule = new StandardRule<T>();

            // Equivalent à: (p0 is MJSObject)
            rule.Test = Ast.TypeIs(rule.Parameters[0], typeof(MJSObject));

            // Equivalent à: (p0 as MJSObject).Set(name, p1)
            rule.Target =
                rule.MakeReturn(this,
                    Ast.Call(
                        Ast.Convert(rule.Parameters[0], typeof(MJSObject)),
                        typeof(MJSObject).GetMethod("Set", new Type[] { typeof(string), typeof(object) }),
                        Ast.Constant(SymbolTable.IdToString(setmember.Name)),
                        Ast.Convert(rule.Parameters[1], typeof(object))
                    )
                );

            return rule;
        }


        private StandardRule<T> MakeGetMemberObjectRule<T>(CodeContext callerContext, DynamicAction action, object[] args)
        {
            GetMemberAction getmember = (GetMemberAction)action;
            StandardRule<T> rule = new StandardRule<T>();

            // Equivalent à: (p0 is MJSObject)
            rule.Test = Ast.TypeIs(rule.Parameters[0], typeof(MJSObject));

            // Equivalent à: (p0 as MJSObject).Get(name)
            rule.Target =
                rule.MakeReturn(this,
                    Ast.Call(
                        Ast.Convert(rule.Parameters[0], typeof(MJSObject)),
                        typeof(MJSObject).GetMethod("Get", new Type[] { typeof(string) }),
                        Ast.Constant(SymbolTable.IdToString(getmember.Name))
                    )
                );

            return rule;
        }

La première règle est appelée lorsque la méthode MJSBinder.MakeRule rencontre une opération de type "SetMember". La condition d'exécution est que le type de l'objet soit un MJSObject. La règle à exécuter et l'appel de la méthode "Set" de notre objet "MJSObject" en lui passant en paramètre le nom de la propriété et sa valeur. La deuxième règle est appelée pour les opérations de type "GetMember", elle est similaire à la première mais déclenche un appel de la méthode "MJSObject.Get".

Grâce à ses règles, notre compilateur MyJScript sait désormais exécuter les instructions:

    x.name = "Hello";
    write(x.name);

Construction et appel des méthodes

Intéressons nous maintenant à l'utilisation des méthodes. Voici les instructions MyJScript pour déclarer et appeler une méthode membre:

    x.foo = function(n) { write(n); }
    x.foo("Hello");

Nous avons décrit un peu plus haut qu'une fonction était transformée par notre parseur en objet LambdaExpression. Lorsque la DLR génère le code correspondant à un AST, elle transforme les objets LambdaExpression en Delegate. Un bloc peut ainsi être appelé très simplement d'autant que la DLR fourni un nœud spécifique pour gérer un appel. Nous avons donc peu de chose à écrire pour exécuter l'appel d'une fonction d'instance. Voici l'arbre qui est construit:

        public Expression MethodCall(Expression instance, String function, List<Expression> values)
        {
            int length = values.Count;
            Expression[] array = new Expression[length+1];

            array[0] = instance;
            for (int i = 0; i < length; i++)
                array[i+1] = values[i];

            return Ast.Action.InvokeMember(
                SymbolTable.StringToId(function),
                typeof(object),
                InvokeMemberActionFlags.None,
                new CallSignature(values.Count),
                array
                );
        }

En fait, il s'agit simplement de construire un tableau des paramètres en passant l'instance comme premier paramètre puis de lancer l'appel de la fonction.

Cela est suffisant pour des appels de méthodes habituels mais en MyJScript, nous ajoutons systématiquement l'instance en paramètre de manière à ce qu'il soit récupéré par le paramètre "this", nous devons donc ajouter une nouvelle règle à notre classe MJSBinder. Cette règle sera utilisée pour les opérations "InvokeMember" sur les MJSObject:

        private StandardRule<T> MakeInvokeMemberRule<T>(CodeContext callerContext, DynamicAction action, object[] args)
        {
            StandardRule<T> rule = new StandardRule<T>();
            InvokeMemberAction invokeMember = (InvokeMemberAction)action;

            // Equivalent à: (p0 is MJSObject)
            rule.Test = Ast.TypeIs(rule.Parameters[0], typeof(MJSObject));

            Expression method = Ast.Action.GetMember(
                invokeMember.Name,
                typeof(object),
                rule.Parameters[0]
             );

            Expression[] newparam = new Expression[rule.Parameters.Length + 1];
            newparam[0] = method;
            for (int i = 0; i < rule.Parameters.Length; i++)
                newparam[i + 1] = rule.Parameters[i];

            // Equivalent à: p0.name(p0, p1, … pn)
            rule.Target = 
                rule.MakeReturn(this,
                    Ast.Action.Call(
                        typeof(object),
                        newparam
                    )
                );

            return rule;
        }

Après avoir testé qu'elle était appelée sur un MJSObject, cette règle effectue trois opérations: récupérer la valeur du membre, ajouter aux paramètres l'objet courant (qui représente le paramètre "this") et appeler la méthode avec les nouveaux paramètres.

Contexte global et fonctions built-in

Pour compléter le langage MyJScript, il manque encore un point: le contexte global. En effet, en JavaScript tout ce qui est défini de manière globale est en fait défini comme membre d'un objet global. Ainsi, une variable ou une fonction déclarée globale est en fait attachée à cet objet global. En voici un premier exemple à lancer en dehors de toute fonction:

    x = 'Hello';
    alert(this.x);     // Affiche Hello

Encore plus fort, transformons notre objet global en un objet "Cat" (à éviter dans la réalité). Pour cela, il nous suffit d'appeler le constructeur "Cat()" qui n'est rien d'autre qu'une variable d'instance de l'objet global:

    this.Cat('Felix');
    alert(name);      // Affiche Felix
    miaow();          // Affiche Felix said Miaow

Pour représenter ce contexte global en MyJScript, il nous suffit d'utiliser une classe qui stocke un singleton. Voilà ce que cela donne:

        public class MJSContext
        {
            static MJSObject @this = null;
    
            public static MJSObject GetContext()
            {
                if (@this == null)
                    @this = new MJSObject();
                return @this;
            }
        }

Les manipulations des variables globales et des fonctions doivent ensuite se faire dans ce contexte. Ainsi, lorsque nous assignons la valeur d'une variable ou lorsque nous créons une fonction, nous générons deux instructions dans l'arbre AST:

        public Expression AssignGlobalVariable(Variable variable, Expression value)
        {
            List<Expression> statements = new List<Expression>();

            // variable = value
            statements.Add(
                    Ast.Assign(
                        variable,
                        Ast.Convert(value,  typeof(object))
                    )
                );

            // MJSContext.GetContext().Set(variable, value)
            statements.Add(
                    Ast.Action.SetMember(
                        SymbolTable.StringToId(variable.Name),
                        typeof(object),
                        Ast.Call( typeof(MJSContext).GetMethod("GetContext", new Type[0]))
                    ),
                    Ast.Read(variable)
                );

            return Ast.Block(statements);
        }

Ceci permet à la fois d'assigner la variable pour la DLR mais aussi d'assigner la variable dans notre contexte global (MJSContext.GetContext()). Nous utilisons également le contexte global pour stocker les fonctions "built-in" de notre langage, ici la fonction "write":

    public class MJSLibrary
    {
        internal static void Initialize()
        {
            MJSObject mjscontext = MJSContext.GetContext();
            mjscontext.Set(
                "write",
                ReflectionUtils.CreateDelegate(
                    typeof(MJSLibrary).GetMethod("Write"),
                    typeof(MyJScriptCallTarget)
                )
            );
           ...
        }

        public static object Write(object @this, object value)
        {
            Console.WriteLine(value != null ? value.ToString() : "<null>");
            return null;
        }
    }

Revenons maintenant sur la méthode MJSContext.TryLookupGlobal du contexte du langage que nous évoquions dans les premiers paragraphes de l'article. Cette méthode est utilisée par la DLR pour résoudre les noms des variables qu'elle rencontre et qu'elle ne connait pas. Dans notre cas, il suffit de rechercher leur valeur dans le contexte global. Voici donc l'implémentation de la méthode:

        public override bool TryLookupGlobal(CodeContext context, SymbolId name, out object value)
        {
            MJSObject mjscontext = MJSContext.GetContext();
            string memberName = SymbolTable.IdToString(name);
            if (mjscontext.HasMember(memberName))
            {
                value = mjscontext.Get(memberName);
                return true;
            }

            return base.TryLookupGlobal(context, name, out value);
        }

Interopérabilité avec la CLR

Nous avons déjà vu comment les types de base comme "string" ou "int" servaient également de type de base pour les langages de la DLR. Grâce aux règles de base de la DLR, on peut ainsi utiliser de manière transparente leurs valeurs, mais aussi leurs propriétés et méthodes. En voici quelques exemples en MyJScript:

        a='Hello';
        write(a.Length);
        write(a.ToUpper() + ' WORLD!');

On notera que les appels des propriétés et des méthodes ne déclenchent pas les règles spécifiques des objets du MyJScript puisque celles-ci sont uniquement configurées pour les objets MJSObject. Par contre, la concaténation entre le résultat de "ToUpper()" et d'une constante est bien une règle que nous avons ajouté spécifiquement en MyJScript.

La DLR autorise également l'import d'objets d'autres assembly de la CLR. Pour que cela soit possible en MyJScript, nous avons emprunté la syntaxe "using" du C# car il n'y a pas d'équivalent en JavaScript. Voilà l'exemple de code que nous pouvons exécuter en MyJScript:

        using System;

        var date = System.DateTime;
        var now=date.Now;
        write(now.Year);
        write(now.ToString());

Nous ne détaillerons pas l'implémentation du "using". Elle consiste simplement à déclencher à l'exécution l'appel d'une méthode spécifique de la DLR appelée "LanguageContext.DomainManager.Globals.TryGetName(name)". Cette méthode se charge de créer dans le contexte actuel une variable pour le namespace fourni en y intégrant des membres pour les différentes classes et objets. Tous les appels de propriétés et méthodes sont alors à nouveau réalisés de manière transparente par les règles de la DLR.

Interpréteur et ligne de commande

Une fois le compilateur de notre langage réalisé, nous aimerions écrire un interpréteur en ligne de commande pour exécuter des commandes et des scripts. La DLR fournit en standard les moyens de réaliser très simplement une console. Voici le code nécessaire, qui nécessite simplement de dériver de la classe Microsoft.Script.Hosting.ConsoleHost:

    public class MJSConsole : ConsoleHost
    {
        protected override void Initialize()
        {
            base.Initialize();
            this.Options.ScriptEngine = ScriptEnvironment.GetEnvironment().GetEngine(typeof(MJSLanguageContext));
            Environment.LoadAssembly(typeof(string).Assembly)
        }

        [STAThread]
        static int Main(string[] args)
        {
            return new MJSConsole().Run(args);
        }
    }

Pour que cela soit moins "brut", on y ajoute un peu de personnalisation sur le logo et le prompt en ajoutant une classe MJSCommandLine qui est référencée par notre MJSLanguageContext.

    class MJSCommandLine : CommandLine
    {
        protected override string Logo
        {
            get
            {
                return "MyJScript Command line\r\nLGPL Copyright (c) Lionel Laské 2008\r\nType CTRL-Z and RETURN to quit\r\n\r\n";
            }
        }

        protected override string Prompt
        {
            get
            {
                return "mjs> ";
            }
        }
    }

Avec ce peu de ligne de code nous disposons déjà:

  • De la possibilité d'exécuter des instructions saisies sur la ligne de commande,
  • De la possibilité d'exécuter des fichiers de scripts,
  • Du nombre considérable d'options en standard (voir ci-dessous).

Interopérabilité avec d'autres langages de la DLR

Nous avons vu comment le MyJScript pouvait intéropérer avec des types de la CLR. Etudions maintenant comment peut se faire l'interopérabilité avec d'autres langages reposant sur la DLR et en particulier le plus célèbre d'entre eux, IronPython.

Le paragraphe précédent montrait l'utilisation de la console standard qui se charge elle-même du hosting de la DLR. Il est néanmoins possible de gérer dans son application le hosting d'un langage de la DLR. Cela se réalise en tout d'abord en créant un runtime d'exécution pour la DLR.

    // Créer un runtime pour la DLR
    ScriptRuntime runtime = ScriptRuntime.Create();

    // Créer le niveau global
    ScriptScope globals = runtime.CreateScope();

Il faut ensuite charger le moteur du langage à partir du type de son contexte. Ce qui renvoi un objet Microsoft.Script.Hosting.ScriptEngine :

    // Charge le moteur MyJScript
    ScriptEngine myjscript = runtime.GetEngine(typeof(MyJScript.DLR.MJSLanguageContext));

    // Exécuter une commande MyJScript
    ScriptSource mjssrc = myjscript.CreateScriptSourceFromString("write('Hello world!');");
    mjssrc.Execute(globals);

Le code précédent charge le moteur MyJScript puis lance l'exécution d'une instruction. Chargeons de la même manière le moteur IronPython:

    // Charge le moteur IronPython
    ScriptEngine python = runtime.GetEngine(typeof(IronPython.Runtime.PythonContext));

    // Exécuter une commande IronPython
    SriptSource pysrc = python.CreateScriptSourceFromString("print 'Hello world!';", SourceCodeKind.Statements);
    pysrc.Execute(globals);

Ecrivons un fonction utilitaire RunProgram pour faciliter l'écriture des exemples suivants.

    static private void RunProgram(ScriptEngine engine, string command)
    {
        ScriptSource src = engine.CreateScriptSourceFromString(command, SourceCodeKind.Statements);
        src.Execute(globals);
    }

Nous pouvons maintenant mélanger les appels des deux compilateurs IronPython et MyJScript:

    // Appelle une variable MyJScript depuis IronPython
    RunProgram(myjscript, "a='MyJScript';");
    RunProgram(python, "print 'Hello',a;");
    
    // Appelle une variable IronPython depuis MyJScript
    RunProgram(python, "b='IronPython';");
    RunProgram(myjscript, "write('Hello '+b);");

Ici, nous initialisons à chaque fois une variable dans un des langages puis manipulons sa valeur dans l'autre langage. A chaque exécution, ce sont les règles spécifiques de chaque langage qui sont appliquées.

Peut-on allons plus loin, par exemple en créant des objets MyJScript et en les manipulant dans IronPython ou l'inverse ? Pas exactement. En effet, comme nous l'avons vu plus haut, la manipulation des objets nécessite des règles spécifiques qui ne sont utilisées que dans le contexte d'exécution du langage. Autrement dit, en IronPython, le moteur ne sait pas manipuler que des MJSObject et inversement, MyJScript ne sait pas manipuler des PyObject. Il y a donc un peu plus à creuser sur le mode de fonctionnement multi-langages pour arriver à échanger des objets.

Pour aller plus loin

Sur la DLR

Peu de documentation est publiée sur la DLR aujourd'hui. Le blog de Jim Hugunin's est la bible de la DLR mais hélas, il est mis à jour très rarement. En fait il n'a pas évolué depuis mon précédent article sur la DLR il y a plusieurs mois. Sa lecture est néanmoins intéressante à la lumière de ce que nous venons de voir pour mieux comprendre la conception de la DLR.

Le blog de John Lam est également une source précieuse d'information sur la DLR. John travaille sur IronRuby. Son blog est mis à jour très régulièrement même s'il ne traite pas exclusivement de la DLR.

La seule source d'information est sinon l'étude du code source de la DLR lui-même. Il est hélas très peu commenté et la seule documentation fournie est une compilation des commentaires ! Il peut être par contre instructif d'étudier l'implémentation des langages utilisant la DLR: le langage Toyscript est livré avec le code source de la DLR, c'est une sorte de Basic simplifié réalisé comme exemple. Le blog de Martin Maly donne de nombreuses explications de son fonctionnement mais hélas, sur une ancienne version de la DLR. L'étude de IronPython ou IronRuby est également intéressante mais beaucoup plus complexe vu leur taille actuelle.

Plusieurs webcast permettent également de se plonger dans la DLR, on notera en particulier, si vous avez accès aux vidéos du TechEd 2007, la session WEB404. Plus récemment, le symposium Lang.Net propose également des vidéos indispensables pour tous ceux qui s'intéressent à la compilation.

Sur MyJScript

L'ensemble du code source de MyJScript est téléchargeable sur CodePlex (http://www.codeplex.com/MyJScript).   Le code est largement commenté et intègre des tests unitaires. MyJScript est un JavaScript suffisamment complexe pour aborder des problèmes de l'implémentation d'un langage objet mais son implémentation est simplifiée pour se concentrer sur l'utilisation de la DLR. Il ne peut donc être réellement utilisé comme un "vrai" compilateur JavaScript.

Deux simplifications réalisées en MyJScript par rapport à JavaScript sont à noter:

  • En MyJScript, la déclaration d'une fonction doit précéder son utilisation. Cela est lié au fait que l'on génère directement un AST au moment du parsing. Pour éviter ce problème, la plupart des langages génèrent un arbre intermédiaire plutôt que de générer directement un AST de la DLR.
  • En MyJScript, il n'y a pas d'héritage entre objets comme cela est possible en JavaScript via la fonction prototype (voir l'excellent article de Ray Djajadinata publié dans le MSDN de mai 2007 pour plus de détail). Pour réaliser cela il faudrait gérer l'héritage en ajoutant le membre prototype dans MJSObject et en modifiant en conséquence les méthodes d'accès aux membres.

Conclusion

Cet article a abordé les trois points les plus importants de la DLR:

  • Les AST: qui permettent de construire facilement la représentation d'un ensemble d'instructions qui vont ensuite être générées en code IL.
  • Les règles: qui permettent de manière non intrusive d'apprendre à la DLR les spécificités (conversions, fonctions membres, types étendus, ...) du langage que l'on veut implémenter.
  • Le hosting: qui permet soit de générer une console d'exécution de son langage, soit d'intégrer un langage reposant sur la DLR dans son application.

Grâce à ces trois concepts, la DLR permet facilement d'implémenter son propre langage en disposant d'une interopérabilité native avec la CLR et les autres langages de la DLR. C'est incontestablement la grande force de la DLR.

Lionel Laské est architecte et Directeur Innovation à C2S. C2S est la société de services informatique du groupe Bouygues. C2S est spécialisée dans les technologie .NET et est Microsoft Gold Certified Partner. C2S est également à l'origine de NSCAPE, une offre de migration des applications NS-DK/NatStar vers .NET.