Einleitung

Softwareentwicklung ist ein wichtiger Teil unserer Arbeit als Data Scientists. Egal ob es um Data Science, Operations Research oder Spezialprojekte geht, selten bleibt es bei Prototypen und Einmalanalysen. Wir entwickeln Lösungen, die dauerhaft einen Mehrwert bringen.

Daher braucht es klare Prinzipien und Maßnahmen, um die Qualität unserer Arbeit sicherzustellen. Und da wir viele Projekte in R umsetzen, ist es wichtig zu wissen, welche Möglichkeiten zur Unterstützung und Vereinfachung angeboten werden.

In dieser Artikelserie geht es nicht darum, das Thema ‘Softwareentwicklung (in R)’ abschließend zu behandeln. Wir möchten aber einen Überblick darüber geben, welche Vorgehensweisen und Tools wir einsetzen, um unsere Arbeit zu erleichtern.

In diesem ersten Teil beschäftigen wir uns mit grundlegenden Prinzipien. In weiteren Teilen geht es dann um fortgeschrittene Themen wie eigene Pakete, Versionierung und plattformübergreifende Entwicklung sowie auch einige kritische Betrachtungen zum Thema Qualitätssicherung.

Qualitätssicherung durch eleganten Code

Was bedeutet eigentlich guter Code? Er löst das Problem (des Kunden), ist verständlich (für Kollegen) und effizient (aus Entwicklersicht).

Gerade wenn die Zeit (vermeintlich) knapp ist, verlässt man sich darauf, dass “alles schon so passt” und die Arbeit beendet ist, sobald die Lösung durchläuft. Das geht solange gut, bis Änderungen vorgenommen werden müssen, am besten noch von einem Kollegen, der bisher nicht beteiligt war.

Wie wird also “eleganter” Code erreicht?

Ein Ansatz dafür ist die Annahme, dass nie für einen selbst entwickelt wird. Sondern stets für den Kunden, die Kollegen und das zukünftige Ich. Wer schon einmal älteren (selbstgeschriebenen!) Code angesehen hat, den man fertigstellen wollte, “wenn mal Zeit ist”, weiß, wie furchtbar das ist.

Programmiert man jedoch von Anfang an für andere, spricht der Code anstelle des Entwicklers und erklärt den Lösungsweg. Die Lösung hat einen eleganten Fluss und ist leichter zu warten.

Was bedeutet das in der Praxis?

Ein simples Beispiel

In einem Kundenprojekt benötigen wir eine Funktion, die zwei Zahlen durcheinander teilt. Da in diesem Beispiel keine Implementierung vorhanden ist, schreiben wir sie selbst:

f<-function(x,y) x/y

Was kann bei einer so simplen Aufgabe jetzt noch schiefgehen? Einiges. Vieles. Alles.

Punkt 1: Die Dokumentation (RStudio Snippets)

Was genau erwarte ich bei dieser Funktion bei In- und Output? Gibt es Einschränkungen an x oder y? Wie sieht das Ergebnis aus?

Hier fallen einem schon zahlreiche Möglichkeiten ein, für unerwartetes Verhalten zu sorgen. Wie gehen wir mit vektorwertigem Input um? Darf x aus mehreren Elementen bestehen? y auch? Wenn ja, was passiert bei verschiedenen Längen?

Darf der Output den Wert Unendlich annehmen? Sollte stattdessen eine Warnung erfolgen? Ein Programmabbruch?

Hier geht es nicht zwangsläufig um ein Richtig oder Falsch, sondern vielmehr um eine klare Festlegung der Schnittstelle und des Verhaltens.

Wir legen also fest, dass x und y jeweils aus einem (numerischen) Wert bestehen und der Output auch “Inf” enthält. Sollte durch 0 geteilt werden, soll eine Warnung, jedoch kein Abbruch erfolgen. Das Ergebnis kann also der Wert “NaN” sein, wenn (0/0) berechnet wird. Unzulässige Werte sollen zu einem Abbruch führen.

#' @title Division zweier Zahlen
#'
#' @description Teilt zwei Zahlen durcheinander.
#'
#' @details Akzeptiert nur einzelne Zahlen, keine Vektoren. 
#' Der Output kann "Inf" enthalten, wenn durch 0 geteilt wird.
#' 0 umfasst auch Werte kleiner als die Maschinengenauigkeit.
#'
#' @param x Der Divisor (Zahl)
#' @param y Der Dividend (Zahl)
#' @return Der (numerische) Wert von x/y

f<-function(x,y) x/y

Wie erleichtert R diesen Punkt?

In der populärsten Entwicklungsumgebung RStudio können Snippets definiert werden, die mir ein Template für die Dokumentation liefern:

snippet cddsComment
#' @title Der erste Absatz wird die Überschrift.
#'
#' @description Der zweite liefert die Kurzbeschreibung.
#'
#' @details Dieser Absatz enthält eine ausführliche Beschreibung.
#'
#' @param parameter1 Was dieser Parameter tut
#' @param parameter2 Was dieser zweite Parameter tut
#' @return Welche Werte zurückgegeben werden.

Diese Art Dokumentation ist der Roxygen-Stil, der vor allem bei selbstgebauten Paketen eine übersichtliche Funktionsbeschreibung erzeugt. Auch ohne Paket ist diese Art von Code-Kommentierung allerdings sehr gut lesbar und sorgt für Struktur.

Jetzt ist das gewünschte Verhalten klar dokumentiert. Es ist aber immer noch nicht geprüft und gesichert.

Punkt 2: Die Validierung (assertions)

Bisher erfährt der Benutzer keine “Sanktionen”, wenn er dennoch falsche Eingaben tätigt. Ohne die Dokumentation weiß er auch nicht, wenn er “falsch” handelt. Daher müssen Vorabprüfungen vorhanden sein, die genau dies tun.

Aus der oben definierten Schnittstelle ergibt sich, dass x und y aus jeweils einem numerischen Wert bestehen.

#' @title Division zweier Zahlen
#'
#' @description Teilt zwei Zahlen durcheinander.
#'
#' @detais Akzeptiert nur einzelne Zahlen, keine Vektoren. 
#' Der Output kann "Inf" enthalten, wenn durch 0 geteilt wird.
#' 0 umfasst auch Werte kleiner als die Maschinengenauigkeit.
#'
#' @param x Der Divisor (Zahl)
#' @param y Der Dividend (Zahl)
#' @return Der (numerische) Wert von x/y

f<-function(x,y){
  if(!is.numeric(x)||!is.numeric(y)){
    stop("x und y müssen numerisch sein!")
  }  
  if(length(x)>1||length(y)>1){
    stop("x und y müssen aus einem Wert bestehen!")
  }
  if(is.na(x)||is.na(y)){
    stop("x und y dürfen nicht NA sein!")
  }
  
  x/y}

Hier kann man mit Assertions arbeiten, wir haben aber festgestellt, dass eigene Fehlermeldungen für mehr Klarheit sorgen. Hierbei ist es wichtig, dass Fehlermeldungen möglichst präzise sind und mehr enthalten, als nur die Mitteilung, dass etwas nicht funktioniert hat. In unserem Beispiel wäre es sicher hilfreich, wenn angegeben würde, welcher Parameter einen fehlerhaften Wert hat. Eine andere Erweiterung wäre z.B. auch durch den Gebrauch von abort aus dem rlang-Paket möglich, womit wir auch Fehlerklassen definieren können. Da das Thema Inputvalidierung sehr umfangreich ist, wollen wir es erst einmal hierbei belassen.

Punkt 3: Codestil (styler / lintr)

Unser Beispiel ist derzeit noch einigermaßen lesbar und verständlich - bei größeren Funktionen sieht das allerdings schnell anders aus.

Um dies zu vermeiden, nutzen wir einen einheitlichen Codestil (maximiert die Lesbarkeit und den Wiedererkennungswert) und beschränken Funktionen auf eine spezifische Aufgabe (sorgt für kleine Funktionen).

Zum Codestil gehören unter anderem einheitliche, verständliche Benennung, Einrückungen und Zeilenumbrüche. Dadurch wird der Code deutlich entzerrt:

#' @title Division zweier Zahlen
#'
#' @description Teilt zwei Zahlen durcheinander.
#'
#' @detais Akzeptiert nur einzelne Zahlen, keine Vektoren.
#' Der Output kann "Inf" enthalten, wenn durch 0 geteilt wird.
#' 0 umfasst auch Werte kleiner als die Maschinengenauigkeit.
#'
#' @param zaehler Der Zähler (Zahl)
#' @param nenner Der Nenner (Zahl)
#' @return Der (numerische) Wert von x/y

division <- function(zaehler, nenner) {
  if (!is.numeric(zaehler) || !is.numeric(nenner)) {
    stop("Zähler und Nenner müssen numerisch sein!")
  }
  if (length(zaehler) > 1 || length(nenner) > 1) {
    stop("Zähler und Nenner müssen aus einem Wert bestehen!")
  }
  if (is.na(zaehler) || is.na(nenner)) {
    stop("Zähler und Nenner dürfen nicht NA sein!")
  }
  result <- zaehler/nenner
  return(result)
}

Außerdem ist es ersichtlich, dass es den Lesefluss erleichtern würde, wenn unsere Vorabprüfungen ausgelagert würden:

#' @title Division zweier Zahlen
#'
#' @description Teilt zwei Zahlen durcheinander.
#'
#' @detais Akzeptiert nur einzelne Zahlen, keine Vektoren.
#' Der Output kann "Inf" enthalten, wenn durch 0 geteilt wird.
#' 0 umfasst auch Werte kleiner als die Maschinengenauigkeit.
#'
#' @param zaehler Der Zähler (Zahl)
#' @param nenner Der Nenner (Zahl)
#' @return Der (numerische) Wert von x/y

division <- function(zaehler, nenner) {
  checkZaehler(zaehler)
  checkNenner(Nenner)
  result <- Zähler/nenner
  return(result)
}

In diesem einfachen Beispiel könnte sogar dieselbe Check-Funktion benutzt werden. Generell gilt dies natürlich nicht.

Die automatisierte Prüfung des Codestils wird durch die Pakete styler und lintr ermöglicht.

Ein Hinweis: Für den Codestil gibt es kein Richtig und Falsch. Es ist wichtig, sich auf einen Standard festzulegen. Die beiden bekanntesten Codestile finden sich im Google R Style Guide und dem tidyverse style guide.

Punkt 4: Das Testen (testthat)

Woher wissen wir jetzt, dass die Division korrekt arbeitet? Gibt es möglicherweise Ausnahmen und Randfälle, die wir übersehen haben?

Um das herauszufinden, ist die leichteste Möglichkeit, Testfälle zu schreiben und damit das korrekte Verhalten zu prüfen. Hierzu unterscheidet man verschiedene Testklassen:

  1. Happy Path (Arbeitet die Funktion für korrekte Eingaben richtig?)
  2. Negative Path (Bricht die Funktion korrekt ab?)
  3. Randfälle (Was passiert genau an der Grenze?)

Hier geht es nicht darum, möglichst viele Testfälle zu schreiben. Gerade im Positivfall (Happy Path) gibt es unendlich viele Möglichkeiten.

Wichtig ist es eher, dass jedes Abbruchkriterium tatsächlich einmal geprüft wird.

In R lässt sich das leicht mit dem Paket testthat bewerkstelligen, das einem erlaubt, viele verschiedene Testfälle umfassend zu prüfen.

In unserem Beispiel könnten diese Testfälle sein:

library(testthat)
test_that("Division funktioniert wie erwartet", code = {
  # positive Fälle
  expect_equal(division(0, 1), 0)
  expect_equal(division(1, 1), 1)
  expect_equal(division(3, 2), 1.5)
  expect_equal(division(4, 2), 2)
  expect_equal(division(-4, 2), -2)
  expect_equal(division(1, 0), Inf)
  expect_equal(division(0, 0), NaN)
})

test_that("Division bricht korrekt ab", code = {
  # negative Fälle: falscher Typ
  expect_error(division("ein string", 1), "Zähler und Nenner müssen numerisch sein!")
  expect_error(division(1, "ein string"), "Zähler und Nenner müssen numerisch sein!")
  expect_error(division("ein string", "ein anderer string"), "Zähler und Nenner müssen numerisch sein!")

  # negative Fälle: Länge
  expect_error(division(c(1, 2), 1), "Zähler und Nenner müssen aus einem Wert bestehen!")
  expect_error(division(1, c(2, 3)), "Zähler und Nenner müssen aus einem Wert bestehen!")
  expect_error(division(c(1, 2), c(3, 4)), "Zähler und Nenner müssen aus einem Wert bestehen!")

  # negative Fälle: NA
  expect_error(division(NA, 1), "Zähler und Nenner dürfen nicht NA sein!")
  expect_error(division(1, NA), "Zähler und Nenner dürfen nicht NA sein!")
  expect_error(division(NA, NA), "Zähler und Nenner dürfen nicht NA sein!")
})

Eine umfassende Einführung in das Testen mit R findet sich in einem Artikel unseres Kollegen Andreas Gruber, der in Kürze erscheinen wird. Weitere Anknüpfungspunkte können beispielsweise die Test Coverage und die Automatisierung von Tests sein.

Zusammenfassung

Wir haben in diesem ersten Teil einige Prinzipien kennengelernt, um von normalem Code zu besserer Software zu gelangen. Eine klare Definition und Dokumentation von Schnittstellen, umfassende Eingabevalidierung, einheitlicher Codestil und sinnvolle Tests sorgen für Übersicht und Stabilität. So schafft man es dann, dass der Kunde zufrieden ist und die Kollegen gerne an dem Projekt mitarbeiten.

Weitere sinnvolle Prinzipien und Vorgehensweisen stellen wir dann in den zukünftigen Teilen dieser Serie vor.


Möchten Sie mehr darüber erfahren, wie wir (nicht nur) mit R robuste Lösungen für unsere Kunden entwickeln? Wir freuen uns auf Ihre Anfrage!