Abhängigkeiten sind eine der größten Geißeln der Softwareentwicklung. Wenn es ein allgemein anerkanntes Prinzip gibt, dann ist es, Abhängigkeiten zu minimieren. Jede Hilfe ist da willkommen. Was können Sie also tun, um Abhängigkeiten im Code los zu werden?
Für die Suche nach einer Lösung und die Beurteilung von Hilfsangeboten ist es nützlich, Abhängigkeiten zu kategorisieren. Nicht alle Abhängigkeiten sind gleich. Worüber reden wir also eigentlich?
Abhängigkeit
Eine Abhängigkeit ist vorhanden, wenn einer etwas braucht, ohne dass er seine Arbeit nicht verrichten kann. Eine Funktionseinheit kann eine andere Funktionseinheit brauchen; oder sie braucht nur eine Datei an einem bestimmten Ort. Oder sie ist von einem bestimmten Format von Daten abhängig, die als Zeichenkette an sie übergeben werden.
Abhängigkeiten lauern also überall.
Mir geht es allerdings vor allem um Abhängigkeiten zwischen den grundlegenden Funktionseinheiten Assembly, Klasse und Methode.
Statische Abhängigkeit
Wenn eine Funktionseinheit (FE) schon zur Compilezeit von einer anderen abhängig ist, dann nennt man das statische Abhängigkeit oder auch statische Kopplung. Hier ein Beispiel: Klasse Client ist von Klasse Service statisch abhängig.
1: class Client
2: {
3: private Service s;
4: }
5:
6: class Service
7: {}
Oder hier eine Methode, die mit einer anderen statisch gekoppelt ist:
1: void Foo()
2: {
3: Bar();
4: }
5:
6: void Bar()
7: {}
Oder hier eine statische Abhängigkeit von Client nicht zur Klasse Service, sondern auch noch zu einem Member von Service:
1: class Client
2: {
3: private Service s;
4:
5: void Foo()
6: {
7: s.Text = "hello";
8: }
9: }
10:
11: class Service
12: {
13: public string Text;
14: }
Oder hier statische Kopplung zwischen Assemblies:
Ohne, dass die Funktionseinheiten, zu denen eine Kopplung besteht, bei der Compilation vorhanden sind, ist keine fehlerfreie Übersetzung möglich. Das macht statische Abhängigkeiten vergleichsweise zahm. Es fällt einfach schnell auf, ob eine Abhängigkeit nicht erfüllt ist.
Ted Faison hat in seinem Buch “Event-based Programming” für Abhängigkeiten eine Notation eingeführt, die ich hier auch benutzen möchte. Abhängigkeiten bezeichnet er dabei so:
Der Pfeil zeigt vom Abhängigen zum Unabhängigen und das Symbol (Zeichen Ou des erweiterten Lateinischen Alphabets) macht klar, dass der Pfeil ein Abhängigkeitspfeil ist.
Eine statische Abhängigkeit kann dann so qualifiziert werden:
Dynamische Abhängigkeit
Dynamisch ist eine Abhängigkeit, wenn erst zur Laufzeit die eigentliche unabhängige Funktionseinheit verfügbar ist; der abhängige Code kann zur Compilezeit dann nämlich nur Annahmen darüber treffen, wie das zur Laufzeit “nachgereichte” Unabhängige aussieht.
Ein typsiches Beispiel sind Abhängigkeiten von Klassen, die zur Compilezeit durch Interfaces vertreten werden:
1: class Client
2: {
3: public IService s;
4:
5: void Foo()
6: {
7: s.Text = "hello";
8: }
9: }
10:
11: interface IService
12: {
13: string Text { get; set; }
14: }
15:
16: class Service : IService
17: {
18: public string Text
19: {
20: get { ... }
21: set { ... }
22: }
23: }
Das macht klar, wie Komponentenorientierung funktioniert: Die Assembly, die Client implementiert, muss die referenzieren, die IService implementiert – aber nicht die Assembly von Service. Client und Service können also getrennt von einander entwickelt werden. Aber die IService-Assembly, der Kontrakt von Service,muss vorher da sein, weil die Client-Assembly wie die Service-Assembly daran statisch gekoppelt sind.
Statische Abhängigkeiten erfordern mithin Colocation von Code in derselben Assembly oder zumindest, dass Assemblies einander statisch referenzieren. Die unabhängige Funktionseinheit muss dann vor der abhängigen implementiert werden.
Ein typisches Muster, um diese Form der Abhängigkeitskonstellation zwischen Klassen und Interfaces auszudrücken, ist die Inversion of Control mit der ctor-Injection:
1: class Client
2: {
3: private IService s;
4: public Client(IService s)
5: {
6: this.s = s;
7: }
8:
9: void Foo()
10: {
11: s.Text = "hello";
12: }
13: }
14:
15: class Program
16: {
17: static void Main(string[] args)
18: {
19: Client c = new Client(new Service());
20: }
21: }
So werden Klassen entkoppelt, um einfacher testbar zu werden, um Implementationen austauschen zu können und um produktiver bei der Entwicklung zu sein.
Dynamische Kopplung ist loser als statische – das ist vorteilhaft. Allerdings werden Fehler erst zur Laufzeit sichtbar. Die Entscheidung zwischen statischen und dynamischen Abhängigkeiten ist also eine zwischen Flexibilität/Entkopplung und Gewissheit.
Wer flexibel sein will, der setzt z.B. die neue dynamische Typisierung ein:
1: class Client
2: {
3: public dynamic s;
4:
5: public void Foo()
6: {
7: s.Text = "hello";
8: }
9: }
Zur Entwicklungszeit muss man sich dann nicht entscheiden, wie die Instanzen von s aussehen sollen. Sie müssen lediglich eine Property oder ein Feld Text bieten.
Das funktioniert dann gut mit dem bisherigen Service:
1: static void Main(string[] args)
2: {
3: Client c = new Client();
4: c.s = new Service();
5: c.Foo();
6: }
Ohne Kontrolle durch den Compiler kann aber auch jeder andere Typ zugewiesen werden:
1: static void Main(string[] args)
2: {
3: Client c = new Client();
4: c.s = 42;
5: c.Foo();
6: }
Und das funktioniert dann gar nicht mehr gut.
Also: Vorsicht mit dynamischen Abhängigkeiten. Flexibilität, Entkopplung hat ihren Preis. Dynamische Abhängigkeiten lassen sich schlechter kontrollieren als statische. Sollte sich am Unabhängigen bzw. bei der Bedienung einer Abhängigkeit etwas ändern, ist erst viel später klar, auf welche Abhängigen das eine Auswirkung hat.
Logische Abhängigkeit
Der Compiler meldet kein Problem und Sie haben auch Ihre dynamischen Abhängigkeiten im Griff? Das ist gut – aber Ihre Software ist dann noch nicht in trockenen Tüchern, was die Abhängigkeiten angeht. Denn da sind noch die logischen Abhängigkeiten. Und das sind die fiesesten.
Hier eine kleine Denksportaufgabe. Welche Abhängigkeiten enthält diese Funktion, die die Nachkommastellen einer Zahl abtrennen soll:
1: static double Trunc(double n)
2: {
3: var s = n.ToString();
4: s = s.Substring(0, s.IndexOf(','));
5: return double.Parse(s);
6: }
Gibt es dynamische Abhängigkeiten? Nein. Hier wird zur Laufzeit nichts konkretisiert, nachgereicht.
Dennoch gibt es im Code eine Abhängigkeit. Und ob die Erwartung daran erfüllt wird, zeigt sich auch erst zur Laufzeit.
Trunc() ist davon abhängig, dass die String-Repräsentation einer double-Zahl ein Komma enthält, das die Nachkommastellen abtrennt. Zahlen müssen diesem Format folgen:
Zahl ::= Vorkommastellen [ “,” Nachkommastellen ].Das ist eine legitime Annahme für eine Software, die nur in Deutschland laufen soll – für eine internationale Software hingegen sollte sie nicht getroffen werden.
Vorkommastellen, Nachkommastellen ::= Ziffer { Ziffer }.
Eine solche Abhängigkeit nennt man logische Abhängigkeit. Sie besteht immer dann, wenn zwei Funktionseinheiten Annahmen übereinander machen, insbesondere Annahmen über ihre Funktionsweise. Deshalb drücken sich logische Abhängigkeiten oft in Daten aus, da die das Bindeglied zwischen Funktionseinheiten sind.
Hier besteht die Annahme der Funktionseinheit Trunc() darin, dass die Funktionseinheit double.ToString() ein Komma vor die Nachkommastellen setzt.
Als Kontrast ein Beispiel logischer Unabhängigkeit:
1: static int GetIndexOf(string item, string[] list)
2: {
3: for (var i = 0; i < list.Length; i++)
4: if (list[i] == item) return i;
5: return -1;
6: }
Die Funktion ist nicht (!) abhängig davon, dass die Einträge in der Liste in einer bestimmten Reihenfolge stehen. Sie bestimmt mit und ohne Ordnung der Listenelemente den Index des gesuchten korrekt. So ist diese Funktion logisch unabhängig von potenziellen Quellen für Listen.
Keine Annahme über die Reihenfolge der Einträge zu machen, dient also der Entkopplung. Das ist gut für die Evolvierbarkeit (oder auch Wiederverwendbarkeit), hat jedoch seinen Preis. Die Performance dieses Verfahrens ist nicht optimal. Ob das allerdings schlimm ist… das hängt vom Verwendungszusammenhang ab. Im Sinne der Prinzipien KISS und Beware-of-Premature-Optimization (BoPO) mag es sinnvoll sein, keine Annahmen zu machen und die lose Kopplung einzustreichen – bis das an eine Grenze stößt.
Logische Abhängigkeiten machen die Softwareentwicklung wahrhaft komplex. Denn erstens wird erst zur Laufzeit sichtbar, ob sie erfüllt werden. Und zweitens führt ihre Nichterfüllung nicht immer und sofort zu einem Fehler.
Trunc() kann während der Entwicklung alle automatisierten Tests bestehen. Software, die die Funktion einsetzt, kann bei Hunderten Kunden fehlerfrei laufen. Doch dann, eines Tages, kommt es zu einem Fehler. Warum? Weil ein Anwender erstmalig auf die Idee gekommen ist, die Software auf einem Rechner mit anderer default Einstellung für das Dezimaltrennzeichen laufen zu lassen.
Logische Abhängigkeiten können sehr subtil sein. Kein Compiler zeigt sie an. Kein Laufzeitsystem deckt sie auf. Womöglich werden sie nur sporadisch nicht erfüllt.
Vorsicht also an den Schnittstellen zwischen Funktionseinheiten. Dort lauern immer wieder Annahmen über empfangene Daten, die die Funktionseinheiten mehr oder weniger offensichtlich und oft unerwartet stark miteinander logisch koppeln.
Zusammenschau
Abhängigkeiten machen das Softwareentwicklerleben schwer. Denn wo Abhängigkeiten bestehen, besteht immer die Gefahr, dass sich Änderungen am Unabhängigen auf Abhängige auswirken.
Sind die Abhängigkeiten nur statisch, dann zeigt ein Übersetzungslauf an, wo Erwartungen bei Abhängigen enttäuscht werden. Insofern sind statische Abhängigkeiten unkritisch, was die Entdeckung von Nichterfüllung angeht. Diese Effizienz, diese Sicherheit wird jedoch durch Inflexibilität erkauft. Statische Abhängigkeit bedeutet immer enge Kopplung.
Dynamische Abhängigkeiten koppeln loser – lassen sich jedoch erst zur Laufzeit entdecken. Durch die heutzutage sehr einfach mögliche Automatisierung von Tests lassen sich Probleme bei der Erfüllung dynamischer Abhängigkeiten jedoch auch fast so schnell erkennen wie bei statischen Abhängigkeiten.
Programme geschrieben in dynamischen Sprachen wie Python oder Ruby leiden daher auch nicht unter größerer Fehlerhäufigkeit als Programme geschrieben in einer streng typisierten Sprache wie C#. Wer mit einer dynamischen Sprache entwickelt, stützt sich einfach nur weniger auf den Compiler und setzt stattdessen mehr auf eine Sammlung von automatisierten Tests.
Die Komponentenorientierung hilft ebenfalls weiter, da sie dynamische Abhängigkeiten bewusst plant und Kontrakte auf beiden Seiten der Abhängigkeit für Stabilität sorgen. So kann lose Kopplung als Vorteil dynamischer Abhängigkeit eingestrichen werden, ohne die Sicherheit statischer Kopplung aufzugeben.
Logische Abhängigkeiten teilen mit den dynamischen Abhängigkeiten, dass sie erst zur Laufzeit festgestellt werden können. Sie gehen in ihrer Gefährlichkeit jedoch darüber hinaus. Sie sind oft unsichtbar, sie sind oft subtil, sie machen womöglich nur sporadisch Probleme. Es hilft aber nichts: Wir müssen mit ihnen leben.
Ohne logische Abhängigkeiten keine Zusammenarbeit. Wer sich nicht mindestens logische abhängig macht, steht allein.
Bei Planung, Test und Review Ihres Codes achten Sie daher besonders auf logische Abhängigkeiten. Wo Sie sie erkennen, dokumentieren Sie sie. Am besten mit einem Test. Beispiel:
1: [Test]
2: public void GetIndexOf_does_not_require_the_list_to_be_sorted()
3: {
4: Assert.AreEqual(2,
5: Helpers.GetIndexOf("x",
6: new[] { "k", "f", "x", "s" }));
7: }
.NET 4 Code Contracts könnten auch helfen, um logische Abhängigkeiten zu dokumentieren.
In jedem Fall gilt jedoch: zentralisieren Sie logische Abhängigkeiten. Lassen Sie möglichst nur eine Funktionseinheit in einer bestimmten Hinsicht logisch von einer anderen abhängig sein.
Ein Beispiel dafür ist ein Proxy in der verteilten Kommunikation. Der zentralisiert die logische Abhängigkeit zwischen Client und Server in Bezug auf das Datenformat auf der Leitung. Der Client nimmt z.B. an, dass der Server SOAP-Nachrichten versteht. Statt nun aber diese Annahme an vielen Stellen im Code zu treffen, zentralisiert man sie im Proxy. Sollte sich das Datenformat ändern, trifft die Annahme also nicht mehr zu, dann ist nur eine Funktionseinheit betroffen.
Der Proxy ist sozusagen dafür zuständig, eine logische Abhängigkeit in eine statische zu verwandeln. Denn wo der Proxy von SOAP-Nachrichten logisch abhängt, da hängt Code, der ihn nutzt, z.B. nur von einem POCO ab, das der Proxy umwandelt in einen Teil einer SOAP-Nachricht.
Seien Sie also wachsam, was die Abhängigkeiten in Ihrer Software angeht. Unterscheiden Sie zwischen statischen, dynamischen und logischen. Werden Sie sich der Abhängigkeiten in Ihrem Code bewusst. Wählen Sie die eine oder andere Form mit Bedacht. Prüfen Sie die Erfüllung von Abhängigkeiten automatisiert. Dann sind Sie auf einem guten Weg, die Komplexität Ihrer Software zu verringern.
5 Kommentare:
Ist eine Benutzereingabe auch eine logische Abhängigkeit? Ist die Erreichbarkeit eines Webservices eine physische Abhängigkeit?
Bei logischer Abhängigkeit musste ich beim Lesen immer an mathematische Logik denken. Es ist für die Beispiele passender von Datenabhängigkeit zu sprechen.
@Robert: Wenn es bei der Benutzereingabe um das Format der Daten geht, dann wäre das eine logische Abhängigkeit.
Von einem Webservice ist Code aber dynamisch abhängig. Vom Proxy des Webservice hingegen statisch.
Datenabhängigkeit ist zu beschränkend. Logische Abhängigkeit kann sich auch auf die Performance beziehen. Eine Funktionseinheit erwartet, dass eine andere in einer bestimmten Zeit antwortet. Solche Annahmen werden bei Parallelprog oft subtil getroffen.
Datenabhängigkeit passt für mich sowohl für eine Benutzereingabe, als auch für den Webservice. Kommen Daten nicht, fehlerhaft oder anders als erwartet, habe ich auf unterschiedlichste Art ein Problem - ein physisches, eine algorithmisches, vieleicht sogar ein logisches Problem!
Wenn man jetzt zum Beispiel mit Single-Responsability oder einfach nur guter Architektur argumentiert, dann sollte auch eine Klasse oder eine Funktion nicht unbedingt Ahnung davon haben, ob das Datenproblem jetzt Aufgrund eines Parallelisierungs-problems oder durch falschen Input entstanden ist.
Ein Compiler kann sowohl statische, dynamische als auch Datenabhängigkeiten erkennen. Wenn Datenabhängigkeiten nicht bedient werden, kann der Compiler warnen (siehe „Code Contracts“), bei logischen Fehlern auch, aber das sind logische Fehler im Sinne der Prädikaten- oder Aussagenlogik, hier wird auch der Compiler nicht von einer abhängigen Funktion/Klasse aus warnen.
Sprachlich ist „logisch“ auch schwierig, denn „logisch“ ließe sich im Sinne von „daraus folgend“ oder "folgerichtig" verwenden aber so recht stimmig wird das nicht. Oder wie ist denn die Wortdefinition für „logisch“ in diesem Zusammenhang?
Ich klebe ganz schön an der Begrifflichkeit, obwohl ich den Tenor des Posts sehr wichtig finde! Die Abhängigkeitsdiagramme gefallen mir sehr gut! Ich probieren die mal in meinem Alltag einzubinden. Danke.
Schöner Überblick über Abhängigkeiten. Aber: die statischen Abhängigkeiten werden als unkritischer dargestellt, als sie meiner Ansicht nach sind. Wird Assembly A (Component) von Assembly B (Application) benutzt und beide von zwei Entwicklerteams geschrieben, so herrscht hier durchaus Konfliktpotential, wenn Assembly A nicht (wenigstens compiler-, besser binär-)kompatibel geändert wird.
Grüße.
Carsten
@Carsten: Wenn zwei Team beteiligt sind, sollte eben nicht so wie von dir beschrieben werden. Dafür ist statische Kopplung zu eng. Stattdessen sollten dynamisch an die Implementation und statisch an ein Interface gekoppelt werden. Und damit sind wir bei der Komponentenorientierung.
Kommentar veröffentlichen