Komplexität: die tägliche Herausforderung
Der Umgang mit Komplexität ist die zentrale Herausforderung der meisten Softwareprojekte. Viele Projekte wachsen über die Jahre zu wahren Ungetümen an, deren Wartung immer schwieriger wird. Die Folgen sind oft beschrieben worden: Weitere Veränderungen werden fehleranfällig und dauern immer länger. Neue Kollegen brauchen zunehmend mehr Zeit, sich in den bestehenden Code einzuarbeiten.
Was ist eigentlich Komplexität?
Komplexität in dem hier beschriebenen Sinne entspricht nicht dem, was die Informatik zumeist unter dem Wort versteht, wenn über die Zeit- und Platzkomplexität von Algorithmen gesprochen wird. Sicherlich gibt es Zusammenhänge zwischen beiden Begriffen; aber das wäre ein ganz eigenes Thema. Es gibt weitere Kennzahlen, deren Bezeichnung sie als Kennwerte für Komplexität ausweisen: die zyklomatische Komplexität, die sich aus der Anzahl u.a. von Verzweigungen errechnet, und die Pfadkomplexität, die die Anzahl möglicher Wege durch eine Funktion beschreibt. Daneben gibt es weitere Kennzahlen für Komplexität. Meiner Erfahrung nach werden diese Kennzahlen aber eher selten in direkten Zusammenhang zu der von Entwicklerinnen und Entwicklern in ihrem Arbeitsalltag wahrgenommenen Komplexität gebracht. Sie erscheinen vielen Menschen nicht hinreichend relevant zu sein für diejenige Art von Komplexität, die uns tatsächlich beschäftigt.
Was aber ist dann der Sinn von Komplexität, um den es geht? Wir wissen zunächst, dass Komplexität das ist, was uns bei der Wartung und Weiterentwicklung Probleme bereitet. Ein System wird mit der Zeit komplex, wenn es durch neue Anforderungen, Anpassungen und Weiterentwicklungen wächst. Eine Anwendung hat beispielsweise als einfache Anwendung mit 500 Zeilen Source Code angefangen; fünf Jahre später arbeiten zehn Entwickler an mittlerweile 1.000.000 Zeilen Source Code. Die Komplexität ist über die Jahre enorm gestiegen. Aber die Anzahl der Code Zeilen (LOC – Lines of Code) ist kein Maß der Komplexität.
Ist denn dann die Anzahl der Objekte, Klassen, Strukturen oder Funktionen ein Maß der Komplexität? Es ist einfach zu sehen, dass die Anzahl irgendwelcher Objekte nicht ursächlich für Komplexität ist. Ein Sandhaufen besteht aus sehr vielen Objekten (Sandkörnern), ist aber nicht komplex. Ein Schaltkreis besteht dagegen in aller Regel aus viel weniger Objekten, ist aber komplex. Denn was Komplexität ausmacht, ist nicht die schiere Anzahl an Entitäten, sondern die Zahl der faktischen und potentiellen Verbindungen zwischen diesen Objekten. Das ist letztlich auch, was zyklomatische und Pfadkomplexität oder auch Maße für afferente und efferente Kopplungen versuchen in numerischen Werten abzubilden. Es geht um Spezifizierungen des Begriffs der Komplexität. Sie erscheinen uns manchmal irrelevant, weil sie zu speziell sind oder auch gerade die falsche Spezialisierung abbilden. In einem abstrakteren Sinne geht es einfach um die Anzahl und Übersichtlichkeit der möglichen und tatsächlichen Verbindungen zwischen irgendwelchen Dingen. Sind diese Verbindungen schwer zu durchschauen, dann macht uns dies das Leben als Entwickler schwer. Die Software ist dann komplex.
Lässt sich Komplexität vermeiden?
Es gibt eine ganz natürliche Reaktion auf das Problem der Komplexität: Vermeidung durch Zurückweisung zusätzlicher Anforderungen. Und das ist sicherlich auch nicht falsch. Bei jeder neuen Anforderung sollte bedacht werden, dass sie zusätzliche Komplexität in das Projekt einführt, die mit unvermeidbaren Folgekosten verbunden ist. Daher sollte neben dem Entwicklungsaufwand für die Anforderung selbst (also eine bestimmte Anzahl von Entwicklerstunden) auch der Sachverhalt in die Kosten-Nutzen-Abwägung einbezogen werden, dass dadurch erstens ein zusätzlicher dauerhafter Wartungsaufwand generiert wird und zweitens zukünftige Änderungen durch die höhere Ausgangskomplexität teurer werden.
Nichtsdestotrotz gehört es zu den Realitäten der Softwareentwicklung, das Anforderungen hinzukommen und die Komplexität der Projekte dadurch stetig erhöhen. Wenn wir uns erfolgreiche Softwareprojekte anschauen, etwa Google Suche oder Facebook, dann ist klar, dass sie eine erhebliche Komplexität aufweisen, die sich aus den Anforderungen an das jeweilige Produkt zwingend ergibt. Manche sprechen hier auch von essentieller Komplexität – im Unterschied zu akzidentieller Komplexität, also derjenigen Komplexität, die wir unnötigerweise durch schlechte Arbeit hinzufügen.
Wie lässt sich Komplexität beherrschen?
Wir können also nicht vermeiden, uns mit der entstehenden (essentiellen) Komplexität zu arrangieren. Lasst uns also Wege finden, mit ihr umzugehen. Hier sind einige Ansätze:
Divide et impera: Modularisierung und Kapselung
Es ist das klassische Mittel im Kampf gegen die Folgen der Komplexität: die Aufteilung eines komplexen Systems in zwei oder mehr weniger komplexe Systeme. Der offensichtliche Effekt ist, dass wir bei guter Aufteilung zukünftig meist nur ein Teilsystem betrachten müssen, dessen Komplexität deutlich geringer ist. Was weniger offensichtlich ist: Modularisierung macht Komplexität nicht nur beherrschbarer, sie kann Komplexität in der Tat auch reduzieren.
Um das einzusehen, wollen wir eine sehr schematische Überlegung anstellen: Nehmen wir an, wir haben ein System mit zehn Entitäten (Klassen, Strukturen), die alle Verbindungen zueinander pflegen. Die Anzahl der Verbindungen ist dann 9 + 8 … + 1 = (9/2) * 10 = 45
. Wir können also 45 (die Zahl der Verbindungen zwischen je zwei Elementen) als Maß für die Gesamtkomplexität nehmen. Teilen wir nun das ganze in zwei Teile mit je fünf Elementen. Diese fünf gehen jeweils Beziehungen zu allen anderen Elementen in derselben Gruppe ein. Zusätzlich gehen beide Gruppen eine Verbindung zueinander ein. Die Gruppen haben eine jeweilige Einzelkomplexität von 4 + 3 + 2 + 1 = (4/2) * 5 = 10
, woraus sich eine Gesamtkomplexität von 10 + 10 + 1 = 21
ergibt. Das ist weniger als die Hälfte der Ausgangskomplexität. Und selbst wenn wir einen gewissen Overhead für die architektonische Umgestaltung annehmen und jeweils ein zusätzliches Element pro Gruppe hinzufügen, steigt die Komplexität nur auf 31, liegt also weiterhin deutlich unterhalb des Ausgangswertes.
Es geht aber nicht nur um die Vermeidung überflüssiger (akzidenteller) Komplexität, sondern auch darum, die anforderungsgetriebene essentielle Komplexität beherrschbar zu machen: durch Kapselung und klare Strukturen. Kapselung hat das Ziel, die Anzahl möglicher Verbindungen zu reduzieren. Die Zahl der möglichen unterschiedlichen Verbindungen wird dadurch reduziert, was die Komplexität verringert. Klare Strukturen ermöglichen uns ein besseres Verständnis, indem wir uns auf einen Teilbereich der Verbindungen konzentrieren und andere Verbindungen ausblenden können.
Wichtig ist bei der Strukturierung die Orientierung an Abstraktionsebenen. Jede Softwareeinheit (Klasse, Methode oder Funktion) sollte sich durchgängig auf einer ähnlichen Abstraktionsebene befinden. Es ist eine schlechte Idee, in ein und derselben Funktion abstrakte Businesslogik mit kleinteiligen Stringformatierungen und Speichermanagement zu verbinden. Gerade das sieht man aber häufig in Projekten, in denen die Komplexität zu einem ernsthaften Problem geworden ist. Für diejenigen, die solchen Code warten müssen, ist es dann deutlich schwieriger, die Struktur der Software zu erkennen.
Mut zum Wandel: Was obsolet ist, muss weg
Wer viel mit großen Projekten zu tun hatte, wird es kennen: Man stößt auf Passagen im Source Code, bei denen niemand auf Anhieb sagen kann, ob und wenn ja wozu sie eigentlich noch verwendet werden. Bei vielen Projekten, die ich kennenlernen durfte, waren beträchtliche Teile der Anwendung überhaupt nicht mehr in Verwendung. Dennoch trugen sie erheblich zur Steigerung der Komplexität bei. Da wurde etwa eine alte API durch eine neue API abgelöst, die alte aber "zur Sicherheit" noch im Code behalten; es könnte ja die Notwendigkeit eines Zurückschwenkens eintreten. Zumeist ist dies eine trügerische Hoffnung: Wenn die alte API nicht mehr verwendet wird, dann wird sie auch nicht mehr gepflegt und die Chancen, dass sie tatsächlich noch zuverlässig funktioniert, schwinden. Das Wissen um ihren Ursprung schwindet ebenso und irgendwann weiß niemand mehr, aus welchem Grund sich die Code-Abschnitte noch im Repository befinden. Wer früh und konsequent alte Zöpfe abschneidet, vermeidet solche unnötige Komplexität und verringert den zukünftigen Wartungsaufwand.
KISS oder besser: Strive for Simplicity
Keep it simple and stupid (KISS) gehört zu den bekannteren Prinzipien der Softwareentwicklung. Es ist allerdings ein Prinzip mit dem ich zugegebenermaßen meine Schwierigkeiten habe, denn es suggeriert, dass Einfachheit etwas ist, was mühelos (quasi naturgegeben) am Anfang steht. Dabei ist Einfachheit in aller Regel nicht der leicht erreichbare Ausgangspunkt, sondern das späte Ergebnis umfangreicher Mühen und gedanklicher Anstrengungen.
Menschen tendieren dazu, Änderungen primär über das Hinzufügen weiterer Teile vorzunehmen. Das ist mitnichten nur ein Phänomen der Softwareentwicklung, sondern fast schon so etwas wie eine anthropologische Konstante. Wir kennen das auch von Gesetzen oder Verwaltungsstrukturen: Unzulänglichkeiten bekämpfen wir oft mit neuen Gesetzen, neuen Verordnungen und neuen Organisationseinheiten, die das Gesamtgebilde immer komplexer und undurchsichtlicher machen.
Das einfachste scheint immer zu sein, alles bestehende unangetastet zu lassen und nur neues hinzuzufügen. Immerhin gibt es uns das Gefühl, keinen Schaden an der bereits realisierten Funktionalität anzurichten. Aber das kann nur zu höherer Komplexität führen. In der Tat ist das ein zentrales Phänomen von Legacy-Code: Die längsten, kompliziertesten und unverständlichsten Funktionen und Klassen, die mir begegnet sind, hatten alle eine ähnliche Geschichte: Sie begannen als einfache Gebilde an einer zentralen Stelle des Projekts, in die viele Anforderungen der Folgejahre einflossen. Da sich aber niemand die Mühe machte, das Design selbst infrage zu stellen, wurden weitere Codezeilen eingefügt. Es entstand das bekannte Gewusel aus if
-else
-Statements und geschachtelten Schleifen, das wir mit Legacy-Code verbinden. Nach einiger Zeit durchblickt auch niemand mehr das Resultat, was jede Refaktorierung noch schwieriger macht. Ähnliches geschieht auch schnell bei der Wartung der Infrastruktur: Eine Serverlandschaft, in der jeder Admin seine eigene Hütte neben die schon bestehenden Gebäude stellt, wird unbeherrschbar.
Das Vorgehen ist hier einfach, aber das Ergebnis ist es nicht. Daher ist Einfachheit nichts, was von Anfang an gegeben ist, sondern etwas, was mühsam erreicht werden muss. Sie ist das Ergebnis langen Nachdenkens und vieler Refaktorierungen, in denen die Einsicht über das zu lösende Problem und die einfachsten Lösungsmöglichkeiten erst entsteht. Bekannte Entwurfsmuster sind oft elaborierte Strategien, komplexe Probleme möglichst einfach zu lösen.
Automatisiertes Testen
Der CRAP-Index steht für Change Risk Anti-Pattern und soll ein Maß dafür sein, wie schwierig Wartung und Weiterentwicklung eines Softwareprojekts ist. In die Berechnung gehen dabei zwei Faktoren ein: die Komplexität als positiver Faktor (je höher die Komplexität, desto größer der CRAP-Index) und die Testabdeckung als negativer Faktor (eine höhere Testabdeckung verringert den CRAP-Index). Grund dafür ist, dass eine gute Testabdeckung die negativen Folgen steigender Komplexität abfedern kann: Wenn ein Projekt über gute Tests verfügt, kann die nächste Entwicklerin auch dann Änderungen vornehmen, wenn sie nicht alle bisherigen Anforderungen präsent hat und nicht jeden möglichen Programmablauf überblickt.
Testen hat – darauf weisen Anhänger des Test Driven Developments zurecht immer wieder hin – aber auch einen erheblichen Einfluss auf das Design innerhalb unserer Anwendungen. Es ermöglicht es, Teile unserer Software primär aus der Sicht des Anwenders zu betrachten. So entstehen einfache und intuitiv nutzbare Schnittstellen sowie leicht beschreibbare und besser dokumentierte Klassen und Funktionen. Gute Tests testen nicht nur, sie gestalten die Struktur unserer Software in einer Weise, die Komplexität beherrschbarer macht.
Standards und Entwurfsmuster
Komplexe Strukturen zu durchschauen und zu beherrschen fällt uns leichter, wenn sie uns bekannt sind und sich oft wiederholen. Das ist auch ein Wert verbreiteter Design Patterns: Wir erkennen beispielsweise das Observer-Pattern und wissen, was wir zu erwarten haben. Daher ist eine Software, die sich an verbreiteten Mustern des Designs orientiert, für uns als Entwickler leichter zu verstehen als eine Software, die stets nach eigenen Lösungen sucht oder gänzlich vermeidet, erkennbare Muster zu nutzen (oft geschieht dies aus Angst, solche Muster machten die Software komplizierter).
Vorhandene weit verbreitete Bibliotheken sind oft besser strukturiert als die eigenen Entwicklungen; sie sind erprobte Mittel bei der Beherrschung von Komplexität, in deren Design oft viel Arbeit geflossen ist. Ein weiterer Vorteil ist: Neue Kolleginnen und Kollegen kennen sie oft schon. Sie haben aus anderen Projekten Erfahrungen mit ihnen mit und erkennen die Verwendungsweisen im aktuellen Projekt wieder. Ähnliches gilt für verbreitete Standards. Sich an diese zu halten, statt eigene Standards einzufügen, erleichtert die Orientierung durch Bekanntheit der Elemente.