Imperativ oder funktional – praktische Auswirkungen

27. November 2017

C# ist eine Multiparadigmensprache wie viele andere auch. Über LINQ hat auch funktionale Programmierung ihren Weg in unser Tagesgeschäft gefunden.

Die meisten der von C# unterstützten Paradigmen sind dem typischen Entwickler derart in Fleisch und Blut übergegangen, dass er kaum noch darüber nachdenkt. Strukturierte Programmierung, imperative Programmierung, Objektorientierung, um nur die grundlegendsten zu nennen. Dass es sich bei funktionaler Programmierung ebenfalls um ein eigenständiges Paradigma handelt, bleibt aber leider oft auf der Strecke. Stattdessen wird LINQ oft mit der Brille “klassischer” imperativer Herangehensweise betrachtet.

Damit tut man sich aber keinen Gefallen. Die theoretischen Grundlagen funktionaler Programmierung zu kennen und im eigenen Code zu honorieren, führt zu besser strukturiertem, klarerem und nicht zuletzt auch effizienterem Code.

Ich möchte dies an einem typischen Beispiel verdeutlichen. Aber zunächst…

…ein wenig Theorie und Geschichte

Es gibt eine ganze Reihe von Merkmalen, die funktionale Programmierung ausmachen. Bei F# etwa würde man Lambda-Ausdrücke, Pattern Matching, Tail Recursion und andere aufzählen. Das Merkmal mit der für LINQ größten Bedeutung sind sicherlich Sequenzen; die passendste Definition dazu habe ich in einer Vorlesung zu F# gefunden:

A lazy list or sequence in F# is a possibly infinite, ordered collection of elements, where the elements are computed by demand only.

Eine […] Sequenz in F# ist eine möglicherweise unendliche, geordnete Sammlung von Elementen, bei der die Elemente nur bei Bedarf berechnet werden.

Wesentlich ist also u. a. der “lazy” Aspekt, auch als “deferred execution” oder “Auswertung bei Bedarf” bekannt.

Das Pendant für eine Sequenz in LINQ ist IEnumerable<T> (bzw. die nicht-generische Variante). Entstanden sind diese Interfaces zur Unterstützung von foreach in C# 2.0 zusammen mit Iteratoren (Stichwort “yield return”) – also bevor LINQ mit .NET 3.5 Einzug hielt. Und auch wenn damit bereits alle Anlagen von Sequenzen gegeben waren, ging insbesondere der “lazy” Charakter in der Praxis etwas unter. foreach war syntaktischer Zucker für die Verarbeitung von gängigen Datenstrukturen (z. B. ArrayList, die neuen generischen Datentypen, aber auch Arrays selbst). Iteratoren waren ein Hilfsmittel, um dies für eigene Datenstrukturen zu realisieren, ohne umständlich Hilfsklassen schreiben zu müssen. Im Grunde handelte es sich dabei aber um ein isoliertes Feature, dessen zukünftige Bedeutung zumindest damals noch nicht klar wurde.

Mit C# 3.0 hielten die fehlenden Bestandteile und LINQ als Begriff Einzug in die Sprache. Iteratoren und andere vorher vorhandene Features wurden in einen größeren Zusammenhang gestellt, und funktionale Programmierung als konzeptioneller Unterbau wurde sichtbar.

Diese Einführung von LINQ in Etappen trägt sicher dazu bei, dass IEnumerable<T> und Iteratoren oft als rein technisch motiviertes Feature der Base Class Library (BCL) und des Compilers betrachtet werden. Einige funktionalen Aspekte schlagen sich aber gerade nicht in der Common Language Runtime oder der BCL nieder; sie sind rein semantischer Natur. Und diese Semantik muss auch bei eigenen Schnittstellen und Implementierungen berücksichtigt werden, wenn funktionale Ansätze durchgängig Bestand haben sollen.

Ein konkretes Beispiel

Es ist durchaus üblich, in einer Schichtenarchitektur Datenzugriffskomponenten zu haben und diese über Interfaces zu abstrahieren, so dass Dependency Injection zum Einsatz kommen kann.

Folgendes Code-Beispiel zeigt eine entsprechende (sehr vereinfachte) Schnittstelle:

   1: interface IDatabase

   2: {

   3:     IEnumerable<Employee> GetEmployees(int companyID);

   4: }

Eine (im Grundsatz durchaus übliche) Implementierung könnte wie folgt aussehen:

   1: public class DatabaseAccess : IDatabase

   2: {

   3:     IEnumerable<Employee> IDatabase.GetEmployees(int companyID)

   4:     {

   5:         using (var db = new Entities())

   6:         {

   7:             // ToArray um das SQL-Statement abzusetzen, bevor der DbContext verloren geht...    

   8:             var result = db.Employees.Where(e => e.CompanyID == companyID).ToArray();

   9:             return result;

  10:         }

  11:     }

  12: }

Einfach und nachvollziehbar. Mapping-Code für Daten und Fehlerbehandlung kann leicht ergänzt werden. Ähnlicher Code dürfte sich in Datenzugriffskomponenten vieler Projekte wiederfinden.

Leider ist dieser Code aus Sicht funktionaler oder imperativer Programmierung weder Fisch noch Fleisch.

Nebenbei: Bei dieser Betrachtung spielt es keine Rolle, dass Entity Framework genutzt und auf dieses mit LINQ-Mechanismen zugegriffen wird. Das ist hier nur Mittel zum Zweck.

Das Problem

Die Schnittstelle im Beispiel verwendet IEnumerable<T>, erweckt also den Eindruck, nach funktionalen Gesichtspunkten designt zu sein. Die Implementierung hingegen erzeugt den Datenbank-Kontext, führt die Abfrage durch und liefert die vollständige Ergebnismenge als Array zurück. Klassische imperative Herangehensweise, keinerlei “deferred execution” – womit die Eigenschaften einer Sequenz, das heißt die Anforderungen an funktionale Eigenschaften, verletzt werden.

Man könnte annehmen, dass das eine rein akademische Unterscheidung ist. Der Code funktioniert schließlich fehlerfrei. Aber bei näherer Betrachtung fällt auf, dass der Code unnötig ineffizient ist:

  • Will der Aufrufer das gesamte Resultset lesen, dann wird er ToArray() aufrufen, da ihm die Schnittstelle nur eine Enumeration liefert, der er nicht ansieht, dass es sich bereits um ein Array handelt. Dies führt zu einem unnötigen Duplizieren der Liste.
  • Will der Aufrufer die Datensätze der Reihe nach verarbeiten – womöglich weil es sich um eine sehr große Datenmenge handelt – dann ist es sinnvoll, nicht alle Daten gleichzeitig in den Speicher zu laden. Schlechtere Skalierbarkeit oder gar OutOfMemoryExceptions könnten sonst die Konsequenz sein.

Das Problem entsteht, weil das Design der Schnittstelle funktionale Prinzipien suggeriert, die Implementierung aber imperativ ausgestaltet ist. Die Lösung besteht darin, sich für ein Paradigma zu entscheiden – für welches hängt von den Anforderungen ab.

Funktionale Herangehensweise

Die Schnittstelle ist bereits nach funktionalen Gesichtspunkten designt – wenn die Verwendung von IEnumerable<T> hierfür kein ausreichender Indikator ist, sollte ein entsprechender Kommentar das bekräftigen.

Eine funktionale Implementierung muss jedoch “deferred execution” unterstützen, was aber – dank der Unterstützung durch den Compiler – für Iteratoren keine große Sache ist:

   1: IEnumerable<Employee> IDatabase.GetEmployees(int companyID)

   2: {

   3:     using (var db = new Entities())

   4:     {

   5:         var result = db.Employees.Where(e => e.CompanyID == companyID);

   6:         foreach (var c in result)

   7:             yield return c;

   8:     }

   9: }

An Stelle des ToArray() ist eine Schleife mit “yield return” getreten (C# kennt leider kein “yield enumeration“). Was wie eine einfache Methode aussieht, hat den Compiler dazu veranlasst, eine State Machine zu bauen, die “deferred execution” umsetzt:

  • Der erste “Aufruf” liefert eine Instanz der generierten Klasse, die einen IEnumerator<T> zurückliefern kann, aber ohne eine Zeile des obigen Codes abzuarbeiten.
  • Mit dem ersten MoveNext() auf dem Enumerator wird der Code bis zum ersten “yield return” abgearbeitet, inklusive des Aufbaus der Datenbankverbindung. Der Datensatz wird zurückgeliefert und die Arbeit zunächst unterbrochen.
  • Mit dem nächsten MoveNext() wird die Arbeit wieder aufgenommen und bis zum nächsten “yield return” fortgesetzt.
  • Wenn die innere Schleife abgearbeitet ist gibt der Iterator die Datenbankverbindung wieder frei und kommt zum Ende, MoveNext() liefert false.

Üblicherweise macht der Aufrufer das nicht “von Hand”, sondern verwendet foreach oder andere Mechanismen, die diese Details verbergen.

Dieses Vorgehen hat einige Vorteile:

Die iterative Verarbeitung der Datensätze geht dank Lazy Evaluation effizient mit Ressourcen (in diesem Falle Speicher) um. Die Leseschleife hält nur eine Referenz auf den gerade in der Verarbeitung befindlichen Datensatz, was die Verarbeitung auch sehr großer Datenmengen erlaubt.

Andererseits steht es dem Aufrufer frei, ToArray() aufzurufen, wenn er alle Daten benötigt. Auch dies wäre effizienter als im initialen Beispiel, weil dieser Aufruf nur ein Mal stattfindet.

Leider bringt dieses Vorgehen auch Nachteile mit sich:

Zunächst ist schon dieses einfache Beispiel nicht nur unüblich, sondern auch umständlicher als der Code aus dem initialen Beispiel. Dies kann zu einem Wartungsproblem werden, wenn Entwickler sich der Hintergründe nicht bewusst sind und dieses Idiom verletzen.

Dazu kommt, dass der Code bei zusätzlichen Anforderungen schnell komplex werden kann. Zum Beispiel lässt ein Iterator kein try/catch zu:

A yield return statement can’t be located in a try-catch block.

Das macht eine allgemeine Fehlerbehandlung sehr umständlich. Es ist möglich, das zu lösen. Der resultierende Code ist aber weder schön, noch einfach nachvollziehbar.

Imperative Herangehensweise

Auch imperative Herangehensweise ist eine Option.

Nur der Vollständigkeit halber: Auch hier gibt es die Möglichkeit, “Lazy Evaluation” zu nutzen. Üblicherweise geschieht dies über “Cursor” oder “Reader”, zum Beispiel DataReader, StreamReader oder XmlReader.

Die Implementierung eines solchen Readers würde dem entsprechen, was der Compiler im Falle von Iteratoren für uns übernimmt. Womit wir auch den Grund haben, warum das heute niemand mehr so tun würde: Warum von Hand nachprogrammieren, was uns C# und LINQ schenken?

Relevant ist die Variante, bei der die Schnittstelle zum Ausdruck bringt, dass alle Daten geliefert werden:

   1: public interface IDatabase

   2: {

   3:     Employee[] GetEmployees(int companyID);

   4: }

Geändert hat sich rein technisch nur der Typ des Ergebnisses. Aber die implizite semantische Bedeutung – alle Daten werden in einem Rutsch geladen und zurückgeliefert – wird damit eindeutig erkennbar.

An der Implementierung des Beispiels muss ebenfalls nur der Ergebnistyp geändert werden:

   1: Employee[] IDatabase.GetEmployees(int companyID)

   2: {

   3:     using (var db = new Entities())

   4:     {

   5:         // ToArray um alle Daten zu lesen!

   6:         var result = db.Employees.Where(e => e.CompanyID == companyID).ToArray();

   7:         return result;

   8:     }

   9: }

Geändert hat sich aber sehr wohl der Grund, aus dem ToArray() aufgerufen wird: Geschah dies vorher aus einer technischen Notwendigkeit heraus, so ist es jetzt Teil der spezifizierten Semantik.

Vorteile diese Ansatzes:

  • Eindeutige Aussage der Schnittstelle, dass alle Daten gelesen werden!
  • Der Code der Implementierung ist sowohl einfacher, als auch bzgl. des notwendigen Hintergrundwissens besser verständlich als die funktionale Variante. (Ein Effekt der noch verstärkt wird, wenn zusätzliche Anforderungen hinzukommen.)
  • Ein redundantes nachgelagertes ToArray() durch den Aufrufer ist nicht mehr nötig. Der Code wird effizienter.
  • Zusätzliche Anforderungen (z. B. Fehlerbehandlung) sind leichter umzusetzen.

Nachteile:

Wesentlicher Nachteil aus meiner Sicht ist wieder, dass dieses Vorgehen vergleichsweise unüblich ist. Und damit ebenfalls ein potentielles Wartungsproblem.

Welcher Weg ist der beste?

Der beste Weg wäre meiner Ansicht nach der funktionale Ansatz – er ist am flexibelsten – aber ohne die oben erwähnten Nachteile. Dazu wäre es allerdings nötig, dass Microsoft den C#-Sprachumfang erweitert (Stichworte “yield enumeration” und try/catch).

Solange das nicht der Fall ist, ist es wichtig, eine bewusste und informierte Entscheidung zu treffen. Eine Entscheidung basierend auf den Anforderungen einerseits und einem soliden Verständnis der Paradigmen und ihrer Umsetzung auf der anderen Seite. Die Tatsache dass der Code aus dem initialen Beispiel durchaus nicht unüblich ist, legt nahe, dass das oft nicht der Fall ist.

Fazit

LINQ verführt dazu, Schnittstellen mit IEnumerable<T> zu bauen. Und LINQ hat ja auch eine Menge Vorteile, warum also nicht?

LINQ bringt aber auch die “funktionale Denkweise” mit ins Spiel, was leider (auch in der einschlägigen Dokumentation) gerne vergessen wird. Ob und wie sich das in meinem Code niederschlägt, ist aber eine ganz andere Frage. Und das ist der wichtige Faktor: Ich muss funktionale Aspekte in meinem Code berücksichtigen; das ist nichts, was sich aus der reinen Nutzung von LINQ-Abfragen ergibt oder aus der Tatsache, dass meine Methode IEnumerable<T> zurückgibt.