Wie komplex darf mein Code sein?

Blog – Technik und Methoden
Verfasst von Vladimir Schmidt am 24. April 2018

Zyklomatische Komplexität (Cyclomatic Complexity) ist eine von vielen wichtigen Software-Metriken, mit der man die Komplexität eines Modules oder einer Methode messen kann. Laut theoretischer Definition handelt es sich dabei um die Anzahl linear unabhängiger Pfade auf dem Kontrollflussgrafen. Mit einfachen Worten beschreibt es, wie oft man Entscheidungen im Algorithmus treffen muss.

Beispiel Cyclomatic Complexity

Zyklomatische Komplexität hat auch einen praktischen Zweck. Sie ist die minimale Anzahl der Testfälle für Unit-Tests, die nötig sind, um eine vollständige Zweigüberdeckung des Kontrollflussgrafen zu erreichen. Die Unit-Tests sind eine wichtige Ergänzung des produktiven Codes, die dessen Qualität sichern und dessen Beschreibung im digitalen Format enthalten. Bei geänderten Anforderungen wird der Quellcode der Unit-Tests angepasst. Deswegen müssen die Unit-Tests unter anderem auch kompakt sein, um deren Wartungskosten zu minimieren.

Die Idee liegt darin, dass die Softwareentwickler während ihrer Arbeit auf den CC-Wert einzelner Methoden aufpassen. Sobald die Komplexität eine festgelegte Grenze übersteigt, muss man diese Methode in kleinere Komponenten splitten und ggf. in externe Klassen auslagern. Hinter dieser Herangehensweise steckt der Gedanke, dass ab einer bestimmten Komplexität das Modul für den Menschen nicht mehr begreifbar ist.

 

Tools

Es gibt keinen optimalen Wert für zyklomatische Komplexität. Bei der Einhaltung dieser Regel wird die algorithmische Komplexität nur in strukturelle Komplexität umgewandelt. Das MSDN empfiehlt den maximalen CC-Wert zwischen 10 und 15, wobei die Werte ab 10 als Sonderfälle begründet und dokumentiert werden müssen. Es gibt Teams innerhalb der HEC GmbH, in denen die Softwareentwickler sich auf den Wert 7 oder sogar 5 geeinigt haben.

Visual Studio 2015 bietet die statische Berechnung einiger Metriken an (Menu → Analyze → Calculate Code Metric). Nach Aktivierung der Codeanalyseregel CA1502 (Avoid excessive complexity) wird die Komplexität bei jedem Build berechnet und ein Fehler geworfen, sobald die Komplexität von einer Methode größer als 25 wird. Hier muss man die große Diskrepanz zu dem von MSDN empfohlenen Wert erwähnen.

Code Metrics Result

Die Dritt-Anbieter-Lösungen haben deutlich flexiblere Möglichkeiten diesen Wert individuell pro Projekt anzupassen (FxCop, NDepend). Auch ReSharper hat ein Plug-In „Cyclomatic complexity“, das den CC-Wert für einzelne Methoden laufend in der IDE berechnet und zu komplexe Methoden markiert.

Options Cyclomatic ComplexityTooltip Cyclomatic Complexity

Vereinfachungsmöglichkeit

Es gibt zahlreiche Strukturverbesserungen und Entwurfsmuster, mit denen man zyklomatische Komplexitäten im Quellcode reduzieren kann.

Methode extrahieren (Extract Method)

Komplexe Algorithmen oder Ausdrücke kann man in einer privaten Methode verstecken, deren Namen den Zweck kennzeichnet. Das hilft zwar die Übersichtlichkeit des bestehenden Codes zu erhöhen, aber der tatsächliche CC-Wert und dementsprechend die Anzahl der Testfälle bleiben unverändert.

Im folgenden Beispiel enthält die Process-Methode eine Verzweigung mit einer komplexen Bedingung und zwei Codeabschnitten. Insgesamt müssen drei Testfälle geprüft werden

  • id ist nicht null UND ids enthält -1
  • id ist nicht null UND ids enthält -1 nicht
  • id ist null
public class LogicService
{
	public void Process(int[] ids)
	{
		if (ids != null && !ids.Contains(-1))
		{
			// logic 1
		}
		else
		{
			// logic 2
		}
	}
}

Anhand des Refakturierungsschrittes wird die Bedingung in eine Methode ausgelagert. 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 validen Wert zurückgeben kann, gibt es oft zwei Lösungen:

  • eine komplexe Struktur zurückgeben (z.B. Struktur mit einem zusätzlichen Fehlerfeld),
  • nicht benutze Wertebereiche zurückgeben (z.B. negative Anzahl, Summe oder NULL).

Beide Lösungen führen zu höheren CC-Werten der aufrufenden Klasse, da der Rückgabewert dort analysiert wird. Nach dem genannten Entwurfsmuster muss man bei invaliden Werten eine Ausnahme auslösen. Das hilft den CC-Wert des Aufrufers gering zu halten.

Im folgenden 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-Entwurfsmusters 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 Verzweigung mit komplexerer Bedingung und zwei komplexeren Codeabschnitten enthält, kann man diese Bedingung ggf. mit deren Abhängigkeiten in eine Fabrik-Methode-Klasse auslagern. Diese Klasse gibt Objekte desselben Interfaces zurück, die jeweils die Anweisungen aus dem einen oder anderen ursprünglichen Codeabschnitt enthält. Damit kann zyklomatische Komplexität einzelner Methoden erheblich reduziert werden.

Um diese Vorgehensweise zu zeigen, refaktorieren wir folgendes einfacheres Beispiel:

public class LogicService
{
	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 eins reduziert. Der Preis dafür ist eine erheblich höhere strukturelle Komplexität: drei neue Klassen (LogicFactory, Logic1, Logic2) und zwei neue Schnittstellen (ILogicFactory, ILogic).

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
	}
}

Deswegen ist es bei diesem Entwurfsprinzip wichtig, dass die neue Struktur existierende Objekte abbildet, damit man die fachliche Bedeutung der einzelnen Objekte besser verstehen kann.

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

Wenn eine Methode einer Klasse nur unter bestimmten Bedingungen innerhalb eines Bereichs aufgerufen wird, kann man diese Klasse notfalls mit einem Null-Objekt ersetzen. Dieses Objekt enthält Dummy-Implementation von Schnittstellen des ursprünglichen Objekts und kann immer benutzt werden, ohne diese Bedingungen zu prüfen.

Im folgenden 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-Entwurfsprinzips kann man am Anfang diese Bedingungen prüfen und notfalls die Variable 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 regelmäßige und automatisierte Kontrolle der zyklomatischen Komplexität hilft dabei den Quellcode übersichtlich zu halten und damit die Anzahl von Unit-Tests, die Wartungskosten und die Risiken zu minimieren. Sobald die Komplexität die festgelegte Grenze übersteigt, müssen entsprechende Maßnahmen vorgenommen werden, um die Komplexität in den normalen Bereich zu bringen. In diesem Blog wurden einige passende Strukturverbesserungsprinzipen und Entwurfsmuster präsentiert. Die umfassende Liste von unterschiedlichen Praktiken kann man im Buch „Refactoring: Improving the Design of Existing Code“ von Martin Fowler und Kent Beck oder auf deren Internet-Seite (http://refactoring.com/catalog) finden.