Meta Crush Saga: jeu de compilation

image

Dans le processus de transition vers le titre tant attendu de Lead Senior C ++ Over-Engineer , l'année dernière, j'ai décidé de réécrire le jeu que je développe pendant les heures de travail (Candy Crush Saga), en utilisant la quintessence du C ++ moderne (C ++ 17). Et c'est ainsi que Meta Crush Saga est née: un jeu qui tourne au stade de la compilation . J'ai été très inspiré par le jeu Nibbler de Matt Birner, qui utilisait de la métaprogrammation pure sur des modèles pour recréer le célèbre Snake avec le Nokia 3310.

"Quel type de jeu exécute-t-il au stade de la compilation ?", "A quoi cela ressemble-t-il?", "Quelles fonctionnalités de C ++ 17 avez-vous utilisées dans ce projet?", "Qu'avez-vous appris?" - Des questions similaires peuvent vous venir à l'esprit. Pour y répondre, vous devrez soit lire l'intégralité du message, soit supporter votre paresse intérieure et regarder une version vidéo du message - mon rapport de l' événement Meetup à Stockholm:


Remarque: dans l'intérêt de votre santé mentale et parce que errare humanum est , certains faits alternatifs sont donnés dans cet article.

Un jeu qui tourne au moment de la compilation?



Je pense que pour comprendre ce que j'entends par le "concept" d'un jeu exécuté au stade de la compilation , il faut comparer le cycle de vie d'un tel jeu avec le cycle de vie d'un jeu ordinaire.

Le cycle de vie d'un jeu régulier:


En tant que développeur régulier de jeux avec une vie normale, travaillant sur un travail régulier avec un niveau de santé mentale normal, vous commencez généralement par écrire la logique du jeu dans votre langue préférée (en C ++, bien sûr!), Puis exécutez le compilateur pour convertir trop souvent ces spaghettis logique dans un fichier exécutable . Après avoir double-cliqué sur le fichier exécutable (ou à partir de la console), le système d'exploitation génère un processus . Ce processus exécutera la logique du jeu , qui consiste en un cycle de jeu dans 99,42% du temps. Le cycle de jeu met à jour l' état du jeu conformément à certaines règles et à l'entrée de l'utilisateur , rend le nouvel état calculé du jeu en pixels, encore et encore et encore.


Le cycle de vie d'un jeu en cours de compilation:


En tant que sur-ingénieur qui crée son nouveau jeu de compilation sympa, vous utilisez toujours votre langage préféré (toujours en C ++, bien sûr!) Pour écrire la logique du jeu . Puis, comme précédemment, la phase de compilation se poursuit, mais il y a une torsion de l'intrigue: vous exécutez votre logique de jeu au stade de la compilation. Vous pouvez l'appeler «exécution» (compilation). Et ici, C ++ est très utile; il a des fonctionnalités telles que Template Meta Programming (TMP) et constexpr qui vous permettent d'effectuer des calculs dans la phase de compilation . Plus tard, nous considérerons les fonctionnalités qui peuvent être utilisées pour cela. Étant donné qu'à ce stade, nous exécutons la logique du jeu, nous devons également insérer à ce moment -là l'entrée du joueur . Évidemment, notre compilateur créera toujours un fichier exécutable en sortie. À quoi peut-il servir? Le fichier exécutable ne contiendra plus la boucle du jeu , mais il a une mission très simple: afficher un nouvel état calculé . Appelons ce fichier exécutable le rendu , et les données qu'il rend sont rendues . Dans notre rendu, ni beaux effets de particules ni ombres d'occlusion ambiante ne seront contenus, ce sera ASCII. Le rendu ASCII du nouvel état calculé est une propriété pratique qui peut être facilement démontrée au lecteur, mais en plus, nous le copions dans un fichier texte. Pourquoi un fichier texte? Évidemment, car il peut être en quelque sorte combiné avec le code et répéter toutes les étapes précédentes, obtenant ainsi une boucle .

Comme vous pouvez déjà le comprendre, le jeu exécuté pendant le processus de compilation consiste en un cycle de jeu dans lequel chaque image du jeu est une étape de compilation . Chaque étape de la compilation calcule un nouvel état du jeu, qui peut être montré au joueur et inséré dans la trame / étape suivante de la compilation .

Vous pouvez contempler ce magnifique diagramme autant que vous le souhaitez jusqu'à ce que vous compreniez ce que je viens d'écrire:


Avant d'entrer dans les détails de la mise en œuvre d'un tel cycle, je suis sûr que vous voulez me poser la seule question ...

"Pourquoi s'embêter à faire ça?"



Pensez-vous vraiment que ruiner mon idylle de métaprogrammation C ++ est une question si fondamentale? Oui, pour rien dans la vie!

  • La première chose et la plus importante est que le jeu exécuté au stade de la compilation aura une vitesse d'exécution incroyable, car la majeure partie des calculs sont effectués pendant la phase de compilation . La vitesse d'exécution est la clé du succès de notre jeu AAA avec des graphismes ASCII!
  • Vous réduisez la probabilité que certains crustacés apparaissent dans votre référentiel et vous demandez de réécrire le jeu dans Rust . Son discours bien préparé s'effondrera dès que vous lui expliquerez qu'un pointeur invalide ne peut pas exister au moment de la compilation. Les programmeurs confiants de Haskell peuvent même confirmer la sécurité des types dans votre code.
  • Vous gagnerez le respect du royaume Javascript hipster, dans lequel tout cadre repensé avec un fort syndrome NIH peut régner, à condition qu'il trouve un nom sympa.
  • Un de mes amis disait que n'importe quelle ligne de code Perl peut être utilisée de facto comme un mot de passe très fort. Je suis sûr qu'il n'a jamais essayé de générer des mots de passe à partir de la compilation C ++ .

Comment? Êtes-vous satisfait de mes réponses? Alors peut-être que votre question devrait être: "Comment réussissez-vous même à faire cela?"

En fait, je voulais vraiment expérimenter avec les fonctionnalités ajoutées en C ++ 17 . Un certain nombre de fonctionnalités sont destinées à augmenter l'efficacité du langage, ainsi que pour la métaprogrammation (principalement constexpr). J'ai pensé qu'au lieu d'écrire de petits exemples de code, il serait beaucoup plus intéressant de transformer tout cela en jeu. Les projets pour animaux de compagnie sont un excellent moyen d'apprendre des concepts que vous n'avez pas souvent à utiliser dans votre travail. La possibilité d'exécuter la logique de jeu de base au moment de la compilation prouve à nouveau que les modèles et constepxr sont des sous- ensembles Turing-complets du langage C ++.

Revue du jeu Meta Crush Saga


Match 3:


Meta Crush Saga est un jeu de jonction de tuiles similaire à Bejeweled et Candy Crush Saga . Le cœur des règles du jeu est de connecter trois tuiles avec le même motif pour obtenir des points. Voici un bref aperçu de l' état du jeu que j'ai «vidé» (le dumping en ASCII est sacrément facile à obtenir):

  R "(
     Saga Meta Crush      
 ------------------------  
 |  | 
 |  RBGBBYGR | 
 |  | 
 |  | 
 |  YYGRBGBR | 
 |  | 
 |  | 
 |  RBYRGRYG | 
 |  | 
 |  | 
 |  RYBY (R) YGY | 
 |  | 
 |  | 
 |  BGYRYGGR | 
 |  | 
 |  | 
 |  RYBGYBBG | 
 |  | 
 ------------------------  
 > score: 9009
 > coups: 27
 ) " 


Le gameplay de ce jeu Match-3 lui-même n'est pas particulièrement intéressant, mais qu'en est-il de l'architecture sur laquelle tout cela fonctionne? Pour que vous puissiez le comprendre, je vais essayer d'expliquer chaque partie du cycle de vie de ce jeu à la compilation en termes de code.

Injection de l'état du jeu:



Si vous êtes un passionné ou un amateur de C ++, vous avez peut-être remarqué que le vidage de l'état du jeu précédent commence par le modèle suivant: R "( . En fait, il s'agit d'un littéral de chaîne C ++ 11 brut , ce qui signifie que je n'ai pas besoin d'échapper les caractères spéciaux, par exemple, la traduction chaînes : le littéral de chaîne brut est stocké dans un fichier appelé current_state.txt .

Comment injecter cet état actuel du jeu dans un état de compilation? Ajoutons-le simplement aux entrées de boucle!

// 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; }; 

Qu'il s'agisse d'un fichier .txt ou d'un fichier .h , la directive include du préprocesseur C fonctionnera de la même manière: elle copie le contenu du fichier à son emplacement. Ici, je copie le littéral de chaîne brut de l'état du jeu dans ascii dans une variable appelée game_state_string .

Notez que le fichier d'en- tête loop_inputs.hpp étend également la saisie au clavier à l'étape de trame / compilation actuelle. Contrairement à l'état du jeu, l'état du clavier est assez petit et peut facilement être obtenu comme définition d'un préprocesseur.

Calcul d'un nouvel état au moment de la compilation:



Maintenant que nous avons collecté suffisamment de données, nous pouvons calculer le nouvel état. Enfin, nous avons atteint le point où nous devons écrire le fichier 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; } 

Étrange, mais ce code C ++ ne semble pas si déroutant compte tenu de ce qu'il fait. La plupart du code est exécuté dans la phase de compilation, cependant, il suit les paradigmes traditionnels de programmation POO et procédurale. Seule la dernière ligne - le rendu - est un obstacle pour effectuer pleinement les calculs au moment de la compilation. Comme nous le verrons ci-dessous, en jetant un peu de constexpr aux bons endroits, nous pouvons obtenir une métaprogrammation assez élégante en C ++ 17. Je trouve délicieuse la liberté que C ++ nous donne quand il s'agit d'une exécution mixte lors de l'exécution et de la compilation.

Vous remarquerez également que ce code n'exécute qu'une seule trame, il n'y a pas de boucle de jeu . Résolvons ce problème!

Nous collons tout ensemble:



Si vous dégoûtez mes astuces avec C ++ , alors j'espère que cela ne vous dérangera pas de voir mes compétences Bash . En fait, ma boucle de jeu n'est rien de plus qu'un script bash qui compile constamment.

 #  !  ,    !!! 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 

En fait, j'avais un peu de mal à obtenir une entrée clavier depuis la console. Au départ, je voulais me mettre en parallèle avec la compilation. Après de nombreux essais et erreurs, j'ai réussi à faire fonctionner plus ou moins quelque chose avec la commande de read de Bash . Je n'ose jamais combattre le magicien Bash en duel - cette langue est trop sinistre!

Donc, je dois admettre que pour gérer le cycle de jeu, j'ai dû recourir à une autre langue. Bien que techniquement rien ne m'empêche d'écrire cette partie du code en C ++. De plus, cela ne nie pas le fait que 90% de la logique de mon jeu est exécutée au sein de l'équipe de compilation g ++ , ce qui est assez étonnant!

Un petit gameplay pour se reposer les yeux:


Maintenant que vous avez éprouvé le tourment d'expliquer l'architecture du jeu, le temps est venu pour des peintures accrocheuses:


Ce gif pixelisé est un enregistrement de la façon dont je joue à Meta Crush Saga . Comme vous pouvez le voir, le jeu fonctionne assez bien pour être jouable en temps réel. De toute évidence, elle n'est pas si attirante que je peux diffuser son Twitch et devenir la nouvelle Pewdiepie, mais elle fonctionne!

L'un des aspects amusants du stockage de l' état d'un jeu dans un fichier .txt est la possibilité de tricher ou de tester des cas extrêmes très facilement.

Maintenant que je vous ai brièvement présenté l'architecture, nous allons nous plonger dans la fonctionnalité C ++ 17 utilisée dans ce projet. Je ne considérerai pas la logique du jeu en détail, car elle se réfère exclusivement au Match-3, mais je parlerai plutôt des aspects de C ++ qui peuvent être appliqués dans d'autres projets.

Mes tutoriels sur C ++ 17:



Contrairement à C ++ 14, qui contenait principalement des correctifs mineurs, le nouveau standard C ++ 17 peut nous offrir beaucoup. On espérait que finalement les fonctionnalités tant attendues (modules, coroutines, concepts ...) feraient enfin leur apparition, mais ... en général ... elles n'apparaissent pas; cela a bouleversé beaucoup d'entre nous. Mais après avoir levé le deuil, nous avons trouvé de nombreux petits trésors inattendus qui sont néanmoins tombés dans la norme.

J'ose dire que les enfants qui aiment la métaprogrammation sont trop gâtés cette année! Des modifications et des ajouts mineurs séparés à la langue vous permettent désormais d'écrire du code qui fonctionne très bien au moment de la compilation et après, au moment de l'exécution.

Constepxr dans tous les domaines:


Comme Ben Dean et Jason Turner l'avaient prédit dans leur rapport C ++ 14 , C ++ vous permet d'améliorer rapidement la compilation des valeurs au moment de la compilation avec le mot-clé omnipotent constexpr . En localisant ce mot clé aux bons endroits, vous pouvez indiquer au compilateur que l'expression est constante et peut être évaluée directement au moment de la compilation. En C ++ 11, nous pouvions déjà écrire ce code:

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

Bien que le mot clé constexpr soit très puissant, il a un certain nombre de restrictions d'utilisation, ce qui rend difficile l'écriture de code expressif de cette manière.

C ++ 14 a considérablement réduit les exigences de constexpr et est devenu beaucoup plus naturel à utiliser. Notre fonction factorielle précédente peut être réécrite comme suit:

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

C ++ 14 s'est débarrassé de la règle selon laquelle une fonction constexpr ne devrait comprendre qu'une seule instruction return, ce qui nous a obligés à utiliser l' opérateur ternaire comme bloc de construction principal. Maintenant, C ++ 17 apporte encore plus d'applications de mots clés constexpr que nous pouvons explorer!

Branchement au moment de la compilation:


Avez-vous déjà été dans une situation où vous devez obtenir un comportement différent en fonction du paramètre de modèle que vous manipulez? Supposons que nous ayons besoin d'une fonction paramétrée serialize , qui appellera .serialize() si l'objet le fournit, sinon il aura recours à to_string pour cela. Comme expliqué plus en détail dans cet article sur SFINAE , vous devrez probablement écrire un tel code étranger:

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

Ce n'est que dans un rêve que vous pourriez réécrire cette astuce laide de l' astuce SFINAE en C ++ 14 dans un code aussi magnifique:

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

Malheureusement, lorsque vous vous êtes réveillé et avez commencé à écrire du vrai code C ++ 14 , votre compilateur a émis un message désagréable concernant l'appel à serialize(42); . Il a expliqué qu'un obj type int n'a pas de fonction membre serialize() . Peu importe comment cela vous exaspère, le compilateur a raison! Avec ce code, il essaiera toujours de compiler les deux branches - return obj.serialize(); et
return std::to_string(obj); . Pour la branche int , return obj.serialize(); Cela pourrait bien se révéler être une sorte de code mort, car has_serialize(obj) retournera toujours false , mais le compilateur devra toujours le compiler.

Comme vous l'avez probablement deviné, C ++ 17 nous sauve d'une situation aussi désagréable, car il a permis d'ajouter constexpr après l'instruction if pour «forcer» la ramification au moment de la compilation et éliminer les constructions inutilisées:

 // 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 } } 


Évidemment, c'est une énorme amélioration par rapport à l'astuce SFINAE que nous avons dû appliquer auparavant. Après cela, nous avons commencé à ressentir la même dépendance que Ben et Jason - nous avons commencé à utiliser constexpr partout et toujours. Hélas, il existe un autre endroit où le mot-clé constexpr conviendrait, mais pas encore utilisé: les paramètres constexpr .

Paramètres Constexpr:


Si vous faites attention, vous remarquerez peut-être un motif étrange dans l'exemple de code précédent. Je parle des entrées de boucle:

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

Pourquoi la variable game_state_string est- elle encapsulée dans un lambda constexpr? Pourquoi ne fait-elle pas d'elle une variable globale constexpr ?

Je voulais transmettre cette variable et son contenu profondément dans certaines fonctions. Par exemple, vous devez le passer à mon parse_board et l'utiliser dans certaines expressions 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...”); 

Si nous procédons de cette façon, le compilateur grincheux se plaindra que le paramètre game_state_string n'est pas une expression constante. Lorsque je crée mon tableau de tuiles, je dois calculer directement sa capacité fixe (nous ne pouvons pas utiliser de vecteurs au moment de la compilation car ils nécessitent une allocation de mémoire) et le passer comme argument au modèle de valeur dans std :: array . Par conséquent, l' expression parse_board_size (game_state_string) doit être une expression constante. Bien que parse_board_size soit explicitement marqué comme constexpr , game_state_string ne l'est pas et ne peut pas l'être! Dans ce cas, deux règles interfèrent avec nous:

  • Les arguments d'une fonction constexpr ne sont pas constexpr!
  • Et nous ne pouvons pas ajouter constexpr devant eux!

Tout cela se résume au fait que les fonctions constexpr DOIVENT être applicables dans le calcul à la fois du temps d'exécution et du temps de compilation. En supposant l'existence de paramètres constexpr , cela ne permettra pas leur utilisation au moment de l'exécution.


Heureusement, il existe un moyen de régler ce problème. Au lieu d'accepter la valeur comme paramètre régulier d'une fonction, nous pouvons encapsuler cette valeur dans un type et passer ce type comme paramètre de modèle:

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

Dans cet exemple de code, je crée un type structurel GameString qui a une fonction de membre statique constexpr value () qui renvoie le littéral de chaîne que je veux transmettre à parse_board . Dans parse_board, j'obtiens ce type via le paramètre de modèle GameStringType , en utilisant les règles d'extraction des arguments de modèle. Ayant un GameStringType , étant donné que value () est constexpr, je peux simplement appeler la valeur de la fonction membre statique () au bon moment pour obtenir un littéral de chaîne même dans les endroits où des expressions constantes sont nécessaires.

Nous avons réussi à encapsuler le littéral afin de le transmettre en quelque sorte à parse_board en utilisant constexpr. Cependant, il est très ennuyeux de devoir définir un nouveau type chaque fois que vous devez envoyer un nouveau littéral parse_board : "... quelque chose1 ...", "... quelque chose2 ...". Pour résoudre ce problème en C ++ 11 , vous pouvez appliquer des macros laides et des adressages indirects en utilisant l'union anonyme et lambda. Michael Park a bien expliqué ce sujet dans l' un de ses articles .

En C ++ 17, la situation est encore meilleure. Si nous listons les exigences pour passer notre littéral de chaîne, nous obtenons ce qui suit:

  • Fonction générée
  • C'est constexpr
  • Avec un nom unique ou anonyme

Ces exigences devraient vous donner un indice. Nous avons besoin de constexpr lambda ! Et en C ++ 17, ils ont naturellement ajouté la possibilité d'utiliser le mot clé constexpr pour les fonctions lambda. Nous pouvons réécrire notre exemple de code comme suit:

 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. 

Croyez-moi, cela semble déjà beaucoup plus pratique que le piratage précédent en C ++ 11 à l' aide de macros. J'ai découvert ce truc génial grâce à Bjorn Fahler , un membre du groupe mitap C ++ auquel je participe. En savoir plus sur cette astuce dans son blog . Il convient également de considérer qu'en fait le mot clé constexpr est facultatif dans ce cas: tous les lambdas ayant la capacité de devenir constexpr le seront par défaut. L'ajout explicite de constexpr est une signature qui simplifie notre dépannage.

Vous devez maintenant comprendre pourquoi j'ai été obligé d'utiliser un lambda constexpr pour transmettre une chaîne représentant l'état du jeu. Regardez cette fonction lambda et vous aurez à nouveau une autre question. Quel est ce type constexpr_string que j'utilise également pour envelopper le stock littéral?

constexpr_string et constexpr_string_view:

Lorsque vous travaillez avec des chaînes, vous ne devez pas les traiter dans le style C. Vous devez oublier tous ces algorithmes ennuyeux qui effectuent des itérations brutes et vérifier la complétion zéro! L'alternative offerte par C ++ est les algorithmes omnipotents std :: string et STL . Malheureusement, std :: string peut nécessiter une allocation de mémoire sur le tas (même avec Small String Optimization) pour stocker son contenu. Une ou deux normes en arrière, nous pourrions utiliser constexpr new / delete ou nous pourrions passer des allocateurs constexpr à std :: string , mais maintenant nous devons trouver une autre solution.

Mon approche était d'écrire une classe constexpr_string avec une capacité fixe. Cette capacité est transmise en tant que paramètre au modèle de valeur. Voici un bref aperçu de ma classe:

 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_; } //     . // ... }; 

Ma classe constexpr_string s'efforce d'imiter l'interface std :: string le plus près possible (pour les opérations dont j'ai besoin): nous pouvons demander des itérateurs du début et de la fin , obtenir la taille (taille) , accéder aux données (données) , supprimer (effacer) une partie d'entre elles, obtenir sous-chaîne en utilisant substr et ainsi de suite. Cela facilite la conversion d'un morceau de code de std :: string en constexpr_string . Vous vous demandez peut-être ce qui se passe lorsque nous devons utiliser des opérations qui nécessitent généralement une mise en évidence dans std :: string . Dans de tels cas, j'ai été obligé de les convertir en opérations immuables qui créent une nouvelle instance de constexpr_string .

Jetons un coup d'œil à l'opération d' ajout :

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


Vous n'avez pas besoin d'avoir un prix Fields pour supposer que si nous avons une chaîne de taille N et une chaîne de taille M , alors une chaîne de taille N + M sera suffisante pour stocker leur concaténation. Nous pouvons gaspiller une partie du «référentiel de compilation», car les deux lignes peuvent ne pas utiliser toute la capacité, mais c'est un prix plutôt petit pour plus de commodité. Évidemment, j'ai également écrit un doublon de std :: string_view , qui s'appelait constexpr_string_view .

Avec ces deux classes, j'étais prêt à écrire un code élégant pour analyser mon état de jeu . Pensez à quelque chose comme ça:

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

Il était assez facile de parcourir les joyaux du terrain de jeu - au fait, avez-vous remarqué une autre caractéristique précieuse de C ++ 17 dans cet exemple de code?

Oui! Je n'ai pas eu à spécifier explicitement la capacité de constexpr_string lors de sa construction. Auparavant, lors de l'utilisation d'un modèle de classe , nous devions indiquer explicitement ses arguments. Pour éviter ces pangs , nous créons des fonctions make_xxx car les paramètres des modèles de fonction peuvent être tracés. Découvrez comment le suivi des arguments des modèles de classe change nos vies pour le mieux:

 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"); // ^    ,  . 

Dans certaines situations difficiles, vous devrez aider le compilateur à calculer correctement les arguments. Si vous rencontrez un tel problème, étudiez les manuels pour les calculs d'arguments définis par l'utilisateur .

Nourriture gratuite de STL:


Eh bien, nous pouvons toujours tout réécrire par nous-mêmes. Mais peut-être que les membres du comité ont généreusement préparé quelque chose pour nous dans la bibliothèque standard?

Nouveaux types d'assistance:

En C ++ 17 , std :: variant et std :: optional sont ajoutés aux types de dictionnaire standard, basés sur constexpr . Le premier est très intéressant car il nous permet d'exprimer des associations de type sûr, mais l'implémentation dans la bibliothèque libstdc ++ avec GCC 7.2 a des problèmes lors de l'utilisation d'expressions constantes. Par conséquent, j'ai abandonné l'idée d'ajouter std :: variant à mon code et d'utiliser uniquement std :: optional .

Avec le type T, le type std :: optional nous permet de créer un nouveau type std :: optional <T> , qui peut contenir soit une valeur de type T, soit rien. Ceci est assez similaire aux types significatifs qui autorisent une valeur indéfinie en C # . Regardons la fonction find_in_board , qui retourne la position du premier élément sur un champ qui confirme que le prédicat est correct. Il n'y a peut-être pas un tel élément sur le terrain. Pour gérer cette situation, le type de position doit être facultatif:

 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, ""   *. /* ... */ } 

Auparavant, nous devions recourir à la sémantique des pointeurs , ou ajouter un «état vide» directement au type de position, ou renvoyer un booléen et prendre le paramètre de sortie . Certes, c'était assez gênant!

Certains types préexistants ont également reçu un support constexpr : tuple et pair . Je ne vais pas expliquer en détail leur utilisation, car beaucoup a déjà été écrit à leur sujet, mais je partagerai une de mes déceptions. Le comité a ajouté du sucre syntaxique à la norme pour extraire les valeurs contenues dans un tuple ou une paire . Ce nouveau type de déclaration appelé liaison structurée, utilise des parenthèses pour spécifier dans quelles variables stocker le tuple ou la paire divisé :

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

Très intelligent! Mais il est dommage que les membres du comité [n'aient pas pu, ne voulaient pas, n'aient pas trouvé le temps, aient oublié] de les rendre amicaux avec constexpr . Je m'attendrais à quelque chose comme ça:

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

Nous avons maintenant des conteneurs et des types d'assistance complexes, mais comment pouvons-nous les manipuler facilement?

Algorithmes:

La mise à niveau d'un conteneur pour le traitement de constexpr est une tâche assez monotone. Par rapport à cela, le portage de constexpr vers des algorithmes non modificateurs semble assez simple. Mais il est plutôt étrange qu'en C ++ 17 nous n'ayons pas vu de progrès dans ce domaine, il n'apparaîtra qu'en C ++ 20 . Par exemple, les merveilleux algorithmes std :: find n'ont pas reçu de signatures constexpr .

Mais n'ayez pas peur! Comme Ben et Jason l'ont expliqué, vous pouvez facilement transformer l'algorithme en constexpr en copiant simplement l'implémentation actuelle (mais n'oubliez pas les droits d'auteur); cppreference est bon. Mesdames et messieurs, je présente à votre attentionconstexpr 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'entends déjà depuis les tribunes les cris des fans d'optimisation! Oui, le simple fait d'ajouter constexpr devant l'exemple de code aimablement fourni par cppreference pourrait ne pas nous donner une vitesse idéale lors de l'exécution . Mais si nous devons améliorer cet algorithme, il sera nécessaire pour la vitesse au moment de la compilation . Pour autant que je sache, en ce qui concerne la vitesse de compilation , les solutions simples sont les meilleures.

Vitesse et bugs:


Les développeurs de tout jeu AAA devraient investir dans la résolution de ces problèmes, non?

Vitesse:


Lorsque j'ai réussi à créer une version à moitié fonctionnelle de Meta Crush Saga , le travail s'est déroulé plus facilement. En fait, j'ai réussi à atteindre un peu plus de 3 FPS (images par seconde) sur mon ancien portable avec i5 overclocké à 1,80 GHz (la fréquence est importante dans ce cas). Comme dans tout projet, j'ai rapidement réalisé que le code précédemment écrit était dégoûtant et j'ai commencé à réécrire l'analyse de l'état du jeu en utilisant constexpr_string et des algorithmes standard. Bien que cela ait rendu le code beaucoup plus pratique à maintenir, les changements ont sérieusement affecté la vitesse; le nouveau plafond est de 0,5 FPS .

Malgré le vieil adage à propos de C ++, les «abstractions zéro-tête» ne sont pas applicables aux calculs au moment de la compilation. Ceci est tout à fait logique si nous considérons le compilateur comme un interprète d'un «code temporel de compilation». Des améliorations pour divers compilateurs sont encore possibles, mais il existe également des opportunités de croissance pour nous, les auteurs de ce code. Voici une liste incomplète d'observations et de conseils que j'ai trouvés, peut-être spécifiques à GCC:

  • Les tableaux C fonctionnent beaucoup mieux que std :: array . std :: array est un peu de cosmétiques C ++ modernes au-dessus d'un tableau de style C et vous devez payer un prix pour l'utiliser dans de telles conditions.
  • , ( ) . , , , . : , , , , ( ) , .
  • , . , .
  • . GCC. , «».

:



Plusieurs fois, mon compilateur a craché de terribles erreurs de compilation et ma logique de code a souffert. Mais comment trouver l'endroit où se cache le bug? Sans débogueur et printf, les choses se compliquent. Si votre "barbe de programmeur" métaphorique ne s'est pas encore mise à genoux (la barbe métaphorique et ma vraie barbe sont encore loin de ces attentes), alors vous n'avez peut-être pas la motivation d'utiliser templight ou de déboguer le compilateur.

Notre premier ami sera static_assert , ce qui nous donne la possibilité de vérifier la valeur booléenne du temps de compilation. Notre deuxième ami sera une macro qui active et désactive constexpr dans la mesure du possible:

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

Avec cette macro, nous pouvons faire fonctionner la logique au moment de l'exécution, ce qui signifie que nous pouvons y attacher un débogueur.

Meta Crush Saga II - efforcez-vous de jouer complètement au moment de l'exécution:


De toute évidence, Meta Crush Saga ne remportera pas les Game Awards cette année . Il a un grand potentiel, mais le gameplay n'est pas entièrement exécuté au moment de la compilation . Cela peut ennuyer les joueurs hardcore ... Je ne peux pas me débarrasser du script bash à moins que quelqu'un ajoute une entrée au clavier et une logique impure dans la phase de compilation (et c'est une folie franche!). Mais je pense qu'un jour je pourrai abandonner complètement le fichier exécutable du moteur de rendu et afficher l' état du jeu au moment de la compilation :


Le fou avec l'alias saarraz a étendu GCC pour ajouter la construction static_print au langage . Cette construction doit prendre plusieurs expressions constantes ou littéraux de chaîne et les afficher au stade de la compilation. Je serais heureux si un tel outil était ajouté au standard, ou au moins étendu static_assert afin qu'il accepte des expressions constantes.

Cependant, en C ++ 17, il peut y avoir un moyen d'obtenir ce résultat. Les compilateurs produisent déjà deux choses - des erreurs et des avertissements ! Si nous pouvons en quelque sorte gérer ou modifier les avertissements selon nos besoins, nous recevrons déjà une conclusion valable. J'ai essayé plusieurs solutions, notammentattribut obsolète :

 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] 

Bien que la sortie soit évidemment présente et qu'elle puisse être analysée, malheureusement, le code est injouable! Si, par pure coïncidence, vous êtes membre d'une société secrète de programmeurs C ++ qui peuvent effectuer une sortie pendant la compilation, alors je serai heureux de vous embaucher dans mon équipe pour créer la parfaite Meta Crush Saga II !

Conclusions:


J'ai fini par vous vendre mon jeu d' arnaque . J'espère que vous trouverez ce post curieux et apprendrez quelque chose de nouveau dans le processus de lecture. Si vous trouvez des erreurs ou des moyens d'améliorer l'article, contactez-moi.

Je tiens à remercier l' équipe de SwedenCpp de m'avoir permis de réaliser mon rapport de projet lors d'un de leurs événements. En outre, je tiens à exprimer ma profonde gratitude à Alexander Gurdeev , qui m'a aidé à améliorer les aspects importants de la saga Meta Crush .

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


All Articles