R 4.1.0: Base R Pipe! |>

Base R pipe / magrittr pipe

Am 18.5.2021 wurde R Version 4.1.0 veröffentlicht, und sie brachte (fast) eine Revolution: Einen Pipe Operator, nativ in Base R eingebaut!

Pipe Operator in R seit 2014: magrittr / dplyr

Mit dem magrittr-Paket wurde 2014 der Pipe-Operator %>% in R zur Verfügung gestellt. Er hat sich rasch durchgesetzt und erfreut sich sehr großer Beliebtheit. Viele Anwender nutzen magrittr von Stefan Milton Bache, Hadley Wickham und Lionel Henry nicht direkt, sondern über das bekanntere dplyr-Paket bzw. die Paket-Sammlung tidyverse. Code-Beispiele folgen weiter unten; mehr zur magrittr / dplyr-Pipe findet Ihr auch im Beitrag R-Programmierung: Was ist %>% ? dplyr vs. Base R und in diesem Video:

Meilensteine in R: Ideen aus Erweiterungspaketen in Base R

Base R wird sehr vorsichtig weiterentwickelt, da früherer R-Code möglichst auch weiterhin funktionieren soll (Rückwärts-Kompatibilität). Die Kerngruppe (R Core Group) entscheidet über Änderungen an Base R, der Basis-Installation.

Es geschieht selten, dass Ideen aus Erweiterungspaketen in Base R integriert werden. Ein solcher Meilenstein ist nun die Aufnahme eines Pipe Operators in Base R. Das data.table-Paket, das vor allem für seine enorme Effizienz hinsichtlich Ausführungszeit und Arbeitsspeicher bekannt ist, erreichte 2016 einen vergleichbaren Meilenstein, als der Sortieralgorithmus von forder() in Base R Version 3.3.0 aufgenommen wurde – ein großer Erfolg für die Paket-Autoren Matt Dowle und Arun Srinivasan (die von vielen Beitragenden unterstützt wurden).

Nun also die Base R Pipe!

Base R Pipe: Grundidee und Code-Beispiele

Die Grundidee der Base R Pipe sieht so aus:

f(x)
kann geschrieben werden als
x |> f()

Hier ein Beispiel:

head(mtcars, n = 2)

              mpg cyl disp  hp drat    wt  qsec vs am gear carb
Mazda RX4      21   6  160 110  3.9 2.620 16.46  0  1    4    4
Mazda RX4 Wag  21   6  160 110  3.9 2.875 17.02  0  1    4    4

mtcars |> head(n = 2)

              mpg cyl disp  hp drat    wt  qsec vs am gear carb
Mazda RX4      21   6  160 110  3.9 2.620 16.46  0  1    4    4
Mazda RX4 Wag  21   6  160 110  3.9 2.875 17.02  0  1    4    4

Das sieht nicht nach spektakulärer Verbesserung aus – in welchen Situationen ist die Pipe nützlicher?

Base R Pipe: Geschachtelte Klammerausdrücke ersetzen

Bleiben wir beim berüchtigten mtcars-Datensatz für einfache Nachvollziehbarkeit:

rownames(mtcars)[1:3]

[1] "Mazda RX4"     "Mazda RX4 Wag" "Datsun 710"

Wir können darauf die summary-Funktion anwenden …

summary(rownames(mtcars))

   Length     Class      Mode 
       32 character character

… aber die Ausgabe ist nicht besonders hilfreich. mtcars umfasst 32 Zeilen; dass es sich um eine Textvariable (character) handelt, wusste ich bereits.

Eine etwas hilfreichere Häufigkeitstabelle erhält man, wenn man summary auf den Datentyp factor anwendet. Um die Ausgabe abzukürzen, verbinden wir den Aufruf wieder mit head():

# summary(as.factor(rownames(mtcars)))
head(summary(as.factor(rownames(mtcars))), n = 2)

       AMC Javelin Cadillac Fleetwood 
                 1                  1

Dieser Code mit mehreren geschachtelten Klammerausdrücken ist leider nicht untypisch für Base R – ich wünsche niemandem, Projekte mit bestehendem Code übernehmen zu müssen, der in diesem Stil geschrieben ist. Zu lesen ist der Ausdruck von innen nach außen bzw. nicht gerade intuitiv von rechts nach links. Der Parameter n = 2 gehört zur head()-Funktion und ist weit von dieser abgeschnitten.

Hier hilft die Pipe:

mtcars |>
  rownames() |>
  as.factor() |>
  summary() |>
  head(n = 2)

       AMC Javelin Cadillac Fleetwood 
                 1                  1

Das Ergebnis ist identisch – der Code dürfte jedoch deutlich besser lesbar sein: intuitiv von oben nach unten. Die leeren Klammern können auf den ersten Blick evtl. etwas irritieren – die Pipe fügt dort jeweils das Ergebnis der vorigen Codezeile ein. Der Parameter n = 2 steht nun gut erkennbar bei head().

Base R Pipe: Zwischenergebnisse nicht in Objekten speichern

Eine weitere Eigenart, die man häufig in R-Code findet, besteht darin, Zwischenergebnisse in Objekten zu speichern. Das Beispiel von eben könnte man so schreiben und damit ebenfalls den geschachtelten Klammerausdruck vermeiden:

cars <- rownames(mtcars)
cars <- as.factor(cars)
cars_summary <- summary(cars)
cars_summary_head <- head(cars_summary, n = 2)
cars_summary_head

       AMC Javelin Cadillac Fleetwood 
                 1                  1

rm(cars, cars_summary, cars_summary_head)

Hier kann man, wie ich finde, etwas besser als beim geschachtelten Klammerausdruck Zeile für Zeile nachverfolgen, was geschieht. Allerdings wurden drei Objekte zugewiesen, die möglicherweise im weiteren Verlauf nicht weiter benötigt werden: cars, cars_summary und cars_summary_head. Alternativ hätte ich auch das gleiche Objekt (etwa cars) mehrfach überschreiben können. Jedenfalls entstehen Objekte, die die Arbeitsumgebung (Global Environment) füllen und irgendwann aufgeräumt werden sollten. Bei zeitkritischem Code sind zudem die mehrfachen Speicherzuweisungen ineffektiv.

Diesem Stil können wir die gleiche Pipeline gegenüberstellen wie im vorigen Beispiel:

mtcars |>
  rownames() |>
  as.factor() |>
  summary() |>
  head(n = 2)

       AMC Javelin Cadillac Fleetwood 
                 1                  1

Hier findet gar keine Zuweisung statt, d. h. wir erhalten ein Ergebnis in der Konsole, ohne dass ein Objekt gebildet wird. Entsprechend ist auch nichts in der Arbeitsumgebung aufzuräumen.

Base R Pipe: Wie ist sie intern umgesetzt?

Um zu sehen, wie die Pipe intern umgesetzt ist, ist die quote()-Funktion hilfreich: sie „fängt“ R-Ausdrücke ein, ohne sie auszuwerten (zu berechnen). Was macht sie aus Ausdrücken, die die Pipe enthalten?

quote(mtcars |> head())

# Ergebnis
head(mtcars)

Intern wird mctars |> head() als alternative Schreibweise zu head(mtcars) erkannt. Das ist ein kürzerer Weg zum Ziel als bei der magrittr-Pipe – damit entfällt etwas Overhead: die magrittr Pipe ist intern als Funktionsaufruf umgesetzt und kann, da sie in einem Erweiterungspaket liegt, nicht so direkt ausgewertet werden wie der Base R-Code. Wir können also einen Geschwindigkeitsgewinn erwarten – im nächsten Abschnitt wird das auch getestet. Zunächst noch das zweite Beispiel von oben:

quote(
  mtcars |>
    rownames() |>
    as.factor() |>
    summary() |>
    head(n = 2)
)

# Ergebnis
head(summary(as.factor(rownames(mtcars))), n = 2)

Tatsächlich „versteht“ R den Pipe-Ausdruck so, als hätten wir den geschachtelten Klammerausdruck geschrieben.

Base R Pipe vs. magrittr Pipe: Geschwindigkeitsvergleich

Nun wollen wir es genauer wissen. Stimmt es, dass die Base R Pipe schneller ausgeführt wird? Wie stark wirkt sich das aus?

bench::mark(
  BaseR = mtcars |>
          rownames() |>
          as.factor() |>
          summary() |>
          head(n = 2),
  magrittr = mtcars %>% 
          rownames() %>% 
          as.factor() %>% 
          summary() %>% 
          head(n = 2)
)

# A tibble: 2 x 6
  expression      min   median `itr/sec` mem_alloc `gc/sec`
  <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
1 BaseR         147us    154us     6243.    4.22KB     7.18
2 magrittr      155us    159us     6253.    4.22KB     6.17

Tatsächlich ist die Base R-Variante mit der neuen Pipe ein wenig schneller. Für die allermeisten Anwendungsfälle dürfte dieser Unterschied allerdings eine geringe Rolle spielen. Übrigens wäre der Kontrast bis Ende 2020 deutlich schärfer ausgefallen. Für die magrittr-Version 2.0 wurde die Pipe neu in C programmiert und dadurch nicht nur schneller – auch die Fehlersuche wurde erheblich erleichtert, da der Backtrace nun entscheidend aufgeräumt wurde.

Wird sich die Base R Pipe durchsetzen?

Bleibt die Frage, ob R-Anwender lieber bei der gewohnten %>% bleiben oder sich |> rasch weit verbreiten wird?

Neben dem leichten Effizienzvorteil der Base R Implementierung sehe ich einen weiteren, nicht zu unterschätzenden Vorteil: den Verzicht auf eine externe Paket-Abhängigkeit. Insbesondere bei der Entwicklung eigener R-Pakete kann es sehr sinnvoll sein, solche Abhängigkeiten zu vermeiden oder zumindest auf ein notwendiges Minimum zu beschränken – siehe diverse Artikel zum Stichwort dependency hell. Empfehlung: Folien von Jim Hester „it depends“.

Im Moment wird die sog. „dot-notation“ von Base R nicht direkt unterstützt, mit der man bei der magrittr-Pipe mit einem einfachen Punkt auf das Datenobjekt verweisen kann. Das ist etwa dann nützlich, wenn die Daten bei der zu verwendenden Funktion nicht das erste Argument bilden. Es gibt wohl in Base R eine Lösung dafür, die Stand jetzt jedoch noch nicht ausgereift ist und daher per Voreinstellung deaktiviert ist. Mal sehen, wann sich das ändert. Umgesetzt ist sie wohl mit dem Operator =>.

Was meint Ihr: Lust auf die neue Base R Pipe? Im gewohnten dplyr-Stil bleiben? Oder seid Ihr data.table-Fans, die auf tidyverse-Pakete ganz verzichten? (Zum Geschwindigkeitsvergleich dplyr vs. data.table sowie die „Brücke“ dtplyr siehe den Beitrag data.table vs. dplyr und dtplyr: Benchmarks.)

Übrigens hat Michael Barrowman schon im Dezember 2020 über die Base R Pipe geschrieben (getestet mit der Entwicklungsversion von R) und Jumping Rivers sehr früh über R 4.1.0 berichtet. Neu ist in R 4.1.0 auch eine Abkürzung für Anonyme Funktionen (= Lambda-Funktionen), über die ich evtl. ein andermal berichte.