Nesta parte da criação de sua própria linguagem de programação, vamos implementar classes como uma extensão sobre as estruturas definidas anteriormente. Confira as partes anteriores: Construindo sua própria linguagem de programação do zero Construindo sua própria linguagem de programação do zero: Parte II - Algoritmo de duas pilhas de Dijkstra Crie sua própria linguagem de programação Parte III: Melhorando a análise léxica com Regex Lookaheads Construindo sua própria linguagem de programação do zero Parte IV: Implementando funções Construindo sua própria linguagem de programação do zero: Parte V - Arrays Construindo sua própria linguagem de programação do zero: Parte VI - Loops O código-fonte completo está disponível lá no GitHub . 1. Análise Lexical Na primeira seção, abordaremos a análise lexical. Resumindo, é um processo para dividir o código-fonte em lexemas de linguagem, como palavra-chave, variável, operador, etc. Você deve se lembrar das partes anteriores que eu estava usando as expressões regex na enumeração para definir todos os tipos de lexemas. TokenType Vejamos o seguinte protótipo de classe e adicionemos as partes regex que faltam aos nossos lexemas : TokenType Primeiro, precisamos colocar a palavra de na expressão do lexema de palavra- para permitir que o analisador léxico saiba onde começa nossa declaração 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; } Em segundo lugar, precisamos do novo lexema como um marcador para uma referência ao objeto atual: This public enum TokenType { ... This("(this)(?=,|\\s|$)"); private final String regex; } 2. Análise de Sintaxe Na segunda seção, transformaremos os lexemas recebidos do analisador léxico em enunciados finais seguindo nossas regras linguísticas. 2.1 Escopo da Definição Quando declaramos uma classe ou função, esta declaração deve estar disponível dentro dos limites isolados definidos. Por exemplo, se declararmos uma função chamada na listagem a seguir, ela estará disponível para execução após a declaração: turn_on [] Mas se declararmos a mesma função dentro do escopo de uma classe, esta função não será mais acessada diretamente do bloco principal: Para implementar esses limites de definição, criaremos a classe e armazenaremos todas as definições declaradas dentro de dois conjuntos para classes e funções: 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<>(); } } Além disso, podemos querer acessar o escopo de definição do pai. Por exemplo, se declararmos duas classes separadas e criarmos uma instância da primeira classe dentro da segunda: Para fornecer essa capacidade, adicionaremos a instância pai como uma referência à camada superior, que usaremos para subir até a camada de definição 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; } } Agora, vamos terminar a implementação fornecendo as interfaces para adicionar uma definição e recuperá-la pelo nome usando o escopo pai: 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); } } Por fim, para gerenciar os escopos de definição declarados e alternar entre eles, criamos a classe de contexto usando a coleção (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 Escopo de memória Nesta seção, abordaremos o para gerenciar variáveis de classe e função. MemoryScope Cada variável declarada, de forma semelhante à definição de classe ou função, deve ser acessível apenas dentro de um bloco de código isolado. Por exemplo, se definirmos uma variável na listagem a seguir, você pode acessá-la logo após a declaração: Mas se declararmos uma variável dentro de uma função ou classe, a variável não estará mais disponível no bloco de código principal (superior): Para implementar essa lógica e armazenar variáveis definidas em um escopo específico, criamos a classe que conterá um mapa com o nome da variável como chave e a variável como valor: MemoryScope Value public class MemoryScope { private final Map<String, Value<?>> variables; public MemoryScope() { this.variables = new HashMap<>(); } } Em seguida, de forma semelhante ao , fornecemos acesso às variáveis de escopo do pai: 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; } } Em seguida, adicionamos métodos para obter e definir variáveis. Quando definimos uma variável, sempre reatribuímos o valor definido anteriormente se já houver uma variável definida na camada 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); } } Além dos métodos e , adicionamos mais duas implementações para interagir com a camada atual (local) do : 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); } } Esses métodos serão usados posteriormente para inicializar argumentos de função ou argumentos de instância de classe. Por exemplo, se criamos uma instância da classe e passamos a variável de global pré-definida, esta variável não deve ser alterada quando tentamos atualizar a propriedade : Lamp type lamp_instance :: type Por fim, para gerenciar variáveis e alternar entre escopos de memória, criamos a implementação usando a coleção : 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 Definição de Classe Nesta seção, leremos e armazenaremos as definições de classe. Primeiro, criamos a implementação da . Esta instrução será executada toda vez que criarmos uma instância de classe: Declaração package org.example.toylanguage.statement; public class ClassStatement { } Cada classe pode conter instruções aninhadas para inicialização e outras operações, como um construtor. Para armazenar essas instruções, estendemos o que contém a lista de instruções aninhadas a serem executadas: CompositeStatement public class ClassStatement extends CompositeStatement { } Em seguida, declaramos a para armazenar o nome da classe, seus argumentos, instruções do construtor e o escopo da definição com as funções da 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; } Agora, estamos prontos para ler a declaração da classe usando . Quando encontramos o lexema de palavras-chave com valor de , primeiro precisamos ler o nome da classe e seus argumentos dentro dos colchetes: 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 } } } Após os argumentos, esperamos ler as instruções do construtor aninhado: Para armazenar essas instruções, criamos uma instância do definido anteriormente: ClassStatement private void parseClassDefinition() { ... ClassStatement classStatement = new ClassStatement(); } Além de argumentos e instruções aninhadas, nossas classes também podem conter funções. Para tornar essas funções acessíveis apenas dentro da definição de classe, inicializamos uma nova camada de : DefinitionScope private void parseClassDefinition() { ... ClassStatement classStatement = new ClassStatement(); DefinitionScope classScope = DefinitionContext.newScope(); } Em seguida, inicializamos uma instância de e a colocamos no : 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); } Por fim, para ler as instruções e funções do construtor de classe, chamamos o método estático que coletará as instruções dentro da instância até encontrarmos o lexema de finalização que deve ser ignorado no final: StatementParser#parse() classStatement end private void parseClassDefinition() { ... //parse class statements StatementParser.parse(this, classStatement, classScope); tokens.next(TokenType.Keyword, "end"); } 2.4 Instância de Classe Neste ponto, já podemos ler as definições de classe com instruções e funções do construtor. Agora, vamos analisar a instância da classe: Primeiro, definimos , que conterá o estado de cada instância de classe. As classes, ao contrário das funções, devem ter um permanente e esse escopo deve estar disponível com todos os argumentos de instância da classe e variáveis de estado toda vez que interagirmos com a instância da classe: ClassValue MemoryScope public class ClassValue extends IterableValue<ClassDefinition> { private final MemoryScope memoryScope; public ClassValue(ClassDefinition definition, MemoryScope memoryScope) { super(definition); this.memoryScope = memoryScope; } } Em seguida, fornecemos métodos para trabalhar com as propriedades de instância da classe 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); } } Observe que ao chamar os e , trabalhamos com a camada atual de variáveis . Mas antes de acessar o estado da instância da classe, precisamos colocar seu no e liberá-lo quando terminarmos: 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(); } } } Em seguida, podemos implementar a restante que será usada para construir instâncias de classe definidas durante a análise de sintaxe. Para declarar a definição da instância da classe, fornecemos a e a lista de argumentos que serão transformados nas instâncias finais do durante a execução da instrução: 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() { ... } } Vamos implementar o método que será utilizado durante a execução para criar uma instância do definido anteriormente. Primeiro, avaliamos os argumentos nos argumentos : Expression#evaluate() ClassValue Expression Value @Override public Value<?> evaluate() { //initialize class arguments List<Value<?>> values = arguments.stream() .map(Expression::evaluate) .collect(Collectors.toList()); } Em seguida, criamos um escopo de memória vazio que deve ser isolado das outras variáveis e pode conter apenas as variáveis de estado da instância da 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(); } } Em seguida, criamos uma instância de e escrevemos os argumentos da classe no escopo de memória isolado: 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(); } Observe que transformamos os argumentos nos argumentos antes de configurar um vazio. Caso contrário, não conseguiremos acessar os argumentos de instância da classe, por exemplo: Expression Value MemoryScope E, finalmente, podemos executar o . Mas antes disso, devemos definir o da classe para poder acessar as funções da classe nas instruções do construtor: 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*. Uma última coisa, podemos tornar as classes mais flexíveis e permitir que um usuário crie instâncias de classe antes de declarar a definição de classe: Isso pode ser feito delegando a inicialização de para o e acessando-o somente quando avaliamos uma expressão: 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); ... } } Você pode fazer a mesma delegação para invocar funções antes da definição. FunctionExpression Por fim, podemos concluir a leitura das instâncias da classe com o . Não há diferença entre as . Precisamos apenas ler os argumentos e construir a : ExpressionReader instâncias da estrutura previamente definidas 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 Função de Classe Neste momento, podemos criar uma classe e executar as instruções do construtor da classe. Mas ainda não conseguimos executar as funções da classe. Vamos sobrecarregar o método que aceitará como uma referência à instância da classe da qual queremos invocar uma função: FunctionExpression#evaluate ClassValue package org.example.toylanguage.expression; public class FunctionExpression implements Expression { ... public Value<?> evaluate(ClassValue classValue) { } } A próxima etapa é transformar os argumentos da função nos argumentos usando o atual: Expression Value MemoryScope public Value<?> evaluate(ClassValue classValue) { //initialize function arguments List<Value<?>> values = argumentExpressions.stream() .map(Expression::evaluate) .collect(Collectors.toList()); } Em seguida, precisamos passar o e o da classe para o 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 fim, para esta implementação, invocamos o método padrão e passamos os argumentos avaliados: 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(); } } Para invocar a função da classe, usaremos o operador de dois pontos . Atualmente, este operador é gerenciado pela implementação ( ), que é responsável por acessar as propriedades da classe: :: ClassPropertyOperator StructureValueOperator Vamos melhorá-lo para oferecer suporte a invocações de função com os mesmos dois pontos duplos caractere: :: A função da classe pode ser gerenciada por este operador somente quando a expressão da esquerda é a e a segunda é a : 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. Conclusão Nesta parte, implementamos classes. Agora podemos criar coisas mais complexas, como implementação de pilha: 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