Hallo Habr. Wenn Sie die Antwort auf die Frage im Titel kennen, herzlichen Glückwunsch, brauchen Sie diesen Artikel nicht. Es richtet sich an Programmieranfänger wie mich, die nicht immer alle Feinheiten von C ++ und anderen typisierten Sprachen unabhängig voneinander verstehen können. Wenn dies möglich ist, ist es besser, aus den Fehlern anderer zu lernen.
In diesem Artikel werde ich nicht nur die Frage "
Warum brauchen wir virtuelle Funktionen in C ++ " beantworten, sondern auch ein Beispiel aus meiner Praxis geben. Für eine kurze Antwort können Sie sich an Suchmaschinen wenden, die Folgendes erzeugen: "
Virtuelle Funktionen werden benötigt, um Polymorphismus bereitzustellen - einer der drei OOP-Wale. Dank ihnen kann die Maschine selbst den Objekttyp per Zeiger bestimmen, ohne den Programmierer mit dieser Aufgabe zu laden. " Okay, aber die Frage „Warum“ bleibt bestehen, obwohl sie jetzt etwas anders bedeutet: „
Warum sollten Sie sich auf die Maschine verlassen, zusätzliche Zeit und Speicherplatz aufwenden, wenn Sie den Zeiger selbst podcasten können, da die Art des Objekts, auf das er sich bezieht, fast immer bekannt ist? “ In der Tat lässt das Casting auf den ersten Blick virtuelle Funktionen ohne Arbeit, und dies führt zu Missverständnissen und schlechtem Code. In kleinen Projekten ist der Verlust unsichtbar, aber wie Sie bald sehen werden, erhöhen Kasten mit dem Wachstum des Programms die Auflistung in einem fast geometrischen Verlauf.
Lassen Sie uns zunächst daran erinnern, wo Castes und virtuelle Funktionen überhaupt benötigt werden. Ein Typ geht verloren, wenn einem mit Typ A deklarierten Objekt eine neue Operation zugewiesen wird, um Speicher für ein Objekt vom Typ B zuzuweisen, das mit Typ A kompatibel ist und normalerweise von A geerbt wird. Meistens ist das Objekt nicht eines, sondern ein gesamtes Array. Ein Array von Zeigern desselben Typs, von denen jeder auf die Zuweisung eines Speicherbereichs mit Objekten völlig unterschiedlicher Typen wartet. Hier ist ein Beispiel, das wir betrachten werden.
Ich werde mich nicht lange hinziehen, die Aufgabe war folgende: Basierend auf einem Dokument, das mit der Markedit-Hypertext-Markup-Sprache gekennzeichnet ist (Sie können hier darüber lesen), erstellen Sie einen Analysebaum und erstellen Sie eine Datei, die dasselbe Dokument im HTML-Markup enthält. Meine Lösung besteht aus drei aufeinander folgenden Routinen: Analysieren des Quelltextes in Token, Erstellen eines Syntaxbaums aus Token und Erstellen eines darauf basierenden HTML-Dokuments. Wir interessieren uns für den zweiten Teil.
Tatsache ist, dass die Knoten des Zielbaums unterschiedliche Typen haben (Abschnitt, Absatz, Textknoten, Link, Fußnote usw.), aber für übergeordnete Knoten werden Zeiger auf untergeordnete Knoten im Array gespeichert und haben daher einen Typ - Knoten.
Der Parser selbst funktioniert in vereinfachter Form folgendermaßen: Er erstellt die „Wurzel“ des
Baumsyntaxbaums mit dem
Stammtyp , deklariert einen
open_node- Zeiger vom allgemeinen Typ
Node , dem sofort die
Baumadresse zugewiesen wird, und die Typvariable des aufgezählten Typs
Node_type . Anschließend beginnt die Schleife und
iteriert von Anfang an über die Token bis zum letzten. Bei jeder Iteration wird zuerst der Typ des offenen Knotens
open_node in die Typvariable eingegeben (Typen in Form einer Aufzählung werden in der Knotenstruktur gespeichert), gefolgt von der
switch-Anweisung , die den Typ des nächsten Tokens überprüft (die Token-Typen werden vom Lexer bereits sorgfältig bereitgestellt). In jedem Zweig des Schalters wird ein anderer Zweig dargestellt, der die Typvariable überprüft, wobei, wie wir uns erinnern, der Typ des offenen Knotens enthalten ist. Abhängig von seinem Wert werden verschiedene Aktionen ausgeführt, zum Beispiel: Hinzufügen einer Knotenliste eines bestimmten Typs zu einem offenen Knoten, Öffnen eines anderen Knotens eines bestimmten Typs in einem offenen Knoten und
Übergeben seiner Adresse an
open_node , Schließen des offenen Knotens,
Auslösen einer Ausnahme. Für das Thema des Artikels ist das zweite Beispiel interessant. Jeder offene Knoten (und im Allgemeinen jeder Knoten, der geöffnet werden kann) enthält bereits ein Array von Zeigern auf Knoten vom Typ
Knoten . Wenn wir einen neuen Knoten in einem geöffneten Knoten öffnen (wir weisen dem nächsten Array-Zeiger den Speicherbereich für ein Objekt eines anderen Typs zu), bleibt dieser für einen semantischen C ++ - Analysator eine Instanz des
Knotentyps, ohne neue Felder und Methoden zu erwerben. Ein Zeiger darauf wird jetzt der Variablen
open_node zugewiesen , ohne den
Knotentyp zu verlieren. Aber wie arbeitet man mit einem Zeiger eines allgemeinen
Knotentyps, wenn man eine Methode aufrufen muss, zum Beispiel einen Absatz? Zum Beispiel
open_bold () , das einen fetten Schriftknoten darin öffnet? Schließlich wird
open_bold () als Methode der
Paragraph- Klasse deklariert und definiert, und
Node ist sich dessen überhaupt nicht bewusst. Darüber hinaus wird
open_node auch als Zeiger auf
Node deklariert und muss Methoden von allen Arten von öffnenden Knoten akzeptieren.
Hier gibt es zwei Lösungen: die offensichtliche und die richtige. Für einen Anfänger ist
static_cast offensichtlich , und virtuelle Funktionen sind richtig. Schauen wir uns zunächst einen Zweig des Switch-Parsers an, der mit der ersten Methode geschrieben wurde:
case Lexer::BOLD_START: { if (type == Node::ROOT) { open_node = tree->open_section(); open_node = static_cast<Section*>(open_node)->open_paragraph(); open_node = static_cast<Paragraph*>(open_node)->open_bold(); } else if (type == Node::SECTION) { open_node = static_cast<Section*>(open_node)->open_paragraph(); open_node = static_cast<Paragraph*>(open_node)->open_bold(); } else if (type == Node::PARAGRAPH) open_node = static_cast<Paragraph*>(open_node)->open_bold(); else if (type == Node::TITLE) open_node = static_cast<Title*>(open_node)->open_bold(); else if (type == Node::QUOTE) open_node = static_cast<Quote*>(open_node)->open_bold(); else if (type == Node::UNORDERED_LIST) { open_node = static_cast<Unordered_list*>(open_node)->close(); while (open_node->get_type() != Node::SECTION) { if (open_node->get_type() == Node::UNORDERED_LIST) open_node = static_cast<Unordered_list*>(open_node)->close(); else if (open_node->get_type() == Node::UNORDERED_LIST) open_node = static_cast<Unordered_list*>(open_node)->close(); else if (open_node->get_type() == Node::PARAGRAPH) open_node = static_cast<Paragraph*>(open_node)->close(); } open_node = static_cast<Section*>(open_node)->open_paragraph(); open_node = static_cast<Paragraph*>(open_node)->open_bold(); } else if (type == Node::ORDERED_LIST) { open_node = static_cast<Ordered_list*>(open_node)->close(); while (open_node->get_type() != Node::SECTION) { if (open_node->get_type() == Node::UNORDERED_LIST) open_node = static_cast<Unordered_list*>(open_node)->close(); else if (open_node->get_type() == Node::UNORDERED_LIST) open_node = static_cast<Unordered_list*>(open_node)->close(); else if (open_node->get_type() == Node::PARAGRAPH) open_node = static_cast<Paragraph*>(open_node)->close(); } open_node = static_cast<Section*>(open_node)->open_paragraph(); open_node = static_cast<Paragraph*>(open_node)->open_bold(); } else if (type == Node::LINK) open_node = static_cast<Link*>(open_node)->open_bold(); else
Nicht schlecht. Und jetzt werde ich es nicht lange ziehen, ich werde den gleichen Codeabschnitt zeigen, der mit virtuellen Funktionen geschrieben wurde:
case Lexer::BOLD_START: { if (type == Node::ROOT) { open_node = tree->open_section(); open_node = open_node->open_paragraph(); open_node = open_node->open_bold(); } else if (type == Node::SECTION) { open_node = open_node->open_paragraph(); open_node = open_node->open_bold(); } else if (type == Node::UNORDERED_LIST) { open_node = open_node->close(); while (open_node->get_type() != Node::SECTION) open_node = open_node->close(); open_node = open_node->open_paragraph(); open_node = open_node->open_bold(); } else
Der Gewinn ist offensichtlich, aber brauchen wir ihn wirklich? Schließlich müssen Sie in der
Node- Klasse alle Methoden aller abgeleiteten Klassen als virtuell deklarieren und sie irgendwie in jeder abgeleiteten Klasse implementieren. Die Antwort lautet ja. Es gibt nicht so viele Methoden speziell in diesem Programm (29), und ihre Implementierung in abgeleiteten Klassen, die nicht mit ihnen verwandt sind, besteht nur aus einer Zeile:
throw string ("error!"); . Sie können den Kreativmodus aktivieren und für jeden Ausnahmefall eine eindeutige Zeile erstellen. Vor allem aber - aufgrund der Code-Reduzierung hat sich die Anzahl der Fehler verringert. Casting ist eine der wichtigsten Ursachen für Fehler im Code. Denn nach dem Anwenden von
static_cast hört der Compiler auf zu
fluchen, wenn die aufgerufene Klasse in der angegebenen Klasse enthalten ist. In der Zwischenzeit können verschiedene Klassen unterschiedliche Methoden mit demselben Namen enthalten. In meinem Fall war 6 im Code versteckt !!! Fehler, während einer von ihnen in mehreren Switch-Zweigen dupliziert wurde. Da ist sie:
else if (type == Node:: open_node = static_cast<Title*>(open_node)->open_italic();
Als nächstes bringe ich unter den Spoilern vollständige Listen der ersten und zweiten Version des Parsers.
Parser mit Casting Root * Parser::parse (const Lexer &lexer) { Node * open_node(tree); Node::Node_type type; for (unsigned long i(0), len(lexer.count()); i < len; i++) { type = open_node->get_type(); if (type == Node::CITE || type == Node::TEXT || type == Node::NEWLINE || type == Node::NOTIFICATION || type == Node::IMAGE) throw string("error!"); switch (lexer[i].type) { case Lexer::NEWLINE: { if (type == Node::ROOT || type == Node::SECTION) ; else if (type == Node::PARAGRAPH) open_node = static_cast<Paragraph*>(open_node)->add_text("\n"); else if (type == Node::TITLE) open_node = static_cast<Title*>(open_node)->add_text("\n"); else if (type == Node::QUOTE) open_node = static_cast<Quote*>(open_node)->add_text("\n"); else if (type == Node::UNORDERED_LIST) { open_node = static_cast<Unordered_list*>(open_node)->close(); while (open_node->get_type() != Node::SECTION) { if (open_node->get_type() == Node::UNORDERED_LIST) open_node = static_cast<Unordered_list*>(open_node)->close(); else if (open_node->get_type() == Node::UNORDERED_LIST) open_node = static_cast<Unordered_list*>(open_node)->close(); else if (open_node->get_type() == Node::PARAGRAPH) open_node = static_cast<Paragraph*>(open_node)->close(); } } else if (type == Node::ORDERED_LIST) { open_node = static_cast<Ordered_list*>(open_node)->close(); while (open_node->get_type() != Node::SECTION) { if (open_node->get_type() == Node::UNORDERED_LIST) open_node = static_cast<Unordered_list*>(open_node)->close(); else if (open_node->get_type() == Node::UNORDERED_LIST) open_node = static_cast<Unordered_list*>(open_node)->close(); else if (open_node->get_type() == Node::PARAGRAPH) open_node = static_cast<Paragraph*>(open_node)->close(); } } else if (type == Node::LINK) { open_node = static_cast<Link*>(open_node)->add_text(lexer[i].lexeme); } else
Parser mit Zugriff auf virtuelle Methoden Root * Parser::parse (const Lexer &lexer) { Node * open_node(tree); Node::Node_type type; for (unsigned long i(0), len(lexer.count()); i < len; i++) { type = open_node->get_type(); if (type == Node::CITE || type == Node::TEXT || type == Node::NEWLINE || type == Node::NOTIFICATION || type == Node::IMAGE) throw string("error!"); switch (lexer[i].type) { case Lexer::NEWLINE: { if (type == Node::ROOT || type == Node::SECTION) ; else if (type == Node::PARAGRAPH || type == Node::TITLE || type == Node::QUOTE || type == Node::TITLE || type == Node::QUOTE) open_node = open_node->add_text("\n"); else if (type == Node::UNORDERED_LIST || type == Node::ORDERED_LIST) { open_node = open_node->close(); while (open_node->get_type() != Node::SECTION) open_node = open_node->close(); } else
Von 1357 Zeilen wurde der Code auf 487 reduziert - fast dreimal, ohne die Länge der Zeilen!Eine Frage bleibt: Was ist mit der Vorlaufzeit? Wie viele Millisekunden müssen wir für den Computer selbst bezahlen, um den Typ des offenen Knotens zu bestimmen? Ich habe ein Experiment durchgeführt - ich habe die Parser-Arbeitszeit in Millisekunden im ersten und zweiten Fall für dasselbe Dokument auf meinem Heimcomputer festgelegt. Hier ist das Ergebnis:Casting - 538 ms.Virtuelle Funktionen - 1174 ms.Insgesamt 636 ms - eine Gebühr für die Kompaktheit des Codes und das Fehlen von Fehlern. Ist das viel? Möglicherweise.
Wenn wir jedoch ein Programm benötigen, das so schnell wie möglich arbeitet und so wenig Speicher wie möglich benötigt, würden wir nicht zu OOP gehen und es insgesamt in Assemblersprache schreiben, eine Woche verbringen und riskieren, eine große Anzahl von Fehlern zu machen. Meine Wahl ist also, wo immer sich static_cast und dynamic_cast im Programm treffen , sie durch virtuelle Funktionen zu ersetzen. Was ist deine Meinung?