Meta Crush Saga: jogo em tempo de compilação

imagem

No processo de mudança para o tão esperado título de Lead Senior C ++ Over-Engineer , no ano passado, decidi reescrever o jogo que estou desenvolvendo durante o horário de trabalho (Candy Crush Saga), usando a quintessência do C ++ moderno (C ++ 17). E assim nasceu a Meta Crush Saga : um jogo que é executado na fase de compilação . Fiquei muito inspirado pelo jogo Nibbler de Matt Birner, que usou pura metaprogramação em modelos para recriar a famosa Snake com o Nokia 3310.

“Que tipo de jogo está sendo executado no estágio de compilação ?”, “Como ele se parece?”, “Que funcionalidade do C ++ 17 você usou neste projeto?”, “O que você aprendeu?” - Perguntas semelhantes podem vir à sua mente. Para respondê-las, você terá que ler a postagem inteira ou aguentar sua preguiça interior e assistir a uma versão em vídeo da publicação - meu relatório do evento Meetup em Estocolmo:


Nota: para o bem da sua saúde mental e porque errare humanum est , são apresentados alguns fatos alternativos neste artigo.

Um jogo que roda em tempo de compilação?



Penso que, para entender o que quero dizer com o "conceito" de um jogo executado na fase de compilação , você precisa comparar o ciclo de vida de um jogo com o ciclo de vida de um jogo comum.

O ciclo de vida de um jogo regular:


Como um desenvolvedor regular de jogos com uma vida normal, trabalhando em um trabalho regular com um nível normal de saúde mental, você geralmente começa escrevendo a lógica do jogo no seu idioma favorito (em C ++, é claro!) E depois executa o compilador para convertê-lo, muitas vezes como espaguete lógica em um arquivo executável . Após clicar duas vezes no arquivo executável (ou iniciar no console), o sistema operacional gera um processo . Esse processo executará a lógica do jogo , que consiste em um ciclo de jogo em 99,42% do tempo. O ciclo do jogo atualiza o estado do jogo de acordo com certas regras e informações do usuário , renderiza o novo estado calculado do jogo em pixels, novamente, novamente e novamente.


O ciclo de vida de um jogo em execução durante o processo de compilação:


Como um engenheiro em excesso que cria seu novo jogo legal de compilação, você ainda usa sua linguagem favorita (ainda em C ++, é claro!) Para escrever a lógica do jogo . Então, como antes, a fase de compilação continua, mas há uma reviravolta na trama: você executa a lógica do jogo na fase de compilação. Você pode chamá-lo de "execução" (compilação). E aqui C ++ é muito útil; possui recursos como Teta (Template Meta Programming) e constexpr que permitem realizar cálculos na fase de compilação . Mais tarde, consideraremos a funcionalidade que pode ser usada para isso. Como nesta fase executamos a lógica do jogo, nesse momento também precisamos inserir a entrada do jogador . Obviamente, nosso compilador ainda criará um arquivo executável na saída. Para que ele pode ser usado? O arquivo executável não conterá mais o loop do jogo , mas possui uma missão muito simples: exibir um novo estado calculado . Vamos chamar esse arquivo executável de renderizador e os dados renderizados são renderizados . Em nossa renderização, nem belos efeitos de partículas nem sombras de oclusão ambiental serão contidas, será ASCII. A renderização ASCII do novo estado calculado é uma propriedade conveniente que pode ser facilmente demonstrada ao player, mas, além disso, a copiamos para um arquivo de texto. Por que um arquivo de texto? Obviamente, porque ele pode de alguma forma ser combinado com o código e executar novamente todas as etapas anteriores, obtendo assim um loop .

Como você já pode entender, o jogo executado durante o processo de compilação consiste em um ciclo de jogo no qual cada quadro do jogo é um estágio de compilação . Cada estágio da compilação calcula um novo estado do jogo, que pode ser mostrado ao jogador e inserido no próximo quadro / estágio da compilação .

Você pode contemplar esse diagrama magnífico o quanto quiser até entender o que acabei de escrever:


Antes de entrarmos nos detalhes da implementação desse ciclo, tenho certeza de que deseja me fazer a única pergunta ...

"Por que se preocupar em fazer isso?"



Você realmente acha que arruinar meu idílio de metaprogramação em C ++ é uma questão tão fundamental? Sim, por nada na vida!

  • A primeira e mais importante é que o jogo executado na fase de compilação terá uma incrível velocidade de tempo de execução, porque a maior parte dos cálculos é realizada na fase de compilação . A velocidade do tempo de execução é a chave para o sucesso do nosso jogo AAA com gráficos ASCII!
  • Você reduz a probabilidade de aparecer algum crustáceo em seu repositório e solicita que você reescreva o jogo no Rust . Seu discurso bem preparado desmoronará assim que você lhe explicar que um ponteiro inválido não pode existir no momento da compilação. Os programadores autoconfiantes de Haskell podem até confirmar a segurança do tipo no seu código.
  • Você ganhará o respeito do reino moderno do Javascript , no qual qualquer estrutura reprojetada com uma forte síndrome do NIH pode governar, desde que tenha um nome interessante.
  • Um amigo meu costumava dizer que qualquer linha de código Perl pode ser usada de fato como uma senha muito forte. Estou certo de que ele nunca tentou gerar senhas no tempo de compilação do C ++ .

Como Você está satisfeito com minhas respostas? Então talvez sua pergunta deva ser: "Como você consegue fazer isso?"

Na verdade, eu realmente queria experimentar a funcionalidade adicionada no C ++ 17 . Alguns recursos visam aumentar a eficácia da linguagem, bem como a metaprogramação (principalmente constexpr). Eu pensei que, em vez de escrever pequenos exemplos de código, seria muito mais interessante transformar tudo isso em um jogo. Projetos de animais de estimação são uma ótima maneira de aprender conceitos que você não precisa usar frequentemente em seu trabalho. A capacidade de executar lógica de jogo básica em tempo de compilação novamente prova que modelos e constepxr são subconjuntos completos de Turing da linguagem C ++.

Revisão do jogo Meta Crush Saga


Jogo de combinação 3:


Meta Crush Saga é um jogo de combinação de peças semelhante ao Bejeweled e Candy Crush Saga . O núcleo das regras do jogo é conectar três peças com o mesmo padrão para obter pontos. Aqui está uma rápida olhada no estado do jogo que eu "despejei" (despejar no ASCII é muito fácil de obter):

  R "(
     Meta crush saga      
 ------------------------  
 |  | 
 |  RBGBBYGR 
 |  | 
 |  | 
 |  YYGRBGBR 
 |  | 
 |  | 
 |  RBYRGRYG 
 |  | 
 |  | 
 |  RYBY (R) YGY 
 |  | 
 |  | 
 |  BGYRYGGR 
 |  | 
 |  | 
 |  RYBGYBBG 
 |  | 
 ------------------------  
 > pontuação: 9009
 > movimentos: 27
 ) " 


A jogabilidade deste jogo Match-3 em si não é particularmente interessante, mas e a arquitetura na qual tudo funciona? Para que você entenda, tentarei explicar todas as partes do ciclo de vida desse jogo em tempo de compilação em termos de código.

Injeção do estado do jogo:



Se você é um apaixonado ou pedante apaixonado por C ++, pode ter notado que o despejo de estado do jogo anterior começa com o seguinte padrão: R "( . Na verdade, esse é um literal de cadeia de caracteres C ++ 11 bruto , o que significa que não preciso escapar de caracteres especiais, por exemplo, tradução strings : o literal da string bruta é armazenado em um arquivo chamado current_state.txt .

Como injetamos esse estado atual do jogo em um estado de compilação? Vamos adicioná-lo às entradas de loop!

// loop_inputs.hpp constexpr KeyboardInput keyboard_input = KeyboardInput::KEYBOARD_INPUT; //       constexpr auto get_game_state_string = []() constexpr { auto game_state_string = constexpr_string( //       #include "current_state.txt" ); return game_state_string; }; 

Seja um arquivo .txt ou um arquivo .h , a diretiva de inclusão do pré-processador C funcionará da mesma maneira: copia o conteúdo do arquivo para seu local. Aqui eu copio a string bruta literal do estado do jogo em ASCII para uma variável chamada game_state_string .

Observe que o arquivo de cabeçalho loop_inputs.hpp também expande a entrada do teclado para a etapa atual do quadro / compilação. Ao contrário do estado do jogo, o estado do teclado é bastante pequeno e pode ser facilmente obtido como uma definição de pré-processador.

Computando um novo estado em tempo de compilação:



Agora que coletamos dados suficientes, podemos calcular o novo estado. Finalmente, chegamos ao ponto em que precisamos escrever o arquivo main.cpp :

 // main.cpp #include "loop_inputs.hpp" //   ,   . // :    . constexpr auto current_state = parse_game_state(get_game_state_string); //      . constexpr auto new_state = game_engine(current_state) //    , .update(keyboard_input); //  ,    . constexpr auto array = print_game_state(new_state); //      std::array<char>. // :    . //  :   . for (const char& c : array) { std::cout << c; } 

Estranho, mas esse código C ++ não parece tão confuso, considerando o que faz. A maior parte do código é executada na fase de compilação, no entanto, segue os paradigmas tradicionais de programação processual e POO. Somente a última linha - renderização - é um obstáculo para realizar cálculos completos em tempo de compilação. Como veremos abaixo, lançando um pouco de consexpr nos lugares certos, podemos obter uma metaprogramação bastante elegante no C ++ 17. Acho deliciosa a liberdade que o C ++ nos dá quando se trata de execução mista em tempo de execução e compilação.

Você também notará que esse código executa apenas um quadro, não há loop de jogo . Vamos resolver esse problema!

Colamos tudo juntos:



Se você repugna meus truques com C ++ , espero que não se importe em ver minhas habilidades em Bash . De fato, meu loop de jogo nada mais é do que um script bash que é compilado constantemente.

 #  !  ,    !!! while; do : #      G++ g++ -o renderer main.cpp -DKEYBOARD_INPUT="$keypressed" keypressed=get_key_pressed() #  . clear #   current_state=$(./renderer) echo $current_state #    #     current_state.txt file       . echo "R\"(" > current_state.txt echo $current_state >> current_state.txt echo ")\"" >> current_state.txt done 

Na verdade, eu estava tendo um pouco de dificuldade em obter a entrada do teclado no console. Inicialmente, eu queria entrar em paralelo com a compilação. Depois de muitas tentativas e erros, consegui trabalhar mais ou menos com o comando read do Bash . Eu nunca ouso lutar contra o bruxo Bash em um duelo - essa linguagem é muito sinistra!

Portanto, devo admitir que, para gerenciar o ciclo do jogo, tive que recorrer a outro idioma. Embora tecnicamente nada me impedisse de escrever esta parte do código em C ++. Além disso, isso não nega o fato de que 90% da lógica do meu jogo é executada dentro da equipe de compilação do g ++ , o que é incrível!

Um pouco de jogabilidade para descansar os olhos:


Agora que você experimentou o tormento de explicar a arquitetura do jogo, chegou a hora de pinturas atraentes:


Este gif pixelizado é um registro de como eu jogo Meta Crush Saga . Como você pode ver, o jogo funciona sem problemas o suficiente para ser jogado em tempo real. Obviamente, ela não é tão atraente que eu possa transmitir seu Twitch e me tornar a nova Pewdiepie, mas ela funciona!

Um dos aspectos divertidos de armazenar o estado de um jogo em um arquivo .txt é a capacidade de enganar ou testar casos extremos de maneira muito conveniente.

Agora que apresentei brevemente a arquitetura, vamos nos aprofundar na funcionalidade C ++ 17 usada neste projeto. Não considerarei a lógica do jogo em detalhes, porque se refere exclusivamente ao Match-3; em vez disso, falarei sobre aspectos do C ++ que podem ser aplicados em outros projetos.

Meus tutoriais sobre C ++ 17:



Ao contrário do C ++ 14, que continha principalmente pequenas correções, o novo padrão C ++ 17 pode nos oferecer muito. Havia esperanças de que finalmente os recursos esperados (módulos, corotinas, conceitos ...) finalmente aparecessem, mas ... em geral ... eles não apareciam; isso incomodou muitos de nós. Mas, depois de remover o luto, encontramos muitos pequenos tesouros inesperados que, no entanto, caíram no padrão.

Ouso dizer que as crianças que amam metaprogramação são muito mimadas este ano! Agora, pequenas alterações e adições separadas ao idioma permitem que você escreva um código que funciona muito em tempo de compilação e depois em tempo de execução.

Constepxr em todos os campos:


Como Ben Dean e Jason Turner previram em seu relatório sobre o C ++ 14 , o C ++ permite melhorar rapidamente a compilação de valores em tempo de compilação com a palavra-chave onipotente constexpr . Ao localizar essa palavra-chave nos lugares certos, você pode informar ao compilador que a expressão é constante e pode ser avaliada diretamente no momento da compilação. No C ++ 11, já poderíamos escrever este código:

 constexpr int factorial(int n) //    constexpr       . { return n <= 1? 1 : (n * factorial(n - 1)); } int i = factorial(5); //  constexpr-. //      : // int i = 120; 

Embora a palavra-chave constexpr seja muito poderosa, ela possui algumas restrições de uso, dificultando a gravação de código expressivo dessa maneira.

O C ++ 14 reduziu bastante os requisitos para o constexpr e tornou-se muito mais natural de usar. Nossa função fatorial anterior pode ser reescrita da seguinte forma:

 constexpr int factorial(int n) { if (n <= 1) { return 1; } return n * factorial(n - 1); } 

O C ++ 14 se livrou da regra de que uma função constexpr deveria consistir em apenas uma declaração de retorno, o que nos forçou a usar o operador ternário como o principal componente. Agora, o C ++ 17 traz ainda mais aplicativos de palavras-chave constexpr que podemos explorar!

Ramificação em tempo de compilação:


Você já esteve em uma situação em que precisa ter um comportamento diferente, dependendo do parâmetro do modelo que está manipulando? Suponha que precisamos de uma função parametrizada serialize , que chamará .serialize() se o objeto fornecer, caso contrário, ele recorrerá à chamada to_string . Como explicado em mais detalhes neste post sobre SFINAE , provavelmente você precisará escrever um código alienígena:

 template <class T> std::enable_if_t<has_serialize_v<T>, std::string> serialize(const T& obj) { return obj.serialize(); } template <class T> std::enable_if_t<!has_serialize_v<T>, std::string> serialize(const T& obj) { return std::to_string(obj); } 

Somente em um sonho você poderia reescrever esse truque feio do truque do SFINAE para o C ++ 14 em um código tão magnífico:

 // has_serialize -  constexpr-,  serialize  . // .    SFINAE,  ,    . template <class T> constexpr bool has_serialize(const T& /*t*/); template <class T> std::string serialize(const T& obj) { //  ,  constexpr    . if (has_serialize(obj)) { return obj.serialize(); } else { return std::to_string(obj); } } 

Infelizmente, quando você acordou e começou a escrever código C ++ 14 real, seu compilador emitiu uma mensagem desagradável sobre a chamada serialize(42); . Ele explicou que um obj tipo int não possui uma função de membro serialize() . Não importa como isso o enfurece, o compilador está certo! Com esse código, ele sempre tentará compilar os dois ramos - return obj.serialize(); e
return std::to_string(obj); . Para int branch return obj.serialize(); Pode muito bem ser algum tipo de código morto, porque has_serialize(obj) sempre retornará false , mas o compilador ainda precisará compilá-lo.

Como você provavelmente adivinhou, o C ++ 17 nos salva de uma situação tão desagradável, porque tornou possível adicionar constexpr após a instrução if para "forçar" a ramificação no tempo de compilação e descartar construções não utilizadas:

 // has_serialize... // ... template <class T> std::string serialize(const T& obj) if constexpr (has_serialize(obj)) { //     constexpr   'if'. return obj.serialize(); //    ,    ,  obj  int. } else { return std::to_string(obj);branch } } 


Obviamente, essa é uma grande melhoria em relação ao truque da SFINAE que tínhamos que aplicar antes. Depois disso, começamos a ter o mesmo vício de Ben e Jason - começamos a usar constexpr em todos os lugares e sempre. Infelizmente, há outro local onde a palavra-chave constexpr se ajustaria, mas ainda não foi usada: parâmetros constexpr .

Parâmetros Constexpr:


Se você for cuidadoso, poderá observar um padrão estranho no exemplo de código anterior. Eu estou falando sobre entradas de loop:

 // loop_inputs.hpp constexpr auto get_game_state_string = []() constexpr // ? { auto game_state_string = constexpr_string( //       #include "current_state.txt" ); return game_state_string; }; 

Por que a variável game_state_string está encapsulada em um lambda constexpr? Por que ela não a torna uma variável global constexpr ?

Eu queria passar essa variável e seu conteúdo para algumas funções. Por exemplo, você precisa passá-lo para meu parse_board e usá-lo em algumas expressões constantes:

 constexpr int parse_board_size(const char* game_state_string); constexpr auto parse_board(const char* game_state_string) { std::array<GemType, parse_board_size(game_state_string)> board{}; // ^ 'game_state_string' -   - // ... } parse_board(“...something...”); 

Se seguirmos esse caminho, o compilador ranzinza reclamará que o parâmetro game_state_string não é uma expressão constante. Ao criar minha matriz de blocos, preciso calcular diretamente sua capacidade fixa (não podemos usar vetores em tempo de compilação porque eles exigem alocação de memória) e passá-la como argumento para o modelo de valor em std :: array . Portanto, a expressão parse_board_size (game_state_string) deve ser uma expressão constante. Embora parse_board_size esteja explicitamente marcado como constexpr , game_state_string não é e não pode ser! Nesse caso, duas regras interferem conosco:

  • Argumentos de uma função constexpr não são constexpr!
  • E não podemos adicionar constexpr na frente deles!

Tudo isso se resume ao fato de que as funções constexpr DEVEM ser aplicáveis ​​ao cálculo do tempo de execução e do tempo de compilação. Assumindo a existência de parâmetros constexpr , isso não permitirá que eles sejam usados ​​em tempo de execução.


Felizmente, existe uma maneira de nivelar esse problema. Em vez de aceitar o valor como parâmetro regular de uma função, podemos encapsular esse valor em um tipo e passar esse tipo como parâmetro de modelo:

 template <class GameStringType> constexpr auto parse_board(GameStringType&&) { std::array<CellType, parse_board_size(GameStringType::value())> board{}; // ... } struct GameString { static constexpr auto value() { return "...something..."; } }; parse_board(GameString{}); 

Neste exemplo de código, estou criando um tipo estrutural de GameString que possui uma função de membro estática constexpr value () que retorna a literal de cadeia de caracteres que eu quero passar para parse_board . Em parse_board, eu recebo esse tipo através do parâmetro do modelo GameStringType , usando as regras para extrair argumentos do modelo. Tendo um GameStringType , devido ao fato de que value () é constexpr, posso simplesmente chamar a função de membro estático value () no momento certo para obter uma literal de string mesmo em locais onde são necessárias expressões constantes.

Conseguimos encapsular o literal para, de alguma forma, passá-lo para parse_board usando constexpr. No entanto, é muito chato precisar definir um novo tipo toda vez que você precisar enviar um novo literal parse_board : "... alguma coisa1 ...", "... alguma coisa2 ...". Para resolver esse problema no C ++ 11 , você pode aplicar alguns endereços macro e indiretos feios usando união anônima e lambda. Michael Park explicou bem esse tópico em uma de suas postagens .

No C ++ 17, a situação é ainda melhor. Se listarmos os requisitos para passar nossa literal de string, obtemos o seguinte:

  • Função gerada
  • Isso é constexpr
  • Com um nome exclusivo ou anônimo

Esses requisitos devem fornecer uma dica. O que precisamos é constexpr lambda ! E no C ++ 17, eles adicionaram completamente a capacidade de usar a palavra - chave constexpr para funções lambda. Podemos reescrever nosso código de exemplo da seguinte maneira:

 template <class LambdaType> constexpr auto parse_board(LambdaType&& get_game_state_string) { std::array<CellType, parse_board_size(get_game_state_string())> board{}; // ^      constexpr-. } parse_board([]() constexpr -> { return “...something...”; }); // ^    constexpr. 

Acredite, isso já parece muito mais conveniente do que o hacking anterior no C ++ 11 usando macros. Descobri esse truque incrível graças a Bjorn Fahler , membro do grupo de mitap do C ++ em que participo. Leia mais sobre esse truque em seu blog . Também vale a pena considerar que, de fato, a palavra-chave constexpr é opcional neste caso: todas as lambdas com a capacidade de se tornar constexpr serão por padrão. A adição explícita de constexpr é uma assinatura que simplifica nossa solução de problemas.

Agora você deve entender por que fui forçado a usar um constexpr lambda para transmitir uma string que representa o estado do jogo. Veja esta função lambda e você terá outra pergunta. O que é esse tipo constexpr_string que eu também uso para quebrar o literal do estoque?

constexpr_string e constexpr_string_view:

Ao trabalhar com strings, você não deve processá-las no estilo C. Você precisa esquecer todos esses algoritmos irritantes que executam iterações brutas e verificam se a conclusão é zero! A alternativa oferecida pelo C ++ é o onipotente std :: string e os algoritmos STL . Infelizmente, std :: string pode exigir alocação de memória no heap (mesmo com a Small String Optimization) para armazenar seu conteúdo. Um ou dois padrões anteriores, poderíamos usar constexpr new / delete ou passar os alocadores constexpr para std :: string , mas agora precisamos encontrar outra solução.

Minha abordagem foi escrever uma classe constexpr_string com uma capacidade fixa. Essa capacidade é passada como um parâmetro para o modelo de valor. Aqui está uma breve visão geral da minha turma:

 template <std::size_t N> // N -    . class constexpr_string { private: std::array<char, N> data_; //  N char   -. std::size_t size_; //   . public: constexpr constexpr_string(const char(&a)[N]): data_{}, size_(N -1) { //   data_ } // ... constexpr iterator begin() { return data_; } //    . constexpr iterator end() { return data_ + size_; } //     . // ... }; 

Minha classe constexpr_string procura imitar a interface std :: string o mais próximo possível (para as operações que preciso): podemos solicitar iteradores do início e do fim , obter o tamanho (tamanho) , acessar os dados (dados) , excluir (apagar) parte deles, obter substring usando substr e assim por diante. Isso facilita a conversão de um pedaço de código de std :: string para constexpr_string . Você pode se perguntar o que acontece quando precisamos usar operações que geralmente exigem destaque em std :: string . Nesses casos, fui forçado a convertê-los em operações imutáveis que criam uma nova instância de constexpr_string .

Vamos dar uma olhada na operação de acréscimo :

 template <std::size_t N> // N -    . class constexpr_string { // ... template <std::size_t M> // M -    . constexpr auto append(const constexpr_string<M>& other) { constexpr_string<N + M> output(*this, size() + other.size()); // ^    . ^     output. for (std::size_t i = 0; i < other.size(); ++i) { output[size() + i] = other[i]; ^     output. } return output; } // ... }; 


Você não precisa ter um prêmio Fields para assumir que, se tivermos uma sequência de tamanho N e uma sequência de tamanho M , uma sequência de tamanho N + M será suficiente para armazenar sua concatenação. Podemos desperdiçar parte do "repositório em tempo de compilação", pois as duas linhas podem não usar toda a capacidade, mas esse é um preço bastante pequeno por conveniência. Obviamente, também escrevi uma duplicata do std :: string_view , que chamava constexpr_string_view .

Com essas duas classes, eu estava pronto para escrever um código elegante para analisar meu estado de jogo . Pense em algo como isto:

 constexpr auto game_state = constexpr_string(“...something...”); //          : constexpr auto blue_gem = find_if(game_state.begin(), game_state.end(), [](char c) constexpr -> { return c == 'B'; } ); 

Era muito fácil percorrer as joias no campo de jogo - a propósito, você notou outro recurso precioso do C ++ 17 neste exemplo de código?

Sim Não tive que especificar explicitamente a capacidade de constexpr_string ao construí-lo. Anteriormente, ao usar um modelo de classe , tínhamos que indicar explicitamente seus argumentos. Para evitar essas dores, criamos funções make_xxx porque os parâmetros dos modelos de função podem ser rastreados. Veja como acompanhar os argumentos do modelo de classe muda nossas vidas para melhor:

 template <int N> struct constexpr_string { constexpr_string(const char(&a)[N]) {} // .. }; // ****  C++17 **** template <int N> constexpr_string<N> make_constexpr_string(const char(&a)[N]) { //      N ^   return constexpr_string<N>(a); // ^    . } auto test2 = make_constexpr_string("blablabla"); // ^      . constexpr_string<7> test("blabla"); // ^      ,    . // ****  C++17 **** constexpr_string test("blabla"); // ^    ,  . 

Em algumas situações difíceis, você precisará ajudar o compilador a calcular corretamente os argumentos. Se você encontrar esse problema, estude os manuais para cálculos de argumentos definidos pelo usuário .

Comida grátis da STL:


Bem, sempre podemos reescrever tudo por conta própria. Mas talvez os membros do comitê tenham generosamente preparado algo para nós na biblioteca padrão?

Novos tipos de auxiliar:

No C ++ 17 , std :: variant e std :: optional são adicionados aos tipos de dicionário padrão, com base no constexpr . O primeiro é muito interessante porque nos permite expressar associações de tipo seguro, mas a implementação na biblioteca libstdc ++ com o GCC 7.2 tem problemas ao usar expressões constantes. Portanto, abandonei a ideia de adicionar std :: variant ao meu código e use apenas std :: optional .

Com o tipo T, o tipo std :: optional nos permite criar um novo tipo std :: optional <T> , que pode conter um valor do tipo T ou nada. Isso é bastante semelhante aos tipos significativos que permitem valor indefinido em C # . Vejamos a função find_in_board , que retorna a posição do primeiro elemento em um campo que confirma que o predicado está correto. Pode não haver esse elemento no campo. Para lidar com essa situação, o tipo de posição deve ser opcional:

 template <class Predicate> constexpr std::optional<std::pair<int, int>> find_in_board(GameBoard&& g, Predicate&& p) { for (auto item : g.items()) { if (p(item)) { return {item.x, item.y}; } //   ,     . } return std::nullopt; //      . } auto item = find_in_board(g, [](const auto& item) { return true; }); if (item) { // ,   optional. do_something(*item); //    optional, ""   *. /* ... */ } 

Anteriormente, tínhamos que recorrer à semântica dos ponteiros , ou adicionar um "estado vazio" diretamente ao tipo de posição, ou retornar um booleano e pegar o parâmetro de saída . É certo que isso foi bastante estranho!

Alguns tipos pré-existentes também receberam suporte constexpr : tupla e par . Não explicarei em detalhes seu uso, porque muito já foi escrito sobre eles, mas compartilharei uma de minhas decepções. O comitê adicionou açúcar sintático ao padrão para extrair os valores contidos em uma tupla ou par . Esse novo tipo de declaração chamado ligação estruturada, usa parênteses para especificar em quais variáveis ​​armazenar a tupla ou par dividido :

 std::pair<int, int> foo() { return {42, 1337}; } auto [x, y] = foo(); // x = 42, y = 1337. 

Muito esperto! Mas é uma pena que os membros do comitê [não pudessem, não quisessem, não encontrassem tempo, se esquecessem] de torná-los amigáveis ​​para se expressar . Eu esperaria algo assim:

 constexpr auto [x, y] = foo(); // OR auto [x, y] constexpr = foo(); 

Agora temos contêineres complexos e tipos auxiliares, mas como os manipulamos convenientemente?

Algoritmos:

Atualizar um contêiner para processar constexpr é uma tarefa bastante monótona. Comparado a isso, portar constexpr para algoritmos que não modificam parece bastante simples. Mas é bastante estranho que no C ++ 17 não vimos progresso nessa área, ele aparecerá apenas no C ++ 20 . Por exemplo, os maravilhosos algoritmos std :: find não receberam assinaturas constexpr .

Mas não tenha medo! Como Ben e Jason explicaram, você pode facilmente transformar o algoritmo em constexpr simplesmente copiando a implementação atual (mas não se esqueça dos direitos autorais); cppreference é bom. Senhoras e senhores, apresento a sua atençãoconstexpr std :: find :

 template<class InputIt, class T> constexpr InputIt find(InputIt first, InputIt last, const T& value) // ^ !!!    constexpr. { for (; first != last; ++first) { if (*first == value) { return first; } } return last; } //  http://en.cppreference.com/w/cpp/algorithm/find 

Já posso ouvir dos estandes os gritos dos fãs de otimização! Sim, apenas adicionar constexpr na frente do código de amostra gentilmente fornecido pelo cppreference pode não nos dar a velocidade ideal em tempo de execução . Mas se precisarmos melhorar esse algoritmo, será necessário obter velocidade no tempo de compilação . Tanto quanto eu sei, quando se trata de velocidade de compilação , soluções simples são as melhores.

Velocidade e bugs:


Os desenvolvedores de qualquer jogo AAA devem investir na solução desses problemas, certo?

Velocidade:


Quando consegui criar uma versão semi-funcional do Meta Crush Saga , o trabalho foi mais suave. Na verdade, consegui obter um pouco mais de 3 FPS (quadros por segundo) no meu laptop antigo com o i5 com overclock para 1,80 GHz (a frequência é importante neste caso). Como em qualquer projeto, percebi rapidamente que o código escrito anteriormente era nojento e comecei a reescrever a análise do estado do jogo usando constexpr_string e algoritmos padrão. Embora isso tenha tornado o código muito mais conveniente de manter, as alterações afetaram seriamente a velocidade; o novo teto é de 0,5 FPS .

Apesar do velho ditado sobre C ++, "abstrações sem cabeçalho" não são aplicáveis ​​a cálculos em tempo de compilação. Isso é bastante lógico se considerarmos o compilador como um intérprete de algum "código de tempo de compilação". Melhorias para vários compiladores ainda são possíveis, mas também existem oportunidades de crescimento para nós, os autores desse código. Aqui está uma lista incompleta de observações e dicas que encontrei, possivelmente específicas do GCC:

  • Matrizes C funcionam muito melhor do que std :: array . O std :: array é um pouco de cosméticos C ++ modernos em cima de um array no estilo C e você precisa pagar um preço por usá-lo nessas condições.
  • , ( ) . , , , . : , , , , ( ) , .
  • , . , .
  • . GCC. , «».

:



Muitas vezes, meu compilador gerou terríveis erros de compilação e minha lógica de código sofreu. Mas como encontrar o lugar onde o bug está escondido? Sem depurador e printf, as coisas ficam mais complicadas. Se sua "barba metafórica do programador" ainda não se ajoelhou (tanto a barba metafórica quanto a barba real ainda estão longe dessas expectativas), então talvez você não tenha motivação para usar a luz direta ou depurar o compilador.

Nosso primeiro amigo será static_assert , o que nos dá a oportunidade de verificar o valor booleano do tempo de compilação. Nosso segundo amigo será uma macro que habilita e desabilita o constexpr sempre que possível:

 #define CONSTEXPR constexpr //      //  #define CONSTEXPR //     

Com essa macro, podemos fazer a lógica funcionar em tempo de execução, o que significa que podemos anexar um depurador a ela.

Meta Crush Saga II - lute pela jogabilidade completamente em tempo de execução:


Obviamente, a Meta Crush Saga não ganhará o The Game Awards este ano . Tem um grande potencial, mas a jogabilidade não é totalmente executada em tempo de compilação . Isso pode incomodar os jogadores hardcore ... Não consigo me livrar do script bash, a menos que alguém adicione entrada do teclado e lógica suja na fase de compilação (e isso é loucura!). Mas acredito que um dia poderei abandonar completamente o arquivo executável do renderizador e exibir o estado do jogo em tempo de compilação :


O maluco com o pseudônimo saarraz estendeu o GCC para adicionar a construção static_print ao idioma . Essa construção deve pegar várias expressões constantes ou literais de string e produzi-las no estágio de compilação. Eu ficaria feliz se uma ferramenta desse tipo fosse adicionada ao padrão ou, pelo menos, estendida static_assert para que ela aceite expressões constantes.

No entanto, no C ++ 17, pode haver uma maneira de alcançar esse resultado. Os compiladores já produzem duas coisas - erros e avisos ! Se, de alguma maneira, pudermos gerenciar ou alterar os avisos para nossas necessidades, já receberemos uma conclusão digna. Eu tentei várias soluções, em particularatributo obsoleto :

 template <char... words> struct useless { [[deprecated]] void call() {} // Will trigger a warning. }; template <char... words> void output_as_warning() { useless<words...>().call(); } output_as_warning<'a', 'b', 'c'>(); // warning: 'void useless<words>::call() [with char ...words = {'a', 'b', 'c'}]' is deprecated // [-Wdeprecated-declarations] 

Embora a saída esteja obviamente presente e possa ser analisada, infelizmente, o código não pode ser reproduzido! Se, por pura coincidência, você for membro de uma sociedade secreta de programadores em C ++ que pode executar resultados durante a compilação, ficarei feliz em contratá-lo em minha equipe para criar a perfeita Meta Crush Saga II !

Conclusões:


Acabei vendendo o meu jogo de fraude . Espero que você ache este post curioso e aprenda algo novo no processo de lê-lo. Se você encontrar erros ou maneiras de melhorar o artigo, entre em contato comigo.

Quero agradecer à equipe do SwedenCpp por me permitir conduzir meu relatório de projeto em um de seus eventos. Além disso, quero expressar minha profunda gratidão a Alexander Gurdeev , que me ajudou a melhorar os aspectos significativos da Saga Meta Crush .

Source: https://habr.com/ru/post/pt414465/


All Articles