Im Netz existieren zahlreiche Tutorials, wie man reguläre Ausdrücke schreibt. Leider konzentrieren sich wenige davon auf die Java-API. Dieser Artikel zeigt, wie man mit capturing groups aus praktischer Sicht umgeht und wie wir unseren bestehenden Code mit den Erweiterungen im JDK 20 erweitern können.

Wie wurden capturing groups vor JDK 20 verwendet?

Einzelne capturing group

Nehmen wir an, wir wollen IDs aus einem Text extrahieren. Der Einfachheit halber nehmen wir an, dass die IDs aus Ziffernfolgen bestehen und wir sie mit einer einfachen Regex abgleichen können: \d+. Außerdem wird jeder ID in der Regel ihr Name in folgendem Format vorangestellt Id: {id}, e.g. Id: 123.

Der herkömmliche Ansatz für den Abgleich von Text in einem solchen Fall beinhaltet die Verwendung einer capturing group. Dazu muss das übereinstimmende pattern in Klammern eingeschlossen werden, wie (\d+): Die Regex könnte wie folgt aussehen:

(?i)\bId[: ]*?(\d+)

vorausgesetzt:

  • (?i) - Groß- und Kleinschreibung wird nicht berücksichtigt

  • \b - Wortgrenze, d.h. es wird nach einem ganzen Wort gesucht, nicht nach einem Teil davon

  • Id - Übereinstimmung mit den Buchstaben “Id”

  • [: ]*? - Übereinstimmung mit Doppelpunkt oder Leerzeichen null oder mehr Mal, aber so wenig wie nötig (non-greedy)

  • (\d+) - Übereinstimmung und Erfassung einer oder mehrerer Ziffern

Werfen wir einen Blick auf ein vollständiges Beispiel:

@ParameterizedTest
@CsvSource(textBlock = """
    Id: 123 | 123 | # happy path
    Id: 1 | 1 | # shorter id
    Id: . | | # missing id
    Id: unknown | | # text instead of number
    Id  123 | 123 | # space instead of ':'
    ID: 123 | 123 | # upper case
    Id:123 | 123 | #missing separating space
    OtherId: 123 |  |
    """, delimiterString = "|")
void matchId(String input, String expectedId) {
    var pattern = Pattern.compile("(?i)\\bId[: ]*?(\\d+)");

    var matcher = pattern.matcher(input);
    // using "java.util.regex.MatchResult.group(int)" to extract capture
    var id = matcher.results().findFirst().map(m -> m.group(1)).orElse(null);

    assertThat(id).isEqualTo(expectedId);
}

Dieser Ansatz scheint ein wenig wackelig zu sein. Was bedeutet die Gruppe 1? Zählt (?i) als eine Gruppe? (Es ist eigentlich das CASE_INSENSITIVE-Flag). Warum beginnen die Gruppen nicht bei 0? Was ist, wenn wir mehr Gruppen haben?

Eine Nebenbemerkung: Es gibt einen Trick, eine einzelne capturing group zu vermeiden, indem man ein Look-Behind verwendet:

var pattern = Pattern.compile("(?i)(?<=\\bId[: ]{0,10})\\d+");
var matcher = pattern.matcher(input);
var id = matcher.results().findFirst().map(MatchResult::group).orElse(null);

Ich habe {0,10} anstelle von * verwendet, weil das Look-Behind die Länge der potenziellen Übereinstimmung berechnet, um zurückzugehen und sie zu überprüfen. Diese Methode kann langsam sein und hat ihre Grenzen.

Mehrere capturing groups

Nehmen wir an, der Text enthält verschiedene Arten von IDs von verschiedenen Institutionen (Steuer-ID, Gerichts-ID, Statistik-ID). In diesem Fall müssen wir eine weitere Gruppe einführen, um die Art der ID zu erfassen. Best Practise ist es, zur besseren Lesbarkeit mehrere Gruppen zu benennen, etwa (?<groupName>.*). Schauen wir uns ein Beispiel an:

@Test
void matchDifferentTypesOfIds() {
    String text = """
        Some text. Tax Id: 123. Some text.
        Some text, Court Id: 456, Stats Id: 789.
        """;
    var pattern = Pattern.compile("(?i)(?<type>\\w+ *Id)[: ]*?(?<id>\\d+)");

    var matcher = pattern.matcher(text);
    // using "java.util.regex.MatchResult.group(int)" to extract capture
    List<IdEntry> idEntries = matcher.results()
        .map(m -> new IdEntry(m.group(1), m.group(2)))
        .toList();

    assertThat(idEntries)
        .extracting(IdEntry::type, IdEntry::id)
        .containsExactly(tuple("Tax Id", "123"),
            tuple("Court Id", "456"),
            tuple("Stats Id", "789"));
}

Vor dem JDK 20 wurden named capturing groups leider nicht vollständig unterstützt. Wir konnten Gruppen nur über ihren Index referenzieren.

Neue Features des JDK 20

Seit dem JDK 11 hat es keine wesentlichen Änderungen an java.util.regex gegeben. Auch JDK 20 weicht nicht wesentlich davon ab (obwohl es ein Feature-Release ist), wie man bei einem Vergleich der Versionen sehen kann. Neben der Erweiterung der named capturing groups gibt es nur noch eine kosmetische Änderung, die die Methoden java.util.regex.Matcher.hasMatch und java.util.regex.MatchResult.hasMatch einführt.

Erweiterte Unterstützung für named capturing groups

Issue: https://bugs.openjdk.org/browse/JDK-8292872.

Diese Funktion führt die folgenden neuen API-Methoden ein:

java.util.regex.MatchResult
+ String group(String name)
+ int start(String name)
+ int end(String name)
+ Map<String,Integer> namedGroups()

java.util.regex.Pattern
+ Map<String,Integer> namedGroups()

Gehen wir noch einmal auf das vorherige Beispiel ein. Im JDK 20 können wir es wie folgt schreiben:

// using "java.util.regex.MatchResult.group(String)" to extract capture
var idEntries = matcher.results()
    .map(m -> new IdEntry(m.group("type"), m.group("id")))
    .toList();

Mit den Methoden start und end von MatchResult können wir sogar die Position im Text von passenden Gruppen ermitteln:

assertThat(matcher.results())
   .extracting(m -> m.group("type"),
        m -> m.group("id"), m -> m.start("type"), m -> m.end("id"))
   .containsExactly(tuple("Tax Id", "123", 11, 22),
      tuple("Court Id", "456", 46, 59),
      tuple("Stats Id", "789", 61, 74));

Außerdem ist jetzt eine neue Methode enthalten, die eine Karte Map<String,Integer> der Gruppennamen und der entsprechenden Indizes ausgibt:

assertThat(pattern.namedGroups())
   .isEqualTo(matcher.namedGroups());
assertThat(pattern.namedGroups())
   .asString()
   .isEqualTo("{id=2, type=1}");

Fazit

JDK 20 bietet endlich volle Unterstützung für named capturing groups und erlaubt nicht nur deren Definition, sondern auch Operationen mit ihnen. Jetzt können Entwickler Gruppen über ihren Namen referenzieren, anstatt über ihren numerischen Index. Das verbessert die Lesbarkeit, da der Code intuitiver wird. Auch die Wartbarkeit wird verbessert, da der Code weniger fehleranfällig ist. Ein großes Dankeschön an die Java-Gemeinschaft und die Entwickler, die diese nützliche Verbesserung umgesetzt haben!