Meta Crush Saga: Spiel zur Kompilierungszeit

Bild

Auf dem Weg zum lang erwarteten Titel des Lead Senior C ++ Over-Engineer habe ich mich letztes Jahr entschlossen, das Spiel, das ich während der Arbeitszeit entwickle (Candy Crush Saga), unter Verwendung der Quintessenz des modernen C ++ (C ++ 17) neu zu schreiben. Und so wurde Meta Crush Saga geboren: ein Spiel, das in der Kompilierungsphase läuft . Ich war sehr inspiriert von Matt Birners Nibbler- Spiel, bei dem die Metapher mithilfe der reinen Metaprogrammierung auf Vorlagen nachgebildet wurde, um die berühmte Schlange mit dem Nokia 3310 nachzubilden.

"Was für ein Spiel läuft es in der Kompilierungsphase ?", "Wie sieht es aus?", "Welche Funktionalität von C ++ 17 haben Sie in diesem Projekt verwendet?", "Was haben Sie gelernt?" - Ähnliche Fragen können Ihnen in den Sinn kommen. Um sie zu beantworten, müssen Sie entweder den gesamten Beitrag lesen oder sich mit Ihrer inneren Faulheit abfinden und eine Videoversion des Beitrags ansehen - mein Bericht vom Meetup-Event in Stockholm:


Hinweis: Aus Gründen Ihrer psychischen Gesundheit und weil Sie sich menschlich irren , werden in diesem Artikel einige alternative Fakten aufgeführt.

Ein Spiel, das zur Kompilierungszeit läuft?



Ich denke, um zu verstehen, was ich unter dem "Konzept" eines Spiels verstehe, das in der Kompilierungsphase ausgeführt wird , müssen Sie den Lebenszyklus eines solchen Spiels mit dem Lebenszyklus eines normalen Spiels vergleichen.

Der Lebenszyklus eines regulären Spiels:


Als regelmäßiger Entwickler von Spielen mit normalem Leben, der an einem normalen Job mit normaler psychischer Gesundheit arbeitet, schreiben Sie normalerweise zunächst Spielelogik in Ihrer Lieblingssprache (natürlich in C ++!) Und führen dann den Compiler aus , um dies zu konvertieren, zu oft wie Spaghetti Logik in einer ausführbaren Datei . Nach einem Doppelklick auf die ausführbare Datei (oder ausgehend von der Konsole) erzeugt das Betriebssystem einen Prozess . Dieser Prozess führt die Spiellogik aus , die in 99,42% der Fälle aus einem Spielzyklus besteht. Der Spielzyklus aktualisiert den Status des Spiels gemäß bestimmten Regeln und Benutzereingaben und gibt den neu berechneten Status des Spiels immer wieder in Pixel wieder.


Der Lebenszyklus eines Spiels, das während des Kompilierungsprozesses ausgeführt wird:


Als Überentwickler, der sein neues cooles Kompilierungsspiel erstellt, verwenden Sie immer noch Ihre Lieblingssprache (natürlich immer noch C ++!), Um Spielelogik zu schreiben. Dann geht die Kompilierungsphase nach wie vor weiter , aber es gibt eine Wendung in der Handlung: Sie führen Ihre Spielelogik in der Kompilierungsphase aus. Sie können es "Ausführung" (Kompilierung) nennen. Und hier ist C ++ sehr nützlich; Es verfügt über Funktionen wie Template Meta Programming (TMP) und constexpr , mit denen Sie Berechnungen in der Kompilierungsphase durchführen können . Später werden wir die Funktionalität betrachten, die dafür verwendet werden kann. Da wir zu diesem Zeitpunkt die Logik des Spiels ausführen, müssen wir in diesem Moment auch die Eingabe des Spielers einfügen. Offensichtlich erstellt unser Compiler bei der Ausgabe weiterhin eine ausführbare Datei . Wofür kann es verwendet werden? Die ausführbare Datei enthält nicht mehr die Spielschleife , hat jedoch eine sehr einfache Aufgabe: Anzeigen eines neuen berechneten Status . Nennen wir diese ausführbare Datei den Renderer , und die gerenderten Daten werden gerendert . In unserem Rendering werden weder schöne Partikeleffekte noch Umgebungsokklusionsschatten enthalten sein, es wird ASCII sein. Das ASCII- Rendering des neuen berechneten Status ist eine praktische Eigenschaft, die dem Player leicht demonstriert werden kann. Zusätzlich kopieren wir sie in eine Textdatei. Warum eine Textdatei? Offensichtlich, weil es irgendwie mit dem Code kombiniert werden kann und alle vorherigen Schritte erneut ausführt, wodurch eine Schleife erhalten wird .

Wie Sie bereits verstehen können, besteht das während des Kompilierungsprozesses ausgeführte Spiel aus einem Spielzyklus, in dem jeder Frame des Spiels eine Kompilierungsphase darstellt . Jede Kompilierungsstufe berechnet einen neuen Status des Spiels, der dem Spieler angezeigt und in den nächsten Frame / die nächste Kompilierungsstufe eingefügt werden kann.

Sie können dieses großartige Diagramm so oft betrachten, wie Sie möchten, bis Sie verstehen, was ich gerade geschrieben habe:


Bevor wir uns mit den Details der Implementierung eines solchen Zyklus befassen, möchten Sie mir sicher die einzige Frage stellen ...

"Warum sich die Mühe machen, das zu tun?"



Denken Sie wirklich, dass es eine so grundlegende Frage ist, meine C ++ - Metaprogrammier-Idylle zu ruinieren? Ja, für nichts im Leben!

  • Das erste und wichtigste ist, dass das in der Kompilierungsphase ausgeführte Spiel eine erstaunliche Ausführungszeit aufweist, da der Großteil der Berechnungen in der Kompilierungsphase ausgeführt wird . Die Laufzeitgeschwindigkeit ist der Schlüssel zum Erfolg unseres AAA-Spiels mit ASCII-Grafik!
  • Sie verringern die Wahrscheinlichkeit, dass Krebstiere in Ihrem Repository erscheinen, und bitten Sie, das Spiel in Rust neu zu schreiben. Seine gut vorbereitete Rede wird auseinanderfallen, sobald Sie ihm erklären, dass zum Zeitpunkt der Kompilierung kein ungültiger Zeiger vorhanden sein kann. Die selbstbewussten Programmierer von Haskell können sogar die Typensicherheit in Ihrem Code bestätigen.
  • Sie werden den Respekt des Javascript- Hipster-Königreichs gewinnen, in dem jedes neu gestaltete Framework mit einem starken NIH-Syndrom herrschen kann, vorausgesetzt, es hat einen coolen Namen.
  • Ein Freund von mir pflegte zu sagen, dass jede Zeile Perl-Code de facto als sehr sicheres Passwort verwendet werden kann. Ich bin sicher, dass er nie versucht hat, Passwörter aus der C ++ - Kompilierungszeit zu generieren.

Und wie? Bist du mit meinen Antworten zufrieden? Dann sollte Ihre Frage vielleicht lauten: "Wie schaffen Sie das überhaupt?"

Eigentlich wollte ich unbedingt mit der in C ++ 17 hinzugefügten Funktionalität experimentieren. Nicht wenige Funktionen sollen die Effektivität der Sprache sowie die Metaprogrammierung (hauptsächlich constexpr) erhöhen. Ich dachte, anstatt kleine Codebeispiele zu schreiben, wäre es viel interessanter, all dies in ein Spiel zu verwandeln. Haustierprojekte sind eine großartige Möglichkeit, Konzepte zu lernen, die Sie in Ihrer Arbeit nicht oft verwenden müssen. Die Fähigkeit, die grundlegende Spielelogik zur Kompilierungszeit erneut auszuführen, beweist, dass Vorlagen und constepxr Turing-vollständige Teilmengen der C ++ - Sprache sind.

Meta Crush Saga Game Review


Match-3-Spiel:


Meta Crush Saga ist ein Spiel , das Bejeweled und Candy Crush Saga ähnelt. Der Kern der Spielregeln besteht darin, drei Kacheln mit demselben Muster zu verbinden, um Punkte zu erhalten. Hier ist ein kurzer Blick auf den Stand des Spiels , den ich "gedumpt" habe (Dumping in ASCII ist verdammt einfach zu bekommen):

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


Das Gameplay dieses Match-3-Spiels selbst ist nicht besonders interessant, aber was ist mit der Architektur, auf der alles funktioniert? Damit Sie es verstehen, werde ich versuchen, jeden Teil des Lebenszyklus dieses Spiels zur Kompilierungszeit in Form von Code zu erklären.

Spielstatus-Injektion:



Wenn Sie ein leidenschaftlicher C ++ - Liebhaber oder Pedant sind, haben Sie möglicherweise bemerkt, dass der vorherige Spielstatus-Dump mit dem folgenden Muster beginnt: R "( . Tatsächlich handelt es sich um ein rohes C ++ 11-Zeichenfolgenliteral , was bedeutet, dass ich Sonderzeichen, z. B. Übersetzung, nicht entkommen muss Zeichenfolgen : Das rohe Zeichenfolgenliteral wird in einer Datei namens current_state.txt gespeichert.

Wie fügen wir diesen aktuellen Status des Spiels in einen Kompilierungsstatus ein? Fügen wir es einfach zu den Schleifeneingängen hinzu!

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

Unabhängig davon , ob es sich um eine TXT- Datei oder eine H- Datei handelt, funktioniert die Include- Anweisung des C-Präprozessors auf dieselbe Weise: Sie kopiert den Inhalt der Datei an ihren Speicherort. Hier kopiere ich das Raw-String-Literal des Spielstatus in ASCII in eine Variable namens game_state_string .

Beachten Sie, dass die Header- Datei loop_inputs.hpp auch die Tastatureingabe auf den aktuellen Frame- / Kompilierungsschritt erweitert. Im Gegensatz zum Status des Spiels ist der Status der Tastatur recht klein und kann leicht als Definition eines Präprozessors abgerufen werden.

Berechnen eines neuen Status zur Kompilierungszeit:



Nachdem wir genügend Daten gesammelt haben, können wir den neuen Status berechnen. Endlich haben wir den Punkt erreicht, an dem wir die Datei main.cpp schreiben müssen:

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

Seltsam, aber dieser C ++ - Code sieht nicht so verwirrend aus, wenn man bedenkt, was er tut. Der größte Teil des Codes wird in der Kompilierungsphase ausgeführt, folgt jedoch den traditionellen OOP- und prozeduralen Programmierparadigmen. Nur die letzte Zeile - das Rendern - ist ein Hindernis, um Berechnungen zur Kompilierungszeit vollständig ausführen zu können. Wie wir weiter unten sehen werden, können wir in C ++ 17 eine recht elegante Metaprogrammierung erzielen, wenn wir ein wenig constexpr an die richtigen Stellen werfen. Ich finde die Freiheit, die C ++ uns bei der gemischten Ausführung zur Laufzeit und Kompilierung gibt, sehr erfreulich.

Sie werden auch feststellen, dass dieser Code nur einen Frame ausführt, es gibt keine Spielschleife . Lösen wir dieses Problem!

Wir kleben alles zusammen:



Wenn Sie meine Tricks mit C ++ abschrecken , dann hoffe ich, dass es Ihnen nichts ausmacht, meine Bash- Fähigkeiten zu sehen. Tatsächlich ist meine Spieleschleife nichts anderes als ein Bash-Skript , das ständig kompiliert wird.

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

Tatsächlich hatte ich einige Probleme, Tastatureingaben von der Konsole zu erhalten. Anfangs wollte ich parallel zur Kompilierung gehen. Nach vielen Versuchen und Irrtümern gelang es mir, etwas mehr oder weniger mit dem read von Bash zum Laufen zu bringen . Ich wage es nie, den Zauberer Bash im Zweikampf zu bekämpfen - diese Sprache ist zu unheimlich!

Ich muss also zugeben, dass ich zur Verwaltung des Spielzyklus auf eine andere Sprache zurückgreifen musste. Obwohl mich technisch nichts daran hinderte, diesen Teil des Codes in C ++ zu schreiben. Darüber hinaus negiert dies nicht die Tatsache, dass 90% der Logik meines Spiels im g ++ - Kompilierungsteam ausgeführt werden, was ziemlich erstaunlich ist!

Ein kleines Gameplay, um Ihren Augen eine Pause zu gönnen:


Nachdem Sie die Qual erlebt haben, die Architektur des Spiels zu erklären, ist es an der Zeit, auffällige Bilder zu malen:


Dieses pixelige GIF zeigt, wie ich Meta Crush Saga spiele. Wie Sie sehen können, läuft das Spiel reibungslos genug, um in Echtzeit spielbar zu sein. Offensichtlich ist sie nicht so attraktiv, dass ich ihr Twitch streamen und die neue Pewdiepie werden kann, aber sie arbeitet!

Einer der unterhaltsamen Aspekte beim Speichern des Status eines Spiels in einer TXT- Datei ist die Möglichkeit, Extremfälle zu betrügen oder extrem praktisch zu testen.

Nachdem ich Sie kurz in die Architektur eingeführt habe, werden wir uns mit der in diesem Projekt verwendeten C ++ 17-Funktionalität befassen. Ich werde die Spielelogik nicht im Detail betrachten, da sie sich ausschließlich auf Match-3 bezieht, sondern über Aspekte von C ++ sprechen, die in anderen Projekten angewendet werden können.

Meine Tutorials zu C ++ 17:



Im Gegensatz zu C ++ 14, das hauptsächlich kleinere Korrekturen enthielt, kann uns der neue C ++ 17-Standard viel bieten. Es gab Hoffnungen, dass endlich die lang erwarteten Funktionen (Module, Coroutinen, Konzepte ...) endlich auftauchen würden, aber ... im Allgemeinen ... erschienen sie nicht; es hat viele von uns verärgert. Nachdem wir die Trauer beseitigt hatten, fanden wir viele kleine unerwartete Schätze, die dennoch in den Standard fielen.

Ich wage zu sagen, dass Kinder, die Metaprogrammierung lieben, dieses Jahr zu verwöhnt sind! Durch separate geringfügige Änderungen und Ergänzungen der Sprache können Sie jetzt Code schreiben, der zur Kompilierungszeit und danach zur Laufzeit sehr gut funktioniert.

Constepxr in allen Bereichen:


Wie Ben Dean und Jason Turner in ihrem Bericht zu C ++ 14 vorausgesagt haben, können Sie mit C ++ die Kompilierung von Werten zur Kompilierungszeit mit dem allmächtigen Schlüsselwort constexpr schnell verbessern. Wenn Sie dieses Schlüsselwort an den richtigen Stellen suchen, können Sie dem Compiler mitteilen, dass der Ausdruck konstant ist und direkt zur Kompilierungszeit ausgewertet werden kann. In C ++ 11 konnten wir diesen Code bereits schreiben:

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

Obwohl das Schlüsselwort constexpr sehr leistungsfähig ist, gibt es einige Verwendungsbeschränkungen, die es schwierig machen, ausdrucksstarken Code auf diese Weise zu schreiben.

C ++ 14 hat die Anforderungen an constexpr erheblich reduziert und ist viel natürlicher zu verwenden. Unsere vorherige Fakultätsfunktion kann wie folgt umgeschrieben werden:

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

C ++ 14 hat die Regel beseitigt, dass eine constexpr-Funktion nur aus einer return-Anweisung bestehen sollte, was uns gezwungen hat, den ternären Operator als Hauptbaustein zu verwenden. Jetzt bringt C ++ 17 noch mehr constexpr- Keyword-Anwendungen, die wir untersuchen können!

Verzweigung zur Kompilierungszeit:


Warst du jemals in einer Situation, in der du abhängig von dem Vorlagenparameter, den du manipulierst, ein anderes Verhalten haben musst? Angenommen, wir benötigen eine parametrisierte Funktion serialize , die .serialize() wenn das Objekt dies bereitstellt, andernfalls wird dazu to_string . Wie in diesem Beitrag über SFINAE ausführlicher erläutert , müssen Sie höchstwahrscheinlich einen solchen Alien-Code schreiben:

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

Nur in einem Traum könnten Sie diesen hässlichen Trick von SFINAE-Trick zu C ++ 14 in solch großartigen Code umschreiben:

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

Als Sie aufwachten und anfingen, echten C ++ 14-Code zu schreiben, gab Ihr Compiler leider eine unangenehme Nachricht über den Aufruf von serialize(42); . Es wurde erklärt, dass ein obj Typ int keine Mitgliedsfunktion serialize() . Egal wie wütend es Sie macht, der Compiler hat Recht! Mit diesem Code wird er immer versuchen, beide Zweige zu kompilieren - return obj.serialize(); und
return std::to_string(obj); . Für int branch return obj.serialize(); Es kann sich durchaus als eine Art toter Code has_serialize(obj) , da has_serialize(obj) immer false has_serialize(obj) , der Compiler ihn jedoch weiterhin kompilieren muss.

Wie Sie wahrscheinlich vermutet haben, bewahrt uns C ++ 17 vor einer solch unangenehmen Situation, da es möglich war, constexpr nach der if-Anweisung hinzuzufügen, um die Verzweigung zur Kompilierungszeit zu erzwingen und nicht verwendete Konstruktionen zu verwerfen:

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


Dies ist offensichtlich eine enorme Verbesserung gegenüber dem SFINAE-Trick, den wir zuvor anwenden mussten. Danach bekamen wir die gleiche Sucht wie Ben und Jason - wir begannen überall und immer mit constexpr . Leider gibt es einen anderen Ort, an den das Schlüsselwort constexpr passen würde, aber noch nicht verwendet wird: Parameter constexpr .

Constexpr-Parameter:


Wenn Sie vorsichtig sind, stellen Sie möglicherweise ein seltsames Muster im vorherigen Codebeispiel fest. Ich spreche von Schleifeneingängen:

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

Warum ist die Variable game_state_string in einem constexpr-Lambda eingekapselt? Warum macht sie sie nicht zu einer globalen Variablen constexpr ?

Ich wollte diese Variable und ihren Inhalt tief in einige Funktionen übergeben. Zum Beispiel müssen Sie es an mein parse_board übergeben und in einigen konstanten Ausdrücken verwenden:

 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...”); 

Wenn wir diesen Weg gehen, beschwert sich der mürrische Compiler, dass der Parameter game_state_string kein konstanter Ausdruck ist. Wenn ich mein Kachelarray erstelle, muss ich seine feste Kapazität direkt berechnen (wir können zur Kompilierungszeit keine Vektoren verwenden, da sie eine Speicherzuweisung erfordern) und es als Argument an die Wertvorlage in std :: array übergeben . Daher muss der Ausdruck parse_board_size (game_state_string) ein konstanter Ausdruck sein. Obwohl parse_board_size explizit als constexpr markiert ist, ist und kann game_state_string nicht sein! In diesem Fall stören uns zwei Regeln:

  • Argumente einer constexpr-Funktion sind nicht constexpr!
  • Und wir können constexpr nicht vor ihnen hinzufügen!

All dies läuft darauf hinaus, dass constexpr-Funktionen sowohl für die Berechnung der Laufzeit als auch der Kompilierungszeit anwendbar sein MÜSSEN . Unter der Annahme, dass constexpr-Parameter vorhanden sind , können diese zur Laufzeit nicht verwendet werden.


Glücklicherweise gibt es eine Möglichkeit, dieses Problem zu lösen. Anstatt den Wert als regulären Parameter einer Funktion zu akzeptieren, können wir diesen Wert in einen Typ einkapseln und diesen Typ als Vorlagenparameter übergeben:

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

In diesem Codebeispiel erstelle ich einen GameString- Strukturtyp mit einer statischen Elementfunktion constexpr value () , die das Zeichenfolgenliteral zurückgibt, das ich an parse_board übergeben möchte . In parse_board erhalte ich diesen Typ über den Vorlagenparameter GameStringType , wobei die Regeln zum Extrahieren von Vorlagenargumenten verwendet werden. Mit einem GameStringType kann ich aufgrund der Tatsache, dass value () constexpr ist, einfach zum richtigen Zeitpunkt den statischen Member-Funktionswert () aufrufen, um ein String-Literal auch an Stellen abzurufen, an denen konstante Ausdrücke benötigt werden.

Wir haben es geschafft, das Literal zu kapseln, um es mit constexpr irgendwie an parse_board zu übergeben. Es ist jedoch sehr ärgerlich, jedes Mal einen neuen Typ definieren zu müssen, wenn Sie ein neues parse_board- Literal senden müssen : "... etwas1 ...", "... etwas2 ...". Um dieses Problem in C ++ 11 zu lösen, können Sie eine hässliche Makro- und indirekte Adressierung mit anonymer Union und Lambda anwenden. Michael Park hat dieses Thema in einem seiner Beiträge gut erklärt .

In C ++ 17 ist die Situation noch besser. Wenn wir die Anforderungen für die Übergabe unseres String-Literal auflisten, erhalten wir Folgendes:

  • Funktion generiert
  • Das ist constexpr
  • Mit einem eindeutigen oder anonymen Namen

Diese Anforderungen sollten Ihnen einen Hinweis geben. Was wir brauchen, ist constexpr Lambda ! Und in C ++ 17 haben sie ganz natürlich die Möglichkeit hinzugefügt, das Schlüsselwort constexpr für Lambda-Funktionen zu verwenden. Wir können unseren Beispielcode wie folgt umschreiben:

 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. 

Glauben Sie mir, dies sieht bereits viel praktischer aus als das vorherige Hacken in C ++ 11 mit Makros. Ich habe diesen großartigen Trick dank Björn Fahler entdeckt , einem Mitglied der C ++ Mitap-Gruppe, an der ich teilnehme. Lesen Sie mehr über diesen Trick in seinem Blog . Es ist auch zu berücksichtigen, dass das Schlüsselwort constexpr in diesem Fall tatsächlich optional ist: Alle Lambdas mit der Fähigkeit, constexpr zu werden, sind standardmäßig diese. Das explizite Hinzufügen von constexpr ist eine Signatur, die unsere Fehlerbehebung vereinfacht.

Jetzt müssen Sie verstehen, warum ich gezwungen war, ein constexpr Lambda zu verwenden, um eine Zeichenfolge weiterzugeben, die den Status des Spiels darstellt. Schauen Sie sich diese Lambda-Funktion an und Sie werden wieder eine andere Frage haben. Was ist dieser constexpr_string- Typ, den ich auch zum Umschließen des Aktienliteral verwende?

constexpr_string und constexpr_string_view:

Wenn Sie mit Zeichenfolgen arbeiten, sollten Sie sie nicht im C-Stil verarbeiten. Sie müssen all diese nervigen Algorithmen vergessen, die rohe Iterationen ausführen und auf Null-Vervollständigung prüfen! Die von C ++ angebotene Alternative sind die allmächtigen Algorithmen std :: string und STL . Leider erfordert std :: string möglicherweise eine Speicherzuweisung auf dem Heap (auch bei Small String Optimization), um dessen Inhalt zu speichern. Ein oder zwei Standards zurück, wir könnten constexpr new / delete verwenden oder constexpr-Allokatoren an std :: string übergeben , aber jetzt müssen wir eine andere Lösung finden.

Mein Ansatz war es, eine constexpr_string- Klasse mit einer festen Kapazität zu schreiben. Diese Kapazität wird als Parameter an die Wertvorlage übergeben. Hier ist ein kurzer Überblick über meine Klasse:

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

Meine constexpr_string- Klasse versucht, die std :: string- Schnittstelle so nah wie möglich zu imitieren (für die Operationen, die ich benötige): Wir können Iteratoren des Anfangs und des Endes anfordern, die Größe (Größe) abrufen , auf die Daten (Daten) zugreifen, einen Teil davon löschen (löschen) , abrufen Teilstring mit substr und so weiter. Dies macht es sehr einfach, einen Code von std :: string in constexpr_string zu konvertieren . Sie fragen sich vielleicht, was passiert, wenn wir Operationen verwenden müssen, die normalerweise in std :: string hervorgehoben werden müssen . In solchen Fällen musste ich sie in unveränderliche Operationen konvertieren, die eine neue Instanz von constexpr_string erstellen .

Werfen wir einen Blick auf die Append- Operation:

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


Sie benötigen keinen Fields-Preis, um davon auszugehen, dass bei einer Zeichenfolge der Größe N und einer Zeichenfolge der Größe M eine Zeichenfolge der Größe N + M ausreicht, um deren Verkettung zu speichern. Wir verschwenden möglicherweise einen Teil des "Repository zur Kompilierungszeit", da beide Leitungen möglicherweise nicht die gesamte Kapazität nutzen, dies ist jedoch aus Bequemlichkeitsgründen ein eher geringer Preis. Natürlich habe ich auch ein Duplikat von std :: string_view geschrieben , das constexpr_string_view heißt.

Mit diesen beiden Klassen war ich bereit, eleganten Code zu schreiben, um meinen Spielstatus zu analysieren. Denken Sie an so etwas:

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

Es war ziemlich einfach, die Juwelen auf dem Spielfeld zu durchlaufen. Haben Sie in diesem Codebeispiel übrigens eine weitere wertvolle Funktion von C ++ 17 bemerkt?

Ja! Ich musste die Kapazität von constexpr_string beim Erstellen nicht explizit angeben. Bisher mussten wir bei der Verwendung einer Klassenvorlage deren Argumente explizit angeben. Um diese Probleme zu vermeiden, erstellen wir make_xxx- Funktionen, da die Parameter von Funktionsvorlagen nachverfolgt werden können. Sehen Sie, wie das Verfolgen von Argumenten für Klassenvorlagen unser Leben zum Besseren verändert:

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

In einigen schwierigen Situationen müssen Sie dem Compiler helfen, die Argumente korrekt zu berechnen. Wenn Sie auf ein solches Problem stoßen, lesen Sie die Handbücher für benutzerdefinierte Argumentberechnungen .

Kostenloses Essen von STL:


Nun, wir können immer alles selbst umschreiben. Aber vielleicht haben Komiteemitglieder in der Standardbibliothek großzügig etwas für uns vorbereitet?

Neue Hilfstypen:

In C ++ 17 werden std :: variante und std :: optional zu den Standardwörterbuchtypen hinzugefügt, basierend auf constexpr . Das erste ist sehr interessant, weil es uns erlaubt, typsichere Assoziationen auszudrücken, aber die Implementierung in der libstdc ++ - Bibliothek mit GCC 7.2 hat Probleme bei der Verwendung konstanter Ausdrücke. Aus diesem Grund habe ich die Idee aufgegeben, meinem Code eine std :: -Variante hinzuzufügen und nur std :: optional zu verwenden .

Mit Typ T können wir mit dem Typ std :: optional einen neuen Typ std :: optional <T> erstellen , der entweder einen Wert vom Typ T oder nichts enthalten kann. Dies ist ziemlich ähnlich zu aussagekräftigen Typen, die einen undefinierten Wert in C # zulassen . Schauen wir uns die Funktion find_in_board an , die die Position des ersten Elements in einem Feld zurückgibt, das bestätigt, dass das Prädikat korrekt ist. Möglicherweise befindet sich kein solches Element auf dem Feld. Um diese Situation zu bewältigen, muss der Positionstyp optional sein:

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

Zuvor mussten wir entweder auf die Semantik von Zeigern zurückgreifen oder einen „leeren Zustand“ direkt zum Positionstyp hinzufügen oder einen Booleschen Wert zurückgeben und den Ausgabeparameter übernehmen . Zugegeben, das war ziemlich umständlich!

Einige bereits vorhandene Typen erhielten auch constexpr- Unterstützung : Tupel und Paar . Ich werde ihre Verwendung nicht im Detail erklären, da bereits viel über sie geschrieben wurde, aber ich werde eine meiner Enttäuschungen teilen. Das Komitee fügte dem Standard syntaktischen Zucker hinzu , um die in einem Tupel oder Paar enthaltenen Werte zu extrahieren . Diese neue Art der Deklaration wird als strukturierte Bindung bezeichnet, verwendet Klammern, um anzugeben, in welchen Variablen das geteilte Tupel oder Paar gespeichert werden soll :

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

Sehr schlau! Aber es ist schade, dass die Komiteemitglieder [konnten, wollten nicht, fanden nicht die Zeit, vergaßen] sie freundlich zu constexpr machen . Ich würde so etwas erwarten:

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

Jetzt haben wir komplexe Container und Hilfstypen, aber wie können wir sie bequem manipulieren?

Algorithmen:

Das Aktualisieren eines Containers für die Verarbeitung von constexpr ist eine ziemlich eintönige Aufgabe. Im Vergleich dazu scheint es einfach genug zu sein, constexpr auf nicht modifizierende Algorithmen zu portieren . Es ist jedoch ziemlich seltsam, dass wir in C ++ 17 keine Fortschritte in diesem Bereich gesehen haben, sondern nur in C ++ 20 . Zum Beispiel haben die wunderbaren std :: find- Algorithmen keine constexpr- Signaturen erhalten .

Aber keine Angst! Wie Ben und Jason erklärt haben, können Sie den Algorithmus leicht in constexpr umwandeln, indem Sie einfach die aktuelle Implementierung kopieren (aber vergessen Sie nicht die Urheberrechte). cppreference ist gut. Meine Damen und Herren, ich präsentiere Ihnen Ihre Aufmerksamkeitconstexpr 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 

Ich kann schon von den Ständen die Schreie der Optimierungsfans hören! Ja, nur das Hinzufügen von constexpr vor dem von cppreference freundlicherweise bereitgestellten Beispielcode führt zur Laufzeit möglicherweise nicht zu einer idealen Geschwindigkeit . Wenn wir diesen Algorithmus jedoch verbessern müssen, wird er für die Geschwindigkeit bei der Kompilierung benötigt . Soweit ich weiß, sind einfache Lösungen für die Kompilierungsgeschwindigkeit am besten.

Geschwindigkeit und Fehler:


Entwickler eines AAA-Spiels sollten in die Lösung dieser Probleme investieren, oder?

Geschwindigkeit:


Als ich es schaffte, eine halb funktionierende Version von Meta Crush Saga zu erstellen , verlief die Arbeit reibungsloser. Tatsächlich habe ich auf meinem alten Laptop mit i5, das auf 1,80 GHz übertaktet wurde, etwas mehr als 3 FPS (Bilder pro Sekunde) erreicht (Frequenz ist in diesem Fall wichtig). Wie in jedem Projekt wurde mir schnell klar, dass der zuvor geschriebene Code ekelhaft war, und ich begann, das Parsen des Spielzustands mithilfe von constexpr_string und Standardalgorithmen neu zu schreiben . Obwohl dies die Wartung des Codes wesentlich komfortabler machte, wirkten sich die Änderungen ernsthaft auf die Geschwindigkeit aus. Die neue Decke beträgt 0,5 FPS .

Trotz des alten Sprichworts über C ++ sind „Null-Kopf-Abstraktionen“ nicht für Berechnungen zur Kompilierungszeit anwendbar. Dies ist ziemlich logisch, wenn wir den Compiler als Interpreter eines „Kompilierungszeitcodes“ betrachten. Verbesserungen für verschiedene Compiler sind weiterhin möglich, aber es gibt auch Wachstumschancen für uns, die Autoren eines solchen Codes. Hier ist eine unvollständige Liste von Beobachtungen und Tipps, die ich gefunden habe, möglicherweise spezifisch für GCC:

  • C-Arrays funktionieren viel besser als std :: array . std :: array ist ein bisschen moderne C ++ - Kosmetik auf einem Array im C-Stil, und Sie müssen einen Preis dafür zahlen, wenn Sie es unter solchen Bedingungen verwenden.
  • , ( ) . , , , . : , , , , ( ) , .
  • , . , .
  • . GCC. , «».

:



Oft hat mein Compiler schreckliche Kompilierungsfehler ausgegeben, und meine Codelogik hat darunter gelitten. Aber wie findet man den Ort, an dem sich der Käfer versteckt? Ohne Debugger und printf werden die Dinge komplizierter. Wenn Ihr metaphorischer „Bart des Programmierers“ noch nicht auf die Knie gegangen ist (sowohl der metaphorische als auch der echte Bart von mir sind noch weit von diesen Erwartungen entfernt), haben Sie möglicherweise keine Motivation, Templight zu verwenden oder den Compiler zu debuggen.

Unser erster Freund wird static_assert sein , was uns die Möglichkeit gibt, den booleschen Wert der Kompilierungszeit zu überprüfen. Unser zweiter Freund wird ein Makro sein, das constexpr aktiviert und deaktiviert, wo immer dies möglich ist:

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

Mit diesem Makro können wir die Logik zur Laufzeit arbeiten lassen, was bedeutet, dass wir einen Debugger daran anhängen können.

Meta Crush Saga II - strebe das Gameplay zur Laufzeit vollständig an:


Offensichtlich wird Meta Crush Saga dieses Jahr nicht die The Game Awards gewinnen . Es hat großes Potenzial, aber das Gameplay wird zum Zeitpunkt der Kompilierung nicht vollständig ausgeführt . Dies kann Hardcore-Gamer ärgern ... Ich kann das Bash-Skript nur loswerden, wenn jemand in der Kompilierungsphase Tastatureingaben und unreine Logik hinzufügt (und das ist offener Wahnsinn!). Ich glaube jedoch, dass ich eines Tages die ausführbare Datei des Renderers vollständig verlassen und den Status des Spiels zur Kompilierungszeit anzeigen kann :


Der Verrückte mit dem Alias Saarraz hat GCC erweitert , um der Sprache das Konstrukt static_print hinzuzufügen . Dieses Konstrukt sollte mehrere konstante Ausdrücke oder Zeichenfolgenliterale verwenden und diese in der Kompilierungsphase ausgeben. Ich würde mich freuen, wenn ein solches Tool zum Standard hinzugefügt oder zumindest static_assert erweitert würde, damit es konstante Ausdrücke akzeptiert .

In C ++ 17 kann es jedoch eine Möglichkeit geben, dieses Ergebnis zu erzielen. Compiler geben bereits zwei Dinge aus - Fehler und Warnungen ! Wenn wir die Warnungen irgendwie verwalten oder an unsere Bedürfnisse anpassen können, erhalten wir bereits eine würdige Schlussfolgerung. Ich habe insbesondere verschiedene Lösungen ausprobiertveraltetes Attribut :

 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] 

Obwohl die Ausgabe offensichtlich vorhanden ist und analysiert werden kann, ist der Code leider nicht spielbar! Wenn Sie zufällig Mitglied einer Geheimgesellschaft von C ++ - Programmierern sind, die während der Kompilierung Ausgaben ausführen können, werde ich Sie gerne in meinem Team einstellen, um die perfekte Meta Crush Saga II zu erstellen !

Schlussfolgerungen:


Am Ende habe ich dir mein Betrugsspiel verkauft . Ich hoffe, Sie finden diesen Beitrag neugierig und lernen beim Lesen etwas Neues. Wenn Sie Fehler oder Möglichkeiten zur Verbesserung des Artikels finden, kontaktieren Sie mich.

Ich möchte dem SwedenCpp-Team dafür danken, dass ich meinen Projektbericht bei einer ihrer Veranstaltungen durchführen durfte. Darüber hinaus möchte ich Alexander Gurdeev meinen tiefen Dank aussprechen , der mir geholfen hat, die wesentlichen Aspekte der Meta Crush Saga zu verbessern .

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


All Articles