Wir Entwickler schreiben jeden Tag Funktionen und Methoden. In diesem Beitrag sprechen wir deswegen über Clean-Code-Grundlagen, gutes Funktionsdesign. Was unterscheidet gute von schlechten Funktionen?

Wenn wir in der Softwareentwicklung Funktionen entwerfen, gibt es drei zentrale Gestaltungspunkte, die den Unterschied zwischen guten und schlechten Funktionen ausmachen. In diesem Beitrag zeige ich euch, worauf es beim Funktionsdesign ankommt und wie ihr typische Fallstricke vermeidet. Ganz am Ende gibt es auch noch einen kleinen und einfachen Trick um die Lesbarkeit und Verständlichkeit deutlich zu verbessern.

Beim Funktionsdesign unterscheiden wir zwischen drei primären Gestaltungspunkten:

  • den Parameter die an eine Funktion übergeben werden
  • der Verarbeitung der Funktion
  • dem Rückgabewert

Parameteranzahl

Der erste Gestaltungspunkt sind die Parameter. Wichtige Fragen sind: welche Parameter werden übergeben, und wie viele?

Robert Martin unterscheidet in seinem Buch Clean Code zwischen drei Arten von Funktionen: monadischen, dyadischen und triadischen Funktionen. Martin bezieht sich hier auf die Anzahl der Parameter die an eine Funktion übergeben werden.

Monadische Funktionen

Monadische Funktionen sind Funktionen, die nur einen Parameter entgegennehmen. Diese Art von Funktion ist intuitiv sehr einfach zu verstehen. Tendenziell streben wir also eher monadische Funktionen - oder besser gesagt Funktionen mit eher weniger als vielen Parametern an.

Wenn Sie Code schreiben, werden Sie monadische Funktionen quasi automatisch in einem der folgenden Fälle verwenden:

  1. Sie stellen eine Frage über ein Argument.
  2. Sie wandeln ein Argument in ein anderes um.

Gute monadische Funktionen

Beispiele hierfür sind:

  1. Frage
const isLoggedIn = isLoggedIn(user);
  1. Umwandlung
const upperText = toUpper("lowerText");

Die hier gezeigten Beispiele sind gleichzeitig Beispiele für gute monadische Funktionen. Es gibt aber auch Grenzfälle.

Grenzfälle

Die folgende Funktion ist schon nicht mehr so schön:

enableFeature(FEATURE.REPORTING);

Zwar handelt es sich um eine monadische Funktion, allerdings besitzt diese keinen Rückgabewert. Es muss also irgendetwas spezielles in der Funktion "passieren". Von außen, also als Verwender, ist dieses "passieren" nicht so einfach zu erkennen. Es hilft nur eins: Sie müssen in die Funktion "reinspringen" und nachsehen, was genau gemacht wird. Dieses Springen, von Funktion zu Funktion, ist ein typisches Zeichen dafür, dass der Abstraktionsgedanke des "Moduls Funktion" nicht verständlich genug ist.

Merke: besser sind Funktionen mit einem klaren Rückgabewert. Allerdings handelt es sich bei uns um einen Grenzfall, denn in unserem Szenario werden andere Systemteile per Event-Mechanismus über das aktivierte Feature benachrichtigt. In so einem Event Fall, können Sie keinen sinnvollen Rückgabewert definieren.

Schlechte monadische Funktionen

Eher nicht empfehlenswert sind monadische Funktionen wie zum Beispiel:

mergeDefaultConfig(config);

Was passiert hier wirklich? Wenn die Funktion keinen Rückgabewert liefert (wonach es aussieht), dann verändert sie die "config". Dieses Verhalten kann schnell übersehen werden und dann zu unerwarteten Effekten im Programmablauf führen. Besser und klarer ist es immer nach dem EVA-Prinzip zu arbeiten, also mit Eingabe (Parameter), Verarbeitung, Ausgabe (Rückgabewert).

Dyadische Funktionen

Funktionen mit zwei Parametern sind gang und Gäbe in der Entwicklung. Die Parameter sollten dabei in einem inhaltlichen Bezug zueinander stehen.

Gute dyadische Funktionen

const sum = add(1,2);
const origin = new Point(0,0);

Grenzfälle

Es mag sich banal anhören, aber bedingt durch die zwei Parameter, wird die Verständlichkeit sofort deutlisch schwerer - besonders wenn der Namen der Funktion nicht wirklich optimal ist. Wer gewinnt die Konfliktauflösung beispielsweise im folgenden Fall? Die localVersion oder die remoteVersion?

const version = resolveConflictBetween(localVersion, remoteVersion);

Schlechte dyadische Funktionen

moveTo(origin.xCoordinate, origin.yCoordinate);

Bessere dyadische Funktionen

Es gibt einfache Mechanismen die Sie anwenden können um schlechte oder schwer zu verstehende (Grenzfälle) dyadische Funktionen direkt zu verbessern:

const version = resolveConflictAndPreferLocalVersion(localVersion, remoteVersion); // klares Naming

moveTo(origin); // zwei Parameter mittels eines Datenobjekts vereinfachen

Das Beispiel mit moveTo ist besonders ärgerlich, denn augenscheinlich gab es bereits eine Point Datenstruktur, aber diese wurde einfach nicht verwendet.

Merke: reduziere die kognitive Last für den Verwender durch gutes Naming oder durch das Zusammenführen von vielen zu wenigeren Parametern.

Triadische Funktionen und mehr

Eine berechtigte Frage ist nun, wieviele Parameter wir noch tolerieren wollen? Prinzipiell ist es immer schwierig eine pauschale Aussage zu treffen, aber der Beratersatz "es kommt darauf an" hilft natürlich auch niemandem weiter. Ich würde für mich die Grenze daher bei 3-4 Parametern ziehen.

Funktionen mit mehr Parametern werden schnell sehr unintuitiv, müssten wahrscheinlich aufgeteilt werden, und sind wenn, dann nur in begrenztem lokalen Scope zulässig. Zum Beispiel als private Methoden innerhalb einer Klasse.

Bei wichtigen öffentlichen Methoden, oder Komponenten Kontrakten, sollten Sie klare Datenobjekte (im Zweifelsfalle auch Command oder Query Objekte) einführen, um die Lesbarkeit zu gewährleisten und das Verständnis durch den Namen des Datentyps zu verbessern.

Dies ist auch das allgemeine Pattern, das Sie immer anwenden können um zwischen den verschiedenen Funktionstypen zu "wechseln".

Anzahl Rückgabewerte

Mit dem Rückgabewert verhält es sich so ähnlich wie mit den Parametern. Ein klarer Rückgabewert ist besser als die Rückgabe von mehreren Werten mittels z.B. eines generischen Datencontainers (Tuple / Pair, Tripple, Quadrupel, ...). Anstelle eines generischen Datencontainers könnte hier auch Array, anonymes Objekt o.ä. stehen.

Wenn bei einem Rückgabewert stattdessen ein klarer Datentyp verwendet wird sorgt dies für besseres Verständnis bei den Verwendern unserer Methode. Und denken Sie daran: Code wird viel öfter gelesen und verwendet, als geschrieben (nur 1-mal).

Allerdings haben generische Datencontainer teilweise trotzdem ihre Berechtigung. Tendenziell würde ich aber versuchen Ihre Verbreitung im System zu reduzieren. Denn wenn der Context größer wird, wird es immer schwerer zu verstehen, für was der Tuple denn genau steht - immerhin trägt er keinen klaren Namen und durchläuft eine Art "Flüsterpost" wenn er von Variable zu Variable zugewiesen wird und dabei das Naming Stück für Stück degradiert...

Parameter- und Rückgabetyp

Neben der Parameteranzahl ist der Typ des Parameters bzw. des Rückgabewerts selbst ein interessantes Modellierungskriterium.

  • Parameter: den Parametertyp unserer Funktion/Methode möchten wir dabei so allgemein, also so unspezifisch wie möglich halten. Wenn wir beispielsweise die Preise einer Auflistung von Tellern berechnen wollen, bietet es sich an für den Parametertyp die kleinste gemeinsame Basisklasse des Auflistungstyps zu verwenden:
public double AddPrices(IEnumerable<Dish> dishes>) {
// ...
}

Der Vorteil für den Verwender ist jetzt, dass er im Aufruf der AddPrices Funktion sehr flexibel ist. Er kann übergeben: Array, List, Set, ... - solange die Auflistung die er verwendet eben vom Typ IEnumerable ist.

  • Rückgabewert: den Rückgabewert sollten wir so spezifisch wie nötig halten. Beispiel:
OpenCsvFile("myFile.csv"): CsvFile // CsvFile extends File, Rückgabewert aber CsvFile

Erstens möchte der Verwender klar eine Csv Datei öffnen und zweitens würde er wenn wir File zurückgeben nicht an CsvFile spezifische Informationen (wie z.B. das Trennzeichen) kommen können.

Seiteneffekte

Von einem Seiteneffekt spricht man dann, wenn die Funktion unerwartete, quasi versteckte Effekte ausführt. Die Funktion lügt uns sozusagen an. Die folgende Funktion suggeriert durch ihren Namen lediglich das Aktualisieren der EMail Adresse, tatsächlich wird aber auch noch eine E-Mail Benachrichtigung verschickt.

function updateUser(userId: string, newEmail: string): void {
  const user = userRepository.getUser(userId);
  if (!user) return;

  const oldEmail = user.email;
  user.email = newEmail;
  userRepository.save(user);

  try {
    // Seiteneffekt: externer Call
    notificationService.sendEmailChangeNotification(oldEmail, newEmail);
  } catch (err) {
    // ...
  }
}

Besser wäre es gewesen das Verschicken der Notification raus zu ziehen und updateUser gefolgt von sendEMailChangeNotification in einem "UpdateUserEMailUseCase" aufzurufen.

Fazit

Wir Entwickler schreiben jeden Tag Funktionen. Das Wissen um gutes Funktionsdesign sollte daher zum Standardrepertoire eines jeden Entwicklers gehören. Gute Funktionen führen dazu, dass Sie Ihren Quellcode nicht "lesen und interpretieren" müssen, sondern dass Sie Ihr Programm "lesen und verstehen" können.

Wir haben in der Vergangenheit bereits tausende Entwicklerteams in genau diesen Bereichen geschult. Lassen Sie uns gerne sprechen, wir freuen uns jederzeit über Ihren Anruf.