Texte aufsplitten und zusammenführen in Java 8 – Teil 1

In Java kann man schon seit längerem Texte anhand von Trennzeichen in einzelne Bestandteile zerlegen. Für das Gegenteil, das Zusammenführen von Collections oder Arrays mit benutzerdefinierten Trennzeichen zu einem String (join) gab es bis Java 8 keine geeignete Unterstützung im JDK. Natürlich sind verschiedene Bibliotheken wie Apache Commons oder Google Guava eingesprungen. Aber manchmal wollte man in Projekten Abhängigkeiten zu externen Bibliotheken so gering wie möglich halten und wurde dadurch zu einer eigenen Implementierung gezwungen.

In diesem ersten Teil einer kleinen Blog-Reihe schauen wir uns zunächst mal das Aufsplitten an.

Da gab es schon immer verschiedene Varianten, die aber alle ihre Vor- und Nachteile haben. Entweder leidet die Lesbarkeit oder man bekommt als Ergebnis nicht den Datentyp, den man gern hätte. Letzteres passiert beim Klassiker, der split-Methode:

	@Test
	public void testSplitWithRegexpDelimiterSplit() {
		// Array, Delimiter: Regexp
		assertArrayEquals(new String[]{"a", "b", "c"}, "a, b, c".split(", "));
		assertEquals(Arrays.asList("a", "b", "c"), Arrays.asList("a b c".split("\\s+")));
	}

Die split-Methode ist durch die Verwendung von regulären Ausdrücken sehr mächtig. Als Ergebnis wird ein Array zurückgeliefert. Meist benötigt man aber gar kein Array und hätte dafür lieber direkt eine Liste (Collection) als Resultat, um damit einfacher weiterarbeiten zu können. So muss man immer noch den Umweg über Arrays.asList() gehen. Außerdem kompiliert split unter der Haube bei jedem Aufruf ein neues Pattern-Objekt. Das ist sehr zeitaufwändig und wird bei vielen split-Aufrufen zum Flaschenhals.

	public String[] split(String regex, int limit) {
    	return Pattern.compile(regex).split(this, limit);
	}

Besser wäre es also, einmal initial das Pattern zu kompilieren und dann darauf beliebig oft split() aufzurufen:

	Pattern pattern = Pattern.compile(", ");
	assertArrayEquals(new String[]{"a", "b", "c"}, pattern.split("a, b, c"));

Wenn man auf reguläre Ausdrücke verzichten kann, bietet der StringTokenizer eine andere Möglichkeit. Nachteil hier ist die veraltete Enumerator-Schnittstelle. Und wenn man das Ergebnis als Datenstruktur (Liste, Array) weiterverarbeiten möchte, muss man es zunächst wieder einpacken.

	@Test
	public void testSplitWithStringTokenizer() {
		// Enumeration, Delimiter: String
		StringTokenizer tokenizer = new StringTokenizer("a, b, c", ", ");
		assertEquals(3, tokenizer.countTokens());
		assertTrue(tokenizer.hasMoreTokens());
		assertEquals("a", tokenizer.nextToken());
		assertEquals("b", tokenizer.nextToken());
		assertEquals("c", tokenizer.nextToken());
		assertFalse(tokenizer.hasMoreTokens());
	}		

Eine ähnliche Funktionsweise bietet die Klasse Scanner, die statt der Enumeration die etwas modernere Iterator-Schnittstelle implementiert. Zudem kann man auch wieder reguläre Ausdrücke als Trennzeichen verwenden.

	@Test
	public void testSplitWithScanner() {
		// Iterator, Delimiter: Regexp
		try (Scanner scanner = new Scanner("a, b, c")) {
			scanner.useDelimiter(",\\s+");
			assertTrue(scanner.hasNext());
			assertEquals("a", scanner.next());
			assertEquals("b", scanner.next());
			assertEquals("c", scanner.next());
			assertFalse(scanner.hasNext());
		}
	}	

Natürlich kann man das Aufsplitten auch mit einer Schleife (while, for) und Methoden der String-Klasse von Hand implementieren. Das mag für performancekritische Anwendungen nach einer guten Idee aussehen. Eigentlich ist es aber nicht ratsam, bei solchen elementaren Funktionen das Rad neu zu erfinden. Man kann nicht sicherstellen, dabei keine Fehler zu machen, alle Grenzfälle zu beachten und auch noch richtig zu behandeln. Eine mögliche Implementierung könnte folgendermaßen aussehen:

	@Test
	public void testSplitWithLoopIndexOfAndSubstring() {
		assertEquals(Arrays.asList("a", "b", "c"), mySplit("a, b, c", ", "));
		assertEquals(Arrays.asList("a", "b", "c,"), mySplit("a, b, c,", ", "));
		assertEquals(Arrays.asList("a", "b", "c"), mySplit("a, b, c, ", ", "));
		assertEquals(Arrays.asList("a", "b", "c"), mySplit("a, b, , c", ", "));
	}

	private List<String> mySplit(String text, String delimeter) {
		int index1 = 0;
		int index2 = text.indexOf(delimeter, index1);
		List<String> result = new ArrayList<>();
		while(index2 >= 0) {
			if (!text.substring(index1, index2).trim().isEmpty()) {
				result.add(text.substring(index1, index2));
			}
			index1 = index2 + delimeter.length();
			index2 = text.indexOf(delimeter, index1);
		}
		if (!text.substring(index1).trim().isEmpty()) {
			result.add(text.substring(index1));
		}
		return result;
	}

Ob der obige Code korrekt ist, kann ich nicht sagen. Ich schließe mich da vielmehr Donald Knuth an:

„Beware of bugs in the above code; I have only proved it correct, not tried it.“

Mit Java 8 können wir durch Streams und Lambdas nun auf eine funktionale Art und Weise programmieren. Eine Möglichkeit, Zeichenketten in Streams aufzusplitten, verwendet wieder die altbekannte split()-Methode:

	@Test
	public void splitStreamAndCollectNonStrings() {
		assertEquals(Arrays.asList(1, 2, 3), Arrays.stream(("1, 2, 3").split(","))
        	.map(String::trim)
        	.mapToInt(Integer::parseInt)
        	.boxed()
        	.collect(Collectors.toList()));
	}

Das aus split() resultierende Array wird in einen Stream überführt, auf dem dann weitergearbeitet werden kann. In diesem Fall erfolgt mit map() eine Transformation (Abschneiden von vorangehenden und abschließenden Leerzeichen), mit mapToInt() wird der Typ geändert und das Ergebnis dann wieder in einer Liste, diesmal von Integer-Objekten, zusammengefügt.

Des Weiteren kann man auf einem vorkompilierten Regex-Pattern die splitAsStream()-Methode aufrufen. Das spart dann wieder unnötiges Parsen der gleichen regulären Ausdrücke wie bei split().

Im folgenden Beispiel reduzieren wir den aufgesplitteten Stream nur in eine Liste. Trotz der Einfachheit kann man gut die strikte Trennung der Verantwortlichkeiten erkennen. Zuerst wird ein regulärer Ausdruck kompiliert, dann wird auf dessen Basis ein Aufsplitten des Ausgangstextes vorgenommen und anschließend das Ergebnis in einer Liste gesammelt. Das erhöht die Lesbarkeit und hat vor allem auch im Falle nebenläufiger Programmierung entscheidende Vorteile gegenüber dem imperativen Spaghetti-Code Ansatz.

	@Test
	public void splitStringStream() {
		assertEquals(Arrays.asList("A", "B", "c"), 
			Pattern.compile(",\\s+")
			.splitAsStream("a,   b,c")
			.map(String::toUppercase)
			.collect(Collectors.toList()));
	}

Ein bisschen was hat sich mit Java 8 also getan, was das Splitten von Zeichenketten angeht. Eine leichtgewichtige Alternative (ohne reguläre Ausdrücke), wie sie Groovy mit tokenize(CharSequence|Character) anbietet, gibt es aber weiterhin nicht.

Short URL for this post: http://wp.me/p4nxik-2s1
This entry was posted in Java and Quality, Java Basics and tagged , , , , . Bookmark the permalink.

One Response to Texte aufsplitten und zusammenführen in Java 8 – Teil 1

  1. Pingback: Texte aufsplitten und zusammenführen in Java 8 – Teil 2 | techscouting through the java news

Leave a Reply