Tracks
/
R
R
/
Lehrplan
/
Funktionale Programmierung
Fu

Funktionale Programmierung in R

1 Übung

Über Funktionale Programmierung

Was ist funktionale Programmierung?

Das ist eine große Frage, die eine lange Antwort verdient (Wikipedia liefert eine, Advanced R eine andere).

Für unsere Zwecke sind das die wichtigsten Merkmale:

  • Reine Funktionen, die Eingaben zuverlässig auf Ausgaben abbilden und keine Seiteneffekte haben.
  • Unveränderliche Daten.
  • First-Class-Funktionen, die wie jeder andere Typ als Werte behandelt werden können.
  • Funktionen höherer Ordnung, die andere Funktionen als Argumente oder als Rückgabewerte entgegennehmen.

Allerdings ist R keine rein funktionale Sprache (anders als zum Beispiel Haskell).

Sieh R als pragmatische Sprache, die (zunehmend) funktionale Möglichkeiten dort bietet, wo sie ein sauberer und einfacher Weg sind, eine Aufgabe zu erledigen. Meist geht es um Datenanalyse, und Programmieren ist nur ein Mittel zum Zweck.

Pipes

Pipes |> zum Verbinden von Funktionen sind in mehreren Sprachen eine immer gebräuchlichere Technik.

Kurz gesagt:

  • Ein Ergebnis links von der Pipe wird zum ersten Argument der Funktion rechts davon (wobei „rechts“ optionale Zeilenumbrüche ignoriert).
  • Alle weiteren Argumente kannst du im Funktionsaufruf mitgeben.
  • In R müssen wir auf den Aufruf einer Funktion pipen, nicht nur auf ihren Namen, deshalb sind runde Klammern immer erforderlich (in manchen anderen Sprachen ist das anders).

Manche Programmierer finden es hilfreich, die Pipe beim Durchlesen von Code als „dann“ auszusprechen: mach Schritt 1, DANN mach Schritt 2 ...

Das folgende Beispiel zeigt die Syntax.

library(stringr)

"Monday-25-February" |> 
  str_split("-") |>     
  unlist() |>               # [1] "Monday"   "25"       "February"
  str_sub(1, 3) |>          # [1] "Mon" "25"  "Feb"
  str_to_upper() |>         # [1] "MON" "25"  "FEB"
  str_flatten(collapse = ", ")
#> [1] "MON, 25, FEB"

Diese Art von Pipe wurde in Base R mit Version 4.1.0 eingeführt, und du wirst sie vielleicht als „native Pipe“ bezeichnet sehen.

Früher wurde zum Pipen %>% aus dem Paket magrittr verwendet (der Name ist ein Wortspiel für Kunstliebhaber). Die „magrittr-Pipe“ gibt es immer noch, aber native Pipes machen ein externes Paket überflüssig, und die Umsetzung ist in manchen Details einfacher.

Der RStudio-Editor (und einige andere) nutzt Ctrl-Shift-M als Tastenkürzel, um eine Pipe mit passenden Leerzeichen einzufügen.

Apply-Funktionen

Wir haben vorher gesehen, dass viele Funktionen in R „vektorisiert“ sind: Sie arbeiten über ganze Vektoren, ohne dass du explizite Schleifen schreiben musst.

Funktionen, die nur vektorisierte Funktionen verwenden, sind selbst wahrscheinlich automatisch vektorisiert. Ein simples Beispiel:

triple <- function(x) return(3 * x)
triple(1:10)
 [1]  3  6  9 12 15 18 21 24 27 30

Komplexere Funktionen akzeptieren jedoch möglicherweise keine Vektor-Eingabe, zum Beispiel wenn sie Schleifen oder if-else-Blöcke enthalten.

Wir brauchen einen allgemeineren Weg, Funktionen auf Vektoren anzuwenden (ähnlich wie map() in manchen anderen Sprachen).

Weil R in der Basissprache eine reiche Vielfalt an Datenstrukturen hat, gibt es auch eine ganze Familie von *apply()-Funktionen, die darauf arbeiten. Genauer gesagt nennen Programmierer sie oft „Funktionen höherer Ordnung“ und Mathematiker „Funktionale“, weil sie Funktionen als Argumente entgegennehmen.

Betrachten wir zunächst lapply() und sapply().

lapply()

„List-apply“ ist für Listen als Eingabe gedacht, aber Vektoren werden bei Bedarf stillschweigend in Listen umgewandelt.

Die Ausgabe ist eine Liste, du musst sie also eventuell entpacken, um einen Vektor zu bekommen:

h <- function(size) {
    switch(size,
           "small" = 6,
           "medium" = 8,
           "large" = 10
    )
}

v <- c("small", "large", "medium")

h(v) # bad use of an unvectorized function
#> Error in switch(size, small = 6, medium = 8, large = 10) : 
#>   EXPR must be a length 1 vector

lapply(v, h)  # output is a list
#> [[1]]
#> [1] 6

#> [[2]]
#> [1] 10

#> [[3]]
#> [1] 8

lapply(v, h) |> unlist() # output is a vector
#> [1]  6 10  8

Wenn du das Switch-Konzept kennst, fällt dir vielleicht auf, dass wir das skalare switch auch durch das vektorisierte recode_values() aus der Bibliothek dplyr ersetzen könnten. Vektorisierung ist gut, und modernes R bietet mehrere Wege dorthin.

library(dplyr)

h <- function(size) {
  recode_values(size,
         "small" ~ 6,
         "medium" ~ 8,
         "large" ~ 10
  )
}

v <- c("small", "large", "medium")
h(v)
#> [1]  6 10  8

sapply()

„Simplified-apply“ macht etwas Ähnliches wie lapply, versucht aber zusätzlich, den Rückgabewert zu vereinfachen: zum Beispiel einen benannten Vektor statt einer Liste. Je nach Zusammenhang kann der Einsatz von unname() nötig sein:

sapply(v, h)
 small  large medium 
     6     10      8 

sapply(v, h) |> unname()
#> [1]  6 10  8

Die Bibliothek purrr

lapply und sapply sind einfach, den meisten R-Nutzern vertraut und für Vektoren und einfache Listen immer noch recht beliebt.

Base R hat außerdem noch einige andere Apply-Funktionen:

  • vapply() ist ähnlich wie sapply(), hat aber zusätzlich einen Pflichtparameter für das Ausgabeformat.
  • apply() für Matrizen und höherdimensionale Arrays.
  • mapply() für mehrere Listen- oder Vektorargumente parallel.
  • tapply() ist schwerer zu erklären, funktioniert aber mit Gruppierungsoperationen und unregelmäßigen Arrays.
  • vectorize() ist eine ziemlich rätselhafte Funktion, die ein Wrapper für mapply() zu sein scheint.

Genug davon!

Ein zentrales Ziel des Tidyverse ist es, für diese Art von Verwirrung einen einfacheren und einheitlicheren Ersatz zu bieten.

Inzwischen gibt es das Paket purrr, das mehrere zusätzliche Möglichkeiten wie map() bietet und dabei das Design einheitlicher und Fehlermeldungen klarer macht.

Dieses Paket ist weniger an Abwärtskompatibilität gebunden als Base R und bringt R deutlich näher an moderne funktionale Programmierung, wie man sie in anderen Sprachen findet.

In purrr gibt es viele unterschiedliche Funktionen, die sich kaum kurz zusammenfassen lassen. Hilfreiche Ressourcen für die Bibliothek sind:

Note

Die Bibliothek purrr arbeitet fast ausschließlich mit eindimensionalen Daten: Vektoren und Listen.

In einem späteren Konzept werden wir sehen, dass die Bibliothek dplyr die entsprechende Funktionalität für 2-D-Dataframes bietet.

Die vielen map-Funktionen

Die Funktion map() wendet eine Funktion auf jedes Element der Eingabe an und gibt immer eine Liste zurück.

Soweit klingt das wie lapply(), nur umbenannt und vielleicht mit besseren Fehlermeldungen.

Allerdings ist map() nur ein Mitglied einer großen Familie.

Ein paar Fragen, die dir bei der Auswahl einer Funktion helfen:

  • Was möchtest du zurückgeben? map() gibt immer eine Liste zurück, aber wenn du einen bestimmten Typ von atomarem Vektor willst, nimm map_chr() für Strings, map_int() für Ganzzahlen und so weiter.
  • Brauchst du den Positionsindex jedes Elements der Eingabe? Stell dem Funktionsnamen ein i voran, etwa imap().
  • Willst du mehrere Eingaben parallel verarbeiten?
    • Ersetze map im Namen durch map2, etwa map2_dbl() für zwei Eingaben (zum Beispiel Werte und Gewichte, um einen gewichteten Mittelwert zu berechnen)
    • Ersetze es durch pmap für eine Liste paralleler Eingaben, etwa pmap_chr(). Und ja, Parallelität auf Hardware-Ebene ist möglich.
  • Willst du alle Elemente der Eingabe verarbeiten? Wenn nicht, denk an map_if(), um einen Prädikat-(Booleschen) Filter anzuwenden, map_at(), um Kriterien für Positionsindizes festzulegen, head_while(), um die Eingabe zu verarbeiten, bis ein Element ein Prädikat nicht erfüllt.
  • Sind die Eingabedaten tief verschachtelt? Dafür gibt es Funktionen.
  • Soll der Rückgabewert immer denselben Typ wie die Eingabe haben? Verwende modify()-Funktionen statt map oder modify_tree() für rekursive Anwendung.

Diese Funktionen haben, wann immer möglich, die Eingabedaten als erstes Argument, damit sie gut mit Pipes funktionieren.

Außerdem ist purrr (absichtlich) pedantischer als Base R. Wenn du etwas Bestimmtes verlangst, geben die Funktionen genau das zurück oder brechen mit einer Fehlermeldung ab. Hart, aber sehr wirksam, um schwer auffindbare Bugs zu reduzieren.

Reduce-Funktionen

Map-Funktionen haben in der Regel einen Rückgabewert mit derselben Länge wie die Eingabe.

Manche anderen Funktionen reduzieren die Dimensionalität der Daten, zum Beispiel sum().

sum(1:100)
#> [1] 5050

Im obigen Fall wandeln wir einen Vektor der Länge 100 in eine Ganzzahl der Länge 1 um. In einem künftigen Konzept zu Matrices and arrays werden wir sehen, dass das Konzept der Dimensionsreduktion allgemeiner ist.

Die Funktion sum() ist eingebaut (wie viele andere statistische Funktionen). Wir brauchen aber einen Weg, beliebige dimensionsreduzierende Funktionen auf eine Datenstruktur anzuwenden, mit einer Funktion höherer Ordnung, die map() entspricht.

In mehreren anderen Sprachen heißen solche Funktionen fold, foldl und foldr. In R ist die entsprechende Funktion reduce(), passend zum (Algorithmus-Fans) wohlbekannten MapReduce-Framework.

Das erste Argument sind wie üblich die Eingabedaten. Das zweite Argument ist eine Funktion mit zwei Argumenten: oft eine anonyme Funktion, aber manchmal so einfach wie ein Rechenoperator wie +.

# surround an infix operator with backticks, to use as a function
reduce(1:100, `+`)
#> [1] 5050  # same as sum(1:100)

# anonymous function, alternately add and subtract
reduce(1:10, \(accum, nextval) ifelse(nextval %% 2 == 0, accum + nextval, accum - nextval))
#> [1] 7

Macht es einen Unterschied, ob wir die Funktion vom Anfang oder vom Ende der Eingabe her anwenden?

Bei assoziativen Operatoren wie + oder * nicht. Klar: (1 + 2) + 3 == 1 + (2 + 3).

Dagegen sind - und / nicht assoziativ:

(1 - 2) - 3
#> [1] -4
1 - (2 - 3)
#> [1] 2

Für Funktionen, bei denen die Richtung eine Rolle spielt, gibt es ein optionales Argument .dir.

# default is .dir = "forward"
reduce(1:10, `-`)
#> [1] -53
reduce(1:10, `-`, .dir = "backward")
#> [1] -5

Wie bei map() und map2() gibt es auch eine Funktion reduce2(), die zwei Eingabevektoren parallel verarbeitet.

In R arbeiten wir gern mit Vektoren. Angenommen, du möchtest statt eines einzelnen Endergebnisses alle Zwischenschritte sehen.

Dafür gibt es die Funktionen accumulate() und accumulate2(). Die Syntax ähnelt reduce(), mit einigen zusätzlichen optionalen Argumenten für den Ausgabetyp.

accumulate(1:10, `+`)
#> [1]  1  3  6 10 15 21 28 36 45 55

Einige häufige Arten von Accumulate haben eigene Funktionen. Im Konzept Vector Functions ging es um cumsum() und cumprod() in Base R, und die Bibliothek dplyr hat cummean() für numerische Mittelwerte sowie cumall() und cumany() für logische Vektoren.

Filter-Funktionen

Häufig wollen wir Elemente der Eingabe einbeziehen (oder ausschließen), die ein Prädikat erfüllen.

Im Konzept Vector Filtering haben wir einen idiomatischen Weg dafür gesehen.

v <- 1:3
v[v >= 2]
#> [1] 2 3

In traditionellem R wird das viel verwendet, aber es passt schlecht in moderne funktionale Pipelines.

Deshalb bietet purrr eine Reihe von Prädikatfunktionen, die der üblichen Argumentkonvention f(input, func, ...) folgen, damit sie mit Pipes funktionieren.

Die Namen unterscheiden sich etwas von denen der meisten anderen Sprachen (Namen wie filter und drop bedeuten in R bereits etwas völlig anderes).

Zu den entsprechenden purrr-Funktionen gehören keep() und discard() sowie einige weitere.

Außerdem gibt es every(), some() und none(), die all() und any() ersetzen, wenn ein einzelner logischer Wert zurückgegeben werden soll.

Rekursion

Die meisten funktionalen Sprachen nutzen Rekursion als bevorzugte Alternative zu Schleifen.

R hat andere und oft bessere Möglichkeiten: vektorisierte Funktionen und Funktionen höherer Ordnung.

Rekursion wird in R aber unterstützt und kann in Zusammenhängen wie dem Durchlaufen von Baumstrukturen nützlich sein.

Die maximale Rekursionstiefe liegt standardmäßig bei 5000, was relativ großzügig ist (Python liegt standardmäßig bei 1000). Der Standardwert lässt sich überschreiben.

Tail-Call-Optimierung ist in R nicht Standard, es wurden aber einige Workarounds entwickelt.

Die Tradition verlangt, dass wir an dieser Stelle ein Fakultätsbeispiel zeigen, das klassische Beispiel einer rekursiven Definition.

fact <- function(n) ifelse(n == 0, 1, n * fact(n - 1))
fact(5)
#> [1] 120

# some more idiomatic R:
factorial(5)
#> [1] 120
prod(1:5)
#> [1] 120

Du kannst Rekursion in R verwenden, und manchmal ist sie wertvoll, aber oft gibt es einfachere Ansätze.

Über GitHub bearbeiten Der Link öffnet sich in einem neuen Fenster oder Tab

Lerne Funktionale Programmierung