Objektorientiertes Programmieren mit R: S3-Klassen

Die Open-Source-Software R ist ein großartiges Werkzeug zur Datenanalyse. Zahlreiche statistische Verfahren und Visualisierungen können mit wenigen Codezeilen erstellt werden. Dafür steht eine Vielzahl an Funktionen zur Verfügung.

Funktionales Programmieren und Objektorientiertes Programmieren

Automatisiert man solche Analysen, so bewegt man sich im Bereich des Funktionalen Programmierens. Für Datenanalysen ist das eine gute Wahl. Liegt der Fokus jedoch mehr auf Datenstrukturen als auf Analysen und geht es darum, Tools zu bauen (z. B. Shiny-Apps), so bietet sich das objektoriente Programmieren an. Im Folgenden gebe ich eine Einführung in Objektorientes Programmieren mit R mit S3-Klassen.

Systeme für Objektorientiertes Programmieren in R

R bietet mehrere Systeme für objektorientiertes Programmieren:

  • S3-Klassen: recht einfach zu programmieren, wenig formalisiert; Nachteil: Sicherheit
  • S4-Klassen: stärker formalisiert bzw. „strenger“; höhere Lernschwelle; werden z. B. intensiv auf Bioconductor eingesetzt
  • ReferenceClasses (RC): bauen auf S4 auf und stellen damit recht hohe Anforderungen an den Programmierer
  • R6: baut auf S3-Klassen auf, damit einfacher zu handhaben; steht mit gleichnamigem R-Paket zur Verfügung; werden z. B. in Shiny und dplyr (für Datenbankanbindungen) genutzt.

Daneben gibt es weitere Systeme wie proto (in ggplot2 verwendet; Hadley Wickham nutzt es jedoch nicht mehr), R.oo oder mutatr (nicht mehr auf CRAN).

ReferenceClasses (RC) sind relativ populär in der R Community. Im Buch Advanced R (online frei zugänglich) begründet Hadley Wickham, warum er R6 gegenüber RC bevorzugt. Doch wir wollen uns mit den S3-Klassen beschäftigen, die wohl den einfachsten Zugang zum objektorientierten Programmieren in R bieten.

Anwendungsbeispiel: Schere – Stein – Papier

Schere - Stein - Papier (Rock - Paper - Scissors)
Schere – Stein – Papier – Spiel. Quelle: Wikimedia Commons; Urheber: Jeff Eaton

Die folgenden Ideen wurden inspiriert von Garrett Grolemunds Hands on Programming with R.

Unser Anwendungsbeispiel ist das bekannte Spiel Schere – Stein – Papier für zwei Spieler, das wir anstelle von Garretts Spielautomat (slot machine) einsetzen. Die Spielregeln sind einfach: Stein schlägt Schere (Schere wird stumpf), Schere schlägt Papier (Papier wird zerschnitten), Papier schlägt Stein (Stein wird eingewickelt). Entscheiden sich beide Spieler für das gleiche Symbol, endet die Runde unentschieden.

Unser Ziel ist folgendes: R soll für uns spielen, wir wollen mit einem einfachen Funktionsaufruf über das Spiel und den Gewinner informiert werden. Das soll so aussehen:

> ssp()
Spiel: Schere-Stein
Gewinner: Spieler 2

In R werten wir das Spiel mit einer Nachschlage-Tabelle (Lookup Table) folgendermaßen aus: 1 = Spieler 1 gewinnt; 2 = Spieler 2 gewinnt; 0 = unentschieden.

Bewertung <- c(
"Schere-Stein" = 2, "Schere-Papier" = 1, "Schere-Schere" = 0,
"Stein-Schere" = 1, "Stein-Stein" = 0, "Stein-Papier" = 2,
"Papier-Schere" = 2, "Papier-Stein" = 1, "Papier-Papier" = 0)

Die Optionen hinterlegen wir ebenfalls in einem Vektor:

Optionen <- c("Schere", "Stein", "Papier")

Die Spielfunktion: R spielt Schere – Stein – Papier

Das Spielen soll natürlich R für uns übernehmen. Dazu schreiben wir folgende Funktion ssp (Schere-Stein-Papier):

ssp <- function() {  
  Spieler1 <- sample(Optionen, size = 1)
  Spieler2 <- sample(Optionen, size = 1)

  # Wer hat gewonnen?
  Spiel <- paste0(Spieler1, "-", Spieler2)
  Ergebnis <- unname(Bewertung[Spiel])
  Ergebnis <- ifelse(Ergebnis == 0, "unentschieden", paste("Gewinner: Spieler", Ergebnis))
  Ergebnis <- c(paste("Spiel:", Spiel), Ergebnis)
  return(Ergebnis)
}

Die Funktion unname(Bewertung[Spiel]) mag verwirrend aussehen. Sie greift auf den oben dargestellten Vektor Bewertung zu, der die möglichen Spielergebnisse enthält. Mit unname wandelt R den Textvektor (z. B. „Schere-Stein“) in den oben hinterlegten Zahlencode um (z. B. 2).

Nun können wir spielen. Dazu genügt der Aufruf unserer ssp-Funktion – natürlich mit den leeren runden Klammern, um die Funktion als solche zu kennzeichnen. Wie teilt R uns das Ergebnis des Spiels mit?

(Hinweis: Wer das nachspielt, kann selbstverständlich andere Ergebnisse erhalten, da die Entscheidung der Spieler für Schere, Stein oder Papier auf einer Zufallsfunktion beruht: siehe sample(Optionen).)

ssp()
[1] "Spiel: Papier-Schere" "Gewinner: Spieler 2"

ssp()
[1] "Spiel: Papier-Papier" "unentschieden"

ssp()
[1] "Spiel: Papier-Stein" "Gewinner: Spieler 1"

So weit, so gut – das funktioniert. Wir geben uns aber damit nicht zufrieden: Das Ergebnis sieht nicht so aus wie oben gewünscht. Wir erhalten einen Ergebnisvektor mit der R-typischen Kennzeichnung [1]; die Angaben über Spiel und Gewinner stehen in Anführungszeichen hinter einander in einer Zeile. All das wollen wir nicht: Wir wollen die [1] nicht sehen, keine Anführungszeichen, und der Gewinner soll in einer neuen Zeile mitgeteilt werden. Wie erreichen wir das?

Exkurs: R-Objekte können Attribute haben

Wir nähern uns dem Ziel, indem wir Attribute einführen. Dazu spielen wir nicht direkt interaktiv mit ssp(), sondern speichern das Spiel in einem Objekt spiel.

spiel <- ssp()
spiel
[1] "Spiel: Stein-Stein" "unentschieden"
attributes(spiel)
NULL

Das Objekt spiel verfügt erst mal über keine Attribute (NULL). Das können wir einfach ändern. Hier sehen wir, wie einfach und gleichzeitig gefährlich es ist, mit S3-Klassen zu arbeiten: Wir können ad hoc beliebige Attribute „erfinden“ und zuweisen.

attr(spiel, "Symbole") <- c("Papier", "Stein")
attr(spiel, "Symbole")
[1] "Papier" "Stein"
attributes(spiel)
$Symbole
[1] "Papier" "Stein"

Mit attr() können wir ein Attribut definieren und abfragen. Mit attributes() können wir alle Attribute abfragen. Ergebnis: Eine Liste.

Nun bauen wir die Attribute Symbole und Ergebnis in unsere Spielfunktion ein und nennen sie ssp2().

ssp2 <- function() {
Spieler1 <- sample(Optionen, size = 1)
Spieler2 <- sample(Optionen, size = 1)
Spiel <- paste0(Spieler1, "-", Spieler2)
Ergebnis2 <- unname(Bewertung[Spiel])
Ergebnis2 <- ifelse(Ergebnis2 == 0, "unentschieden", paste("Gewinner: Spieler", Ergebnis2))

Ergebnis <- c(paste("Spiel:", Spiel), Ergebnis2)
attr(Ergebnis, "Symbole") <- c(Spieler1, Spieler2)
attr(Ergebnis, "Ergebnis") <- Ergebnis2

return(Ergebnis)
}

spiel <- ssp2()

Geben wir spiel aus, erhalten wir folgendes Ergebnis:

spiel
[1] "Spiel: Papier-Papier" "unentschieden"
attr(,"Symbole")
[1] "Papier" "Papier"
attr(,"Ergebnis")
[1] "unentschieden"

Abfrage der Attribute:

attributes(spiel)
$Symbole
[1] "Papier" "Papier"
$Ergebnis
[1] "unentschieden"

Die Attribute sind da, aber die Ausgabe ist weiterhin unbefriedigend. Für R-Programmierer mag das erträglich sein, aber es sollen auch reine Anwender spielen können, ohne sich mit der Dollar-Schreibweise der Attribute herumzuschlagen.

Kleine Ergänzung: Wir können die Zuweisung der Attribute auch in einem Schritt durchführen mit der structure-Funktion anstelle der beiden attr()-Aufrufe oben:

structure(Ergebnis, Symbole = c(Spieler1, Spieler2),
Ergebnis = Ergebnis2)

Doch wie kommen wir zu einer schöneren Ausgabe, wie wir sie oben als Ziel definiert haben?

Eine benutzerdefinierte Print-Funktion

Die Antwort lautet: Wir schreiben unsere eigene Print-Funktion – ich nenne sie hier schere_print(). Sie soll auf die Attribute (Symbole, Ergebnis) zugreifen und sie zu der gewünschten Ausgabe verbinden. Wir nutzen die cat-Funktion, deren Ergebnis ohne Anführungszeichen in der Konsole erscheint.

schere_print <- function(spiel) {
# Symbole extrahieren
Symbole <- attr(spiel, "Symbole")

# Symbole in einem String verbinden (statt zwei separate)
Symbole <- paste(Symbole, collapse = "-")

# "Spiel" voranstellen
Symbole <- paste("Spiel:", Symbole)

# Ergebnis in neuer Zeile ergänzen
Ergebnis <- attr(spiel, "Ergebnis")
Spiel <- paste(Symbole, Ergebnis, sep = "\n")
cat(Spiel)
}

Wie sieht das in der Praxis aus?

schere_print(spiel)
Spiel: Papier-Papier
unentschieden

Bingo! So soll es sein. Doch noch gibt es einen Wermutstropfen: Diese Ausgabe erhalten wir, indem wir das spiel an unsere eigene Print-Funktion schere_print() übergeben. Spielen wir direkt mit ssp2() oder print(ssp2()), so erhalten wir weiterhin die Ausgabe von oben mit der unschönen Angabe der Attribute. Wie können wir unsere Print-Funktion direkt mit dem Spielaufruf verbinden?

Exkurs: Print ist eine generische Funktion

Was ist mit „generischer Funktion“ gemeint? Die meisten R-Anwender dürften bereits Erfahrungen mit unterschiedlichen Print-Ausgaben gemacht haben. Beispielsweise wird eine lineare Regression anders in der Konsole ausgegeben als eine Zahl. Hier ein einfaches Beispiel, das zeigt, dass die Ausgabe von der Klasse des Objekts abhängt, das an die Print-Funktion übergeben wird:

num <- 1000000000
print(num)
[1] 1e+09
class(num) <- c("POSIXct", "POSIXt")
print(num)
[1] "2001-09-09 03:46:40 CEST"

Die Zahl wird plötzlich als Datum ausgegeben, wenn wir ihr die entsprechende Klasse zuweisen. Schauen wir uns die Print-Funktion an:

print
function (x, …)
UseMethod("print")
...

UseMethod verweist auf die Verzweigung in Abhängigkeit von der Objektklasse. Unter-Funktionen werden mit Punkt gekennzeichnet, zum Beispiel print.POSIXct. methods(print) zeigt eine lange Liste verfügbarer Print-Funktionen.

Die benutzerdefinierte Print-Funktion mit einer Klasse verbinden

Was müssen wir also tun? So wie eine Zahl oder ein Datum passend zu ihrer Objektklasse ausgegeben werden, müssen wir also ebenfalls eine Objektklasse für unser Spiel festlegen und unsere Printmethode damit verknüpfen. Die Objektklasse nennen wir „ssp“ und die Printmethode entsprechend print.ssp():

print.ssp <- function(x, …) {
cat("Ich greife auf print.ssp zu")
}
class(spiel) <- "ssp"
print(spiel)
Ich greife auf print.ssp zu

Das funktioniert! Wir können statt der Testausgabe unsere Print-Funktion verwenden …

print.ssp <- function(x, …) {
schere_print(x)
}

… die Klasse in unserer Spiel-Funktion ergänzen …

ssp3 <- function() {
Spieler1 <- sample(Optionen, size = 1)
Spieler2 <- sample(Optionen, size = 1)
Spiel <- paste0(Spieler1, "-", Spieler2)
Ergebnis2 <- unname(Bewertung[Spiel])
Ergebnis2 <- ifelse(Ergebnis2 == 0, "unentschieden", paste("Gewinner: Spieler", Ergebnis2))
Ergebnis <- c(paste("Spiel:", Spiel), Ergebnis2)
structure(Ergebnis, Symbole = c(Spieler1, Spieler2),
Ergebnis = Ergebnis2,
class = "ssp")
}

… und spielen:

spiel <- ssp3()
spiel
Spiel: Papier-Stein
Gewinner: Spieler 1

Bingo! Es geht auch noch kürzer, direkt mit ssp3().

Dieses Thema Objektorientiertes Programmieren in R mit S3-Klassen ist Teil des Kurses R-Programmierung für Fortgeschrittene, der in Dresden und überregional angeboten wird (siehe Seitenspalte oben) und mit individuell abgestimmten Modulen auch als in-house-Schulung gebucht werden kann (zum Kontaktformular).

Lesestoff zur Vertiefung:

Hadley Wickhams Advanced R ist online frei zugänglich.

Ein Gedanke zu „Objektorientiertes Programmieren mit R: S3-Klassen“

  1. mir gefallen die Hinweise „benutzerdefinierte Print-Funktion mit einer Klasse verbinden“. Als OOP-Beispiel wünschte ich mir das Umzugsbeispiel: Objekt in eine Schachtel tun (Schachtelattribut Objekt) -> Schachtel in Umzugskarton legen (Kartonattribut mit Schachtelattribut) -> Umzugskarton auf eine Palette legen (Palettenattribut mit Kartonattribut). Die Kartons werden von den Umzugshelfern zufällig auf Paletten verteilt und die Paletten zufällig in 4 Kleintransporter (LKW mit Palettenattribut) verladen. Nach dem Ausräumen wird klar, der Wohnungsschlüssel wurde von einem Umzugshelfer als Objekt in eine Schachtel gelegt. Zeige das Fahrzeug, die Palette, den Karton und die Schachtel, wo der Schlüssel steckt.

Freue mich über Kommentare!