Techblog

Functional Programming matters!

Von Florian Löffler
16. August 2017

"Funktionale Programmierung hat in der Praxis keine Relevanz: Sie dient Akademikern doch nur dafür, an Universitäten ihre Paper aufzupolieren". Mit einer solchen Aussagen wurde die funktionale Programmierung oftmals belächelt. Ich bin jedoch der Meinung, dass schon ein Bewusstsein für funktionale Programmierung bessere Entwickler aus uns machen kann. Mit diesem Beitrag möchte ich zu einem Blick über den Tellerand einladen.

Einordnung der funktionalen Programmierung

Im Grunde wollen wir Entwickler doch alle das Gleiche: Probleme lösen! Dafür befehlen wir dem Computer normalerweise, was er zu tun hat. Wir nennen das imperative Programmierung: "Inkrementiere die Zahl an der Speicherstelle i." "Gib mir Speicher für 20 Zahlenwerte der Länge long." "Iteriere über alle Objekte in der Liste Kunden und entferne leere Einträge." "..."

Als Alternative zu diesem Stil beziehungsweise Paradigma existieren die deklarativen Programmierstile. Um ein Problem deklarativ zu lösen, beschreiben wir die Lösung und lassen den Computer einen geeigneten Weg finden. Die funktionale Programmierung ist eine Art der deklarativen Programmierung, bei der die Lösung eines Problems durch Funktionen beschrieben wird, welche ein Computer dann für uns löst. Den Grundstein für funktionale Programmierung legte der Mathematiker Alonzo Church 1930 mit dem Lambda Kalkül, einer formalen Sprache zur Untersuchung von Funktionen. Die Ansätze sind also gar nicht mal so modern...

Funktionen vs. Methoden vs. Prozeduren

Es gibt viel Verwirrung und Streit über die Unterschiede zwischen diesen programmatischen Mitteln (siehe Funktion vs. Methode und Funktion vs. Prozedur). Ich unterscheide sie folgendermaßen: Methoden sind ein Mittel der Objektorientierung und gehören immer zu einer Klasse oder einer Instanz, wohingegen Funktionen und Prozeduren für sich alleine stehen können.

Was unterscheidet dann Prozeduren von Funktionen? Funktionen haben immer einen Rückgabewert, Prozeduren nie. Damit ist sofort klar, dass Prozeduren nur sinnvoll sind, wenn sie den Systemzustand beinflussen (Spoiler: durch einen Seiteneffekt). Funktionen dürfen das nicht. Dazu Beispiele:

  1. class Incrementable {
  2.     private int x;
  3.    
  4.     // Methode (mit Seiteneffekt)
  5.     public int inc() {
  6.         return x++;
  7.     }
  8.    
  9.     // Methode (ohne Seiteneffekt)}
  10.     public int inc(int x) {
  11.         return x+1;
  12.     }
  13. }
  14.  
  15.  
  16. int x = 0;
  17. // Prozedur (mit Seiteneffekt)
  18. void increment() {
  19.     x = x + 1;
  20. }
  21.  
  22. // Funktion
  23. int inc(int x) {
  24.     return x + 1;
  25. }

Was macht eine Programmiersprache funktional?

Es gibt aus meiner Sicht zwei Dinge, die eine Programmiersprache funktional machen. Erstens, Funktionen sind first-class citizens, das bedeutet: Funktionen lassen sich genau wie andere Werte zu Variablen zuweisen, als Parameter übergeben und sind mögliche Rückgabewerte von Methoden oder anderen Funktionen. Wenn Funktionen andere Funktionen als Parameter erhalten oder zurückliefern, werden sie auch als Funktionen höherer Ordnung bezeichnet (higher order functions).

Zweitens: Vermeiden beziehungsweise Kapseln von veränderlichem Zustand und Seiteneffekten, zum Beispiel durch immutable Datatypes und Monaden. Eine Sprache wie Haskell vermeidet alle Seiteneffekte oder kapselt sie sicher durch bestimmte Mechanismen wie Monaden. Deshalb wird Haskell als rein funktional bezeichnet. Das heißt also Programmieren ohne Zuweisung, Exceptions und Schleifen und und und.

Lassen sich dann überhaupt noch sinnvolle Programme schreiben? Ja! Da ich ein Haskell Noob bin und mich eher mit functional style Programmiersprachen à la ClojureScala, Java und C# auskenne, widme ich mich hier diesen. Diese Sprachen unterstützen die Funktionen als first-class citizens sowie immutable Datatypes und ermöglichen damit den Einsatz der wichtigsten Konzepte funktionaler Programmierung.

Vorteile von funktionaler Programmierung

Mit dem Systemzustand umgehen

Als erstes Argument für funktionale Programmierung werden häufig die Minimierung von Problemen mit mutable global state aufgeführt. Der mutable global state ist nicht falsch an sich, er macht es aber bei einem wachsenden System schwierig, alle ändernden Akteuere im Auge zu behalten und Konsistenz zu gewährleisten. Das führt dazu, dass sich extrem schnell Programmierfehler einschleichen.

Warum ist das so? Global und veränderbar gleicht einer "gesetzesfreie Zone", denn von beliebiger Stellen im System kann gelesen und geschrieben werden; Zustand meint: Menge von Werten zu einer bestimmten Zeit. Die Kombination aus beidem schafft nicht sichtbare semantische Abhängigkeiten zwischen vielen Teilen des Systems. Die Auswirkungen zeigen sich dann nur noch zur Laufzeit, nicht mehr zur Entwicklungszeit.

Wenn wir jedoch ehrlich sind, findet sich genau dieser veränderliche globale Zustand in fast jedem System, das wir oder Andere mit Java und Co. entwickeln. Jeder Programmierer, der schon einmal mit Threads gearbeitet und eigene Locking-Mechanismen geschrieben hat, weiß wie gefährlich dieser mutable global state ist. Und wer kennt nicht das Problem, für den einfachen Unit Test einer Methode ein ganzes System in einen bestimmten Zustand überführen zu müssen, da das Ergebnis dieser Methode vom Zustand des restlichen Systems abhängt. Im Vergleich zu Java, C# und Co. bieten "funktionalere" Sprachen wie Clojure und Haskell Mechanismen, um besser mit Systemzustand umzugehen. Dazu zählen unter anderem immutable datastructures, software transactional memory und Monaden (z.B. state monad). Rich Hickey, der die Programmiersprache Clojure erstellt hat, fasst das Thema State ganz gut zusammen: "State - You're doing it wrong"

Kleber für Software

Zudem bietet die funktionale Programmierung zwei neue Arten von Kleber mit der Software zusammengebaut werden kann (John Hughes). In der objektorientierten Programmierung bedienen wir uns häufig Entwurfsmustern wie Strategy, Template, Command, usw.: Wer kennt nicht die Gang of Four. Diese Entwurfsmuster ermöglichen eine Wiederverwendung etablierter Lösungsstrategien zur Lösung unserer domänen-spezifischen Probleme. Mit diesen Strategien implementieren wir die Problemlösungen durch Objekte und deren Kommunikation untereinander. In der funktionalen Welt existiert auch eine solche Wiederverwendung, jedoch auf einem niedrigeren Level. Da wir dort für unsere Probleme meist nur existierende Datentypen (list, map, set) benutzen, entstehen die Lösungen z.B. durch gegebene Funktionalität (Iteration, Filter, Transformation) für diese Datentypen, die mit problem-spezifischem Verhalten angereichert wird ("filtere alle Männer unter 25", "multipliziere jede Zahl mit 2"). Einen solchen Kleber stellen Funktionen höherer Ordnung dar, die higher order functions. Diese Art Funktion kann zusätzlich zu einfachen Daten, auch Blöcke von Logik (Funktionen), entgegennehmen oder auch zurückliefern (siehe map, reduce , filter…). Mario Fusco zeigt in seinem Vortrag [g ∘ f patterns], wie einfach man viele Entwurfsmuster der Gang of Four durch die Nutzung von Funktionen höherer Ordnung stark vereinfachen kann. Als Beispiel sollte uns hier das Strategy Pattern dienen:

  1. Function<string,string> toUpperCase =
  2.     text -> text.toUpperCase();
  3. Function<string,string> inBrackets =
  4.     text ->  "["+text+"]";
  5.  
  6. BiFunction<string,function<string,string>,String> format =
  7.     (text, strategy) -> strategy.apply(text);
  8.  
  9. format.apply("Watch out!", toUpperCase); // WATCH OUT!
  10. format.apply("Functional Style Rocks", inBrackets); // [Functional Style Rocks]
  11. format.apply("Another one", text -> text + " ..."); // Another one ...
  12. </string,function<string,string></string,string></string,string>

 

Mit dem zweiten Komponenten-Kleber lazy evaluation lassen sich Berechnungen beschreiben, die erst ausgeführt werden, wenn sie wirklich benötigt werden - und nur dann. Damit lassen sich auf effiziente Weise Funktionen verbinden. Dazu ein kleines Beispiel:

  1. //endloser Stream von geraden Zahlen Stream
  2. evenNumbers = Stream.iterate(2, n -> n + 2);
  3.  
  4. evenNumbers
  5.     .limit(5)
  6.     .collect(Collectors.toList()); // <2,4,6,8,10>

Streams in Java 8 sind von natur aus lazy. Sie ermöglichen es, unendliche Sequenzen von Daten zu definieren. Da diese erst ausgewertet werden, wenn es wirklich nötig wird, werden sie als LazySequences bezeichnet. In diesem Beispiel definieren wir die unendliche Folge der natürlichen, geraden Zahlen (evenNumbers) als einen Stream mittels Seed beziehungsweise Initialwert und UnaryOperator beziehungsweise Konstruktionsregel für weitere Elemente. "Lazy" ist dieser Strom, da wir zu diesem Zeitpunkt nur beschrieben haben, wie dieser Strom berechnet werden kann. Berechnet werden diese Werte erst, wenn eine spezielle Funktion, im Java Umfeld terminal operation genannt, mindestens einen dieser Werte konsumieren möchte (in diesem Fall collect). Die konsumierende Funktion collect und die erzeugende Funktion evenNumbers wurden hier geschickt miteinander "verklebt". Geschickt deshalb, weil der Produzent nur dann Ergebnisse erzeugt, wenn der Konsument diese benötigt. Sprachen wie Scala, Clojure und Haskell unterstützen die verzögerte Auswertung (lazyness) viel besser als Java, weshalb dieses funktionale Mittel dort viel mehr Anwendung findet.

Reine Funktionen sind ehrlich!

Funktionen, ohne Seiteneffekte (eng. pure functions), bilden bestimmte Eingaben auf bestimmte Ausgaben ab. Nicht mehr und nicht weniger. Es wird in diesem Zusammenhang auch von referencial transparency gesprochen, was so viel bedeutet wie "ein Ausdruck kann durch sein Ergebnis ersetzt werden". An jeder Stelle im System, an der eine Funktion mit den selben Parametern aufgerufen wird, gibt sie den selben Wert zurück. Dadurch lassen sich Funktionen unabhängig vom restlichen System betrachten. Diese simple Eigenschaft ermöglicht besserer Testbarkeit, vereinfacht das Verständnis und erleichtert die Wartung/Weiterentwicklung des Systems. Dazu wieder ein kleines Beispiel:

  1. //referential transparent
  2. int increment(int x){
  3.     return x + 1;
  4. }
  5. //...
  6. int six = increment( 5 );
  7.  
  8.  
  9. //referencial opaque
  10. int current = 0;
  11. int incrementByCurrent(int x) {
  12.     current = current + x;
  13.     return current;
  14. }
  15. //...
  16. int something = incrementByCurrent( 5 );

Jeder Aufruf von Funktion increment lässt sich an allen Stellen im gesamten Programm durch sein Ergebnis ersetzen (z.B. increment(4) ist immer gleich 5). Dies gilt nicht für incrementByCurrent, denn das Ergebnis von z.B. incrementByCurrent(4) hängt von allen vorrangegangenen Aufrufen an diese Methode ab und damit kann das Ergebnis des Aufrufs oft noch zur Laufzeit (Debugging) bestimmt werden. Zusätzlich zu den genannten Vorteilen, ermöglicht referentielle Transparenz auch die Performance-Optimierung Memoization. Dabei werden die berechneten Ergebnisse einer Funktion zwischengespeichert. Beim erneuten Aufruf, mit gleichen Parametern, kann das Ergebnis dann sofort zurückgeliefert werden.

Funktionale Programmierung in der Praxis

In diesem Teil möchte ich darauf eingehen, wie sich funktionale Ansätze in unseren Entwicklungsaltag integrieren lassen. 

Bei dieser Integration geht es nicht primär um Technologien oder Sprach-Features, es geht darum in gewissen Situationen umzudenken (Functional Thinking). Das möchte ich anhand von Java Version 8 tun. Die meisten Beispiele lassen sich in andere Sprache übertragen und können dort meistens eleganter umgesetzt werden. In Java 8 wurden gleich mehrere Neuerungen aus der funktionalen Programmierung aufgenommen. Darunter die Streams, Lambda Expressions und funktional Interfaces. Streams vereinfachen die Verarbeitung von Datensammlungen durch Funktionen höherer Ordnung (map, filter, reduce).

  1. // Imperativer Ansatz
  2. List<artikel> verfUndRedu = new ArrayList<artikel>();
  3.  
  4. for(Artikel artikel : alleArtikel) {
  5.     if(artikel.istVerfügbar())
  6.         if(artikel.istRabbatiert())
  7.             verfuegbarUndReduziert.add(artikel);
  8. }
  9.  
  10. // funktionaler Ansatz durch Nutzung von Streams
  11. List<artikel> verfUndRedu = alleArtikel.stream()
  12.     .filter(Artikel::istRabbatiert)
  13.     .filter(Artikel::istVerfügbar)
  14.     .collect(Collectors.toList());
  15. </artikel></artikel></artikel>

Lambda Expressions sind eine mächtige Erweiterung der Sprache um anonyme Funktionen. Seit Java 8 gelten Schnittstellen mit nur einer Methode als functional interface und lassen sich einfach mittels Lambda Ausdruck implementieren. Ein schönes Beispiel für die Kombination von Lambda Expressions und funktional interfaces bietet die Erzeugung von Threads:

  1. // Standard bis Java 7
  2. Thread t = new Thread( new Runnable() {
  3.     public void run() {
  4.         Price.calculate();
  5.     }
  6. });
  7.  
  8.  
  9. // ab Java 8
  10.  
  11. // Lambda Expression zur Implementierung eines functional Interface
  12. Thread t = new Thread( () -> Price.calculate() );
  13.  
  14. // Method Reference zur Implementierung eines functional Interface
  15. Thread t = new Thread( Price::calculate );

Runnable ist seit Java 8 ein functional interface und lässt sich somit direkt durch einen Lambda Ausdruck oder auch eine Methoden Referenz implementieren. Hiermit wird der Code deutlich lesbarer.

 

Rekursion, als funktionales Äquivalent zu Schleifen, ermöglicht oftmals eine intuitivere Lösungsbeschreibung und kann in vielen Fällen die Lesbarkeit unseres Codes erhöhen. Schauen wir uns dazu mal zwei Varianten an um alle Dateien in einem gegebenen Pfad auszugeben:

  1. // per Schleife
  2. public static void printFiles(String curPath) {
  3.     Stack<file> fileStack = new Stack<file>();
  4.     fileStack.push(new File( curPath ));
  5.     while(! fileStack.empty()) {
  6.         File currentFile = fileStack.pop();
  7.         if(currentFile.isDirectory()) {
  8.             for(File f : currentFile.listFiles())
  9.                 fileStack.push(f);
  10.             }
  11.         else
  12.             System.out.println(currentFile);
  13.     }
  14. }
  15.  
  16. printFiles("C:/");
  17.  
  18.  
  19. // per Rekursion und Funktion höherer Ordnung
  20.  
  21. public static void consumeFiles(String curPath, Consumer<file> fn) {
  22.     File curFile = new File(curPath);
  23.     if(curFile.isDirectory()) {
  24.         for(File child : curFile.listFiles())
  25.             consumeFiles(child.getAbsolutePath(), fn);
  26.     }
  27.     else
  28.         fn.accept(curFile);
  29. }
  30.  
  31. consumeFiles("C:/", f -> System.out.println(f.getName()));
  32. </file></file></file>

Die zweite Variante ist nicht nur lesbarer sondern auch vielseitiger: Sie verwendet eine Funktion höherer Ordnung, die sich nur um die Interaktion über Dateien kümmert. Das Verhalten pro Datei wird als Paramter übergeben, was dem Benutzer der Funktion volle Flexibilität ermöglicht. Auch wenn es unzählige weitere Anwendungsbeispiele für funktionale Programmierung gibt, möchte ich diesen Abschnitt hier abschließen um einen Ausblick zu geben.

 

Funktional oder doch objektorientiert?

Ich halte es in den meisten Fällen für wenig sinnvoll, Software rein funktional oder rein objektorientiert zu entwickeln. Jedes Paradigma hat seine Stärken und damit auch eine Daseinsberechtigung. Objektorientierung spielt meiner Meinung nach ihre Stärken bei der Strukturierung beziehungsweise der Architektur von Systemen aus. Normalerweise sind unsere Problem-Domänen geprägt von Nomen, die sich leicht in Klassen und Instanzen abbilden lassen. Die funktionale Programmierung hingegen hat ihre Stärken bei der Verarbeitung von Daten, bei Nebenläufigkeit und komplexen mathematischen Berechnungen. In diesen Bereichen ist funktionaler Code oft prägnanter, besser lesbar, weniger fehleranfällig und besser testbar.

Um sich in der Java-Welt noch etwas in Richtung des funktionalen Stils zu bewegen, kann ich die Bibliothek Vavr empfehlen. Sie bietet eine saubere Implementierung von Interfaces für Funktionen (Lambdas), bessere Streams, immutable Datatypes (mit map, filter und reduce ohne Streams) bessere Implementierung von Optionals (serializable), den Datentyp Tupel und vieles mehr. Für interessierte Entwicklern lohnt sich zudem ein Ausflug in die functional style Programmiersprache Clojure. Der Designer der Sprache, Rich Hickey, wird nicht umsonst von vielen Größen der Entwicklerszene, wie Robert C. Martin, für seine guten Ideen und seine einfache Umsetzung in dieser Sprache gelobt. Clojure bietet alles, was fürs Schreiben einer marktreifen Software nötig ist (siehe das Beispiel im Blog von Otto) und hat zudem eine extrem einfache Syntax - eine Folie reicht zur Erklärung vollständig aus. Auch ohne tägliche Anwendung von funktionaler Programmierung lohnt es sich diese andere Herangehensweise zu verstehen und als weiteres Werkzeug in seine Entwicklertoolbox aufzunehmen.

Neuen Kommentar schreiben

Public Comment form

  • Zulässige HTML-Tags: <a> <em> <strong> <cite> <blockquote> <code> <ul> <ol> <li> <dl> <dt> <dd><p><h1><h2><h3>

Plain text

  • Keine HTML-Tags erlaubt.
  • Internet- und E-Mail-Adressen werden automatisch umgewandelt.
  • HTML - Zeilenumbrüche und Absätze werden automatisch erzeugt.

ME Landing Page Question

Erhalten Sie regelmäßig Praxis-Tipps per E-Mail

Praxis-Tipps per E-Mail erhalten