Dans cette partie de la création de votre propre langage de programmation, nous allons implémenter des classes en tant qu'extension sur les structures précédemment définies. Veuillez consulter les parties précédentes : Construire votre propre langage de programmation à partir de zéro Construire votre propre langage de programmation à partir de zéro : Partie II - Algorithme à deux piles de Dijkstra Construisez votre propre langage de programmation Partie 3 : Améliorer l'analyse lexicale avec les anticipations de regex Construire votre propre langage de programmation à partir de zéro Partie IV : Implémentation de fonctions Construire votre propre langage de programmation à partir de zéro : Partie V - Tableaux Construire votre propre langage de programmation à partir de zéro : Partie VI - Boucles Le code source complet est disponible sur GitHub . 1. Analyse lexicale Dans la première section, nous aborderons l'analyse lexicale. En bref, c'est un processus pour diviser le code source en lexèmes de langage, tels que mot-clé, variable, opérateur, etc. Vous vous souvenez peut-être des parties précédentes que j'utilisais les expressions regex dans l'énumération pour définir tous les types de lexème. TokenType Examinons le prototype de classe suivant et ajoutons les parties de regex manquantes à nos lexèmes : TokenType Tout d'abord, nous devons mettre le mot de dans l'expression du lexème de mot- pour que l'analyseur lexical sache où commence notre déclaration de classe : class Keyword package org.example.toylanguage.token; ... public enum TokenType { ... Keyword("(if|elif|else|end|print|input|fun|return|loop|in|by|break|next|class)(?=\\s|$)"), ... private final String regex; } Deuxièmement, nous avons besoin du nouveau lexème comme marqueur d'une référence à l'objet courant : This public enum TokenType { ... This("(this)(?=,|\\s|$)"); private final String regex; } 2. Analyse syntaxique Dans la deuxième section, nous transformerons les lexèmes reçus de l'analyseur lexical en énoncés finaux selon nos règles linguistiques. 2.1 Portée de la définition Lorsque nous déclarons une classe ou une fonction, cette déclaration doit être disponible dans les limites isolées définies. Par exemple, si nous déclarons une fonction nommée dans le listing suivant, elle sera disponible pour exécution après déclaration : turn_on [] Mais si nous déclarons la même fonction à l'intérieur d'une portée de classe, cette fonction ne sera plus accessible directement depuis le bloc principal : Pour implémenter ces limites de définition, nous allons créer la classe et stocker toutes les définitions déclarées dans deux ensembles pour les classes et les fonctions : DefinitionScope package org.example.toylanguage.context.definition; public class DefinitionScope { private final Set<ClassDefinition> classes; private final Set<FunctionDefinition> functions; public DefinitionScope() { this.classes = new HashSet<>(); this.functions = new HashSet<>(); } } De plus, nous pouvons vouloir accéder à la portée de définition du parent. Par exemple, si nous déclarons deux classes distinctes et créons une instance de la première classe à l'intérieur de la seconde : Pour fournir cette capacité, nous ajouterons l'instance parent comme référence à la couche supérieure, que nous utiliserons pour monter à la couche de définition supérieure. DefinitionScope public class DefinitionScope { private final Set<ClassDefinition> classes; private final Set<FunctionDefinition> functions; private final DefinitionScope parent; public DefinitionScope(DefinitionScope parent) { this.classes = new HashSet<>(); this.functions = new HashSet<>(); this.parent = parent; } } Maintenant, terminons l'implémentation en fournissant les interfaces pour ajouter une définition et la récupérer par nom à l'aide de la portée parent : public class DefinitionScope { … public ClassDefinition getClass(String name) { Optional<ClassDefinition> classDefinition = classes.stream() .filter(t -> t.getName().equals(name)) .findAny(); if (classDefinition.isPresent()) return classDefinition.get(); else if (parent != null) return parent.getClass(name); else throw new ExecutionException(String.format("Class is not defined: %s", name)); } public void addClass(ClassDefinition classDefinition) { classes.add(classDefinition); } public FunctionDefinition getFunction(String name) { Optional<FunctionDefinition> functionDefinition = functions.stream() .filter(t -> t.getName().equals(name)) .findAny(); if (functionDefinition.isPresent()) return functionDefinition.get(); else if (parent != null) return parent.getFunction(name); else throw new ExecutionException(String.format("Function is not defined: %s", name)); } public void addFunction(FunctionDefinition functionDefinition) { functions.add(functionDefinition); } } Enfin, pour gérer les étendues de définition déclarées et basculer entre elles, nous créons la classe de contexte à l'aide de la collection (LIFO) : java.util.Stack package org.example.toylanguage.context.definition; public class DefinitionContext { private final static Stack<DefinitionScope> scopes = new Stack<>(); public static DefinitionScope getScope() { return scopes.peek(); } public static DefinitionScope newScope() { return new DefinitionScope(scopes.isEmpty() ? null : scopes.peek()); } public static void pushScope(DefinitionScope scope) { scopes.push(scope); } public static void endScope() { scopes.pop(); } } 2.2 Étendue de la mémoire Dans cette section, nous couvrirons le pour gérer les variables de classe et de fonction. MemoryScope Chaque variable déclarée, de la même manière que la définition de classe ou de fonction, ne doit être accessible que dans un bloc de code isolé. Par exemple, si nous définissons une variable dans le listing suivant, vous pouvez y accéder juste après la déclaration : Mais si nous déclarons une variable dans une fonction ou une classe, la variable ne sera plus disponible depuis le bloc de code principal (supérieur) : Pour implémenter cette logique et stocker des variables définies dans une portée spécifique, nous créons la classe qui contiendra une carte avec le nom de la variable comme clé et la variable comme valeur : MemoryScope Value public class MemoryScope { private final Map<String, Value<?>> variables; public MemoryScope() { this.variables = new HashMap<>(); } } Ensuite, comme pour , nous donnons accès aux variables de portée du parent : DefinitionScope public class MemoryScope { private final Map<String, Value<?>> variables; private final MemoryScope parent; public MemoryScope(MemoryScope parent) { this.variables = new HashMap<>(); this.parent = parent; } } Ensuite, nous ajoutons des méthodes pour obtenir et définir des variables. Lorsque nous définissons une variable, nous réattribuons toujours la valeur précédemment définie s'il existe déjà une variable définie dans la couche supérieure de : MemoryScope public class MemoryScope { ... public Value<?> get(String name) { Value<?> value = variables.get(name); if (value != null) return value; else if (parent != null) return parent.get(name); else return NullValue.NULL_INSTANCE; } public void set(String name, Value<?> value) { MemoryScope variableScope = findScope(name); if (variableScope == null) { variables.put(name, value); } else { variableScope.variables.put(name, value); } } private MemoryScope findScope(String name) { if (variables.containsKey(name)) return this; return parent == null ? null : parent.findScope(name); } } En plus des méthodes et , nous ajoutons deux autres implémentations pour interagir avec la couche actuelle (locale) de : set get MemoryScope public class MemoryScope { ... public Value<?> getLocal(String name) { return variables.get(name); } public void setLocal(String name, Value<?> value) { variables.put(name, value); } } Ces méthodes seront utilisées plus tard pour initialiser les arguments de la fonction ou les arguments de l'instance de la classe. Par exemple, si nous créons une instance de la classe et passons la variable de globale prédéfinie, cette variable ne doit pas être modifiée lorsque nous essayons de mettre à jour la propriété : Lamp type lamp_instance :: type Enfin, pour gérer les variables et basculer entre les étendues de mémoire, nous créons l'implémentation à l'aide de la collection : MemoryContext java.util.Stack package org.example.toylanguage.context; public class MemoryContext { private static final Stack<MemoryScope> scopes = new Stack<>(); public static MemoryScope getScope() { return scopes.peek(); } public static MemoryScope newScope() { return new MemoryScope(scopes.isEmpty() ? null : scopes.peek()); } public static void pushScope(MemoryScope scope) { scopes.push(scope); } public static void endScope() { scopes.pop(); } } 2.3 Définition de classe Dans cette section, nous allons lire et stocker les définitions de classe. Tout d'abord, nous créons l'implémentation de . Cette instruction sera exécutée à chaque fois que nous créerons une instance de classe : Statement package org.example.toylanguage.statement; public class ClassStatement { } Chaque classe peut contenir des instructions imbriquées pour l'initialisation et d'autres opérations, comme un constructeur. Pour stocker ces instructions, nous étendons le qui contient la liste des instructions imbriquées à exécuter : CompositeStatement public class ClassStatement extends CompositeStatement { } Ensuite, nous déclarons la pour stocker le nom de la classe, ses arguments, les instructions du constructeur et la portée de la définition avec les fonctions de la classe : ClassDefinition package org.example.toylanguage.context.definition; import java.util.List; @RequiredArgsConstructor @Getter @EqualsAndHashCode(onlyExplicitlyIncluded = true) public class ClassDefinition implements Definition { @EqualsAndHashCode.Include private final String name; private final List<String> arguments; private final ClassStatement statement; private final DefinitionScope definitionScope; } Nous sommes maintenant prêts à lire la déclaration de classe à l'aide de . Lorsque nous rencontrons le mot-clé lexème avec valeur de , nous devons d'abord lire le nom de la classe et ses arguments entre crochets : StatementParser class package org.example.toylanguage; public class StatementParser { ... private void parseClassDefinition() { Token type = tokens.next(TokenType.Variable); List<String> arguments = new ArrayList<>(); if (tokens.peek(TokenType.GroupDivider, "[")) { tokens.next(TokenType.GroupDivider, "["); //skip opening square bracket while (!tokens.peek(TokenType.GroupDivider, "]")) { Token argumentToken = tokens.next(TokenType.Variable); arguments.add(argumentToken.getValue()); if (tokens.peek(TokenType.GroupDivider, ",")) tokens.next(); } tokens.next(TokenType.GroupDivider, "]"); //skip closing square bracket } } } Après les arguments, nous nous attendons à lire les instructions imbriquées du constructeur : Pour stocker ces instructions, nous créons une instance du précédemment défini : ClassStatement private void parseClassDefinition() { ... ClassStatement classStatement = new ClassStatement(); } En plus des arguments et des instructions imbriquées, nos classes peuvent également contenir des fonctions. Pour rendre ces fonctions accessibles uniquement au sein de la définition de classe, nous initialisons une nouvelle couche de : DefinitionScope private void parseClassDefinition() { ... ClassStatement classStatement = new ClassStatement(); DefinitionScope classScope = DefinitionContext.newScope(); } Ensuite, nous initialisons une instance de et la plaçons dans le : ClassDefinition DefinitionContext private void parseClassDefinition() { ... ClassStatement classStatement = new ClassStatement(); DefinitionScope classScope = DefinitionContext.newScope(); ClassDefinition classDefinition = new ClassDefinition(type.getValue(), arguments, classStatement, classScope); DefinitionContext.getScope().addClass(classDefinition); } Enfin, pour lire les instructions et les fonctions du constructeur de classe, nous appelons la méthode statique qui collectera les instructions à l'intérieur de l'instance jusqu'à ce que nous rencontrions le lexème de de finalisation qui doit être ignoré à la fin : StatementParser#parse() classStatement end private void parseClassDefinition() { ... //parse class statements StatementParser.parse(this, classStatement, classScope); tokens.next(TokenType.Keyword, "end"); } 2.4 Instance de classe À ce stade, nous pouvons déjà lire les définitions de classe avec les instructions et les fonctions du constructeur. Maintenant, analysons l'instance de la classe : Tout d'abord, nous définissons , qui contiendra l'état de chaque instance de classe. Les classes, contrairement aux fonctions, doivent avoir une permanente et cette portée doit être disponible avec tous les arguments d'instance de la classe et les variables d'état chaque fois que nous interagissons avec l'instance de classe : ClassValue MemoryScope public class ClassValue extends IterableValue<ClassDefinition> { private final MemoryScope memoryScope; public ClassValue(ClassDefinition definition, MemoryScope memoryScope) { super(definition); this.memoryScope = memoryScope; } } Ensuite, nous fournissons des méthodes pour travailler avec les propriétés d'instance de la classe à l'aide : MemoryContext public class ClassValue extends IterableValue<ClassDefinition> { private final MemoryScope memoryScope; public ClassValue(ClassDefinition definition, MemoryScope memoryScope) { super(definition); this.memoryScope = memoryScope; } @Override public String toString() { return getValue().getArguments().stream() .map(t -> t + " = " + getValue(t)) .collect(Collectors.joining(", ", getValue().getName() + " [ ", " ]")); } public Value<?> getValue(String name) { Value<?> result = MemoryContext.getScope().getLocal(name); return result != null ? result : NULL_INSTANCE; } public void setValue(String name, Value<?> value) { MemoryContext.getScope().setLocal(name, value); } } Veuillez noter qu'en appelant les et , nous travaillons avec la couche actuelle de variables . Mais avant d'accéder à l'état de l'instance de la classe, nous devons mettre son dans le et le libérer lorsque nous avons terminé : MemoryScope#getLocal() MemoryScope#setLocal() MemoryScope MemoryScope MemoryContext public class ClassValue extends IterableValue<ClassDefinition> { ... public Value<?> getValue(String name) { MemoryContext.pushScope(memoryScope); try { Value<?> result = MemoryContext.getScope().getLocal(name); return result != null ? result : NULL_INSTANCE; } finally { MemoryContext.endScope(); } } public void setValue(String name, Value<?> value) { MemoryContext.pushScope(memoryScope); try { MemoryContext.getScope().setLocal(name, value); } finally { MemoryContext.endScope(); } } } Ensuite, nous pouvons implémenter la restante qui sera utilisée pour construire des instances de classe définies lors de l'analyse de la syntaxe. Pour déclarer la définition d'instance de la classe, nous fournissons la et la liste des arguments qui seront transformés en instances finales lors de l'exécution de l'instruction : ClassExpression ClassDefinition Expression Value package org.example.toylanguage.expression; @RequiredArgsConstructor @Getter public class ClassExpression implements Expression { private final ClassDefinition definition; private final List<Expression> arguments; @Override public Value<?> evaluate() { ... } } Implémentons la méthode qui sera utilisée lors de l'exécution pour créer une instance de la précédemment définie. Tout d'abord, nous évaluons les arguments dans les arguments : Expression#evaluate() ClassValue Expression Value @Override public Value<?> evaluate() { //initialize class arguments List<Value<?>> values = arguments.stream() .map(Expression::evaluate) .collect(Collectors.toList()); } Ensuite, nous créons une étendue de mémoire vide qui doit être isolée des autres variables et ne peut contenir que les variables d'état d'instance de la classe : @Override public Value<?> evaluate() { //initialize class arguments List<Value<?>> values = arguments.stream().map(Expression::evaluate).collect(Collectors.toList()); //get class's definition and statement ClassStatement classStatement = definition.getStatement(); //set separate scope MemoryScope classScope = new MemoryScope(null); MemoryContext.pushScope(classScope); try { ... } finally { MemoryContext.endScope(); } } Ensuite, nous créons une instance de et écrivons les arguments de la classe dans la portée de mémoire isolée : ClassValue Value try { //initialize constructor arguments ClassValue classValue = new ClassValue(definition, classScope); IntStream.range(0, definition.getArguments().size()).boxed() .forEach(i -> MemoryContext.getScope() .setLocal(definition.getArguments().get(i), values.size() > i ? values.get(i) : NullValue.NULL_INSTANCE)); ... } finally { MemoryContext.endScope(); } Veuillez noter que nous avons transformé les arguments en arguments avant de configurer un vide. Sinon, nous ne pourrons pas accéder aux arguments d'instance de la classe, par exemple : Expression Value MemoryScope Et enfin, nous pouvons exécuter le . Mais avant cela, nous devons définir le de la classe pour pouvoir accéder aux fonctions de la classe dans les instructions du constructeur : ClassStatement DefinitionScope try { //initialize constructor arguments ClassValue classValue = new ClassValue(definition, classScope); ClassInstanceContext.pushValue(classValue); IntStream.range(0, definition.getArguments().size()).boxed() .forEach(i -> MemoryContext.getScope() .setLocal(definition.getArguments().get(i), values.size() > i ? values.get(i) : NullValue.NULL_INSTANCE)); //execute function body DefinitionContext.pushScope(definition.getDefinitionScope()); try { classStatement.execute(); } finally { DefinitionContext.endScope(); } return classValue; } finally { MemoryContext.endScope(); ClassInstanceContext.popValue(); } 9*. Une dernière chose, nous pouvons rendre les classes plus flexibles et permettre à un utilisateur de créer des instances de classe avant de déclarer la définition de classe : Cela peut être fait en déléguant l'initialisation de au et en y accédant uniquement lorsque nous évaluons une expression : ClassDefinition DefinitionContext public class ClassExpression implements Expression { private final String name; private final List<Expression> arguments; @Override public Value<?> evaluate() { //get class's definition and statement ClassDefinition definition = DefinitionContext.getScope().getClass(name); ... } } Vous pouvez faire la même délégation pour la pour appeler des fonctions avant la définition. FunctionExpression Enfin, nous pouvons terminer la lecture des instances de classe avec . Il n'y a aucune différence entre les . Nous avons juste besoin de lire les arguments et de construire le : ExpressionReader instances de structure précédemment définies Expression ClassExpression public class ExpressionReader ... // read class instance: new Class[arguments] private ClassExpression readClassInstance(Token token) { List<Expression> arguments = new ArrayList<>(); if (tokens.peekSameLine(TokenType.GroupDivider, "[")) { tokens.next(TokenType.GroupDivider, "["); //skip opening square bracket while (!tokens.peekSameLine(TokenType.GroupDivider, "]")) { Expression value = ExpressionReader.readExpression(this); arguments.add(value); if (tokens.peekSameLine(TokenType.GroupDivider, ",")) tokens.next(); } tokens.next(TokenType.GroupDivider, "]"); //skip closing square bracket } return new ClassExpression(token.getValue(), arguments); } } 2.5 Fonction de classe À ce moment, nous pouvons créer une classe et exécuter les instructions du constructeur de la classe. Mais nous sommes toujours incapables d'exécuter les fonctions de la classe. Surchargeons la méthode qui acceptera la comme référence à l'instance de classe à partir de laquelle nous voulons invoquer une fonction : FunctionExpression#evaluate ClassValue package org.example.toylanguage.expression; public class FunctionExpression implements Expression { ... public Value<?> evaluate(ClassValue classValue) { } } L'étape suivante consiste à transformer les arguments de la fonction en arguments à l'aide de : Expression Value MemoryScope public Value<?> evaluate(ClassValue classValue) { //initialize function arguments List<Value<?>> values = argumentExpressions.stream() .map(Expression::evaluate) .collect(Collectors.toList()); } Ensuite, nous devons passer les et de la classe au contexte : MemoryScope DefinitionScope ... //get definition and memory scopes from class definition ClassDefinition classDefinition = classValue.getValue(); DefinitionScope classDefinitionScope = classDefinition.getDefinitionScope(); //set class's definition and memory scopes DefinitionContext.pushScope(classDefinitionScope); MemoryContext.pushScope(memoryScope); Enfin, pour cette implémentation, nous invoquons la méthode par défaut et passons les arguments évalués : FunctionExpression#evaluate(List<Value<?>> values) Value public Value<?> evaluate(ClassValue classValue) { //initialize function arguments List<Value<?>> values = argumentExpressions.stream().map(Expression::evaluate).collect(Collectors.toList()); //get definition and memory scopes from class definition ClassDefinition classDefinition = classValue.getValue(); DefinitionScope classDefinitionScope = classDefinition.getDefinitionScope(); MemoryScope memoryScope = classValue.getMemoryScope(); //set class's definition and memory scopes DefinitionContext.pushScope(classDefinitionScope); MemoryContext.pushScope(memoryScope); try { //proceed function return evaluate(values); } finally { DefinitionContext.endScope(); MemoryContext.endScope(); } } Pour invoquer la fonction de la classe, nous utiliserons l'opérateur double deux-points . Actuellement, cet opérateur est géré par l' ( ), qui est responsable de l'accès aux propriétés de la classe : :: ClassPropertyOperator StructureValueOperator Améliorons-le pour prendre en charge les invocations de fonction avec le même caractère double deux-points : :: La fonction de la classe peut être gérée par cet opérateur uniquement lorsque l'expression de gauche est la et la seconde est la : ClassExpression FunctionExpression package org.example.toylanguage.expression.operator; public class ClassPropertyOperator extends BinaryOperatorExpression implements AssignExpression { public ClassPropertyOperator(Expression left, Expression right) { super(left, right); } @Override public Value<?> evaluate() { Value<?> left = getLeft().evaluate(); if (left instanceof ClassValue) { if (getRight() instanceof VariableExpression) { // access class's property // new ClassInstance[] :: class_argument return ((ClassValue) left).getValue(((VariableExpression) getRight()).getName()); } else if (getRight() instanceof FunctionExpression) { // execute class's function // new ClassInstance[] :: class_function [] return ((FunctionExpression) getRight()).evaluate((ClassValue) left); } } throw new ExecutionException(String.format("Unable to access class's property `%s``", getRight())); } @Override public void assign(Value<?> value) { Value<?> left = getLeft().evaluate(); if (left instanceof ClassValue && getRight() instanceof VariableExpression) { String propertyName = ((VariableExpression) getRight()).getName(); ((ClassValue) left).setValue(propertyName, value); } } } 3. Conclusion Dans cette partie, nous avons implémenté des classes. Nous pouvons maintenant créer des choses plus complexes, telles que l'implémentation de la pile : main [] fun main [] stack = new Stack [] loop num in 0..5 # push 0,1,2,3,4 stack :: push [ num ] end size = stack :: size [] loop i in 0..size # should print 4,3,2,1,0 print stack :: pop [] end end class Stack [] arr = {} n = 0 fun push [ item ] this :: arr << item n = n + 1 end fun pop [] n = n - 1 item = arr { n } arr { n } = null return item end fun size [] return this :: n end end