Windows mit C++

Verwenden regulärer Ausdrücke mit modernem C++

Kenny Kerr

Kenny Kerr
„C++ ist eine Sprache zum Entwickeln und Verwenden eleganter und effizienter Abstraktionen.“ – Bjarne Stroustrup

Dieses Zitat des Entwicklers von C++ fasst wirklich zusammen, was mir an dieser Sprache so gut gefällt. Ich kann elegante Lösungen für meine Probleme entwickeln, indem ich die Sprachfeatures und die Programmierstile kombiniere, die mir am besten geeignet für die Aufgabe erscheinen.

Mit C++11 wurde eine lange Liste mit Features eingeführt, die an sich bereits sehr spannend sind. Aber wer hier nur eine Liste mit isolierten Features sieht, dem entgeht einiges. Die Kombination dieser Features verleiht C++ die Leistungsstärke, die viele zu schätzen gelernt haben. Ich werde diesen Punkt verdeutlichen, indem ich Ihnen zeige, wie reguläre Ausdrücke mit modernem C++ verwendet werden. Der C++11-Standard stellt eine leistungsstarke Bibliothek an regulären Ausdrücken vor, aber wenn diese isoliert genutzt werden – mit dem herkömmlichen C++-Programmierstil –, können sie kompliziert wirken. Leider werden die meisten der C++11-Bibliotheken häufig auf diese Weise verwendet. Jedoch liegt auch in einem solchen Ansatz Leistung. Falls Sie nach einem konkreten Beispiel für die Verwendung einer neuen Bibliothek suchen, ist es eher überwältigend, eine solche Menge an neuen Sprachfeatures auf einmal verstehen zu müssen. Dennoch wird aus C++ aufgrund der Kombination aus C++-Sprach- und C++-Bibliotheksfeatures eine produktive Programmiersprache.

Damit der Schwerpunkt der Beispiele auf C++ liegt (und nicht auf regulären Ausdrücken), muss ich sehr einfache Muster verwenden. Sie fragen sich vielleicht, warum ich reguläre Ausdrücke für solch triviale Probleme nutze, aber das hilft, sich nicht in der Mechanik der Ausdrucksverarbeitung zu verlieren. Hier ist ein einfaches Beispiel: Ich möchte Übereinstimmungen für Zeichenfolgen mit Namen erstellen, die in der Art „Kenny Kerr“ oder „Kerr, Kenny“ formatiert sind. Ich muss den Vornamen und den Nachnamen identifizieren und diese dann auf konsistente Weise ausdrucken. Als Erstes kommt die Zielzeichenfolge:

char const s[] = "Kerr, Kenny";

Um den Code einfach zu halten, bleibe ich bei char-Zeichenfolgen. Die basic_string-Klasse der Standardbibliothek verwende ich nur, um die Ergebnisse bestimmter Übereinstimmungen zu illustrieren. An „basic_string“ ist nichts auszusetzen, aber ich habe festgestellt, dass die von mir genutzten regulären Ausdrücke zum Großteil eher auf im Speicher abgebildete Dateien ausgerichtet sind. Das Kopieren dieser Dateiinhalte in Zeichenfolgenobjekte würde also nur zu einer Verlangsamung meiner Anwendungen führen. Die Unterstützung von regulären Ausdrücken durch die Standardbibliothek beschäftigt sich damit nicht; sie verarbeitet problemlos Sequenzen von Zeichenfolgen, unabhängig davon, wie diese verwaltet werden.

Als Nächstes benötige ich ein Übereinstimmungsobjekt:

auto m = cmatch {};

Tatsächlich ist das eine Auflistung von Übereinstimmungen. Bei „cmatch“ handelt es sich um eine match_results-Klassenvorlage, die speziell für char-Zeichenfolgen geeignet ist. Zu diesem Zeitpunkt ist meine „Auflistung“ der Übereinstimmungen leer:

ASSERT(m.empty());

Außerdem benötige ich ein Zeichenfolgenpaar für den Empfang der Ergebnisse:

string name, family;

Nun kann ich die regex_match-Funktion aufrufen:

if (regex_match(s, m, regex { R"((\w+) (\w+))" }))
{
}

Diese Funktion versucht, eine Übereinstimmung des Musters mit der gesamten Zeichenfolgensequenz zu erzielen. Das ist praktisch das Gegenteil zur regex_search-Funktion, die nach einer Übereinstimmung mit einem beliebigen Punkt in der Zeichenfolge sucht. Ich erstelle nun das regex-Objekt „inline“ aus Gründen der Kürze, aber das geht nicht ohne Kosten. Falls Sie diesen regulären Ausdruck wiederholt für Übereinstimmungen verwenden möchten, sollten Sie am besten das regex-Objekt einmal erstellen und dann für die Lebensdauer der Anwendung daran festhalten. Das vorherige Muster führt die Übereinstimmung der Namen mit dem Format „Kenny Kerr“ aus. Unter der Annahme, dass es sich um eine Übereinstimmung handelt, kann ich die Teilzeichenfolgen einfach herauskopieren:

name   = m[1].str();
family = m[2].str();

Der tiefgestellte Operator gibt das angegebene sub_match-Objekt zurück. Ein Nullindex stellt die Übereinstimmung als Ganzes dar, wohingegen nachfolgende Indizes beliebige Gruppen bestimmen können, die im regulären Ausdruck identifiziert sind. Weder das match_results-Objekt noch das sub_match-Objekt erstellt eine Teilzeichenfolge oder weist diese zu. Stattdessen entwerfen sie einen Zeichenbereich mit einem Zeiger oder Iterator am Anfang und am Ende der Übereinstimmung oder der Teilübereinstimmung, sodass der übliche halb offene Bereich entsteht, der von der Standardbibliothek bevorzugt wird. In diesem Falle rufe ich explizit die str-Method für jedes sub_match-Objekt auf, um von jeder Teilübereinstimmung eine Kopie als Zeichenfolgenobjekte anzulegen.

Damit wird das erste mögliche Format verarbeitet. Für das zweite ist ein weiterer Aufruf von „regex_match“ mit dem alternativen Muster erforderlich (technisch gesehen könnten Sie beide Formate mit einem einzigen Ausdruck abgleichen, aber das ist hier nicht der Punkt):

else if (regex_match(s, m, regex { R"((\w+), (\w+))" }))
{
  name   = m[2].str();
  family = m[1].str();
}

Bei diesem Muster wird die Namensübereinstimmung nach dem Format „Kerr, Kenny“ ausgeführt. Wie Sie sehen, musste ich die Indizes umdrehen, da die erste in diesem regulären Ausdruck dargestellte Gruppe den Nachnamen und die zweite Gruppe den Vornamen identifiziert. Damit ist die regex_match-Funktion abgeschlossen. In Abbildung 1 wird zur Referenz eine vollständige Liste angezeigt.

Abbildung 1: Das regex_match-Referenzbeispiel

char const s[] = "Kerr, Kenny";
auto m = cmatch {};
string name, family;
if (regex_match(s, m, regex { R"((\w+) (\w+))" }))
{
  name   = m[1].str();
  family = m[2].str();
}
else if (regex_match(s, m, regex { R"((\w+), (\w+))" }))
{
  name   = m[2].str();
  family = m[1].str();
}
else
{
  printf("No match\n");
}

Ich weiß nicht, wie Sie das sehen, aber auf mich wirkt der Code in Abbildung 1 aufwendig. Die reguläre Ausdrucksbibliothek ist sicherlich leistungsstark und flexibel, aber nicht besonders elegant. Ich muss die match_results- und die sub_match-Objekte kennen. Ich muss wissen, wie diese „Auflistung“ indexiert ist und wie die Ergebnisse extrahiert werden. Ich könnte das Erstellen der Kopien umgehen, aber das wird schnell beschwerlich.

Ich habe bereits viele der neuen C++-Sprachfeatures genutzt, von denen Sie einige möglicherweise schon kennen, aber der Code sollte kein große Überraschung bergen. Nun möchte ich Ihnen zeigen, wie Sie variadic-Vorlagen (variadische Vorlagen) einsetzen können, um Ihre Verwendung der regulären Ausdrücke „aufzupeppen“. Anstatt nun in die Tiefe zu gehen und mehr Sprachfeatures einzusetzen, zeige ich Ihnen zunächst eine einfache Abstraktion zur Vereinfachung der Textverarbeitung, damit der Code praktisch und elegant bleibt.

Als Erstes definiere ich einen einfachen Typ, der eine Sequenz aus Zeichenfolgen darstellt, die nicht unbedingt auf Null beendet werden müssen. Hier sehen Sie die strip-Klasse:

struct strip
{
  char const * first;
  char const * last;
    strip(char const * const begin,
          char const * const end) :
      first { begin },
      last  { end }
    {}
    strip() : strip { nullptr, nullptr } {}
};

Es gibt ohne Frage zahlreiche solcher Klassen, die ich möglicherweise wiederverwende. Aber ich finde, beim Erstellen einfacher Abstraktionen hilft es, zu viele Abhängigkeiten zu vermeiden.

Die strip-Klasse hat nicht viele Aufgaben, aber ich erweitere sie mit einer Reihe von Funktionen, die keine Memberfunktionen sind. Ich beginne mit einem Funktionspaar zur allgemeinen Definition des Bereichs:

auto begin(strip const & s) -> char const *
{
  return s.first;
}
auto end(strip const & s) -> char const *
{
  return s.last;
}

Obwohl sie für dieses Beispiel nicht unbedingt erforderlich ist, stellt diese Methode meiner Ansicht nach eine gute Maßnahme zur Gewährleistung der Konsistenz mit den Containern und Algorithmen der Standardbibliothek dar. Ich kehre gleich zu den begin- und end-Funktionen (für den Beginn und das Ende) zurück. Als Nächstes folgt die make_strip-Hilfsfunktion:

template <unsigned Count>
auto make_strip(char const (&text)[Count]) -> strip
{
  return strip { text, text + Count - 1 };
}

Diese Funktion ist praktisch, um aus einem Zeichenfolgenliteral einen „Strip“ zu erstellen. Beispielsweise kann ich den Strip wie folgt initialisieren:

auto s = make_strip("Kerr, Kenny");

Anschließend ist es häufig sinnvoll, die Länge oder Größe des Strips festzulegen:

auto size(strip const & s) -> unsigned
{
  return end(s) - begin(s);
}

Hier sehen Sie, dass ich einfach die begin- und end-Funktionen wiederverwende, um eine Abhängigkeit der Stripmember zu vermeiden. Ich könnte die Member der strip-Klasse schützen. Andererseits kann es oftmals hilfreich sein, sie direkt aus einem Algorithmus heraus bearbeiten zu können. Dennoch gilt: Wenn keine feste Abhängigkeit erforderlich ist, lasse ich sie weg.

Offensichtlich ist es einfach genug, eine Standardzeichenfolge aus einem Strip zu erstellen:

auto to_string(strip const & s) -> string
{
  return string { begin(s), end(s) };
}

Das kann sich als praktisch erweisen, wenn einige der Ergebnisse länger als die ursprünglichen Zeichenfolgensequenzen bestehen. Das rundet die grundlegende Stripverarbeitung ab. Ich kann den Strip initialisieren und dessen Größe festlegen. Und dank der begin- und end-Funktionen kann ich mit einer range-for-Anweisung die Zeichen durchlaufen:

auto s = make_strip("Kenny Kerr");
for (auto c : s)
{
  printf("%c\n", c);
}

Beim Schreiben der strip-Klasse habe ich gehofft, ich könnte ihre Member mit „begin“ und „end“ anstelle von „first“ und „last“ benennen. Das Problem ist, dass der Compiler beim Antreffen einer range-for-Anweisung zuerst versucht, geeignete Member zu finden, die als Funktionen aufgerufen werden können. Falls der Zielbereich bzw. die Zielsequenz keine Member mit dem Namen „begin“ und „end“ enthält, sucht der Compiler im einschließenden Bereich nach einem geeigneten Paar. Die Problematik ist die Folgende: Sofern der Compiler Member mit dem Namen „begin“ und „end“ findet, diese aber nicht geeignet sind, sucht der Compiler nicht weiter. Dies mag kurzsichtig wirken, aber C++ verfügt über komplexe Regeln für die Namenssuche, die durch Hinzufügungen nur noch komplexer und inkonsistent werden.

Die strip-Klasse ist ein einfaches kleines Konstrukt, das von sich aus keine Ausgaben ausführt. Ich kombiniere es nun mit der regulären Ausdrucksbibliothek, um eine elegante Abstraktion zu erstellen. Ich möchte die Mechanismen des Übereinstimmungsobjekts verstecken, das ist der aufwendige Teil der Ausdrucksverarbeitung. Hier kommen die variadic-Vorlagen ins Spiel. Der Schlüssel zum Verständnis der variadischen Vorlagen besteht darin zu erkennen, dass das erste Argument vom Rest getrennt werden kann. In der Regel führt das zu einer Kompilierzeitrekursion. Ich kann eine variadic-Vorlage definieren, um ein Übereinstimmungsobjekt mit „unpack“ nachfolgend in Argumente zu entpacken:

template <typename... Args>
auto unpack(cmatch const & m,
            Args & ... args) -> void
{
  unpack<sizeof...(Args)>(m, args...);
}

Mit „typename...“ wird angegeben, dass es sich bei „Args“ um ein Vorlagenparameterpaket handelt. Die entsprechende Angabe „...“ im Typ von „args“ gibt an, dass „args“ ein Funktionsparameterpaket ist. Der Ausdruck „sizeof...“ ermittelt die Anzahl der Elemente im Parameterpaket. Die abschließende Angabe „...“ hinter „args“ weist den Compiler an, das Parameterpaket in seine Elementsequenz zu erweitern.

Der Typ der einzelnen Argumente kann unterschiedlich sein, aber in diesem Fall ist jeder ein nicht konstanter Stripverweis. Ich verwende eine variadic-Vorlage, damit eine unbekannte Anzahl an Argumenten unterstützt wird. Bisher scheint die unpack-Funktion nicht rekursiv zu sein. Sie leitet die Argumente an eine weitere unpack-Funktion mit einem zusätzlichen template-Argument weiter:

template <unsigned Total, typename... Args>
auto unpack(cmatch const & m,
            strip & s,
            Args & ... args) -> void
{
  auto const & v = m[Total - sizeof...(Args)];
  s = { v.first, v.second };
  unpack<Total>(m, args...);
}

Allerdings wird mit dieser unpack-Funktion das erste Argument hinter dem Übereinstimmungsobjekt vom Rest getrennt. Das ist aktive Kompilierzeitrekursion. Unter der Annahme, dass das args-Parameterpaket nicht leer ist, ruft es sich zusammen mit den restlichen Argumenten auf. Die Sequenz der Argumente leert sich zunehmend, sodass für diese Schlussfolgerung eine dritte unpack-Funktion erforderlich ist:

template <unsigned>
auto unpack(cmatch const &) -> void {}

Von dieser Funktion wird nichts ausgeführt. Sie erkennt lediglich die Tatsache an, dass das Parameterpaket möglicherweise leer ist. Die vorherigen unpack-Funktionen enthalten den Schlüssel zum Entpacken des Übereinstimmungsobjekts. Die erste unpack-Funktion erfasst die ursprüngliche Anzahl der im Parameterpaket befindlichen Elemente. Dies ist erforderlich, da mit jedem rekursiven Aufruf ein neues Parameterpaket mit abnehmender Größe erstellt wird. Sie sehen, wie ich die Größe des Parameterpakets von der ursprünglichen Summe abziehe. Anhand dieser Summe bzw. stabilen Größe kann ich die Übereinstimmungsauflistung indizieren, um die einzelnen Teilübereinstimmungen abzurufen und deren entsprechende Begrenzungen in die variadic-Argumente zu kopieren.

Damit wird das Übereinstimmungsobjekt entpackt. Obwohl es nicht erforderlich ist, finde ich es hilfreich, das Übereinstimmungsobjekt auszublenden, sofern es nicht direkt benötigt wird. Das ist zum Beispiel der Fall, wenn es nur für den Zugriff auf das Übereinstimmungspräfix oder -suffix herangezogen wird. Ich umschließe den gesamten Ausdruck, um eine einfachere Abstraktion der Übereinstimmung bereitzustellen:

template <typename... Args>
auto match(strip const & s,
           regex const & r,
           Args & ... args) -> bool
{
  auto m = cmatch {};
  if (regex_match(begin(s), end(s), m, r))
  {
    unpack<sizeof...(Args)>(m, args...);
  }
    return !m.empty();
}

Auch bei dieser Funktion handelt es sich um eine variadic-Vorlage, die aber in sich nicht rekursiv ist. Sie leitet nur ihre Argumente zur Verarbeitung an die ursprüngliche unpack-Funktion weiter. Zudem sorgt sie für die Bereitstellung eines lokalen Übereinstimmungsobjekts und definiert die Suchsequenz hinsichtlich der begin- und end-Hilfsfunktionen des Strips. Zur Einbindung von „regex_search“ anstelle von „regex_match“ lässt sich eine nahezu identische Funktion schreiben. Das Beispiel aus Abbildung 1 kann ich nun deutlich einfacher schreiben:

auto const s = make_strip("Kerr, Kenny");
strip name, family;
if (match(s, regex { R"((\w+) (\w+))"  }, name,   family) ||
    match(s, regex { R"((\w+), (\w+))" }, family, name))
{
  printf("Match!\n");
}

Wie wäre es mit Iteration? Die unpack-Funktionen eignen sich auch gut, um die Übereinstimmungsergebnisse einer iterativen Suche zu verarbeiten. Stellen Sie sich eine Zeichenfolge mit dem kanonischen „Hello world“ in mehreren verschiedenen Sprachen vor:

auto const s =
  make_strip("Hello world/Hola mundo/Hallo wereld/Ciao mondo");

Ich kann jede mit dem folgenden regulären Ausdruck zuordnen:

auto const r = regex { R"((\w+) (\w+))" };

Die reguläre Ausdrucksbibliothek stellt „regex_iterator“ für eine Iteration durch die Übereinstimmungen bereit, aber das direkte Verwenden von Iteratoren kann mühsam sein. Eine Option besteht darin, eine for_each-Funktion zu schreiben, die zu jeder Übereinstimmung ein Prädikat aufruft:

template <typename F>
auto for_each(strip const & s,
              regex const & r,
              F callback) -> void
{
  for (auto i = cregex_iterator { begin(s), end(s), r };
       i != cregex_iterator {};
       ++i)
  {
    callback(*i);
  }
}

Ich könnte dann diese Funktion mit einem lambda-Ausdruck aufrufen, um die einzelnen Übereinstimmungen zu entpacken:

for_each(s, r, [] (cmatch const & m)
{
  strip hello, world;
  unpack(m, hello, world);
});

Das funktioniert sicherlich, aber ich finde es immer frustrierend, dass ich diese Art des Schleifenkonstrukts nicht einfach verlassen kann. Die range-for-Anweisung bietet eine bessere Alternative. Zunächst definiere ich zum Implementieren der range-for-Schleife einen einfachen Iteratorbereich, der vom Compiler erkannt wird:

template <typename T>
struct iterator_range
{
  T first, last;
  auto begin() const -> T { return first; }
  auto end() const -> T { return last; }
};

Anschließend schreibe ich eine einfachere for_each-Funktion, die nur ein iterator_range-Objekt zurückgibt:

auto for_each(strip const & s,
              regex const & r) -> iterator_range<cregex_iterator>
{
  return
  {
    cregex_iterator { begin(s), end(s), r },
    cregex_iterator {}
  };
}

Der Compiler erstellt die Iteration, und ich kann einfach eine range-for-Anweisung mit minimalem syntaktischen Mehraufwand schreiben, die ich frühzeitig verlassen kann, wenn ich das möchte:

for (auto const & m : for_each(s, r))
{
  strip hello, world;
  unpack(m, hello, world);
  printf("'%.*s' '%.*s'\n",
         size(hello), begin(hello),
         size(world), begin(world));
}

Die Konsole präsentiert die erwarteten Ergebnisse:

'Hello' 'world'
'Hola' 'mundo'
'Hallo' 'wereld'
'Ciao' 'mondo'

C++11 und zukünftige Versionen bieten die Möglichkeit, der C++-Softwareentwicklung mit einem modernen Programmierstil, mit dem Sie elegante und effiziente Abstraktionen erstellen können, neues Leben einzuhauchen. Die Grammatik regulärer Ausdrücke kann auch den erfahrensten Entwickler vor Probleme stellen. Warum nicht einige Minuten aufbringen, um eine elegantere Abstraktion zu entwickeln? Zumindest der C++-Teil dieser Aufgabe wird dann zu einem Vergnügen!

Kenny Kerr ist Programmierer aus Kanada, Autor bei Pluralsight und Microsoft MVP. Er veröffentlicht Blogs unter kennykerr.ca, und Sie können ihm auf Twitter unter twitter.com/kennykerr folgen.