ByteInSpace!

Software development with passion

Menu
  • Sample Page
Menu

Java und Wege der Filterung

Posted on June 23, 2020June 23, 2020 by Daniel

Vor wenigen Tagen kam ein studentischer Freund von mir auf mich zu mit der Bitte um Hilfe. Er bekam in der Vorlesung eine Aufgabe und bat mich, sich die Lösung anzuschauen, bevor er sie an der Uni wieder abgibt.

An sich war die Aufgabe sehr trivial: es gebe eine Liste mit Äpfeln in unterschiedlichen Reifegraden. Die Klasse, die das implementiert, war vordefiniert:

public class Apple {

public static enum MATURITY_LEVEL { RED, YELLOW, GREEN}

private  MATURITY_LEVEL color;
private String name;
private int weight;

public Apple(String name, MATURITY_LEVEL color, int weight) {
    super();
    this.color = color;
    this.name = name;
    this.weight = weight;
}
public MATURITY_LEVEL getColor() {
    return color;
}
public void setColor(MATURITY_LEVEL color) {
    this.color = color;
}
public String getName() {
    return name;
}
public void setName(String name) {
    this.name = name;
}
public int getWeight() {
    return weight;
}
public void setWeight(int weight) {
    this.weight = weight;
}   
}

Dazu gab es eine Klasse AppleSorter, welche im Konstruktor paar Beispiele fühlte:

List<Apple> apples = new ArrayList<>();
public AppleSorter() 
{
    Apple apple1 = new Apple("Apfel1", MATURITY_LEVEL.RED, 150);
    apples.add(apple1);
    Apple apple2 = new Apple("Apfel2", MATURITY_LEVEL.YELLOW, 200);
    apples.add(apple2);
    Apple apple3 = new Apple("Apfel3", MATURITY_LEVEL.RED, 50);
    apples.add(apple3);
    Apple apple4 = new Apple("Apfel4", MATURITY_LEVEL.GREEN, 60);
    apples.add(apple4);
    Apple apple5 = new Apple("Apfel5", MATURITY_LEVEL.RED, 200);
    apples.add(apple5);
}

Die erste Aufgabe war trivial: man schreibe alle Äpfel aus, die den Reifegrad RED haben. Die Implementierung:

private List<Apple> getRedApples(List<Apple> apples) {
	List<Apple> redApples = new ArrayList<>();
	for (Apple apple : apples) {
		if (MATURITY_LEVEL.RED.equals(apple.getColor()))
			redApples.add(apple);
	}
	return redApples;
}
	
public void printSortedApples() {
	List<Apple> redApples = getRedApples(apples);
	for (Apple apple: redApples)
	{
		System.out.println(apple.getName());
	}
}

Es folgte die zweite Aufgabe: man gebe alle grünen Äpfel aus. Dann die dritte: man gebe alle Äpfel aus, die schwerer sind als 100 Gramm. Die vierte wiederum kombinierte alles: man gebe alle roten Äpfel aus, die schwerer sind als 100 Gramm.

Prinzipiell eine sehr einfache Lösung, die er auch so geschrieben hat:

private List<Apple> getRedApples(List<Apple> apples) {
	List<Apple> redApples = new ArrayList<>();
	for (Apple apple : apples) {
		if (MATURITY_LEVEL.RED.equals(apple.getColor()))
			redApples.add(apple);
	}
	return redApples;
}
	
private List<Apple> getGreenApples(List<Apple> apples) {
	List<Apple> greenApples = new ArrayList<>();
	for (Apple apple : apples) {
		if (MATURITY_LEVEL.GREEN.equals(apple.getColor()))
			greenApples.add(apple);
	}
	return greenApples;
}
	
private List<Apple> getRedApplesWithWeight(List<Apple> apples) {
	List<Apple> redApples = new ArrayList<>();
	for (Apple apple : apples) {
		if (MATURITY_LEVEL.RED.equals(apple.getColor()) && apple.getWeight() > 100)
			redApples.add(apple);
	}
	return redApples;
}

Die Lösung ist fachlich korrekt und liefert auch das gewünschte Ergebnis. Trotzdem bleibt ein ungutes Gefühl: man sieht auf den ersten Blick, dass der meiste Code exakt gleich ist. Code Duplizierung ist immer doof und gehört vermieden, wo man es nur irgendwie kann. Also kam er zu mir und bat um Hilfe, wie man das vernünftiger und schöner machen kann.

Gemeinsam ging es an Refactoring.

Was ist allen Funktionen gemeinsam? Praktisch alles bis auf das Kriterium, wann ein Apfel die gewünschten Anforderungen erfüllt. Einfachste Idee: man übergibt die Kriterien einfach als Parameter. Für die ersten beiden Funktionen ist es sehr einfach:

private List<Apple> getColorApples(List<Apple> apples, MATURITY_LEVEL maturityLevel) {
		
	List<Apple> sortedApples = new ArrayList<>();
	for (Apple apple : apples) {
		if (maturityLevel.equals(apple.getColor()))
			sortedApples.add(apple);
	}
	return sortedApples;
}

Bei der dritten Funktion aber wird es kompliziert. Sie ist auch praktisch gleich mit den anderen beiden bis auf die Tatsache, dass sie einen zweiten Wert bekommt. Natürlich kann man das auch so lösen:

private List<Apple> getColorApples(List<Apple> apples, MATURITY_LEVEL maturityLevel, Integer weight) {
	List<Apple> sortedApples = new ArrayList<>();
	for (Apple apple : apples) {
		if ( (weight == null && maturityLevel.equals(apple.getColor()) ||
				(weight < apple.getWeight() && maturityLevel.equals(apple.getColor()))))
		{
			sortedApples.add(apple);
		}
	}
	return sortedApples;
}

Vermutlich bin ich nicht der einzige hier, der jetzt Augenkrebs bekommt. Ja, die Lösung funktioniert und man würde sich schon wundern, wie häufig ich sie gesehen habe. Aber das Wort “fürchterlich” dürfte gut beschreiben, was ich davon halte. Zum einen haben wir einen weiteren Parameter, der in den meisten Fällen (hier 2 von 3) auf null steht, daher eine unnötige Verschwendung darstellt. Zweitens sobald eine weitere Einschränkung kommt, beispielsweise alle roten Äpfel die schwerer als 100 Gramm aber leichter als 200 Gramm sind, wird es lustig… Nein, nicht akzeptabel.

Früher, in den seligen Zeiten vor Java 1.8 nutzte ich schon mal für ähnliches Problem eine Lösung mit Interfaces und Klassen, die die gewünschte Filterung vorgenommen haben. Das sah dann so aus:

public interface AppleFilter {
	boolean filter(Apple apple);
}

Anschließend wurde für die gewünschte Funktionalität eine Klasse gebaut, die dieses Interfaces implementiert hatte:

public class AppleFilterRed implements AppleFilter {
	boolean filter(Apple apple) {
		return MATURITY_LEVEL.RED.equals(apple.getColor());
	}
}

Das Coole an dieser Lösung ist, dass man eine gewisse Separation von Funktionalität und Hauptcode hat. Der Code wird damit übersichtlicher, da man zwar sieht, was gemacht wird (Filterung) muss sich mit den Details der Filterung aber im normalen Codeablauf gar nicht beschäftigen.

Man braucht nur noch eine allgemeine Filterungsfunktion, welche einfach als Parameter die gewünschte Implementierungsklasse bekommt.

public filterApplesForColor(List<Apple> apples, AppleFilter appleFilter)
{
	List<Apple> result = new ArrayList<>();
	for(Apple apple: apples)
	{
		if (appleFilter.filter(apple))
			result.add(apple);
	}
	return result;
}

Die Idee ist wirklich sehr interessant.

Zum einen haben wir den gemeinsamen Teil, die Iterierung durch die Schleife, in eine separate Funktion ausgelagert. Lediglich das Filterungskriterium wird als Parameter dazu gegegen. Brauchen wir eine neue Filterung, implementieren wir einen neue Klasse für das Interface AppleFilter und packen die gewünschten Regeln da rein. Der sonstige Code bleibt unverändert. Diese können beliebig kompliziert werden, beeinflussen jedoch nicht den Hauptcode, der weiterhin gut lesbar und aufs wesentliche konzentriert bleibt.

Auch manchmal heute noch wird die Methode gern eingesetzt, wenn es um besonders komplizierte und aufwendige Regeln gibt. Auch die Dokumentation ist sehr einfach, da man fachliche Kriterien (Filterungsregeln) von der Verarbeitungslogik (Schleifendurchlauf) trennt.

Es gibt natürlich einen Nachteil: die vielen Filterungsklassen. Sie kann man natürlich vermeiden, indem man auf eine anonymisierte Klasse greift:

List<Apple> redApples = filterApplesForColor(apples, new AppleFilter() {
	public boolen filter(Apple a) {
		return MATURITY_LEVEL.RED.equals(apple.getColor()); 
	}
});

Hier heißt es, abzuwägen: die inneren Klassen sind einfacher, aber verwischen gern den Code. Separate Klassen sind deutlicher abgetrennt und machen den Code lesbarer, sind aber aufwendiger.

Problem gelöst? Vor Java 1.8. schon. Mittlerweile gibt es aber einen wesentlich schöneren und eleganteren Weg, um das Problem noch sauberer zu lösen. Allerdings bin ich doch immer wieder etwas überrascht, ihn recht wenig in der Praxis anzutreffen. So mancher Javaentwickler scheint leider immer noch etwas Angst vor Lambdas zu haben, dabei sind diese wie prädestiniert für derartige Problemlösungen.

Mit Lambda gibt es nämlich die wundervoll geniale wie einfache Möglichkeit, ein Kriterium bzw. eine Funktion als Parameter zu übergeben. Man nennt sie ein PREDICATE. Man gibt sozusagen die Implementierung der aufgerufenen Methode mit.

Die Implementierung der Iterationsmethode sieht dann so aus:

public  <Apple> List<Apple> filterApples(List<Apple> list, Predicate<Apple> p) {
	List<Apple> result = new ArrayList<>();
	for(Apple e: list) {
		if (p.compareColor(e)) {
			result.add(e);
		}
	}
	return result;
}

und der Aufruf:

List<Apple> coloredApplesLambda = filterApples(apples, (Apple apple) -> MATURITY_LEVEL.RED.equals(apple.getColor()));

Keine separaten Klassen mehr, keine inneren-Codegewüschtels. Der Aufruf übergibt der Funktion filterApples als Parameter die gewünschte Implementierung der Methode compareColor aus dem Predicate – interface. Der Code ist eleganter, sauberer und deutlich besser lesbar, außerdem wesentlich einfacher zum Warten.

Und wenn wir mal statt Äpfel Birne prüfen wollen? Nochmal alles schreiben? Nö, machen wir einfach T daraus:

public static <T> List<T> filter(List<T> list, Predicate<T> p) {
	List<T> result = new ArrayList<>();
	for(T e: list) {
		if (p.compareColor(e)) {
			result.add(e);
		}
	}
	return result;
}

Jetzt können wir Äpfel, Pflaumen, Birnen und alles andere sortieren. Und wenn wir, wie bei der Aufgabe 3, auch noch Gewicht prüfen müssen? Auch einfach:

List<Apple> coloredApplesLambdaTWeight = filter(apples, (Apple apple) -> MATURITY_LEVEL.RED.equals(apple.getColor()) && 100 < apple.getWeight());

Wir haben also eine Methode gebaut, die praktisch jede Art von Objekten nimmt und nach beliebigen Kriterien bearbeiten kann. Man muss nur die gewünschten Objekte und Kriterien im Aufruf mitgeben, fertig. Cool, oder?

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Recent Posts

  • Mounten von Shares einer NAS unter Linux
  • Heimnetzwerksetup mit Ansible II: MariaDB
  • Heimnetzwerksetup mit Ansible mit Ansible I
  • Neuzugang: Schneider PC 1512 DD
  • Java und Wege der Filterung

Recent Comments

    Archives

    • October 2021
    • July 2021
    • March 2021
    • June 2020
    • April 2020
    • March 2020
    • January 2020
    • December 2019
    • May 2019
    • April 2019

    Categories

    • Database
    • Development
    • Java
    • Linux
    • PC
    • Reparatur
    • Retrocomputing
    • Schneider
    • Uncategorized
    ©2023 ByteInSpace! | Built using WordPress and Responsive Blogily theme by Superb