HEC Blog: Technik & Methoden
Verfasst von Vladimir Schmidt am 24. April 2018

Zyklo­ma­ti­sche Komple­xi­tät (Cyclo­ma­tic Comple­xity) ist eine von vielen wich­ti­gen Soft­ware-Metri­ken, mit der man die Komple­xi­tät eines Modu­les oder einer Methode messen kann. Laut theo­re­ti­scher Defi­ni­tion handelt es sich dabei um die Anzahl linear unab­hän­gi­ger Pfade auf dem Kontroll­fluss­gra­fen. Mit einfa­chen Worten beschreibt es, wie oft man Entschei­dun­gen im Algo­rith­mus tref­fen muss.

Zyklo­ma­ti­sche Komple­xi­tät hat auch einen prak­ti­schen Zweck. Sie ist die mini­male Anzahl der Test­fälle für Unit-Tests, die nötig sind, um eine voll­stän­dige Zweig­über­de­ckung des Kontroll­fluss­gra­fen zu errei­chen. Die Unit-Tests sind eine wich­tige Ergän­zung des produk­ti­ven Codes, die dessen Quali­tät sichern und dessen Beschrei­bung im digi­ta­len Format enthal­ten. Bei geän­der­ten Anfor­de­run­gen wird der Quell­code der Unit-Tests ange­passt. Deswe­gen müssen die Unit-Tests unter ande­rem auch kompakt sein, um deren Wartungs­kos­ten zu mini­mie­ren.

Die Idee liegt darin, dass die Soft­wa­re­ent­wick­ler während ihrer Arbeit auf den CC-Wert einzel­ner Metho­den aufpas­sen. Sobald die Komple­xi­tät eine fest­ge­legte Grenze über­steigt, muss man diese Methode in klei­nere Kompo­nen­ten split­ten und ggf. in externe Klas­sen ausla­gern. Hinter dieser Heran­ge­hens­weise steckt der Gedanke, dass ab einer bestimm­ten Komple­xi­tät das Modul für den Menschen nicht mehr begreif­bar ist.

Tools

Es gibt keinen opti­ma­len Wert für zyklo­ma­ti­sche Komple­xi­tät. Bei der Einhal­tung dieser Regel wird die algo­rith­mi­sche Komple­xi­tät nur in struk­tu­relle Komple­xi­tät umge­wan­delt. Das MSDN empfiehlt den maxi­ma­len CC-Wert zwischen 10 und 15, wobei die Werte ab 10 als Sonder­fälle begrün­det und doku­men­tiert werden müssen. Es gibt Teams inner­halb der HEC GmbH, in denen die Soft­wa­re­ent­wick­ler sich auf den Wert 7 oder sogar 5 geei­nigt haben.

Visual Studio 2015 bietet die stati­sche Berech­nung eini­ger Metri­ken an (Menu → Analyze → Calcu­late Code Metric). Nach Akti­vie­rung der Code­ana­ly­se­re­gel CA1502 (Avoid exces­sive comple­xity) wird die Komple­xi­tät bei jedem Build berech­net und ein Fehler gewor­fen, sobald die Komple­xi­tät von einer Methode größer als 25 wird. Hier muss man die große Diskre­panz zu dem von MSDN empfoh­le­nen Wert erwäh­nen.

Die Dritt-Anbie­ter-Lösun­gen haben deut­lich flexiblere Möglich­kei­ten diesen Wert indi­vi­du­ell pro Projekt anzu­pas­sen (FxCop, NDepend). Auch ReShar­per hat ein Plug-In „Cyclo­ma­tic comple­xity“, das den CC-Wert für einzelne Metho­den laufend in der IDE berech­net und zu komplexe Metho­den markiert.

Vereinfachungsmöglichkeit

Es gibt zahl­rei­che Struk­tur­ver­bes­se­run­gen und Entwurfs­mus­ter, mit denen man zyklo­ma­ti­sche Komple­xi­tä­ten im Quell­code redu­zie­ren kann.

Methode extrahieren (Extract Method)

Komplexe Algo­rith­men oder Ausdrücke kann man in einer priva­ten Methode verste­cken, deren Namen den Zweck kenn­zeich­net. Das hilft zwar die Über­sicht­lich­keit des beste­hen­den Codes zu erhö­hen, aber der tatsäch­li­che CC-Wert und dement­spre­chend die Anzahl der Test­fälle blei­ben unver­än­dert.

Im folgen­den Beispiel enthält die Process-Methode eine Verzweigung mit einer komplexen Bedingung und zwei Codeabschnitten. Insgesamt müssen dreiTestfälle geprüft werden

public class LogicService
{
    public void Process(int[] ids)
    {
        if (ids != null && !ids.Contains(-1))
        {
            // logic 1
        }
        else
        {
            // logic 2
        }
    }
}

Anhand des Refak­tu­rie­rungs­schrit­tes wird die Bedin­gung in eine Methode ausge­la­gert. Der CC-Wert wird dabei auf zwei reduziert. Man braucht aber immer drei Testfälle, da das Verhalten der AreIdsReal-Methode innerhalb der Process-Methode nicht simuliert werden kann.

public class LogicService
{
    public void Process(int[] ids)
    {
        if (AreIdsReal(ids))
        {
            // logic 1
        }
        else
        {
            // logic 2
        }
    }
 
    private static bool AreIdsReal(int[] ids)
    {
        return ids != null && !ids.Contains(-1);
    }
}
Fehlercode durch Ausnahmen ersetzen (Replace Error Code with Exception)

Wenn eine Methode keinen vali­den Wert zurück­ge­ben kann, gibt es oft zwei Lösun­gen:

Beide Lösun­gen führen zu höhe­ren CC-Werten der aufru­fen­den Klasse, da der Rück­ga­be­wert dort analy­siert wird. Nach dem genann­ten Entwurfs­mus­ter muss man bei invaliden Werten eine Ausnahme auslösen. Das hilft den CC-Wert des Aufrufers gering zu halten.

Im folgen­den Beispiel wird die InvoiceService-Klasse die Gesamtsumme aller Rechnungen ausgeben. Dafür ruft sie die Find-Methode der InvoiceRepository-Klasse auf, um Entitäten zu laden. Diese Methode gibt eine Struktur mit der Entität und dem Fehlercode zurück, wobei nur eines von diesen Feldern initialisiert wird. Die aufrufende GetTotalCost-Methode muss die Entscheidung treffen, ob sie den Wert mit Gesamtsumme addiert oder -1 als Fehlercode zurückgibt. Hier wird die technische Aufgabe „Fehler dem Aufrufer weitergeben“ mit der Geschäftslogik gemischt, was kein guter Stil ist.

public class InvoiceRepository
{
    public FindEntity<Invoice> Find(int id)
    {
        if (id <= 0)
        {
            return new FindEntity<Invoice>
            {
                ErrorCode = 1
            };
        }
        var invoice = new Invoice();
        // ...
        return new FindEntity<Invoice>
        {
            Entity = invoice
        };
    }
}
 
public class InvoiceService
{
    public InvoiceRepository Repository { get; set; }
 
    public int GetTotalCosts(int[] ids)
    {
        var result = 0;
        foreach (var id in ids)
        {
            var findResult = this.Repository.Find(id);
            if (findResult.ErrorCode == 0)
            {
                result += findResult.Entity.Costs;
            }
            else
            {
                return -1;
            }
        }
        return result;
    }
}

Nach dem Umbau anhand des Ausnahme-Entwurfs­mus­ters wird der CC-Wert der GetTotalCosts-Methode von vier auf drei reduziert und der Quellcode wird übersichtlicher.

public class InvoiceRepository
{
    public Invoice Find(int id)
    {
        if (id <= 0)
        {
            throw new ArgumentException(nameof(id));
        }
        var invoice = new Invoice();
        // ...
        return invoice;
    }
}
 
public class InvoiceService
{
    public InvoiceRepository Repository { get; set; }
 
    public int GetTotalCosts(int[] ids)
    {
        var result = 0;
        foreach (var id in ids)
        {
            var invoice = this.Repository.Find(id);
            result += invoice.Costs;
        }
        return result;
    }
}
Fabrik-Methode-Entwurfsmuster (Factory-Method-Pattern)

Wenn eine Methode eine Verzwei­gung mit komple­xe­rer Bedin­gung und zwei komple­xe­ren Codeab­schnit­ten enthält, kann man diese Bedin­gung ggf. mit deren Abhän­gig­kei­ten in eine Fabrik-Methode-Klasse ausla­gern. Diese Klasse gibt Objekte dessel­ben Inter­fa­ces zurück, die jeweils die Anwei­sun­gen aus dem einen oder ande­ren ursprüng­li­chen Codeab­schnitt enthält. Damit kann zyklo­ma­ti­sche Komple­xi­tät einzel­ner Metho­den erheb­lich redu­ziert werden.

Um diese Vorge­hens­weise zu zeigen, refak­to­rie­ren wir folgen­des einfa­che­res Beispiel:

{
    public DateTime Date { get; set; }
 
    public void Process(Invoice invoice)
    {
        if (this.Date.Hour >= 9 && this.Date.Hour <= 17)
        {
            // logic 1
        }
        else
        {
            // logic 2
        }
    }
}

Nach dem Umbau wird der CC-Wert der Process-Methode von zwei auf einsredu­ziert. Der Preis dafür ist eine erheb­lich höhere struk­tu­relle Komple­xi­tät: drei neue Klas­sen (LogicFactoryLogic1Logic2) und zwei neue Schnittstellen (ILogicFactoryILogic).

public class LogicService
{
    public DateTime Date { get; set; }
 
    public ILogicFactory Factory { get; set; }
 
    public void Process(Invoice invoice)
    {
        var logic = this.Factory.GetLogic(this.Date);
        logic.DoSome(invoice);
    }
}
 
public interface ILogicFactory
{
    ILogic GetLogic(DateTime date);
}
 
public class LogicFactory : ILogicFactory
{
    public ILogic GetLogic(DateTime date)
    {
        if (date.Hour >= 9 && date.Hour <= 17)
        {
            return new Logic1();
        }
        else
        {
            return new Logic2();
        }
    }
}
 
public class Logic1 : ILogic
{
    public void DoSome(Invoice invoice)
    {
        // logic 1
    }
}

Deswe­gen ist es bei diesem Entwurfs­prin­zip wich­tig, dass die neue Struk­tur exis­tie­rende Objekte abbil­det, damit man die fach­li­che Bedeu­tung der einzel­nen Objekte besser verste­hen kann.

Null-Objekt-Entwurfsmuster (Null-Object-Pattern)

Wenn eine Methode einer Klasse nur unter bestimm­ten Bedin­gun­gen inner­halb eines Bereichs aufge­ru­fen wird, kann man diese Klasse notfalls mit einem Null-Objekt erset­zen. Dieses Objekt enthält Dummy-Imple­men­ta­tion von Schnitt­stel­len des ursprüng­li­chen Objekts und kann immer benutzt werden, ohne diese Bedin­gun­gen zu prüfen.

Im folgen­den Beispiel werden die logic-Methoden innerhalb der Process-Methode aufgerufen, wenn die logic vorhanden und initialisiert ist.

public class CustomLogicService
{
    public void Process(Invoice invoice, ILogic logic)
    {
        if (logic != null && logic.IsInitialized)
        {
            logic.DoSome(invoice);
        }
        // ...
        if (logic != null && logic.IsInitialized)
        {
            logic.DoOther(invoice);
        }
    }
}

Anhand des Null-Objekt-Entwurfs­prin­zips kann man am Anfang diese Bedin­gun­gen prüfen und notfalls die Varia­ble logic mit einem Null-Objekt ersetzen. Dann könnten die Methoden ganz normal aufgerufen werden. Dabei wird der CC-Wert der Process-Methode von fünf auf zwei reduziert und der Quellcode wird übersichtlicher.

public class CustomLogicService
{
    public void Process(Invoice invoice, ILogic logic)
    {
        if (logic == null || !logic.IsInitialized)
        {
            logic = new NullLogic();
        }
        logic.DoSome(invoice);
        // ...
        logic.DoOther(invoice);
    }
}
 
public class NullLogic : ILogic
{
    public bool IsInitialized { get; set; }
 
    public void DoSome(Invoice invoice)
    {
    }
 
    public void DoOther(Invoice invoice)
    {
    }
}
Zusammenfassung

Die regel­mä­ßige und auto­ma­ti­sierte Kontrolle der zyklo­ma­ti­schen Komple­xi­tät hilft dabei den Quell­code über­sicht­lich zu halten und damit die Anzahl von Unit-Tests, die Wartungs­kos­ten und die Risi­ken zu mini­mie­ren. Sobald die Komple­xi­tät die fest­ge­legte Grenze über­steigt, müssen entspre­chende Maßnah­men vorge­nom­men werden, um die Komple­xi­tät in den norma­len Bereich zu brin­gen. In diesem Blog wurden einige passende Struk­tur­ver­bes­se­rungs­prin­zi­pen und Entwurfs­mus­ter präsen­tiert. Die umfas­sende Liste von unter­schied­li­chen Prak­ti­ken kann man im Buch „Refac­to­ring: Impro­ving the Design of Exis­ting Code“ von Martin Fowler und Kent Beck oder auf deren Inter­net-Seite (http://refactoring.com/catalog) finden.