paint-brush
Construire votre propre langage de programmation à partir de zéro : partie VII - Classespar@alexandermakeev
6,093 lectures
6,093 lectures

Construire votre propre langage de programmation à partir de zéro : partie VII - Classes

par Alexander Makeev22m2022/12/15
Read on Terminal Reader

Trop long; Pour lire

Dans cette partie de la création de vos propres langages de programmation, nous implémenterons des classes et à la fin nous écrirons la véritable implémentation de Stack
featured image - Construire votre propre langage de programmation à partir de zéro : partie VII - Classes
Alexander Makeev HackerNoon profile picture

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 :


  1. Construire votre propre langage de programmation à partir de zéro
  2. Construire votre propre langage de programmation à partir de zéro : Partie II - Algorithme à deux piles de Dijkstra
  3. Construisez votre propre langage de programmation Partie 3 : Améliorer l'analyse lexicale avec les anticipations de regex
  4. Construire votre propre langage de programmation à partir de zéro Partie IV : Implémentation de fonctions
  5. Construire votre propre langage de programmation à partir de zéro : Partie V - Tableaux
  6. 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 TokenType pour définir tous les types de lexème.


Examinons le prototype de classe suivant et ajoutons les parties de regex manquantes à nos lexèmes TokenType :


  1. Tout d'abord, nous devons mettre le mot de class dans l'expression du lexème de mot- Keyword pour que l'analyseur lexical sache où commence notre déclaration de classe :
 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; }


  1. Deuxièmement, nous avons besoin du nouveau lexème This comme marqueur d'une référence à l'objet courant :
 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 turn_on [] dans le listing suivant, elle sera disponible pour exécution après déclaration :


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 :


  1. Pour implémenter ces limites de définition, nous allons créer la classe DefinitionScope et stocker toutes les définitions déclarées dans deux ensembles pour les classes et les fonctions :
 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<>(); } }


  1. 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 DefinitionScope comme référence à la couche supérieure, que nous utiliserons pour monter à la couche de définition supérieure.

 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; } }


  1. 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); } }


  1. 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 java.util.Stack (LIFO) :
 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 MemoryScope pour gérer les variables de classe et de fonction.


  1. 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 MemoryScope qui contiendra une carte avec le nom de la variable comme clé et la variable Value comme valeur :

 public class MemoryScope { private final Map<String, Value<?>> variables; public MemoryScope() { this.variables = new HashMap<>(); } }


  1. Ensuite, comme pour DefinitionScope , nous donnons accès aux variables de portée du parent :
 public class MemoryScope { private final Map<String, Value<?>> variables; private final MemoryScope parent; public MemoryScope(MemoryScope parent) { this.variables = new HashMap<>(); this.parent = parent; } }


  1. 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); } }


  1. En plus des méthodes set et get , nous ajoutons deux autres implémentations pour interagir avec la couche actuelle (locale) de 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 Lamp et passons la variable de type globale prédéfinie, cette variable ne doit pas être modifiée lorsque nous essayons de mettre à jour la propriété lamp_instance :: type :


  1. Enfin, pour gérer les variables et basculer entre les étendues de mémoire, nous créons l'implémentation MemoryContext à l'aide de la collection 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.


  1. Tout d'abord, nous créons l'implémentation de Statement . Cette instruction sera exécutée à chaque fois que nous créerons une instance de classe :
 package org.example.toylanguage.statement; public class ClassStatement { }


  1. 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 CompositeStatement qui contient la liste des instructions imbriquées à exécuter :
 public class ClassStatement extends CompositeStatement { }


  1. Ensuite, nous déclarons la ClassDefinition 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 :
 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; }


  1. Nous sommes maintenant prêts à lire la déclaration de classe à l'aide de StatementParser . Lorsque nous rencontrons le mot-clé lexème avec valeur de class , nous devons d'abord lire le nom de la classe et ses arguments entre crochets :


 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 } } }


  1. Après les arguments, nous nous attendons à lire les instructions imbriquées du constructeur :


Pour stocker ces instructions, nous créons une instance du ClassStatement précédemment défini :

 private void parseClassDefinition() { ... ClassStatement classStatement = new ClassStatement(); }


  1. 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(); }


  1. Ensuite, nous initialisons une instance de ClassDefinition et la plaçons dans le 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); }


  1. Enfin, pour lire les instructions et les fonctions du constructeur de classe, nous appelons la méthode statique StatementParser#parse() qui collectera les instructions à l'intérieur de l'instance classStatement jusqu'à ce que nous rencontrions le lexème de end de finalisation qui doit être ignoré à la fin :
 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 :


  1. Tout d'abord, nous définissons ClassValue , qui contiendra l'état de chaque instance de classe. Les classes, contrairement aux fonctions, doivent avoir une MemoryScope 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 :
 public class ClassValue extends IterableValue<ClassDefinition> { private final MemoryScope memoryScope; public ClassValue(ClassDefinition definition, MemoryScope memoryScope) { super(definition); this.memoryScope = memoryScope; } }


  1. 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); } }


  1. Veuillez noter qu'en appelant les MemoryScope#getLocal() et MemoryScope#setLocal() , nous travaillons avec la couche actuelle de variables MemoryScope . Mais avant d'accéder à l'état de l'instance de la classe, nous devons mettre son MemoryScope dans le MemoryContext et le libérer lorsque nous avons terminé :
 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(); } } }


  1. Ensuite, nous pouvons implémenter la ClassExpression 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 ClassDefinition et la liste des arguments Expression qui seront transformés en instances Value finales lors de l'exécution de l'instruction :
 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() { ... } }


  1. Implémentons la méthode Expression#evaluate() qui sera utilisée lors de l'exécution pour créer une instance de la ClassValue précédemment définie. Tout d'abord, nous évaluons les arguments Expression dans les arguments Value :
 @Override public Value<?> evaluate() { //initialize class arguments List<Value<?>> values = arguments.stream() .map(Expression::evaluate) .collect(Collectors.toList()); }


  1. 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(); } }


  1. Ensuite, nous créons une instance de ClassValue et écrivons les arguments Value de la classe dans la portée de mémoire isolée :
 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 Expression en arguments Value avant de configurer un MemoryScope vide. Sinon, nous ne pourrons pas accéder aux arguments d'instance de la classe, par exemple :


  1. Et enfin, nous pouvons exécuter le ClassStatement . Mais avant cela, nous devons définir le DefinitionScope de la classe pour pouvoir accéder aux fonctions de la classe dans les instructions du constructeur :
 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 ClassDefinition au DefinitionContext et en y accédant uniquement lorsque nous évaluons une expression :

 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 FunctionExpression pour appeler des fonctions avant la définition.


  1. Enfin, nous pouvons terminer la lecture des instances de classe avec ExpressionReader . Il n'y a aucune différence entre les instances de structure précédemment définies . Nous avons juste besoin de lire les arguments Expression et de construire le 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.

  1. Surchargeons la méthode FunctionExpression#evaluate qui acceptera la ClassValue comme référence à l'instance de classe à partir de laquelle nous voulons invoquer une fonction :
 package org.example.toylanguage.expression; public class FunctionExpression implements Expression { ... public Value<?> evaluate(ClassValue classValue) { } }


  1. L'étape suivante consiste à transformer les arguments Expression de la fonction en arguments Value à l'aide de MemoryScope :
 public Value<?> evaluate(ClassValue classValue) { //initialize function arguments List<Value<?>> values = argumentExpressions.stream() .map(Expression::evaluate) .collect(Collectors.toList()); }


  1. Ensuite, nous devons passer les MemoryScope et DefinitionScope de la classe au contexte :
 ... //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);


  1. Enfin, pour cette implémentation, nous invoquons la méthode par défaut FunctionExpression#evaluate(List<Value<?>> values) et passons les arguments Value évalués :
 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(); } }


  1. Pour invoquer la fonction de la classe, nous utiliserons l'opérateur double deux-points :: . Actuellement, cet opérateur est géré par l' ClassPropertyOperator ( StructureValueOperator ), qui est responsable de l'accès aux propriétés de la classe :


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 ClassExpression et la seconde est la 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