paint-brush
Construyendo su propio lenguaje de programación desde cero: Parte VII - Clasespor@alexandermakeev
6,103 lecturas
6,103 lecturas

Construyendo su propio lenguaje de programación desde cero: Parte VII - Clases

por Alexander Makeev22m2022/12/15
Read on Terminal Reader

Demasiado Largo; Para Leer

En esta parte de crear sus propios lenguajes de programación implementaremos clases y al final escribiremos la implementación real de Stack
featured image - Construyendo su propio lenguaje de programación desde cero: Parte VII - Clases
Alexander Makeev HackerNoon profile picture

En esta parte de crear tu propio lenguaje de programación, implementaremos clases como una extensión sobre las estructuras previamente definidas. Por favor, echa un vistazo a las partes anteriores:


  1. Construyendo su propio lenguaje de programación desde cero
  2. Construyendo su propio lenguaje de programación desde cero: Parte II - Algoritmo de dos pilas de Dijkstra
  3. Cree su propio lenguaje de programación Parte III: Mejora del análisis léxico con expresiones regulares anticipadas
  4. Construyendo su propio lenguaje de programación desde cero Parte IV: Implementando funciones
  5. Construyendo su propio lenguaje de programación desde cero: Parte V - Matrices
  6. Construyendo su propio lenguaje de programación desde cero: Parte VI - Bucles

El código fuente completo está disponible. en GitHub .


1. Análisis léxico

En la primera sección, cubriremos el análisis léxico. En resumen, es un proceso para dividir el código fuente en lexemas de lenguaje, como palabra clave, variable, operador, etc.


Puede recordar de las partes anteriores que estaba usando las expresiones regulares en la enumeración TokenType para definir todos los tipos de lexema.


Veamos el siguiente prototipo de clase y agreguemos las partes de expresiones regulares que faltan a nuestros lexemas TokenType :


  1. Primero, necesitamos poner la palabra de class en la expresión del lexema de Keyword para que el analizador léxico sepa dónde comienza nuestra declaración de clase:
 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. En segundo lugar, necesitamos el nuevo lexema This como marcador para una referencia al objeto actual:
 public enum TokenType { ... This("(this)(?=,|\\s|$)"); private final String regex; }


2. Análisis de sintaxis

En la segunda sección, transformaremos los lexemas recibidos del analizador léxico en las declaraciones finales siguiendo nuestras reglas de lenguaje.

2.1 Alcance de la definición

Cuando declaramos una clase o una función, esta declaración debe estar disponible dentro de los límites aislados definidos. Por ejemplo, si declaramos una función llamada turn_on [] en el siguiente listado, estará disponible para su ejecución después de la declaración:


Pero si declaramos la misma función dentro de un ámbito de clase, ya no se podrá acceder a esta función directamente desde el bloque principal:


  1. Para implementar estos límites de definición, crearemos la clase DefinitionScope y almacenaremos todas las definiciones declaradas dentro de dos conjuntos para clases y funciones:
 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. Además, es posible que deseemos acceder al ámbito de definición de los padres. Por ejemplo, si declaramos dos clases separadas y creamos una instancia de la primera clase dentro de la segunda:


Para proporcionar esta capacidad, agregaremos la instancia principal de DefinitionScope como referencia a la capa superior, que usaremos para subir a la capa de definición superior.

 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. Ahora, terminemos la implementación proporcionando las interfaces para agregar una definición y recuperarla por nombre usando el ámbito principal:
 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. Finalmente, para administrar los ámbitos de definición declarados y cambiar entre ellos, creamos la clase de contexto usando la colección 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 Alcance de la memoria

En esta sección, cubriremos el MemoryScope para administrar variables de clase y función.


  1. Cada variable declarada, de manera similar a la definición de clase o función, debe ser accesible solo dentro de un bloque de código aislado. Por ejemplo, si definimos una variable en el siguiente listado, puedes acceder a ella justo después de la declaración:


Pero si declaramos una variable dentro de una función o una clase, la variable ya no estará disponible desde el bloque de código principal (superior):


Para implementar esta lógica y almacenar variables definidas en un ámbito específico, creamos la clase MemoryScope que contendrá un mapa con el nombre de la variable como clave y la variable Value como valor:

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


  1. A continuación, de manera similar a DefinitionScope , brindamos acceso a las variables de alcance de los padres:
 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. A continuación, agregamos métodos para obtener y establecer variables. Cuando configuramos una variable, siempre reasignamos el valor previamente configurado si ya hay una variable definida en la capa superior 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. Además de los métodos set y get , agregamos dos implementaciones más para interactuar con la capa actual (local) 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); } }


Estos métodos se utilizarán más adelante para inicializar argumentos de funciones o argumentos de instancias de clases. Por ejemplo, si creamos una instancia de la clase Lamp y pasamos la variable de type global predefinida, esta variable no debería cambiarse cuando intentemos actualizar la propiedad lamp_instance :: type :


  1. Finalmente, para administrar variables y cambiar entre ámbitos de memoria, creamos la implementación de MemoryContext usando la colección 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 Definición de Clase

En esta sección, leeremos y almacenaremos definiciones de clases.


  1. Primero, creamos la implementación de la Declaración . Esta sentencia se ejecutará cada vez que creemos la instancia de una clase:
 package org.example.toylanguage.statement; public class ClassStatement { }


  1. Cada clase puede contener declaraciones anidadas para la inicialización y otras operaciones, como un constructor. Para almacenar estas declaraciones, extendemos el CompositeStatement que contiene la lista de declaraciones anidadas para ejecutar:
 public class ClassStatement extends CompositeStatement { }


  1. A continuación, declaramos ClassDefinition para almacenar el nombre de la clase, sus argumentos, las declaraciones del constructor y el alcance de la definición con las funciones de la clase:
 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. Ahora, estamos listos para leer la declaración de clase usando StatementParser . Cuando nos encontramos con el lexema de palabra clave con valor de class , primero debemos leer el nombre de la clase y sus argumentos dentro de los corchetes:


 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. Después de los argumentos, esperamos leer las declaraciones del constructor anidado:


Para almacenar estas declaraciones, creamos una instancia del ClassStatement previamente definido:

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


  1. Además de argumentos y declaraciones anidadas, nuestras clases también pueden contener funciones. Para que estas funciones sean accesibles solo dentro de la definición de clase, inicializamos una nueva capa de DefinitionScope :
 private void parseClassDefinition() { ... ClassStatement classStatement = new ClassStatement(); DefinitionScope classScope = DefinitionContext.newScope(); }


  1. A continuación, inicializamos una instancia de ClassDefinition y la colocamos en 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. Finalmente, para leer las declaraciones y funciones del constructor de clases, llamamos al método estático StatementParser#parse() que recopilará declaraciones dentro de la instancia classStatement hasta que encontremos el lexema end que debe omitirse al final:
 private void parseClassDefinition() { ... //parse class statements StatementParser.parse(this, classStatement, classScope); tokens.next(TokenType.Keyword, "end"); }


2.4 Instancia de clase

En este punto, ya podemos leer definiciones de clase con declaraciones y funciones del constructor. Ahora, analicemos la instancia de la clase:


  1. Primero, definimos ClassValue , que contendrá el estado de cada instancia de clase. Las clases, a diferencia de las funciones, deben tener un MemoryScope permanente y este alcance debe estar disponible con todos los argumentos de la instancia de la clase y las variables de estado cada vez que interactuamos con la instancia de la clase:
 public class ClassValue extends IterableValue<ClassDefinition> { private final MemoryScope memoryScope; public ClassValue(ClassDefinition definition, MemoryScope memoryScope) { super(definition); this.memoryScope = memoryScope; } }


  1. A continuación, proporcionamos métodos para trabajar con las propiedades de instancia de la clase usando 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. Tenga en cuenta que al llamar a los MemoryScope#getLocal() y MemoryScope#setLocal() , trabajamos con la capa actual de variables de MemoryScope . Pero antes de acceder al estado de instancia de la clase, debemos colocar su MemoryScope en MemoryContext y liberarlo cuando terminemos:
 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. A continuación, podemos implementar el ClassExpression restante que se usará para construir instancias de clase definidas durante el análisis de sintaxis. Para declarar la definición de instancia de la clase, proporcionamos la ClassDefinition de clase y la lista de argumentos de Expression que se transformarán en las instancias de Value final durante la ejecución de la declaración:
 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. Implementemos el método Expression#evaluate() que se usará durante la ejecución para crear una instancia del ClassValue previamente definido. Primero, evaluamos los argumentos de Expression en los argumentos de Value :
 @Override public Value<?> evaluate() { //initialize class arguments List<Value<?>> values = arguments.stream() .map(Expression::evaluate) .collect(Collectors.toList()); }


  1. A continuación, creamos un ámbito de memoria vacío que debe estar aislado de las otras variables y puede contener solo las variables de estado de instancia de la clase:
 @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. A continuación, creamos una instancia de ClassValue y escribimos los argumentos de Value de la clase en el ámbito de la memoria aislada:
 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(); }


Tenga en cuenta que transformamos los argumentos de Expression en argumentos de Value antes de configurar un MemoryScope vacío. De lo contrario, no podremos acceder a los argumentos de instancia de la clase, por ejemplo:


  1. Y finalmente, podemos ejecutar ClassStatement . Pero antes de eso, debemos configurar el DefinitionScope de la clase para poder acceder a las funciones de la clase dentro de las declaraciones del constructor:
 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*. Una última cosa, podemos hacer que las clases sean más flexibles y permitir que un usuario cree instancias de clase antes de declarar la definición de clase:


Esto se puede hacer delegando la inicialización de ClassDefinition al DefinitionContext y accediendo a él solo cuando evaluamos una expresión:

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


Puede hacer la misma delegación para FunctionExpression para invocar funciones antes de la definición.


  1. Finalmente, podemos terminar de leer las instancias de la clase con ExpressionReader . No hay diferencia entre las instancias de estructura definidas anteriormente . Solo necesitamos leer los argumentos de Expression y construir 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 Función de clase

En este momento, podemos crear una clase y ejecutar las declaraciones del constructor de la clase. Pero aún no podemos ejecutar las funciones de la clase.

  1. Sobrecarguemos el método FunctionExpression#evaluate que aceptará ClassValue como una referencia a la instancia de clase desde la que queremos invocar una función:
 package org.example.toylanguage.expression; public class FunctionExpression implements Expression { ... public Value<?> evaluate(ClassValue classValue) { } }


  1. El siguiente paso es transformar los argumentos de Expression de la función en argumentos de Value utilizando el MemoryScope actual:
 public Value<?> evaluate(ClassValue classValue) { //initialize function arguments List<Value<?>> values = argumentExpressions.stream() .map(Expression::evaluate) .collect(Collectors.toList()); }


  1. A continuación, debemos pasar MemoryScope y DefinitionScope de la clase al contexto:
 ... //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. Por último, para esta implementación, invocamos el método predeterminado FunctionExpression#evaluate(List<Value<?>> valores) y pasamos argumentos de Value evaluados:
 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. Para invocar la función de la clase, usaremos el operador de dos puntos :: . Actualmente, este operador es administrado por la ClassPropertyOperator ( StructureValueOperator ), que se encarga de acceder a las propiedades de la clase:


Mejorémoslo para admitir invocaciones de funciones con el mismo carácter de dos puntos ::


La función de clase puede ser administrada por este operador solo cuando la expresión de la izquierda es ClassExpression y la segunda es 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. Cierre

En esta parte, implementamos clases. Ahora podemos crear cosas más complejas, como la implementación de pilas:

 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