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: Construyendo su propio lenguaje de programación desde cero Construyendo su propio lenguaje de programación desde cero: Parte II - Algoritmo de dos pilas de Dijkstra Cree su propio lenguaje de programación Parte III: Mejora del análisis léxico con expresiones regulares anticipadas Construyendo su propio lenguaje de programación desde cero Parte IV: Implementando funciones Construyendo su propio lenguaje de programación desde cero: Parte V - Matrices 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 para definir todos los tipos de lexema. TokenType Veamos el siguiente prototipo de clase y agreguemos las partes de expresiones regulares que faltan a nuestros lexemas : TokenType Primero, necesitamos poner la palabra de en la expresión del lexema de para que el analizador léxico sepa dónde comienza nuestra declaración de clase: 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; } En segundo lugar, necesitamos el nuevo lexema como marcador para una referencia al objeto actual: This 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 en el siguiente listado, estará disponible para su ejecución después de la declaración: turn_on [] 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: Para implementar estos límites de definición, crearemos la clase y almacenaremos todas las definiciones declaradas dentro de dos conjuntos para clases y funciones: 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<>(); } } 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 como referencia a la capa superior, que usaremos para subir a la capa de definición superior. 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; } } 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); } } Finalmente, para administrar los ámbitos de definición declarados y cambiar entre ellos, creamos la clase de contexto usando la colección (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 Alcance de la memoria En esta sección, cubriremos el para administrar variables de clase y función. MemoryScope 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 que contendrá un mapa con el nombre de la variable como clave y la variable como valor: MemoryScope Value public class MemoryScope { private final Map<String, Value<?>> variables; public MemoryScope() { this.variables = new HashMap<>(); } } A continuación, de manera similar a , brindamos acceso a las variables de alcance de los padres: 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; } } 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); } } Además de los métodos y , agregamos dos implementaciones más para interactuar con la capa actual (local) 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); } } 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 y pasamos la variable de global predefinida, esta variable no debería cambiarse cuando intentemos actualizar la propiedad : Lamp type lamp_instance :: type Finalmente, para administrar variables y cambiar entre ámbitos de memoria, creamos la implementación de usando la colección : 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 Definición de Clase En esta sección, leeremos y almacenaremos definiciones de clases. Primero, creamos la implementación de la . Esta sentencia se ejecutará cada vez que creemos la instancia de una clase: Declaración package org.example.toylanguage.statement; public class ClassStatement { } Cada clase puede contener declaraciones anidadas para la inicialización y otras operaciones, como un constructor. Para almacenar estas declaraciones, extendemos el que contiene la lista de declaraciones anidadas para ejecutar: CompositeStatement public class ClassStatement extends CompositeStatement { } A continuación, declaramos 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: 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; } Ahora, estamos listos para leer la declaración de clase usando . Cuando nos encontramos con el lexema de palabra clave con valor de , primero debemos leer el nombre de la clase y sus argumentos dentro de los corchetes: 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 } } } Después de los argumentos, esperamos leer las declaraciones del constructor anidado: Para almacenar estas declaraciones, creamos una instancia del previamente definido: ClassStatement private void parseClassDefinition() { ... ClassStatement classStatement = new ClassStatement(); } 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(); } A continuación, inicializamos una instancia de y la colocamos en : 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); } Finalmente, para leer las declaraciones y funciones del constructor de clases, llamamos al método estático que recopilará declaraciones dentro de la instancia hasta que encontremos el lexema que debe omitirse al final: StatementParser#parse() classStatement end 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: Primero, definimos , que contendrá el estado de cada instancia de clase. Las clases, a diferencia de las funciones, deben tener un 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: ClassValue MemoryScope public class ClassValue extends IterableValue<ClassDefinition> { private final MemoryScope memoryScope; public ClassValue(ClassDefinition definition, MemoryScope memoryScope) { super(definition); this.memoryScope = memoryScope; } } 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); } } Tenga en cuenta que al llamar a los y , trabajamos con la capa actual de variables de . Pero antes de acceder al estado de instancia de la clase, debemos colocar su en y liberarlo cuando terminemos: 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(); } } } A continuación, podemos implementar el 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 de clase y la lista de argumentos de que se transformarán en las instancias de final durante la ejecución de la declaración: 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() { ... } } Implementemos el método que se usará durante la ejecución para crear una instancia del previamente definido. Primero, evaluamos los argumentos de en los argumentos de : Expression#evaluate() ClassValue Expression Value @Override public Value<?> evaluate() { //initialize class arguments List<Value<?>> values = arguments.stream() .map(Expression::evaluate) .collect(Collectors.toList()); } 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(); } } A continuación, creamos una instancia de y escribimos los argumentos de de la clase en el ámbito de la memoria aislada: 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(); } Tenga en cuenta que transformamos los argumentos de en argumentos de antes de configurar un vacío. De lo contrario, no podremos acceder a los argumentos de instancia de la clase, por ejemplo: Expression Value MemoryScope Y finalmente, podemos ejecutar . Pero antes de eso, debemos configurar el de la clase para poder acceder a las funciones de la clase dentro de las declaraciones del constructor: 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*. 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 al y accediendo a él solo cuando evaluamos una expresión: 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); ... } } Puede hacer la misma delegación para para invocar funciones antes de la definición. FunctionExpression Finalmente, podemos terminar de leer las instancias de la clase con . No hay diferencia entre las . Solo necesitamos leer los argumentos de y construir : ExpressionReader instancias de estructura definidas anteriormente 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 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. Sobrecarguemos el método que aceptará como una referencia a la instancia de clase desde la que queremos invocar una función: FunctionExpression#evaluate ClassValue package org.example.toylanguage.expression; public class FunctionExpression implements Expression { ... public Value<?> evaluate(ClassValue classValue) { } } El siguiente paso es transformar los argumentos de de la función en argumentos de utilizando el actual: Expression Value MemoryScope public Value<?> evaluate(ClassValue classValue) { //initialize function arguments List<Value<?>> values = argumentExpressions.stream() .map(Expression::evaluate) .collect(Collectors.toList()); } A continuación, debemos pasar y de la clase al contexto: 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); Por último, para esta implementación, invocamos el método predeterminado y pasamos argumentos de evaluados: FunctionExpression#evaluate(List<Value<?>> valores) 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(); } } Para invocar la función de la clase, usaremos el operador de dos puntos . Actualmente, este operador es administrado por la ( ), que se encarga de acceder a las propiedades de la clase: :: ClassPropertyOperator StructureValueOperator 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 y la segunda es : 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. 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