Stork, Parte 4: Implementando Declarações e Encerrando

Publicados: 2022-03-11

Em nossa busca para criar uma linguagem de programação leve usando C++, começamos criando nosso tokenizer três semanas atrás e implementamos a avaliação da expressão nas duas semanas seguintes.

Agora, é hora de encerrar e entregar uma linguagem de programação completa que não será tão poderosa quanto uma linguagem de programação madura, mas terá todos os recursos necessários, incluindo uma pegada muito pequena.

Acho engraçado como as novas empresas têm seções de perguntas frequentes em seus sites que não respondem a perguntas que são feitas com frequência, mas a perguntas que elas querem que sejam feitas. Eu vou fazer o mesmo aqui. As pessoas que seguem meu trabalho costumam me perguntar por que o Stork não compila em algum bytecode ou pelo menos em alguma linguagem intermediária.

Por que o Stork não compila para Bytecode?

Fico feliz em responder a esta pergunta. Meu objetivo era desenvolver uma linguagem de script de pequeno porte que se integrasse facilmente com C++. Não tenho uma definição estrita de “pequeno tamanho”, mas imagino um compilador que seja pequeno o suficiente para permitir a portabilidade para dispositivos menos poderosos e não consumir muita memória quando executado.

C++ cegonha

Eu não me concentrei na velocidade, pois acho que você codificará em C++ se tiver uma tarefa de tempo crítico, mas se precisar de algum tipo de extensibilidade, uma linguagem como Stork pode ser útil.

Não afirmo que não existam outras linguagens melhores que possam realizar uma tarefa semelhante (por exemplo, Lua). Seria realmente trágico se eles não existissem, e estou apenas dando uma ideia do caso de uso dessa linguagem.

Como ele será incorporado ao C++, acho útil usar alguns recursos existentes do C++ em vez de escrever um ecossistema inteiro que servirá a um propósito semelhante. Não só isso, mas também acho essa abordagem mais interessante.

Como sempre, você pode encontrar o código-fonte completo na minha página do GitHub. Agora, vamos dar uma olhada no nosso progresso.

Mudanças

Até esta parte, o Stork era um produto parcialmente completo, então não consegui ver todas as suas desvantagens e falhas. No entanto, como tomou uma forma mais completa, alterei as seguintes coisas introduzidas nas partes anteriores:

  • Funções não são mais variáveis. Há um function_lookup separado no compiler_context agora. function_param_lookup foi renomeado para param_lookup para evitar confusão.
  • Mudei a forma como as funções são chamadas. Existe o método call em runtime_context que recebe std::vector de argumentos, armazena o índice de valor de retorno antigo, coloca argumentos na pilha, altera o índice de valor de retorno, chama a função, exibe argumentos da pilha, restaura o índice de valor de retorno antigo e retorna o resultado. Dessa forma, não precisamos manter a pilha de índices de valor de retorno, como antes, porque a pilha C++ atende a esse propósito.
  • Classes RAII adicionadas em compiler_context que são retornadas por chamadas para suas funções de membro scope e function . Cada um desses objetos cria novos local_identifier_lookup e param_identifier_lookup , respectivamente, em seus construtores e restaura o estado antigo no destruidor.
  • Uma classe RAII adicionada em runtime_context , retornada pela função membro get_scope . Essa função armazena o tamanho da pilha em seu construtor e o restaura em seu destruidor.
  • Eu removi a palavra-chave const e objetos constantes em geral. Eles podem ser úteis, mas não são absolutamente necessários.
  • var palavra-chave removida, pois atualmente não é necessária.
  • Eu adicionei a palavra-chave sizeof , que verificará um tamanho de matriz em tempo de execução. Talvez alguns programadores de C++ achem a escolha do nome uma blasfêmia, já que o C++ sizeof é executado em tempo de compilação, mas eu escolhi essa palavra-chave para evitar colisão com algum nome de variável comum - por exemplo, size .
  • Eu adicionei a palavra-chave tostring , que converte explicitamente qualquer coisa em string . Não pode ser uma função, pois não permitimos sobrecarga de função.
  • Várias mudanças menos interessantes.

Sintaxe

Como estamos usando uma sintaxe muito semelhante a C e suas linguagens de programação relacionadas, darei apenas os detalhes que podem não estar claros.

As declarações de tipo de variável são as seguintes:

  • void , usado apenas para o tipo de retorno da função
  • number
  • string
  • T[] é uma matriz do que contém elementos do tipo T
  • R(P1,...,Pn) é uma função que retorna o tipo R e recebe argumentos dos tipos P1 a Pn . Cada um desses tipos pode ser prefixado com & se for passado por referência.

A declaração da função é a seguinte: [public] function R name(P1 p1, … Pn pn)

Portanto, deve ser prefixado com function . Se for prefixado com public , ele poderá ser chamado de C++. Se a função não retornar o valor, ela avaliará o valor padrão de seu tipo de retorno.

Permitimos -loop for uma declaração na primeira expressão. Também permitimos if -statement e switch -statement com uma expressão de inicialização, como em C++17. A instrução if começa com um bloco if , seguido por zero ou mais blocos elif e, opcionalmente, um bloco else . Se a variável foi declarada na expressão de inicialização da instrução if , ela seria visível em cada um desses blocos.

Permitimos um número opcional após uma instrução break que pode quebrar de vários loops aninhados. Então você pode ter o seguinte código:

 for (number i = 0; i < 100; ++i) { for(number j = 0; j < 100; ++j) { if (rnd(100) == 0) { break 2; } } }

Além disso, ele irá quebrar de ambos os loops. Esse número é validado em tempo de compilação. Quão legal é isso?

Compilador

Muitos recursos foram adicionados nesta parte, mas se eu for muito detalhado, provavelmente perderei até mesmo os leitores mais persistentes que ainda estão comigo. Portanto, vou pular intencionalmente uma parte muito grande da história - a compilação.

Isso porque eu já o descrevi na primeira e na segunda parte desta série de blogs. Eu estava focando em expressões, mas compilar qualquer outra coisa não é muito diferente.

Vou, no entanto, dar-lhe um exemplo. Este código compila as instruções while :

 statement_ptr compile_while_statement( compiler_context& ctx, tokens_iterator& it, possible_flow pf ) { parse_token_value(ctx, it, reserved_token::kw_while); parse_token_value(ctx, it, reserved_token::open_round); expression<number>::ptr expr = build_number_expression(ctx, it); parse_token_value(ctx, it, reserved_token::close_round); block_statement_ptr block = compile_block_statement(ctx, it, pf); return create_while_statement(std::move(expr), std::move(block)); }

Como você pode ver, está longe de ser complicado. Ele analisa while , then ( , então ele constrói uma expressão numérica (não temos booleanos), e então analisa ) .

Depois disso, ele compila uma instrução de bloco que pode estar dentro de { e } ou não (sim, eu permiti blocos de instrução única) e cria uma instrução while no final.

Você já está familiarizado com os dois primeiros argumentos de função. O terceiro, possible_flow , mostra os comandos de mudança de fluxo permitidos ( continue , break , return ) no contexto que estamos analisando. Eu poderia manter essa informação no objeto se as instruções de compilação fossem funções-membro de alguma classe de compiler , mas não sou um grande fã de classes gigantescas, e o compilador definitivamente seria uma dessas classes. Passar um argumento extra, principalmente um fino, não fará mal a ninguém, e quem sabe um dia poderemos paralelizar o código.

Há outro aspecto interessante da compilação que eu gostaria de explicar aqui.

Se quisermos dar suporte a um cenário onde duas funções estão chamando uma à outra, podemos fazê-lo da maneira C: permitindo a declaração de encaminhamento ou tendo duas fases de compilação.

Eu escolhi a segunda abordagem. Quando a definição da função for encontrada, analisaremos seu tipo e nome no objeto chamado incomplete_function . Então, vamos pular seu corpo, sem uma interpretação, simplesmente contando o nível de aninhamento das chaves até fecharmos a primeira chave. Coletaremos tokens no processo, os manteremos em incomplete_function e adicionaremos um identificador de função em compiler_context .

Assim que passarmos o arquivo inteiro, compilaremos cada uma das funções completamente, para que possam ser chamadas em tempo de execução. Dessa forma, cada função pode chamar qualquer outra função no arquivo e acessar qualquer variável global.

Variáveis ​​globais podem ser inicializadas por chamadas para as mesmas funções, o que nos leva imediatamente ao velho problema “galinha e ovo” assim que essas funções acessam variáveis ​​não inicializadas.

Se isso acontecer, o problema é resolvido lançando uma runtime_exception — e isso é só porque eu sou legal. Francamente, violação de acesso é o mínimo que você pode obter como punição por escrever esse código.

O Escopo Global

Existem dois tipos de entidades que podem aparecer no escopo global:

  • Variáveis ​​globais
  • Funções

Cada variável global pode ser inicializada com uma expressão que retorna o tipo correto. O inicializador é criado para cada variável global.

Cada inicializador retorna lvalue , então eles servem como construtores de variáveis ​​globais. Quando nenhuma expressão é fornecida para uma variável global, o inicializador padrão é construído.

Esta é a função de membro initialize em runtime_context :

 void runtime_context::initialize() { _globals.clear(); for (const auto& initializer : _initializers) { _globals.emplace_back(initializer->evaluate(*this)); } }

Ele é chamado a partir do construtor. Ele limpa o contêiner de variável global, como pode ser chamado explicitamente, para redefinir o estado runtime_context .

Como mencionei anteriormente, precisamos verificar se acessamos uma variável global não inicializada. Portanto, este é o acessador de variável global:

 variable_ptr& runtime_context::global(int idx) { runtime_assertion( idx < _globals.size(), "Uninitialized global variable access" ); return _globals[idx]; }

Se o primeiro argumento for avaliado como false , runtime_assertion lançará um runtime_error com a mensagem correspondente.

Cada função é implementada como lambda que captura a instrução única, que é avaliada com o runtime_context que a função recebe.

Escopo da Função

Como você pode ver na compilação da instrução while , o compilador é chamado recursivamente, começando com a instrução block, que representa o bloco de toda a função.

Aqui está a classe base abstrata para todas as instruções:

 class statement { statement(const statement&) = delete; void operator=(const statement&) = delete; protected: statement() = default; public: virtual flow execute(runtime_context& context) = 0; virtual ~statement() = default; };

A única função além das padrão é execute , que executa a lógica da instrução em runtime_context e retorna o flow , que determina para onde a lógica do programa irá em seguida.

 enum struct flow_type{ f_normal, f_break, f_continue, f_return, }; class flow { private: flow_type _type; int _break_level; flow(flow_type type, int break_level); public: flow_type type() const; int break_level() const; static flow normal_flow(); static flow break_flow(int break_level); static flow continue_flow(); static flow return_flow(); flow consume_break(); };

As funções do criador estático são autoexplicativas, e eu as escrevi para evitar flow ilógico com break_level diferente de zero e o tipo diferente de flow_type::f_break .

Agora, consume_break criará um fluxo de interrupção com um nível de interrupção a menos ou, se o nível de interrupção atingir zero, o fluxo normal.

Agora, vamos verificar todos os tipos de instrução:

 class simple_statement: public statement { private: expression<void>::ptr _expr; public: simple_statement(expression<void>::ptr expr): _expr(std::move(expr)) { } flow execute(runtime_context& context) override { _expr->evaluate(context); return flow::normal_flow(); } };

Aqui, simple_statement é a instrução criada a partir de uma expressão. Cada expressão pode ser compilada como uma expressão que retorna void , para que o simple_statement possa ser criado a partir dela. Como nem break continue ou return podem fazer parte de uma expressão, simple_statement retorna flow::normal_flow() .

 class block_statement: public statement { private: std::vector<statement_ptr> _statements; public: block_statement(std::vector<statement_ptr> statements): _statements(std::move(statements)) { } flow execute(runtime_context& context) override { auto _ = context.enter_scope(); for (const statement_ptr& statement : _statements) { if ( flow f = statement->execute(context); f.type() != flow_type::f_normal ){ return f; } } return flow::normal_flow(); } };

O block_statement mantém o std::vector de instruções. Ele os executa, um por um. Se cada um deles retornar fluxo não normal, ele retornará esse fluxo imediatamente. Ele usa um objeto de escopo RAII para permitir declarações de variáveis ​​de escopo local.

 class local_declaration_statement: public statement { private: std::vector<expression<lvalue>::ptr> _decls; public: local_declaration_statement(std::vector<expression<lvalue>::ptr> decls): _decls(std::move(decls)) { } flow execute(runtime_context& context) override { for (const expression<lvalue>::ptr& decl : _decls) { context.push(decl->evaluate(context)); } return flow::normal_flow(); } };

local_declaration_statement avalia a expressão que cria uma variável local e coloca a nova variável local na pilha.

 class break_statement: public statement { private: int _break_level; public: break_statement(int break_level): _break_level(break_level) { } flow execute(runtime_context&) override { return flow::break_flow(_break_level); } };

break_statement tem o nível de quebra avaliado em tempo de compilação. Ele apenas retorna o fluxo que corresponde a esse nível de quebra.

 class continue_statement: public statement { public: continue_statement() = default; flow execute(runtime_context&) override { return flow::continue_flow(); } };

continue_statement apenas retorna flow::continue_flow() .

 class return_statement: public statement { private: expression<lvalue>::ptr _expr; public: return_statement(expression<lvalue>::ptr expr) : _expr(std::move(expr)) { } flow execute(runtime_context& context) override { context.retval() = _expr->evaluate(context); return flow::return_flow(); } }; class return_void_statement: public statement { public: return_void_statement() = default; flow execute(runtime_context&) override { return flow::return_flow(); } };

return_statement e return_void_statement ambos retornam flow::return_flow() . A única diferença é que o primeiro tem a expressão que avalia o valor de retorno antes de retornar.

 class if_statement: public statement { private: std::vector<expression<number>::ptr> _exprs; std::vector<statement_ptr> _statements; public: if_statement( std::vector<expression<number>::ptr> exprs, std::vector<statement_ptr> statements ): _exprs(std::move(exprs)), _statements(std::move(statements)) { } flow execute(runtime_context& context) override { for (size_t i = 0; i < _exprs.size(); ++i) { if (_exprs[i]->evaluate(context)) { return _statements[i]->execute(context); } } return _statements.back()->execute(context); } }; class if_declare_statement: public if_statement { private: std::vector<expression<lvalue>::ptr> _decls; public: if_declare_statement( std::vector<expression<lvalue>::ptr> decls, std::vector<expression<number>::ptr> exprs, std::vector<statement_ptr> statements ): if_statement(std::move(exprs), std::move(statements)), _decls(std::move(decls)) { } flow execute(runtime_context& context) override { auto _ = context.enter_scope(); for (const expression<lvalue>::ptr& decl : _decls) { context.push(decl->evaluate(context)); } return if_statement::execute(context); } };

if_statement , que é criado para um if -block, zero ou mais elif -blocks e um else -block (que pode estar vazio), avalia cada uma de suas expressões até que uma expressão seja avaliada como 1 . Em seguida, ele executa esse bloco e retorna o resultado da execução. Se nenhuma expressão for avaliada como 1 , ela retornará a execução do último bloco ( else ).

if_declare_statement é a instrução que tem declarações como a primeira parte de uma cláusula if. Ele empurra todas as variáveis ​​declaradas para a pilha e então executa sua classe base ( if_statement ).

 class switch_statement: public statement { private: expression<number>::ptr _expr; std::vector<statement_ptr> _statements; std::unordered_map<number, size_t> _cases; size_t _dflt; public: switch_statement( expression<number>::ptr expr, std::vector<statement_ptr> statements, std::unordered_map<number, size_t> cases, size_t dflt ): _expr(std::move(expr)), _statements(std::move(statements)), _cases(std::move(cases)), _dflt(dflt) { } flow execute(runtime_context& context) override { auto it = _cases.find(_expr->evaluate(context)); for ( size_t idx = (it == _cases.end() ? _dflt : it->second); idx < _statements.size(); ++idx ) { switch (flow f = _statements[idx]->execute(context); f.type()) { case flow_type::f_normal: break; case flow_type::f_break: return f.consume_break(); default: return f; } } return flow::normal_flow(); } }; class switch_declare_statement: public switch_statement { private: std::vector<expression<lvalue>::ptr> _decls; public: switch_declare_statement( std::vector<expression<lvalue>::ptr> decls, expression<number>::ptr expr, std::vector<statement_ptr> statements, std::unordered_map<number, size_t> cases, size_t dflt ): _decls(std::move(decls)), switch_statement(std::move(expr), std::move(statements), std::move(cases), dflt) { } flow execute(runtime_context& context) override { auto _ = context.enter_scope(); for (const expression<lvalue>::ptr& decl : _decls) { context.push(decl->evaluate(context)); } return switch_statement::execute(context); } };

switch_statement executa suas instruções uma a uma, mas primeiro pula para o índice apropriado que obtém da avaliação da expressão. Se alguma de suas instruções retornar um fluxo não normal, ele retornará esse fluxo imediatamente. Se tiver flow_type::f_break , ele consumirá uma pausa primeiro.

switch_declare_statement permite uma declaração em seu cabeçalho. Nenhum deles permite uma declaração no corpo.

 class while_statement: public statement { private: expression<number>::ptr _expr; statement_ptr _statement; public: while_statement(expression<number>::ptr expr, statement_ptr statement): _expr(std::move(expr)), _statement(std::move(statement)) { } flow execute(runtime_context& context) override { while (_expr->evaluate(context)) { switch (flow f = _statement->execute(context); f.type()) { case flow_type::f_normal: case flow_type::f_continue: break; case flow_type::f_break: return f.consume_break(); case flow_type::f_return: return f; } } return flow::normal_flow(); } };
 class do_statement: public statement { private: expression<number>::ptr _expr; statement_ptr _statement; public: do_statement(expression<number>::ptr expr, statement_ptr statement): _expr(std::move(expr)), _statement(std::move(statement)) { } flow execute(runtime_context& context) override { do { switch (flow f = _statement->execute(context); f.type()) { case flow_type::f_normal: case flow_type::f_continue: break; case flow_type::f_break: return f.consume_break(); case flow_type::f_return: return f; } } while (_expr->evaluate(context)); return flow::normal_flow(); } };

while_statement e do_while_statement executam sua instrução de corpo enquanto sua expressão é avaliada como 1 . Se a execução retornar flow_type::f_break , eles o consumirão e retornarão. Se ele retornar flow_type::f_return , eles o retornarão. Em caso de execução normal, ou continua, eles não fazem nada.

Pode parecer que continue não surte efeito. No entanto, a declaração interna foi afetada por isso. Se foi, por exemplo, block_statement , não foi avaliado até o final.

Acho legal que while_statement seja implementado com o C++ while e do-statement com o C++ do-while .

 class for_statement_base: public statement { private: expression<number>::ptr _expr2; expression<void>::ptr _expr3; statement_ptr _statement; public: for_statement_base( expression<number>::ptr expr2, expression<void>::ptr expr3, statement_ptr statement ): _expr2(std::move(expr2)), _expr3(std::move(expr3)), _statement(std::move(statement)) { } flow execute(runtime_context& context) override { for (; _expr2->evaluate(context); _expr3->evaluate(context)) { switch (flow f = _statement->execute(context); f.type()) { case flow_type::f_normal: case flow_type::f_continue: break; case flow_type::f_break: return f.consume_break(); case flow_type::f_return: return f; } } return flow::normal_flow(); } }; class for_statement: public for_statement_base { private: expression<void>::ptr _expr1; public: for_statement( expression<void>::ptr expr1, expression<number>::ptr expr2, expression<void>::ptr expr3, statement_ptr statement ): for_statement_base( std::move(expr2), std::move(expr3), std::move(statement) ), _expr1(std::move(expr1)) { } flow execute(runtime_context& context) override { _expr1->evaluate(context); return for_statement_base::execute(context); } }; class for_declare_statement: public for_statement_base { private: std::vector<expression<lvalue>::ptr> _decls; expression<number>::ptr _expr2; expression<void>::ptr _expr3; statement_ptr _statement; public: for_declare_statement( std::vector<expression<lvalue>::ptr> decls, expression<number>::ptr expr2, expression<void>::ptr expr3, statement_ptr statement ): for_statement_base( std::move(expr2), std::move(expr3), std::move(statement) ), _decls(std::move(decls)) { } flow execute(runtime_context& context) override { auto _ = context.enter_scope(); for (const expression<lvalue>::ptr& decl : _decls) { context.push(decl->evaluate(context)); } return for_statement_base::execute(context); } };

for_statement e for_statement_declare são implementados da mesma forma que while_statement e do_statement . Eles são herdados da classe for_statement_base , que faz a maior parte da lógica. for_statement_declare é criado quando a primeira parte do for -loop é uma declaração de variável.

C++ Stork: Implementando Declarações

Estas são todas as classes de instrução que temos. Eles são blocos de construção de nossas funções. Quando runtime_context é criado, ele mantém essas funções. Se a função for declarada com a palavra-chave public , ela poderá ser chamada pelo nome.

Isso conclui a funcionalidade principal do Stork. Tudo o mais que descreverei são reflexões posteriores que adicionei para tornar nossa linguagem mais útil.

Tuplas

Arrays são contêineres homogêneos, pois podem conter apenas elementos de um único tipo. Se queremos contêineres heterogêneos, as estruturas vêm imediatamente à mente.

No entanto, existem contêineres heterogêneos mais triviais: tuplas. Tuplas podem manter os elementos de diferentes tipos, mas seus tipos devem ser conhecidos em tempo de compilação. Este é um exemplo de uma declaração de tupla no Stork:

 [number, string] t = {22321, "Siveric"};

Isso declara o par de number e string e o inicializa.

As listas de inicialização também podem ser usadas para inicializar matrizes. Quando os tipos de expressões na lista de inicialização não correspondem ao tipo de variável, ocorrerá um erro do compilador.

Como os arrays são implementados como contêineres de variable_ptr , obtivemos a implementação de tuplas em tempo de execução gratuitamente. É tempo de compilação quando garantimos o tipo correto de variáveis ​​contidas.

Módulos

Seria bom ocultar os detalhes de implementação de um usuário do Stork e apresentar a linguagem de uma maneira mais amigável.

Esta é a classe que nos ajudará a conseguir isso. Apresento-o sem os detalhes de implementação:

 class module { ... public: template<typename R, typename... Args> void add_external_function(const char* name, std::function<R(Args...)> f); template<typename R, typename... Args> auto create_public_function_caller(std::string name); void load(const char* path); bool try_load(const char* path, std::ostream* err = nullptr) noexcept; void reset_globals(); ... };

As funções load e try_load carregarão e compilarão o script Stork do caminho fornecido. Primeiro, um deles pode lançar um stork::error , mas o segundo irá pegá-lo e imprimi-lo na saída, se fornecido.

A função reset_globals irá reinicializar as variáveis ​​globais.

As funções add_external_functions e create_public_function_caller devem ser chamadas antes da compilação. O primeiro adiciona uma função C++ que pode ser chamada do Stork. O segundo cria o objeto chamável que pode ser usado para chamar a função Stork do C++. Isso causará um erro em tempo de compilação se o tipo de função pública não corresponder a R(Args…) durante a compilação do script Stork.

Adicionei várias funções padrão que podem ser adicionadas ao módulo Stork.

 void add_math_functions(module& m); void add_string_functions(module& m); void add_trace_functions(module& m); void add_standard_functions(module& m);

Exemplo

Aqui está um exemplo de um script Stork:

 function void swap(number& x, number& y) { number tmp = x; x = y; y = tmp; } function void quicksort( number[]& arr, number begin, number end, number(number, number) comp ) { if (end - begin < 2) return; number pivot = arr[end-1]; number i = begin; for (number j = begin; j < end-1; ++j) if (comp(arr[j], pivot)) swap(&arr[i++], &arr[j]); swap (&arr[i], &arr[end-1]); quicksort(&arr, begin, i, comp); quicksort(&arr, i+1, end, comp); } function void sort(number[]& arr, number(number, number) comp) { quicksort(&arr, 0, sizeof(arr), comp); } function number less(number x, number y) { return x < y; } public function void main() { number[] arr; for (number i = 0; i < 100; ++i) { arr[sizeof(arr)] = rnd(100); } trace(tostring(arr)); sort(&arr, less); trace(tostring(arr)); sort(&arr, greater); trace(tostring(arr)); }

Aqui está a parte C++:

 #include <iostream> #include "module.hpp" #include "standard_functions.hpp" int main() { std::string path = __FILE__; path = path.substr(0, path.find_last_of("/\\") + 1) + "test.stk"; using namespace stork; module m; add_standard_functions(m); m.add_external_function( "greater", std::function<number(number, number)>([](number x, number y){ return x > y; } )); auto s_main = m.create_public_function_caller<void>("main"); if (m.try_load(path.c_str(), &std::cerr)) { s_main(); } return 0; }

As funções padrão são adicionadas ao módulo antes da compilação e as funções trace e rnd são usadas a partir do script Stork. A função greater também é adicionada como vitrine.

O script é carregado a partir do arquivo “test.stk”, que está na mesma pasta que “main.cpp” (usando uma definição de pré-processador __FILE__ ), e então a função main é chamada.

No script, geramos um array aleatório, classificando em ordem crescente usando o comparador less , e depois em decrescente usando o comparador greater , escrito em C++.

Você pode ver que o código é perfeitamente legível para qualquer pessoa fluente em C (ou qualquer linguagem de programação derivada de C).

O que fazer a seguir?

Existem muitos recursos que eu gostaria de implementar no Stork:

  • Estruturas
  • Aulas e herança
  • Chamadas entre módulos
  • Funções lambda
  • Objetos tipados dinamicamente

A falta de tempo e espaço é uma das razões pelas quais ainda não os implementamos. Tentarei atualizar minha página do GitHub com novas versões à medida que implemento novos recursos no meu tempo livre.

Empacotando

Criamos uma nova linguagem de programação!

Isso tomou boa parte do meu tempo livre nas últimas seis semanas, mas agora posso escrever alguns scripts e vê-los rodando. É o que eu estava fazendo nos últimos dias, coçando minha careca toda vez que ele batia inesperadamente. Às vezes, era um pequeno bug, e às vezes um bug desagradável. Outras vezes, porém, me sentia constrangido porque se tratava de uma má decisão que eu já havia compartilhado com o mundo. Mas a cada vez, eu corrigia e continuava codificando.

No processo, aprendi sobre if constexpr , que nunca havia usado antes. Também me tornei mais familiarizado com rvalue-references e encaminhamento perfeito, bem como com outros recursos menores do C++17 que não encontro diariamente.

O código não é perfeito - eu nunca faria tal afirmação - mas é bom o suficiente e segue principalmente boas práticas de programação. E o mais importante - funciona.

Decidir desenvolver uma nova linguagem do zero pode parecer loucura para uma pessoa comum, ou mesmo para um programador comum, mas é mais uma razão para fazê-lo e provar a si mesmo que você pode fazê-lo. Assim como resolver um quebra-cabeça difícil é um bom exercício cerebral para ficar mentalmente em forma.

Desafios maçantes são comuns em nossa programação do dia-a-dia, pois não podemos escolher apenas os aspectos interessantes dela e temos que fazer um trabalho sério, mesmo que às vezes seja chato. Se você é um desenvolvedor profissional, sua primeira prioridade é entregar código de alta qualidade ao seu empregador e colocar comida na mesa. Isso às vezes pode fazer com que você evite programar em seu tempo livre e pode diminuir o entusiasmo de seus primeiros dias de escola de programação.

Se você não precisa, não perca esse entusiasmo. Trabalhe em algo se achar interessante, mesmo que já tenha sido feito. Você não precisa justificar o motivo para se divertir.

E se você puder incorporá-lo – mesmo que parcialmente – em seu trabalho profissional, bom para você! Poucas pessoas têm essa oportunidade.

O código para esta parte será congelado com um branch dedicado na minha página do GitHub.