Montag, 30. Januar 2012

TDD im Flow – Teil 3

Was bisher geschah:

Test #4: Blockierte Entnahme

Nun geht es an den Kern meines Abstrakten Datentyps: die Sequentialisierung.

image[42]_thumb

Ich muss die Entnahme aus den Queues nach Round Robbin einschränken. Es darf nur entnommen werden, wenn eine Queue nicht gerade blockiert wird. Die Blockierung beginnt, wenn ein Worker aus einer unblockierten Queue entnimmt – und wird automatisch aufgehoben, wenn er wieder nach Arbeit fragt.

Anders als im Datenmodell entworfen, implementiere ich die Sperren für Queues in einem Dictionary. Die Einträge darin sind die Flags des Datenmodells. Ich finde es jedoch besser, sie von der Liste der Queues zu trennen. Die Blockade der Entnahme ist ein anderer Aspekt als die Entnahme nach Round Robbin. Beide durch Verwaltung in unterschiedlichen Datenstrukturen zu trennen, scheint mir konsequente Anwendung des SRP.

Falls sich etwas an der Blockadestrategie ändert, muss ich nicht die Datenstruktur für die Entnahme anfassen. Es ist auch nur TryDequeue() betroffen.

internal class NotifyingMultiQueue<T>
{
    …
    readonly Dictionary<string, string> _readLocks =
        new Dictionary<string,string>(); 

    …
    public bool TryDequeue(string workerId, out T message)
    {
        message = default(T);

        var namedQueue = _queues[0];
        _queues.RemoveAt(0);
        _queues.Add(namedQueue);

        if (_readLocks.ContainsKey(namedQueue.Key) &&
            _readLocks[namedQueue.Key] != workerId)
            return false;

        message = namedQueue.Value.Dequeue();
        _readLocks[namedQueue.Key] = workerId;

        return true;
    }
    …

Diese Implementation erreicht das Ziel natürlich noch nicht ganz. Sie dient nur der Erfüllung eines Tests, ohne die vorherigen zu brechen.

Bevor ich aber den nächsten Test beschreibe: Haben Sie meine TDD Sünde entdeckt?

Ich habe mehr Code geschrieben, als für den Test nötig ist. Die Prüfung, ob der “arbeitsuchende” Worker derjenige ist, der eine Queue bisher blockiert hat, kommt nicht zum Tragen. Dafür müsste ich einen weiteren Test schreiben. Aber das ist nicht nötig, weil der nächste Test dafür sorgen wird, dass diese Prüfung gar nicht mehr nötig ist.

Warum habe ich dann aber _readLocks[namedQueue.Key] != workerId geschrieben? Weil es mir in dem Moment cool vorkam, an diese Feinheit gedacht zu haben. Da hab ich mich von der Idee davontragen lassen… Erst später beim nächsten Test ist mir die Überflüssigkeit der Bedingung aufgefallen.

Ich hatte sie aber nach erfolgreichem Test eingecheckt. Deshalb zeige ich sie Ihnen hier auch. So kann es halt kommen, auch wenn man sich bemüht. Mit Pair Programming wäre das vielleicht nicht passiert. Am Ende ists aber auch kein Beinbruch. Mir ist es ja aufgefallen. Wichtig ist, daraus zu lernen. Nobody is perfect – but everybody should strive for improvement. Oder so ähnlich ;-)

Test #5: Blockierte Queue wieder freigeben

Bisher wurde eine Queue bei Entnahme nur gesperrt. Jetzt muss sie wieder entsperrt werden. Das geschieht, wenn der Worker, der sie gesperrt hat, wieder frei ist. Der ADT bemerkt, wann ein Worker seine Arbeit an einer Nachricht beendet hat daran, dass der Worker wieder um eine Nachricht bittet:

image[47]_thumb

Beim ersten Aufruf von TryDequeue() sperrt w1 die Queue q1. Bei zweiten Aufruf sperrt er q2 – und gibt damit implizit q1 wieder frei, so dass w2 daraus entnehmen kann.

In der Implementation erreiche ich das, indem ich einfach bei Aufruf immer eine eventuell durch den anfragenden Worker gesetzte Sperre lösche:

internal class NotifyingMultiQueue<T>
{
    …

    public bool TryDequeue(string workerId, out T message)
    {
        message = default(T);

        if (_readLocks.ContainsValue(workerId))
            _readLocks.Remove(
                 _readLocks.Where(kvp => kvp.Value == workerId)
                           .Select(kvp => kvp.Key)
                           .First());

        var namedQueue = _queues[0];
        _queues.RemoveAt(0);
        _queues.Add(namedQueue);

        if (_readLocks.ContainsKey(namedQueue.Key))
            return false;

        message = namedQueue.Value.Dequeue();
        _readLocks[namedQueue.Key] = workerId;

        return true;
    }
    …

Damit entfällt dann auch die Prüfung, ob eine Sperre durch den aktuellen Worker gesetzt worden ist. Der Fall kann nicht mehr eintreten.

Leider ist das Löschen eines Dictionary-Eintrags über den Wert statt dem Key nicht so leicht. Ich muss erst den Key (Queue-Name) aus dem Wert (Worker-ID) ermitteln. Die Linq-Query liest sich etwas umständlich.

Alternativ hätte ich ein zweites Dictionary aufsetzen können, in dem die Worker-ID als Key steht und über den Queue-Namen auf _readLocks zeigt. Aber das würde zusätzlichen Pflegeaufwand bedeuten. So scheint mir der hier eingeschlagene Weg KISS-konform.

Test #6: Blockierte Queue überspringen

Es ist merkwürdig, aber bisher bin ich ohne eine Schleife bei der Entnahme ausgekommen. Wäre ich nicht nach TDD vorgegangen, hätte ich die wahrscheinlich schon gleich am Anfang eingebaut – und die Lösung damit komplexer gemacht.

Mit TDD habe ich dagegen einige Schwierigkeiten schon aus dem Weg geräumt. Anweisungssequenzen sind leichter zu verstehen und zu testen als Schleifen.

Jetzt hilft es aber nichts mehr. Eine Schleife muss sein, wenn blockierte Queues übersprungen werden sollen. Es müssen bei TryDequeue()-Aufruf potenziell ja mehrere Queues geprüft werden.

image_thumb[23]_thumb

TDD besteht aus 3 Phasen: roter Test, grüner Test und Refactoring. Bisher habe ich die letzte Phase übersprungen. Es gab nicht viel zu refaktorisieren. Jetzt wird es mir aber zuviel, was da alles in TryDequeue() passiert. Und auch KeyValuePair ist mir zu wenig aussagekräftig; mehr Domänensprache darf sein.

Zur Befriedigung des neuen Tests füge ich deshalb nicht nur Code hinzu, sondern ziehe auch Code raus in eigene Methoden. Das Listing unterscheidet Änderungen im Rahmen des Refactoring und Änderungen zur Erfüllung der neuen Anforderungen.

internal class NotifyingMultiQueue<T>
{
 
   readonly List<NamedQueue> _queues =
        new List<NamedQueue>();
    readonly Dictionary<string, string> _readLocks =
        new Dictionary<string,string>();

    class NamedQueue
    {
        public string Name;
        public Queue<T> Queue;
    }


    public void Enqueue(T message, string queueName)
    {
        var queue = _queues.Where(nq => nq.Name == queueName)
                           .Select(nq => nq.Queue)
                           .FirstOrDefault();
        if (queue == null)
        {
            queue = new Queue<T>();
            _queues.Add(new NamedQueue{Name=queueName, Queue=queue});
        }
        queue.Enqueue(message);
    }


    public bool TryDequeue(string workerId, out T message)
    {
        message = default(T);

        Free_queue_locked_for_worker(workerId);

        NamedQueue namedQueue = null;
        for (var i = 0; i < _queues.Count(); i++)
        {
            namedQueue = Get_next_queue();
            if (Queue_not_locked(namedQueue)) break;
            namedQueue = null;
        }
        if (namedQueue == null) return false;

        message = namedQueue.Queue.Dequeue();
        Lock_queue_for_worker(workerId, namedQueue);
        return true;
    }
    …


Hier zeigt es sich jetzt, dass TDD letztlich kein Verfahren ist, das zu Unit Tests führt. TryDequeue() ruft jetzt andere Methoden auf, d.h. es integriert. Noch sind diese Methoden einfach und wurden 1:1 aus Code, der eben noch in TryDequeue() stand erzeugt. Bei der Weiterentwicklung des ADT kann es aber jederzeit passieren, dass Änderungen an diesen Methoden nötig werden. Und dann stellt sich die Frage, wie diese Änderungen getestet werden.

Klar, ich kann dafür dann gezielte Tests schreiben. Doch beim Refactoring mit ReSharper sind die “Hilfsmethoden” automatisch als private deklariert worden. Es kostet dann schon einige Überwindung, die auf internal zu setzen und als Units für sich zu testen. Deshalb wird in den meisten Fällen weiter durch das Interface getestet, d.h. mit Integrationstests gearbeitet. Die testen dann natürlich auch immer noch alles andere mit. Vorteil: Black Box Tests sind unabhängig von interner Struktur. Nachteil: Black Box Tests können aufwändig werden, wenn für Kleinigkeiten das Drumherum mit getestet werden muss. Das wird besonders auffallend, sobald Attrappen ins Spiel kommen. Dazu kommt, dass bei Integrationstests eine Fehlerquelle nicht so leicht lokalisiert werden kann.

Fazit

Mit diesem Artikel wollte ich Ihnen zeigen, dass ein expliziter Entwurf von Software mittels Flow-Design nicht bedeutet, alles über Bord zu werfen, was Ihnen lieb und teuer geworden ist: Ich finde, der Abstrakte Datentyp NotifyingMultiQueue<T> ist ein solider Vertreter der Objektorientierung. Und seine Entwicklung ist eine solide Anwendung von TDD.

Darüber hinaus wollte ich Ihnen an einem realen Beispiel zeigen, wie TDD funktionieren kann: mit expliziter Testplanung, in kleinen Schritten. Nicht perfekt, aber good enough.

Schließlich haben Sie gesehen, dass selbst ich als Verfechter von Nachdenken vor dem Codieren nicht dogmatisch bin ;-) Wenn die Codierungsrealität es nahelegt, dann kann ich von meinem Entwurf auch abweichen. Erkenntnisse sind jederzeit willkommen.

Wenn Sie mögen, verfolgen Sie meine TDD-Fortschritte auch im Code. Hier die relevanten Changesets (insb. 43..50) im Mercurial Repository npantarhei.codeplex.com.

image_thumb[24]_thumb

Und nun kommen Sie mit TDD und FD.

Spendieren Sie mir doch einen Kaffee, wenn Ihnen dieser Artikel gefallen hat...

Freitag, 27. Januar 2012

TDD im Flow – Teil 2

Was bisher geschah:

Test #2: Ein Worker entnimmt aus mehreren Queues

Der dritte Test in meiner Planung bleibt sinnig. Er führt zu Änderungen am Produktionscode.

image[27]

Den Testcode zu zeigen, lohnt nicht. Er entspricht der Skizze im Bild. Aber hier der Produktionscode:

internal class NotifyingMultiQueue<T>
{
    List<KeyValuePair<string, Queue<T>>> _queues = 
        new List<KeyValuePair<string,Queue<T>>>();
 

    public void Enqueue(T message, string queueName)
    {
        var queue = _queues.Where(nq => nq.Key == queueName)
                           .Select(nq => nq.Value)
                           .FirstOrDefault();
        if (queue == null)
        {
            queue = new Queue<T>();
            _queues.Add(new KeyValuePair<string, Queue<T>>(queueName,
                                                           queue));
        }
        queue.Enqueue(message);
    }


    public bool TryDequeue(string workerId, out T message)
    {
        var queue = _queues[0];
        _queues.RemoveAt(0);

        message = queue.Value.Dequeue();
        return true;
    }
    …

Die “Änderung mit Zukunft” betrifft Enqueue(). Dort werden nun nach Name unterschieden Nachrichten in verschiedene Warteschlangen eingetragen. Dass ich dafür die Warteschlangen in einer Liste statt einem Dictionary organisiere, ist nicht nur einer gewissen Voraussicht geschuldet – ich habe eine Idee, wie ich das Round Robbin Verfahren einfach implementieren kann –, sondern auch dem Wunsch, TryDequeue() für den Moment möglichst einfach zu halten.

Zwar könnte ich Enqueue() noch simpler mit einem Dictionary arbeiten, doch dann könnte TryDequeue() bei der Entnahme nicht durch die Warteschlangen fortschreiten – und sei es auch nur so simpel wie jetzt. In einem Dictionary gibt es keine verlässliche Reihenfolge der Einträge. Ein Fortschreiten durch verschiedene Warteschlangen ist aber nötig, um hier eine neue Äquivalenzklasse anzugehen. Ansonsten ließe sich das Ergebnis dieses Tests auch mit der bisherigen Implementation erreichen.

Test #3: Queuewechsel mit Round Robbin

Jetzt wird es spannend. Nachrichten in separate Queues zu stellen, ist einfach. Sie aber im Round Robbin Verfahren daraus zu entnehmen, das ist schon kniffliger. Das muss ich nun angehen:

image[32]

Dazu müssen auch in den Queues mehrere Nachrichten stehen, weil es ja der Trick beim Round Robbin ist, nicht erst eine Queue abzuarbeiten, sondern für jede Nachricht eine weiter zu rücken.

Oben habe ich mir Gedanken zu einer Datenstruktur gemacht, mit der das möglich ist. Ein hübscher Plan… den ich nun fallen lasse. Nein, den ich schon mit der vorherigen Implementation habe fallen lassen.

Ich baue mir nicht selbst eine verkette Liste von Warteschlangen, sondern nehme eine normale Liste, der ich Queues in ihrer Reihenfolge vorne entnehme und hinten wieder anfüge. So wandern sie im Kreis durch das Fenster des Listenkopfs.

internal class NotifyingMultiQueue<T>
{
    List<KeyValuePair<string, Queue<T>>> _queues =
        new List<KeyValuePair<string,Queue<T>>>();

    …
    public bool TryDequeue(string workerId, out T message)
    {
        var queue = _queues[0];
        _queues.RemoveAt(0);
        _queues.Add(queue);

        message = queue.Value.Dequeue();
        return true;
    }
    …

Um diesen Test “ergrünen zu lassen” ist nur eine weitere Zeile Code in TryDequeue() nötig. “Pointergehansel” wie im Datenmodell ist nicht nötig. Das hatte ich geahnt beim vorherigen Test und deshalb eine Liste statt eines Dictionary für die benannten Queues gewählt.

Hm… man könnte nun argumentieren, dass hier eine Datenstruktur (Liste) zwei Zwecken dient: der Haltung benannter Queues (insb. für Enqueue()) und dem Durchlaufen der Queues in bestimmter Reihenfolge (TryDequeue()). Diesem Hinweis auf das SRP halte ich aber KISS entgegen: für den Moment stellt diese Verquickung kein Problem dar. TryDequeue() wird dadurch nicht komplizierter. Und Enqueue() eigentlich auch nicht. Im Falle des Wechsels der Entnahmestrategie müsste ich ohnehin beide Methoden anfassen.

Noch ein Testszenario entfällt

Beim nächsten Testszenario muss ich schon wieder feststellen, dass ich übers Ziel hinausgeschossen bin. Es zu erfüllen, bedarf keiner Änderung am Code.

image[37]

Das erkenne ich aber erst jetzt, da ich die Lösung besser verstehe und schon Code geschrieben habe. Macht nichts. Ein Test weniger fällt mir leicht ;-)

Weiter geht es im nächsten Teil…

Spendieren Sie mir doch einen Kaffee, wenn Ihnen dieser Artikel gefallen hat...

Donnerstag, 26. Januar 2012

TDD im Flow – Teil 1

Steht Flow-Design eigentlich im Gegensatz zu Test-Driven Design? Nein. Zwar bin ich überzeugt, dass TDD viel weniger wichtig ist, als viele Vertreter agiler Softwareentwicklung glauben, aber deshalb hat TDD doch seinen Platz. An einem Beispiel möchte ich das demonstrieren.

Vor einiger Zeit habe ich Gedanken zu einer Flow Execution Engine geäußert. Die habe ich inzwischen angefangen zu bauen. Ihr Name ist “PantaRhei” und der Quellcode für eine C#-Implementation liegt bei CodePlex: npantarhei.codeplex.com.

Selbstverständlich ist diese Flow Runtime selbst im Flow-Design entstanden. Ihre Aufgabe ist also, Flows wie den, der sie selbst beschreibt, auszuführen. Hier ein Ausschnitt aus dem Modell:

image

Gezeigt ist die asynchrone Verarbeitung von Nachrichten, die von außen hereinkommen:

  • Sie fließen in einen Flow, der bei der Runtime registriert ist, über die Methode ProcessAsync() hinein.
  • Dann werden sie über die Funktionseinheit Asynchronize auf einen Hintergrund-Thread gehoben.
  • Vor der Ausführung wird dann nochmal geprüft, ob die Verarbeitung parallel zu anderen Nachrichten erfolgen soll (Schedule processing).
  • Zwei Parallelverarbeitungsmodi stehen zu Auswahl: echte Parallelverarbeitung, d.h. jede Nachricht an einen Port wird parallel zu anderen Nachrichten verarbeitet, oder eingeschränkte Parallelverarbeitung, da Nachrichten an den selben Port nur sequenziell verarbeitet werden.

Der Flow im Bild ist nicht durch TDD entstanden. Klar. Den habe ich iterativ wachsen lassen: ein bisschen entwerfen (auf dem Papier), ein bisschen implementieren, dann wieder ein wenig entwerfen usw. Im Repository kann man verfolgen, wie es voran gegangen ist.

Die (Sub-)Flows sind trivial in der Implementation. Die Musik spielt in den Operationen, den Blättern des oben grob erkennbaren Schachtelungsbaums. Blätter sind z.B. Register operation, Asynchronize und Execute task.

Diese Operationen sind so einfach, dass ich sie runtergeschrieben habe. Einige ohne Tests, einige mit Tests – hinterher. Ja, der TDD-Freund wird es den Tests ansehen, dass ich sie hinterher geschrieben habe ;-) Macht aber aus meiner Sicht nichts. Ich brauche sie ja nicht, um zu entwerfen, sondern nur zur Feststellung von Korrektheit kleiner Funktionseinheiten.

Nun aber bin ich an einem Punkt, wo ich meinen Modus umschalte. Das will ich einmal dokumentieren, um zu zeigen, wie TDD und FD Hand in Hand gehen können.

Anforderungen

Ganz rechts im Bild finden Sie die Operation Sequentialize. Bei ihr geht es darum, Nachrichten zur Verarbeitung auf einen von mehreren Threads zu heben. Parallelize tut das ganz einfach: Nachrichten werden dem nächsten frei werdenden Thread zugewiesen. Das führt zu unterschiedsloser Parallelverarbeitung aller Nachrichten. Bei Sequentialize soll es dagegen etwas differenzierter vorgehen. Da sollen manche Nachrichten parallel und manche sequenziell verarbeitet werden.

Unterschieden werden Nachrichten bei Sequenzialize nach den Ports, zu denen sie fließen. Nachrichten an unterschiedliche Ports werden parallel verarbeitet, aber Nachrichten an den selben Port werden sequenziell verarbeitet. Damit wird eine häufige Fehlerquelle des concurrent programming automatisch ausgeschaltet.

Ganz einfach ließe sich die sequenzielle Verarbeitung aller Nachrichten an einen Port natürlich dadurch lösen, jeden Port mit einem eigenen Thread und einer Warteschlange auszustatten. Das würde die Verarbeitung insgesamt jedoch eher verlangsamen, dass es immer viel mehr Ports als Prozessorkerne gibt.

Also soll die sequenzielle Verarbeitung der Nachrichten an einen Port bei gleichzeitiger Parallelität aller Ports mit nur wenigen Threads realisiert werden. Sequentialize muss daher für jede Nachricht prüfen, ob sie an einen Port geht, der gerade noch mit der vorhergehenden beschäftigt ist. Falls ja, muss die Nachricht warten; andernfalls kann sie vom nächsten freien Thread verarbeitet werden.

Wenn eine Nachricht warten muss, kann eine andere, die zu einem anderen Port fließt, an den nächsten Thread zugewiesen werden. Die wartende Nachricht darf darüber natürlich nicht vergessen werden.

Lösungsidee

Mir scheint es für dieses Problem keine Lösung in der TPL zu geben, da ich ja keine fixen Task-Netzwerke aufbaue. Also muss ich selbst Infrastruktur basteln. Das sollte kein Hexenwerk sein – aber es ist schwieriger als das, was ich bisher für die Runtime zu tun hatte.

Der Rahmen ist noch simpel:

image

Zur Sequentialisierung in beschriebener Weise, müssen die Nachrichten in einen Puffer geschrieben werden: Enqueue message. Das geschieht auf dem Thread, der sie annimmt für die Verarbeitung (s. Asynchronize in Flow asynchronously).

Die vielen Threads, auf denen Nachrichten dann parallel abgearbeitet werden können, entnehmen sie aus diesem Puffer, sobald sie frei für neue Arbeit sind: Parallel dequeue.

Diese beiden Funktionseinheiten sind trivial. Die Musik spielt im Message store. Der kann nämlich nicht eine simple Warteschlange wie bei Asynchronize sein. Vielmehr muss der Message store dafür sorgen, dass Nachrichten erstens in der Reihenfolge ihres Eingangs herausgegeben werden, zweitens dabei aber eine Einschränkung für Nachrichten an denselben Port gilt.

Das ist eine Funktionalität, die ich nicht eben mal so runterschreibe. Und dazu fällt mir auch gerade kein sinniger Flow ein. Mein Gefühl ist, hier bei einer Funktionseinheit angelangt zu sein, die aus Sicht von Flow-Design ein Blatt, eine Black Box darstellt.

Dennoch hat der Message store eine Struktur: eine Datenstruktur wie auch eine Verarbeitungsstruktur. Wie komme ich an die heran?

Meine Lösungsidee für die Datenstruktur sieht erst einmal so aus:

  • Jeder Port bekommt eine eigene Warteschlange. Damit wird sichergestellt, dass pro Port die Nachrichten in der ursprünglichen Reihenfolge bearbeitet werden.
  • Allerdings hat jede solche Port-Warteschlange eine Flagge, die anzeigt, ob gerade an einer Nachricht dieses Ports gearbeitet wird. Solange das der Fall ist, kann kein Thread eine Nachricht aus dieser Warteschlange entnehmen. Sie ist dann blockiert.
  • Da es mehrere Warteschlange gibt, die mehrere Threads mit Arbeit versorgen, muss es gerecht zugehen. Kein Port darf bevorzugt werden. Deshalb sollten die Warteschlangen von freien Threads im Round Robbin Verfahren abgefragt werden. Entnimmt ein Thread aus einer Warteschlange eine Nachricht, entnimmt der nächste freie Thread aus einer anderen. Die Warteschlangen bilden deshalb einen Kreis in Form einer einfach verketteten Liste mit einem Zeiger auf die nächste zu befragende Warteschlange.

Im Bild sieht das so aus:

image

TDD-Anhänger mögen angesichts von soviel Entwurf sagen, das sei vielleicht nicht so einfach, wie es sein könnte. Darauf sage ich: Mag sein. Aber warum sollte ich nicht eine Lösungsidee haben auf der Basis der Kenntnis der Anforderungen? Warum sollte ich mich dümmer stellen als ich bin? Ob ich die Datenstruktur am Ende so umsetze, ist ja noch eine zweite Frage. Zunächst trenne ich aber die Phasen Lösungsentwurf und Implementierung. Damit entlaste ich die Implementierung. Ich habe dann nämlich schon eine Vorstellung von der Lösung – an die ich mich allerdings auch nicht sklavisch halten sollte.

Es gibt mehrere Werte, die es auszugleichen gilt. Ein Wert mag die einfachst mögliche Implementation sein. Aber ein anderer ist Geschwindigkeit. Und die ist höher, wenn ich eine (zumindest grobe) Struktur habe, auf die ich hinarbeiten kann, ein Ziel. Dann muss ich nämlich nicht dauernd refaktorisieren, weil mich Erkenntnisse während der TDD-Codierung überraschen.

Solange mein Entwurf verständlich ist und mit Augenmaß auch simpel gehalten, finde ich es völlig ok, vorauszudenken. Nein, ich finde es sogar empfehlenswert. Zu oft habe ich nämlich gesehen, dass mit TDD eine Implementation angegangen wird, ohne eine Vorstellung von der Lösung zu haben – um dann nach anfänglichen Erfolgen steckenzubleiben.

Mit einem Entwurf schaffe ich mir sozusagen selbst eine Karte für ein bis dahin unbekanntes Terrain. Und mit Karte wandert es sich leichter. Das heißt nicht, dass ich auf dem Weg nicht auf Hindernisse treffe. Aber ich kann die dann in einem bigger picture verorten und schauen, wie ich sie umwandere.

Und wie sieht meine Lösungsidee für die Verarbeitungsstruktur aus? Da kenne ich nur die Schnittstelle. Ich will die Datenstruktur als abstrakten Datentyp (ADT) realisieren. D.h. nach außen ist von einem Objektgraphen nichts zu sehen. Funktionseinheiten, die mit der Datenstruktur umgehen, sollen es so einfach wie möglich haben. Ich stelle mir das so vor:

class NotifyingMultiQueue<T> {
    public void Enqueue(T message, string queueId) {…}
    public bool TryDequeue(string workerId, out T message) {…}

    public void Wait(int milliseconds) {…}
}

  • Um eine Nachricht zur Verarbeitung auf irgendeinem Thread einzustellen, wird Enqueue() für eine Warteschlange aufgerufen.
  • Um eine Nachricht von der nächsten Warteschlange zur Verarbeitung abzuholen, wird TryDequeue() aufgerufen. Dabei ist anzugeben, wer die Nachricht dann verarbeitet, damit sichergestellt werden kann, dass keine ungewollte Parallelverarbeitung stattfindet.
    Eine erfolgreiche Entnahme aus einer Warteschlange sperrt diese durch Hinterlegung der Id des Workers, an den eine Nachricht ausgegeben wurde. Wenn der Worker dann das nächste Mal eine Entnahme tätigen will, wird die durch seine vorherige Entnahme gesperrte Warteschlange wieder freigegeben. 
  • Und falls gerade keine Nachricht zur Verarbeitung anliegt (TryDequeue() liefert false), kann ein Thread sich mit Wait() schlafen legen, bis es wieder Arbeit gibt.

Das klingt hoffentlich sinnig. Dass das grundsätzlich funktioniert, habe ich schon bei der asynchronen Nachrichtenverarbeitung überprüft. Mit so einer Schnittstelle läuft das Umheben auf andere Threads ordentlich.

Test-Driven Development

Die Anforderungen sind klar, die Schnittstelle ist einfach – aber wie sieht nun der Code dahinter aus? Den könnte ich jetzt versuchen runterzuschreiben; durch die Datenstruktur habe ich ja einen Überblick über das Nötige gewonnen. Aber dabei würde ich mich nicht wohl fühlen. Etwas mehr Systematik darf sein bei einem so wichtigen Bestandteil der Flow Runtime. Wenn die Datenstruktur nämlich nicht sauber aufgesetzt ist, dann kommt es zu Fehlern in der Arbeit mit mehreren Threads – und die sind sicher nur schwer zu reproduzieren und zu lokalisieren.

Wie aber anfangen mit TDD? Jetzt Visual Studio anwerfen und einen ersten Test für eine der Schnittstellenmethoden schreiben? Nein! Am Anfang von TDD steht die Sammlung von Testfällen. Darauf, dass die Ihnen während der Codierung einfallen, sollten Sie sich nicht verlassen. Und schon gar nicht sollten Sie erwarten, dass sie Ihnen in einer guten Reihenfolge einfallen. Damit würden Sie das TDD-Vorgehen überfrachten.

Testfälle für das Ganze, das Sie realisieren wollen, sind vor dem Beginn der Codierung festzulegen. Es sind schließlich die Akzeptanzkriterien für Ihren Code. Wenn Sie die nicht vorher kennen, lügen Sie sich schnell mit der Codierung – selbst wenn die Test-First geschieht – etwas in die Tasche. Außerdem dient die Sammlung der Testfälle dem tieferen Verständnis der Anforderungen.

Hier die ersten 4 Testfälle von 14, die mir zu dieser speziellen Warteschlange eingefallen sind:

image

Sie sind in aufsteigender “Schwierigkeit” sortiert. Mit jedem Testfall wird das Szenario etwas komplizierter. Es gibt ja mehrere Variablen:

  • Die Zahl der Einträge in einer Warteschlange
  • Die Zahl der Warteschlangen
  • Die Zahl der Worker, die auf die Warteschlangen zugreifen
  • Den Blockierungszustand
  • Benachrichtigungszustand

Allerdings gibt es noch ein zweites Sortierkriterium: den Interessantheitsgrad eines Tests bzw. die Relevanz der durch ihn abgedeckten Funktionalität. Deshalb steht am Anfang ein happy day Szenario und nicht die sonst häufig zu sehende Prüfung, ob eine leere Datenstruktur korrekt behandelt wird. Das ist erst der 9. Test.

Vor dem ersten Test bin ich so frei und setze meine Klasse mit den obigen Methoden auf. Das empfinde ich als keinen schlimmen Bruch mit der strengen TDD-Praxis. Alle Methoden werfen die NotYetImplemented Exception. So mache ich mir das Schreiben der Tests etwas einfacher und habe gleich sozusagen ein kleines Backlog im Code.

Gleichfalls lege ich mir in meiner Testklasse das System under Test (SUT) zurecht. Wie mir die Testplanung zeigt, brauche ich ja immer wieder eine Instanz von NotifyingMultiQueue, die via TryDequeue() eine Nachricht zurückgibt.

[TestFixture]
public class test_NotifyingMultiQueue
{
    private NotifyingMultiQueue<string> _sut;
    private string _result;

    [SetUp]
    public void Before_each_test()
    {
        _sut = new NotifyingMultiQueue<string>();
        _result = null;
    }
    …

Test #1: Ein Worker entnimmt aus einer Queue

Der erste Test ist ganz einfach. So soll es ja auch sein. Leicht beginnen und in kleinen Schritten vorgehen:

[Test]
public void Single_worker_takes_from_single_queue()
{
    _sut.Enqueue("a", "q1");
    Assert.IsTrue(_sut.TryDequeue("w1", out _result));
    Assert.AreEqual("a", _result);
}

Die Zeilen setzen die Testplanung treu um:

image

Ich habe länger überlegt, ob ich den ADT als Black Box testen soll. Am Ende habe ich mich dafür entschieden. Auch wenn ich eine hübsche Datenstruktur entworfen habe, will ich mich nicht daran in Tests binden, wenn es nicht absolut nötig ist. Also teste ich die Funktionalität immer durch den API hindurch. Bei der zu erwartenden Größe des Codes scheint es mir vertretbar, ganz auf Integrationstests zu setzen, auch wenn sich intern die funktionalen Strukturen ausdifferenzieren.

Allerdings muss ich dadurch meist paarweise Änderungen vornehmen: bei Enqueue() und bei TryDequeue() bzw. Wait(). Anders kann ich nicht überprüfen, ob eine interne Zustandsveränderung korrekt erfolgt. Auf beiden Seiten muss ich mich also konzentrieren, das KISS Prinzip nicht aus den Augen zu verlieren.

Für den ersten Test ist das aber noch einfach. Das Szenario kann mit einer Queue abgehandelt werden:

internal class NotifyingMultiQueue<T>
{
    Queue<T> _queue = new Queue<T>();

    public void Enqueue(T message, string queueName)
    {
        _queue.Enqueue(message);
    }

    public bool TryDequeue(string workerId, out T message)
    {
        message = _queue.Dequeue();
        return true;
    }
    …

Die Implementation muss zur Zeit nur einen einzigen Test erfolgreich machen. Wenn also nicht alle Parameter benutzt werden oder von Round Robbin nichts zu erkennen ist, dann macht das nichts. Diese Implementation ist die einfachste mögliche, zur “Testbefriedigung”.

Zumindest ist das so, wenn man den Anspruch hat, dass im Code etwas halbwegs sinniges passiert. Oft ist ja bei TDD Demos zu sehen, dass bei den ersten Tests triviale Implementationen vorgenommen werden, z.B.

internal class NotifyingMultiQueue<T>
{
    public void Enqueue(T message, string queueName)
    {}

    public bool TryDequeue(string workerId, out T message)
    {
        message = “a”;
        return true;
    }
    …

Der Test würde damit grün – ansonsten wäre aber nichts gewonnen. Es wäre kein Schritt in Richtung einer Lösung unternommen worden.

Tests sind ja aber kein Selbstzweck. Das Ziel ist nicht, Tests grün zu bekommen. Tests sind nur ein Mittel, um das Ziel zu ereichen. Das ist mit und ohne Tests grundsätzlich dasselbe: Produktionscode, der die Anforderungen erfüllt.

Es ist gut, wenn kleinschrittige Tests zu einem Wachstum der Implementation auch in kleinen Schritten führt. Triviales Wachstum aber, von dem man weiß, dass es nicht belastbar ist im Sinne der Anforderungen, sollten Sie vermeiden.

Ein Testszenario entfällt

Nicht nur Implementation kann allerdings trivial wachsen. Auch bei Tests besteht die Gefahr. Das weiß man aber nicht immer, wenn man den Test formuliert. Der folgende schien mir beim Entwurf noch sinnig:

image

Nach der einfachen, aber nicht trivialen Implementation zu Test #1 war dieser Test jedoch überflüssig. Es wäre sofort grün gewesen. Ich habe ihn deshalb ausgelassen.

Die Maxime dahinter lautet: Tests, die du keiner Änderung an der Implementation führen (d.h. die nicht zuerst rot sind), sollten nicht geschrieben werden. Sie treiben den Produktionscode nicht voran. Sie gehören zur selben Äquivalenzklasse wie ein anderer Test. Hier wäre es die Äquivalenzklasse “Ein Worker entnimmt aus einer Queue” gewesen.

Würde ich eine Queue implementieren, wäre der Test nötig. Aber ich benutze in meinem ADT eine Queue. Und deren Funktionstüchtigkeit muss ich nicht auch nochmal testen. Darauf verlasse ich mich. In Bezug auf benutzte ADTs sind meine Tests also Integrationstests.

Weiter geht es im nächsten Teil…

Spendieren Sie mir doch einen Kaffee, wenn Ihnen dieser Artikel gefallen hat...

Mittwoch, 11. Januar 2012

Schätzungen auf dem Prüfstand

Kann man verlässlich schätzen, wie lange ein Team braucht, Anforderungen in einsetzbare Software zu übersetzen? Kann man das für einen Horizont von wenigen Wochen? Kann man das für Monate oder Jahre?

Diese Frage führt immer wieder zu hitzigen Diskussionen. Eine der am besten besuchten Sessions auf der letztjährigen DevCon drehte sich auch um diese Frage. Das Interesse an einer Antwort, ja geradezu an einer Erlösung von diesem Thema ist groß.

Ich habe dazu auch meine Meinung und bin stolzes Mitglied der XING-Gruppe "Stop Software Estimation Now!" :-)

image

Aber wenn ein Thema so lange und so fortschrittslos diskutiert wird, frage ich mich auch: Gibt es hinter der Frage nicht ein grundsätzlicheres Problem? Sitzen wir vielleicht einem Missverständnis auf?

Vielleicht rennen wir ja kollektiv gegen eine Wand - und sehen nicht, dass nur ein paar Schritte weiter eine Tür ist.

Solch eine Tür meine ich nun entdeckt zu haben. Ich glaube, die Diskussion zum Thema Schätzen kommt aus einem ganz simplen Grund nicht voran: Wir messen einfach nicht den Erfolg des Schätzens. Keine Partei weiß so richtig, ob Schätzen wirklich erfolgreich ist.

Der Weg aus dem Problem mit dem Schätzen besteht aus meiner Sicht daher aus zwei Schritten:

1. Festlegen, wann eine Schätzung erfolgreich ist; wir brauchen Kriterien.
2. Messen, ob eine Schätzung nach den aufgestellten Kriterien erfolgreich war.

Klingt einfach, oder? Und man fragt sich, ob das nicht immer schon so gehandhabt wurde. Ich glaube, nein. Mit dem Schätzen ist man schnell bei der Hand. Aber weder werden Schätzungsqualitätskriterien bilateral (!) festgelegt, noch wird geprüft, wie die Schätzqualität am Ende war.

Bilateral Schätzungsqualitätskriterien festlegen

Beim Schätzen sind mindestens zwei Parteien beteiligt: der Kunde und das Entwicklungsteam. Der eine hat das Geld und die Zeit, die anderen sollen sagen, was sie im Rahmen dieser Budgets leisten können. Das Team muss also abschätzen, wie viel Geld und Zeit es für einen Scope braucht oder ob es einen Scope innerhalb eines gewissen Budgets realisieren kann.

Wenn nun Geld, Zeit und Scope zu einem Vertrag zwischen diesen Parteien gehören, dann natürlich auch die Schätzung. Deshalb müssen sich beide einig darüber sein, wann eine Schätzung gut war. Sonst ist es schlecht mit der Schätzerfolgskontrolle für beide Seiten. Und ohne Erfolgskontrolle kein Lernen, um es das nächste Mal genauso zu machen, weil es gut war, oder es besser zu machen, weil es nicht gut war.

Hier sehe ich das erste Defizit: Üblicherweise werden Schätzungsqualitätskriterien nicht explizit festgelegt und schon gar nicht bilateral. Es gibt einfach keine Diskussion darüber. Die Schätzung besteht aus zwei Zahlen (“Wir brauchen M Monate und G Euro für den gegebenen Scope.”), die irgendwer irgendwie im Blick behält. Wenn M und G zur Neige gehen, schaut man, wie viel vom Scope noch übrig ist. Dann stellt man fest, dass M und G nicht reichen werden und die Nachverhandlungen beginnen.

So tut man das halt. Darüber wird vorher nicht gesprochen. Das nehmen beide Seite als normal hin – und ärgern sich doch. Oder zumindest eine Seite ärgert sich. Die andere mag es nicht kratzen, weil sie eine Horde von Anwälten für solche Nachverhandlungen beschäftigt.

Dazu kann man nun sagen: “So ist halt die Welt.” Doch das will ich nicht akzeptieren, solange diese Parteien auf der anderen Seite klagen, “alles” sei so teuer. Denn Aufwand für Nachverhandlungen aufgrund schlechter Schätzung ist unnütz, falls schlechte Schätzungen die Norm sein sollten. Es wäre dann auf die Dauer billiger das Schätzen zu verbessern. Dafür braucht man jedoch Qualitätskriterien. Die müssen bilateral und explizit definiert sein, weil sie zum Vertrag gehören. Und die müssen dann auch am Ende überprüft werden.

Explizite Schätzungsqualitätskriterien

Müssen denn die Softwarevertragsparteien aber länglich über Schätzungsqualitätskriterien sprechen? Sind die nicht offensichtlich?

Nein. Das ist ja das Problem. Darüber bestehen unterschiedliche Meinungen, über die nicht gesprochen wird.

Offensichtlich sind natürlich die Schätzwerte selbst, z.B. geschätzte Zeit und geschätztes Geld. Die werden mit dem Soll an Scope verglichen und man erfährt, ob das geschätze Budget ausgereicht hat oder nicht.

Zu diesen offensichtlichen Schätzwerten sollten dann allerdings noch mindestens drei weitere Kriterien treten:

Hinnehmbare Budgetabweichung: Es sollte ausdrücklich darüber gesprochen werden, welche Abweichung von den Schätzwerten noch als Erfolg verbucht werden darf. Sind 5% Abweichung ok oder 10% oder gar 20%? Schätzungen sind eben Schätzungen. Dass ein Team punktgenau landet, ist nicht zu erwarten. Also sollte man sich darüber unterhalten, wie groß der Landeplatz ist.

Hier sind sogar zwei Seiten zu unterscheiden: Am Anfang stehen ein Termin und ein Geldbetrag in Bezug auf einen Scope. Der Kunde ist zufrieden, wenn aus seiner Sicht beides innerhalb einer zu definierenden Abweichung eingehalten wird.

Das Team hat zur Erreichung dieser Fixpunkte aber auch noch einen Aufwand geschätzt, der durch den Geldbetrag gedeckt werden soll. Selbst wenn der Kunde also zufrieden ist, kann es sein, dass das Projekt aus Teamsicht floppt. Falls es den Aufwand erhöhen musste, um den Scope zum Termin zu liefern, ohne mehr Geld zu bekommen, ist die Schätzung auch schlecht gewesen – ohne, dass der Kunde davon etwas merken muss.

Hinnehmbarer Qualitätsverlust: Inzwischen wissen wir ja, dass es eben nicht nur um Geld, Zeit und Scope geht, sondern immer auch um Qualität. Der Kunde stellt funktionale und nicht-funktionale Anforderungen (Scope), die ein Entwicklungsteam mit unterschiedlicher interner Codequalität umsetzen kann. Inwiefern der Scope innerhalb des geschätzt nötigen Budgets umgesetzt wird, beobachtet der Kunde natürlich genau. Fällt der Erfüllungsgrad unter eine bestimmte Marke, übt der Kunde Druck aus. Darunter leidet gewöhnlich die interne Codequalität. Das sollte so nicht sein, ist aber so. Deshalb ist es wichtig, sich darüber Gedanken zu machen, ein wie großer Verlust an dieser Qualität noch als Erfolg beim Schätzen verbucht werden darf. Denn beim Schätzen besteht ja der Anspruch, dass die interne Qualität konstant über die geschätzte Dauer gehalten wird. (Dass man die interne Qualität dann auch noch messen können muss, um eine Abweichung vom Soll feststellen zu können, steht auf einem anderen Blatt.)

Hinnehmbarer Zufriedenheitsverlust: Der Kunde ist zufrieden, wenn er seinen Scope innerhalb des geschätzten Budgets bekommt. Wie ein Team das schafft, ist ihm in der Regel ziemlich egal. Überstunden, Wochenendarbeit, Urlaubssperre, Zuckerbrot, Peitsche… das schert ihn nicht. Schade – aber wohl nicht zu ändern.

Ein Team sollte für sich allerdings in dieser Hinsicht einen Anspruch definieren. Ist eine Schätzung erfolgreich, wenn zwar der Scope zum geschätzten Termin abgeliefert wird – aber die Stimmung auf Null ist? An dieser Stelle geht es mir nicht um eine Abweichung beim Aufwand, ohne dass der Kunde das zu spüren bekommt. Dafür gilt es, eine hinnehmbare Budgetabweichung zu definieren (s.o.).

Nöjd Crispare HistorikIch glaube, genauso wichtig wie die Beobachtung der Ressourcen Zeit und Geld, ist die der Motivation, der Stimmung, der Zufriedenheit, der Kompetenz (s. dazu z.B. Jeff Sutherland, “Happyness Metric – The Wave of the Future”). Alle Teammitglieder sind wertvolle Ressourcen – warum sollte man sie sonst bezahlen? Also sollte man nicht leichtfertig mit ihnen umgehen. Sie können ihren Wert nur voll einbringen, wenn sie “wie geschmiert funktionieren”.

Eigentlich mag ich diesen Ressourcen-Jargon nicht, aber an dieser Stelle scheint er mir nützlich, um den Kontakt zu anderen Schätzungsqualitätskriterien zu halten.

“Wie geschmiert funktionieren” die Teammitglieder nur, wenn sie das Gefühl haben, dass ihre persönlichen Bedürfnisse erfüllt werden. Sie haben einen Anspruch daran, wie ihr Arbeitsumfeld zur Befriedigung ihrer Bedürfnisse beitragen soll. Dazu zählt z.B. “regelmäßiges Gehalt für das Bedürfnis ‘Certainty’” oder “nette Kollegen für das Bedürfnis ‘Connection’” oder “Zeit fürs Lernen für das Bedürfnis ‘Growth’” (Bedürfnisbezeichnungen nach Tony Robbins “Why we do what we do”).

Dass nicht immer alle Bedürfnisse voll erfüllt werden können, weiß jeder. Man ist deshalb damit zufrieden, wenn sie recht verlässlich innerhalb eines gewissen Bereichs erfüllt werden. Geschieht das allerdings nicht… dann geht die Stimmung in den Keller. Unaufmerksamkeit schleicht sich ein, die Fehler nehmen zu, Demotivation zieht ihre Kreise, Dienst nach Vorschrift bekümmert den Kunden, Krankmeldungen nehmen zu, Fluktuation entsteht usw.

Das sind Entwicklungen, die nicht nur persönlich bedauerlich für die Teammitglieder sind, sondern Unternehmen Geld kosten. Diese Kosten sind allerdings meist unsichtbar für das Projekt. Wenn Teammitglieder aus Frust über das Projekt kündigen, das unter hohem Druck steht, weil ein Termin zu halten ist, dann wird die Einarbeitung eines neuen Teammitglieds nicht dem Projekt zugeschlagen – obwohl das Projekt sie verursacht.

Deshalb scheint es mir nützlich, die Zufriedenheit der Teammitglieder als Kriterium heranzuziehen. Dieser Messwert kann innerhalb des Teams erhoben werden, auch wenn niedrige Zufriedenheit außerhalb des Teams zu Kosten führt.

Ein Projekt kann also mit dem gewünschten Scope zum geschätzten Termin mit dem geschätzen Aufwand und mit hinnehmbarer Qualität abgeschlossen werden – und doch war die Schätzung insgesamt nicht erfolgreich, wenn nämlich trotz der Einhaltung dieser Kriterien die Zufriedenheit aus dem definierten Rahmen gefallen sein sollte. Das kann z.B. passieren, wenn die Einhaltung der anderen Kriterien dazu führt, dass Stress entsteht, der messbar unzufrieden macht. Das ist dann ein Raubbau an der “Ressource Entwickler”.

Fazit

Ob Schätzungen funktionieren oder nicht… Ich habe da zwar meine Meinung, doch ich empfehle ihnen heute nur: Messen Sie doch einfach mal. Aber richtig.

Setzen Sie sich im Team oder noch besser mit dem Kunden zusammen und definieren Sie die Erfolgskriterien für Schätzungen. Bleiben Sie allerdings nicht bei Geld und Zeit stehen. Weiten Sie Ihren Blick und definieren Sie auch Ihren Anspruch an die innere Qualität und Ihre persönliche Zufriedenheit.

Dann messen Sie vorher, während und nachher. Und dann vergleichen Sie die Messwerte mit Ihren vorher definierten Ansprüchen.

Wenn die Messungen innerhalb der Toleranzgrenzen sind, dann funktioniert das Schätzen. Glückwunsch.

Aber wenn sie wiederholt außerhalb der Toleranzgrenzen liegen… tja, dann funktioniert das Schätzen eben nicht. Soviel Einsicht sollten Sie dann haben und Ihre Praxis ändern.

 

PS: Dass die Diskussion über das Schätzen so hitzig verlaufen, liegt also daran, dass da unterschiedliche Wertesystem aufeinanderprallen. Die Kriterien, wann Schätzungen erfolgreich sind, differieren. Deshalb lohnt es, einen Schritt zurückzutreten und erst einmal zu schauen, was denn diese Kriterien überhaupt sind.

Montag, 9. Januar 2012

Functions considered harmful

Bisher habe ich die "traditionelle" konzeptionelle Objektorientierung als eine der Ursachen für die heutigen Probleme mit der Wartbarkeit von Software gesehen. Inzwischen regt sich jedoch in mir der Verdacht, dass das Wurzelproblem tiefer liegt.

Womöglich ist die Objektorientierung sogar zu loben, weil sie das irgendwie verstanden hatte und versucht zu helfen. Leider ist das nicht so geglückt, wie man es sich erhoffte. Warum? Weil das Wurzelproblem eben nicht behoben, sondern nur kaschiert wurde. Man hat sich nicht getraut, so tief nach unten zu graben, um es auszureißen.

Denn das Wurzelproblem scheint mir... der Funktionsaufruf.

Ja, genau, der gute alte, unscheinbare und für imperative Sprachen so fundamentale Funktionsaufruf.

Dass wir schreiben können

y = f(x)

ist der Keim vieler Wartbarkeitsübel.

Wie kann das sein?

Problem #1: Unbegrenzte Länge

Durch Funktionen gibt es keine Grenze für das, was eine Codeeinheit tun kann. Ungezügeltes Wachstum von Codeeinheiten wird durch Funktionen ermöglicht. Denn nach einem Funktionsaufruf kann es ja weitergehen im aufrufenden Code.

Funktionen kombinieren Request und Response. Damit kombinieren sie vorbereitenden Code und nachbereitenden Code im Aufrufer. Und selbstverständlich kann nachbereitender Code gleichzeitig vorbereitender Code für den nächsten Funktionsaufruf sein. Nach dem Funktionsaufruf ist vor dem Funktionsaufruf.

Wann sollte also aufrufender Code beendet sein? Wenn er inhaltlich eine Verantwortlichkeit erfüllt. Klar. Aber das ist ein Kriterium, dessen Erfüllung im Auge des Betrachters entsteht. Der eine mag es, Verantwortlichkeiten in höchstens 20 Zeilen zu formulieren, der nächste hat kein Problem, wenn es dafür 100 Zeilen braucht und wieder einem anderen sind auch 10.000 Zeilen recht.

Durch Funktionsaufrufe gibt es kein Halten in Bezug auf den Umfang des aufrufenden Codes. Die Existenz von Regeln, die versuchen, diesen Umfang direkt oder indirekt zu begrenzen, ist der beste Beweis dafür.

Und wie wäre es, wenn es keine Funktionen gäbe? Dann würde eine Codeeinheit immer nur aus soviel Code bestehen wie nötig ist, um einen Request vorzubereiten, der am Ende dann abgeschickt wird. Ohne Funktionen gäbe es keinen Response, den die vorbereitende Codeeinheit nachbereiten könnte, also wäre ihre Aufgabe mit Versand des Request abgeschlossen. Der Response würde von einer anderen Codeeinheit weiterverarbeitet.

Ohne Funktionen ist es aber natürlich sinnlos, von Request und Response zu sprechen. Einen Request gibt es nur, wenn man auch einen Response erwartet. Clients übergeben Requests an Services, die mit Responses antworten.

Ohne Funktionen gibt es nur Daten, die produziert und konsumiert werden. Producer senden Daten an Consumer. Und immer so weiter. Aus Client-Code wie diesem, der sich potenziell unendlich fortsetzt…

Client:
  Vorbereitender Code erzeugt X
  Y= f(X)
  Nachbereitender Code verarbeitet Y

wird Producer-Consumer-Code:

Producer:
  Vorbereitender Code erzeugt X
  Versenden von X

f als Prosumer:
  X nach Y transformieren
  Versenden von Y

Consumer:
  Nachbereiten von Y

Sie sehen, der Umfang jeder Codeeinheit ist ganz natürlich sehr begrenzt, wenn es keine Funktionen gibt. Es lässt sich einfach nur wenig tun, bis wieder Arbeit an eine andere Codeeinheit delegiert werden muss.

Die grenzenlose Kopplung von Vorbereitung und Nachbereitung ist das offensichtliche Problem, zu dem Funktionsaufrufe führen. Sie bläht aufrufende Codeeinheiten auf. Das erschwert schnell die Verständlichkeit und leistet einer Vermischung von Verantwortlichkeiten im Sinne des SRP Vorschub.

Problem #2: Unbegrenzte Tiefe

Unterhalb des Request/Response-Problems liegt leider noch ein weiteres, noch fundamentaleres. Das wird allerdings erst sichtbar, wenn Software weiter wächst. Es ist das Problem des Aufrufs schlechthin.

Zur Erinnerung: Aufrufe von Code sind eigentlich als Mittel zur Platzersparnis erfunden worden. Früher war Speicher eben sehr knapp. Da war jedes Mittel recht, um Bytes zu sparen. Also hat man das CALL/RET Maschinenbefehlpaar erfunden. Aus Code wie:

A
S
T
U
B
S
T
U
C
S
T
U

konnte nun werden:

A
CALL F
B
CALL F
C
CALL F

F: S
T
U
RET

6 Anweisungen statt 10. Das ist selbst mit diesem Pseudocode eine Reduktion um 40%.

Platzersparnis, nicht Wiederverwendbarkeit ist die Motivation hinter Unterprogrammen als Verallgemeinerung von Funktionen. Für Wiederverwendbarkeit wären Macros ausreichend gewesen.

Wie alles im Leben hat natürlich auch die Platzersparnis ihren Preis. Der besteht in der Trennung von Client-Kontext und Service-Code. Solange im obigen Beispiel STU zwischen A und B textuell steht, ist klar, was die Aufgabe von STU ist. STU steht im Nutzungskontext. Der Entwickler sieht zur Entwicklungszeit, was vorher passiert, was zwischendurch passiert und was nachher passiert.

Die Einführung des Unterprogramms F zerstört diese verständliche Einheit. Was zwischendurch passiert, steht nun irgendwo. Wer nun liest

A
CALL F
B

der ist darauf angewiesen, dass F ein ausdrucksstarker Name ist, um zu verstehen, was da passiert. Die vorherige Einheit zur Entwicklungszeit existiert erst wieder zur Laufzeit.

Das bedeutet: Funktionen machen den Aufrufort schwerer lesbar, weil sie dort Code durch einen Namen ersetzen. So eine Ersetzung ist sehr verlust- bzw. missverständnisgefährdet.

Und Funktionen machen Code insgesamt schwerer lesbar, weil sie einen natürlichen Zusammenhang wie

A
S
T
U
B

über die Codebasis verteilen. Auch da gibt es ja kein Halten. Die Aufruf-Schachtelung von Funktionen ist beliebig tief. Aus

A
S
T
U
B

wird zuerst

A
CALL F
B

F: S
T
U
RET

dann vielleicht

A
CALL F
B

F: S
CALL G
U
RET

G: T
X
Y
RET

und dann vielleicht

A
CALL H

H: CALL F
B
RET

F: S
CALL G
U
RET

G: T
X
Y
RET

uns so weiter…

Zur Laufzeit macht das alles keinen Unterschied. Zur Entwicklungszeit jedoch wird es immer schwieriger zu verstehen, was da eigentlich passiert. Inhaltliche Sequenzen werden aufgelöst. Es entsteht ein Granulat an Unterprogrammen, dessen Aufrufhierarchie zur Laufzeit keine formale Entsprechung zur Entwicklungszeit hat. Funktionen führen mithin zu einem fundamentalen Impedance Mismatch zwischen Entwicklungszeit und Laufzeit.

Gut, inzwischen gibt es IDEs, mit denen man die Aufrufhierarchie durchwandern kann. Aber seien wir ehrlich: das ist umständlich. Eine hierarchische Sicht von Funktionsaufrufen ist kein First Class Citizen in den populären IDEs wie eine Projektdateiansicht oder eine Klassenansicht. Und in der Tradition von C gibt es keine geschachtelten Funktionen in C++, Java, C#.

Die Schachtelung von Funktionsaufrufen ist so tief in unser aller Programmiererstammhirn eingebrannt, dass es nicht einmal eine Metrik dafür gibt. Man macht sich Gedanken über LOC pro Funktion oder die Schachtelungstiefe von Kontrollanweisungen in Funktionen. Die Tiefe der Aufrufhierarchie von Funktionen zur Entwicklungszeit hingegen, scheint niemanden zu interessieren. Dabei ist sie es, die den logischen Zusammenhang von Code auseinanderreißt.

Aus unbegrenzter Aufrufschachtelung folgt, dass auch die Problemlösung beliebig über die Tiefe der Aufrufhierarchie verteilt werden kann. Im obigen Beispiel können ja auf jeder Ebene – Aufrufwurzel, H, F oder G – Anteile von “Geschäftslogik” stehen.

  1. Das bedeutet erstens, es bedarf zusätzlichen Aufwands, um zu entscheiden, auf welcher Ebene Geschäftslogik angesiedelt werden sollte. Das ist aber natürlich Aufwand, der nicht zur Lösung des Problems beiträgt. Also scheut man ihn, wo es geht. Das Ergebnis sind flache Hierarchien mit sehr langen Funktionen (s. Problem #1).
  2. Das bedeutet zweitens, Funktionen, die nicht Blätter in der Aufrufhierarchie sind, machen zusätzlichen Aufwand beim Testen. Es muss ja nicht nur ihr Beitrag zur Problemlösung überprüft werden, sondern es müssen auch noch die aufgerufenen Funktionen ersetzt werden (Attrappen).
  3. Und drittens sind Aufrufhierarchien ständig gefährdet, durch Veränderungswellen erschüttert zu werden. Veränderungen breiten sich entlang von Abhängigkeiten aus:
    Wenn C wie Client von S wie Service abhängig ist, dann kann es bei Änderungen an C nötig sein, S nachzuführen. Und falls sich S ändert, kann es nötig sein, C nachzuführen. Je breiter und tiefer Aufrufhierarchien sind, desto unüberschaubarer die Ausbreitung von Änderungen, die immer irgendwo nötig sind. Das ist umso schlimmer, da ja diese Hierarchien nur schwer zu übersehen sind. Sie existieren nicht textuell und auch kaum in anderer Ansicht in den IDEs.

Fazit

Funktionen (oder allgemeiner: Unterprogramme) sind aus meiner Sicht eine der Hauptursachen für die Undurchschaubarkeit von Code. Sie machen seine Ausdehnung in Breite und Tiefe grenzenlos.

Dagegen helfen dann auch keine Ermahnungen und Metriken. Denn die sind sehr geduldig. Wenn es eng wird, hört man nicht hin und setzt sie aus. Immer mit dem Verweis, dass gerade anderes wichtiger sei – und es ja auch ohne ginge.

So entstehen unwartbare Codekonvolute einen Funktionsaufruf nach dem anderen.

*Dagegen hilft nur, Funktionsaufrufe klaren Auges als Gefahr zu sehen und ihre Nutzung zu rigoros zu begrenzen.*

Wir müssen diese Altlast aus den Anfangstagen der Programmierung abwerfen (oder zumindest mit weniger Schädlichem integrieren). Funktionen sind ein Erbe der Nähe zur Mathematik. Das hat uns weit gebracht – aber nun haben wir eine Grenze erreicht, da der Schaden größer als der Nutzen ist.

Mittwoch, 4. Januar 2012

Bei häufigem Kontakt mit Gift refaktorisieren

images/refactoring-toxicity.pngGraham Brooks bricht eine Lanze dafür, Code nicht mit der Gießkanne zu refaktorisieren, sondern den Aufwand dort zu treiben, wo es gerade besonders nutzt. Das hört sich sinnig an.

Noch sinniger wird es, wenn Graham als Nützlichkeitsindikatoren Giftigkeit (Toxicity) und Unbeständigkeit (Volatility) angibt.

Giftig (als Steigerung von schmutzig) ist der Code dort, wo bestimmte Metriken besonders schlimme Werte annehmen. Unbeständig ist er dort, wo häufig an ihm gearbeitet wird.

In Kombination ergibt sich eine Giftigkeit x Unbeständigkeit Matrix, die klar sagt: Refaktorisiere, wo häufiger Veränderungskontakt mit hoher Giftigkeit besteht; aber lass Code stabilen (relativ) ungiftigen Code in Ruhe. Das hört sich sinnig an.

image
Quelle: Graham Brooks

Nun fragt Jens Schauder in einem Tweet aber zurecht: Und wie misst man nun Giftigkeit? (Die Unbeständigkeit ist kein Problem; sie lässt sich aus einem VCS Log herauslesen.)

Graham sagt nichts dazu. Er verweist vielmehr auf diesen Artikel von Erik Dörnenburg. Der zeigt dann ein konkreteres Giftigkeitsdiagramm und gibt konkrete Metriken an. Auch sehr sinnig.

Wenn Sie nun aber einmal diese Grafik genauer anschauen,…

image
Quelle: Erik Dörnenburg

…dann fällt Ihnen sicher auf, dass sich im Grunde nur bei vielleicht 10 (7,5%) von 132 Dateien die Giftigkeit aus mehr als Zyklomatischer Komplexität (ZK) und Methodenlänge (ML) ergibt.

Die Werte anderer Metriken spielen diesen beiden gegenüber kaum eine Rolle - oder sie korrelieren mit ihnen positiv: wo ZK+ML groß ist, da ist z.B. auch der Fan-Out (efferente Kopplung) groß.

Ich frage mich deshalb: Was soll dieser ganze komplizierte Metrikenkram? Wenn man sonst nichts zu tun hat, kann man sich damit einen schönen Tag machen und wichtig aussehen. Für die Entscheidung an der Praxisfront jedoch reichen einfachere Heuristiken. Und um nicht mehr als Heuristiken geht es ohnehin. Denn auch die exaktesten Berechnungen von Metriken sollten nicht darüber hinweg täuschen, dass Giftigkeit vor allem im Auge des Betrachters entsteht.

Ich mache es mir in der Codebeurteilungspraxis einfacher. Mein Fokus liegt auf nur zwei Metriken:

  • LOC: Je länger eine Codeeinheit, desto schmutziger und schließlich giftiger ist sie. (Oder genauer: Es geht eigentlich um Anweisungen und nicht um Lines. Aber LOC hat sich einfach eingebürgert als Begriff.)
    Wieviele LOC eine Codeeinheit hat, kann man sehen.
  • Zyklomatische Komplexität: Je höher die ZK, desto schmutziger und schließlich giftiger ist eine Codeeinheit.
    Wie die ZK einer Codeeinheit ist, kann man abschätzen nach der Zahl der Kontrollanweisungen (Fallunterscheidungen, Schleifen) und ihrer Schachtelungstiefe.

Auch hier gibt es natürlich eine gewisse positive Korrelation: bei wenigen LOC wird die ZK eher gering sein, bei vielen LOC wird sie tendenziell auch steigen.

Dennoch möchte ich auf keine der Metriken verzichten. Auch wenn sie korrelieren, repräsentieren sie unterschiedliche Aspekte des Codes. LOC steht für mich eher im Zusammenhang mit dem SRP. Und ZK steht für mich eher im Zusammenhang mit Testbarkeit.

Ab welcher Länge oder Komplexität wird Code nun aber nicht nur schmutzig oder giftig? Meine persönlichen Alarmglocken gehen an, wenn eine Methode länger als eine Bildschirmseite ist, also bei LOC > 20-30, und/oder mehr als 2-3 Kontrollanweisungen enthält und/oder die Schachtelungstiefe > 2 ist.

Letztlich geht es aber für das Refaktorisieren weniger um absolute Zahlen. Refaktorisiert werden sollte dort, wo die Giftigkeit “nur” größer ist als woanders. Das gilt für Methoden mit 10.000 LOC und ZK 20 vs 8.000 LOC mit ZK 15 genauso wie für Methoden mit 100 LOC und ZK 10 vs 80 LOC mit ZK 6.

Und dieses Verhältnis sollte dann auch noch im Lichte der Unbeständigkeit betrachtet werden. Da stimme ich Graham Brooks zu.

Darüber hinaus interessiert mich allerdings noch die afferente Kopplung (AK, Fan-In). Denn wenn Codeeinheiten von vielen anderen referenziert werden, haben sie eine große Strahlkraft. Änderungen/Fehler an/in ihnen wirken sich leicht auf weite Teile der Software aus. Das bedeutet, Codeeinheiten mit hoher afferenter Kopplung verdienen ein besonderes Augenmerk.

Wenn ich eine Liste mit Codeeinheiten und ihren Metriken wie die folgende hätte

Codeeinheit(Name, LOC, ZK, AK, Unbeständigkeit)

würde ich sie so sortieren:

  1. Zuerst Codeeinheiten mit hoher Unbeständigkeit...
  2. ...dann solche mit hoher afferenter Kopplung...
  3. ...dann solche mit hoher LOC...
  4. ...dann solche mit hoher ZK.

Komplizierter muss die Entscheidungsgrundlage für die Refaktorisierung nicht sein, finde ich. Es sind keine komplizierten Schwellenwerte nötig, keine weiteren Metriken. LOC und ZK kann man sogar mit dem unbewaffneten Auge recht gut beurteilen. Für Unbeständigkeit und AK ist Werkzeugunterstützung nützlich.