So verwenden Sie reguläre Ausdrücke, um Token in Strings in Java zu ersetzen

1. Übersicht

Wenn wir in Java Werte in einer Zeichenfolge suchen oder ersetzen müssen, verwenden wir normalerweise reguläre Ausdrücke. Mit diesen können wir feststellen, ob ein Teil oder die gesamte Zeichenfolge mit einem Muster übereinstimmt. Mit der replaceAll- Methode in Matcher und String können wir problemlos dieselbe Ersetzung auf mehrere Token in einer Zeichenfolge anwenden .

In diesem Tutorial erfahren Sie, wie Sie für jedes in einer Zeichenfolge gefundene Token einen anderen Ersatz anwenden. Dies erleichtert es uns, Anwendungsfälle wie das Escapezeichen bestimmter Zeichen oder das Ersetzen von Platzhalterwerten zu erfüllen.

Wir werden uns auch einige Tricks ansehen, um unsere regulären Ausdrücke so zu optimieren, dass Token korrekt identifiziert werden.

2. Übereinstimmungen individuell verarbeiten

Bevor wir unseren Token-für-Token-Ersetzungsalgorithmus erstellen können, müssen wir die Java-API für reguläre Ausdrücke verstehen. Lösen wir ein kniffliges Matching-Problem mit Capturing- und Non-Capturing-Gruppen.

2.1. Titelbeispiel

Stellen wir uns vor, wir möchten einen Algorithmus erstellen, um alle Titelwörter in einer Zeichenfolge zu verarbeiten. Diese Wörter beginnen mit einem Großbuchstaben und enden entweder oder werden nur mit Kleinbuchstaben fortgesetzt.

Unser Beitrag könnte sein:

"First 3 Capital Words! then 10 TLAs, I Found"

Nach der Definition eines Titelworts enthält dieses die Übereinstimmungen:

  • Zuerst
  • Hauptstadt
  • Wörter
  • ich
  • Gefunden

Und ein regulärer Ausdruck, um dieses Muster zu erkennen, wäre:

"(?<=^|[^A-Za-z])([A-Z][a-z]*)(?=[^A-Za-z]|$)"

Um dies zu verstehen, zerlegen wir es in seine Bestandteile. Wir fangen in der Mitte an:

[A-Z]

erkennt einen einzelnen Großbuchstaben.

Wir erlauben einstellige Wörter oder Wörter, gefolgt von Kleinbuchstaben, also:

[a-z]*

erkennt null oder mehr Kleinbuchstaben.

In einigen Fällen würden die beiden oben genannten Zeichenklassen ausreichen, um unsere Token zu erkennen. Leider gibt es in unserem Beispieltext ein Wort, das mit mehreren Großbuchstaben beginnt. Daher müssen wir zum Ausdruck bringen, dass der einzelne Großbuchstabe, den wir finden, der erste sein muss, der nach Nichtbuchstaben erscheint.

Wenn wir ein einzelnes Großbuchstabenwort zulassen, müssen wir in ähnlicher Weise ausdrücken, dass der einzelne Großbuchstabe, den wir finden, nicht der erste eines Mehrfachbuchstabenworts sein darf.

Der Ausdruck [^ A-Za-z] bedeutet "keine Buchstaben". Wir haben eine davon zu Beginn des Ausdrucks in eine nicht erfassende Gruppe eingefügt:

(?<=^|[^A-Za-z])

Die nicht erfassende Gruppe, beginnend mit (? <=, Führt einen Blick zurück, um sicherzustellen, dass die Übereinstimmung an der richtigen Grenze angezeigt wird. Ihr Gegenstück am Ende erledigt die gleiche Aufgabe für die folgenden Zeichen.

Wenn Wörter jedoch den Anfang oder das Ende der Zeichenfolge berühren, müssen wir dies berücksichtigen. Hier haben wir ^ | hinzugefügt Für die erste Gruppe bedeutet dies "den Anfang der Zeichenfolge oder Zeichen, die keine Buchstaben sind", und wir haben | $ am Ende der letzten nicht erfassenden Gruppe hinzugefügt, damit das Ende der Zeichenfolge eine Grenze sein kann .

Zeichen, die in nicht erfassenden Gruppen gefunden wurden, werden bei der Verwendung von find nicht in der Übereinstimmung angezeigt .

Wir sollten beachten, dass selbst ein einfacher Anwendungsfall wie dieser viele Randfälle haben kann. Daher ist es wichtig, unsere regulären Ausdrücke zu testen . Dazu können wir Unit-Tests schreiben, die integrierten Tools unserer IDE verwenden oder ein Online-Tool wie Regexr verwenden.

2.2. Testen Sie unser Beispiel

Mit unserem Beispieltext in einer Konstante namens EXAMPLE_INPUT und unserem regulären Ausdruck in einem Muster namens TITLE_CASE_PATTERN verwenden wir find in der Matcher- Klasse, um alle unsere Übereinstimmungen in einem Komponententest zu extrahieren:

Matcher matcher = TITLE_CASE_PATTERN.matcher(EXAMPLE_INPUT); List matches = new ArrayList(); while (matcher.find()) { matches.add(matcher.group(1)); } assertThat(matches) .containsExactly("First", "Capital", "Words", "I", "Found");

Hier stellen wir die Verwendung Matcher - Funktion auf Muster , um einen produzieren Matcher . Dann verwenden wir die find- Methode in einer Schleife, bis sie nicht mehr true zurückgibt, um alle Übereinstimmungen zu durchlaufen.

Jedes Mal , wenn find true zurückgibt , wird der Status des Matcher- Objekts so festgelegt, dass er die aktuelle Übereinstimmung darstellt. Wir können die gesamte Übereinstimmung mit Gruppe (0) untersuchen oder bestimmte Erfassungsgruppen mit ihrem 1-basierten Index untersuchen . In diesem Fall gibt es eine Erfassungsgruppe um das gewünschte Stück, daher verwenden wir Gruppe (1) , um die Übereinstimmung zu unserer Liste hinzuzufügen.

2.3. Inspecting Matcher ein bisschen mehr

Bisher haben wir es geschafft, die Wörter zu finden, die wir verarbeiten möchten.

Wenn jedoch jedes dieser Wörter ein Token wäre, das wir ersetzen wollten, müssten wir mehr Informationen über die Übereinstimmung haben, um die resultierende Zeichenfolge zu erstellen. Schauen wir uns einige andere Eigenschaften von Matcher an , die uns helfen könnten:

while (matcher.find()) { System.out.println("Match: " + matcher.group(0)); System.out.println("Start: " + matcher.start()); System.out.println("End: " + matcher.end()); }

Dieser Code zeigt uns, wo jedes Match ist. Es zeigt uns auch das Gruppenspiel (0) , bei dem alles erfasst wird:

Match: First Start: 0 End: 5 Match: Capital Start: 8 End: 15 Match: Words Start: 16 End: 21 Match: I Start: 37 End: 38 ... more

Here we can see that each match contains only the words we're expecting. The start property shows the zero-based index of the match within the string. The end shows the index of the character just after. This means we could use substring(start, end-start) to extract each match from the original string. This is essentially how the group method does that for us.

Now that we can use find to iterate over matches, let's process our tokens.

3. Replacing Matches One by One

Let's continue our example by using our algorithm to replace each title word in the original string with its lowercase equivalent. This means our test string will be converted to:

"first 3 capital words! then 10 TLAs, i found"

The Pattern and Matcher class can't do this for us, so we need to construct an algorithm.

3.1. The Replacement Algorithm

Here is the pseudo-code for the algorithm:

  • Start with an empty output string
  • For each match:
    • Add to the output anything that came before the match and after any previous match
    • Process this match and add that to the output
    • Continue until all matches are processed
    • Add anything left after the last match to the output

We should note that the aim of this algorithm is to find all non-matched areas and add them to the output, as well as adding the processed matches.

3.2. The Token Replacer in Java

We want to convert each word to lowercase, so we can write a simple conversion method:

private static String convert(String token) { return token.toLowerCase(); }

Now we can write the algorithm to iterate over the matches. This can use a StringBuilder for the output:

int lastIndex = 0; StringBuilder output = new StringBuilder(); Matcher matcher = TITLE_CASE_PATTERN.matcher(original); while (matcher.find()) { output.append(original, lastIndex, matcher.start()) .append(convert(matcher.group(1))); lastIndex = matcher.end(); } if (lastIndex < original.length()) { output.append(original, lastIndex, original.length()); } return output.toString();

We should note that StringBuilder provides a handy version of append that can extract substrings. This works well with the end property of Matcher to let us pick up all non-matched characters since the last match.

4. Generalizing the Algorithm

Now that we've solved the problem of replacing some specific tokens, why don't we convert the code into a form where it can be used for the general case? The only thing that varies from one implementation to the next is the regular expression to use, and the logic for converting each match into its replacement.

4.1. Use a Function and Pattern Input

We can use a Java Function object to allow the caller to provide the logic to process each match. And we can take an input called tokenPattern to find all the tokens:

// same as before while (matcher.find()) { output.append(original, lastIndex, matcher.start()) .append(converter.apply(matcher)); // same as before

Here, the regular expression is no longer hard-coded. Instead, the converter function is provided by the caller and is applied to each match within the find loop.

4.2. Testing the General Version

Let's see if the general method works as well as the original:

assertThat(replaceTokens("First 3 Capital Words! then 10 TLAs, I Found", TITLE_CASE_PATTERN, match -> match.group(1).toLowerCase())) .isEqualTo("first 3 capital words! then 10 TLAs, i found");

Here we see that calling the code is straightforward. The conversion function is easy to express as a lambda. And the test passes.

Now we have a token replacer, so let's try some other use cases.

5. Some Use Cases

5.1. Escaping Special Characters

Let's imagine we wanted to use the regular expression escape character \ to manually quote each character of a regular expression rather than use the quote method. Perhaps we are quoting a string as part of creating a regular expression to pass to another library or service, so block quoting the expression won't suffice.

If we can express the pattern that means “a regular expression character”, it's easy to use our algorithm to escape them all:

Pattern regexCharacters = Pattern.compile("[]"); assertThat(replaceTokens("A regex character like [", regexCharacters, match -> "\\" + match.group())) .isEqualTo("A regex character like \\[");

For each match, we prefix the \ character. As \ is a special character in Java strings, it's escaped with another \.

Indeed, this example is covered in extra \ characters as the character class in the pattern for regexCharacters has to quote many of the special characters. This shows the regular expression parser that we're using them to mean their literals, not as regular expression syntax.

5.2. Replacing Placeholders

A common way to express a placeholder is to use a syntax like ${name}. Let's consider a use case where the template “Hi ${name} at ${company}” needs to be populated from a map called placeholderValues:

Map placeholderValues = new HashMap(); placeholderValues.put("name", "Bill"); placeholderValues.put("company", "Baeldung");

All we need is a good regular expression to find the ${…} tokens:

"\\$\\{(?[A-Za-z0-9-_]+)}"

is one option. It has to quote the $ and the initial curly brace as they would otherwise be treated as regular expression syntax.

At the heart of this pattern is a capturing group for the name of the placeholder. We've used a character class that allows alphanumeric, dashes, and underscores, which should fit most use-cases.

However, to make the code more readable, we've named this capturing groupplaceholder. Let's see how to use that named capturing group:

assertThat(replaceTokens("Hi ${name} at ${company}", "\\$\\{(?[A-Za-z0-9-_]+)}", match -> placeholderValues.get(match.group("placeholder")))) .isEqualTo("Hi Bill at Baeldung");

Here we can see that getting the value of the named group out of the Matcher just involves using group with the name as the input, rather than the number.

6. Conclusion

In diesem Artikel haben wir uns angesehen, wie Sie mithilfe leistungsfähiger regulärer Ausdrücke Token in unseren Zeichenfolgen finden. Wir haben gelernt , wie die Fundmethode mit arbeitet Matcher uns , die Spiele zu zeigen.

Dann haben wir einen Algorithmus erstellt und verallgemeinert, mit dem wir Token für Token ersetzen können.

Schließlich haben wir uns einige gängige Anwendungsfälle angesehen, um Zeichen zu maskieren und Vorlagen zu füllen.

Wie immer finden Sie die Codebeispiele auf GitHub.