Currying

Grundsätzliche Idee

Curry mit Currying

Das Currying ist eine Technik in der funktionalen Programmierung, bei der eine Funktion, die normalerweise mehrere Argumente akzeptiert, in eine Abfolge von Funktionen umgewandelt wird, die jeweils ein einzelnes Argument akzeptieren. Das bedeutet, dass anstelle einer Funktion, die alle ihre Argumente auf einmal erwartet, eine Kette von Funktionen erstellt wird, die nacheinander aufgerufen werden, um das gewünschte Ergebnis zu erzielen.

Im Currying wird also eine Funktion, die beispielsweise zwei Argumente erwartet, in eine erste Funktion umgewandelt, die ein Argument akzeptiert und eine zweite Funktion zurückgibt. Die zweite Funktion akzeptiert dann das zweite Argument und gibt das endgültige Ergebnis zurück. Dieser Prozess kann fortgesetzt werden, um Funktionen für beliebig viele Argumente zu erstellen.

Currying ermöglicht es, Funktionen flexibler und komponierbarer zu gestalten, da Sie Teile der Funktion vorab anwenden und dann die verbleibenden Argumente nach Bedarf hinzufügen können. Es ist eine wichtige Technik in der funktionalen Programmierung.

Currying wird auch als ein Entwurfsmuster betrachtet. Außerdem wird Currying in der theoretischen Informatik verwendet, da es oft einfacher ist, Modelle mit mehreren Argumenten in Modelle mit einem Argument umzuwandeln.

Herkunft des Names

Das "Curry" in "Currying" hat nichts mit dem scharfen Currypulver zu tun, auch wenn es den Python-Code durchaus "schmackhafter" machen kann. Der Name ist eine Anspielung auf den Logiker und Mathematiker Haskell Brooks Curry, in "THE KLEENE SYMPOSIUM", (Proceedings of the Symposium held June 18-24, 1978 at Madison, Wisconsin. U.S.A.):(Proceedings of the Symposium held June 18-24, 1978 at Madison, Wisconsin. U.S.A.) referenziert wird:

Some contemporary logicians call this way of looking at a function “currying”, because I made extensive use of it; but Schonfinkel had the idea some 6 years before I did.

(deutsch: Einige zeitgenössische Logiker nennen diese Art, eine Funktion zu betrachten, "currying", weil ich sie ausgiebig benutzt habe; aber Schonfinkel hatte die Idee etwa 6 Jahre vor mir.

Aus diesem Grund wird - wenn auch selten - der Name "Schönfinkelisierung" verwendet. Es gibt sogar weitere Wurzeln des Begriffs, die bis zum Ende des 19. Jahrhunderts und den Mathematiker Gottlob Frege zurückreichen.

Currying-Funktionen

Currying ist eine Technik, bei der die Auswertung einer Funktion mit mehreren Argumenten in die Auswertung einer Folge von Funktionen mit einem Argument unterteilt wird.

In mathematischer Notation sieht das so aus: Wenn wir eine Funktion $f$ haben, die $n$ Argumente annimmt, können wir sie durch eine Komposition von $n$ Funktionen $f_1, f_2, f_n$ "ersetzen", wobei jede Funktion nur ein Argument annimmt:

, Wir erhalten wir denselben Wert x wie beim Aufruf von:

$$f_2 = f_1(a_1)$$ $$f_3 = f_2(a_2)$$ and $$x = f_3(a_3)$$

Ein in Python implementiertes Beispiel könnte wie folgt aussehen:

In [1]:
def f(a1, a2, a3):
    return a1 * a2 * a3

def f1(a1):
    def f2(a2):
        def f3(a3):
            return f(a1, a2, a3)
        return f3
    return f2
In [2]:
for i in range(1, 10):
    print(f(i, i+1, i+2), f1(i)(i+1)(i+2))
6 6
24 24
60 60
120 120
210 210
336 336
504 504
720 720
990 990

Beispiel BMI

Wir illustrieren dies mit der Funktion BMI, die wir in unserem Kapitel über Funktionen kennengelernt haben. Wir schreiben eine Curried-Version von BMI:

In [10]:
def bmi(weight, height):
    return weight / height**2

def bmi_curried(height):
    def bmi_weight(weight):
        return bmi(weight, height)
    return bmi_weight
In [11]:
bmi_curried(1.76)(72)
Out[11]:
23.243801652892564

Benutzung von partial

Die Funktion partial des Moduls functools von Python erlaubt es uns, partiell angewandte Funktionen zu erstellen. Eine partiell angewandte Funktion ist eine neue Funktion, die von einer bestehenden Funktion abgeleitet wird, indem eine bestimmte Anzahl von Argumenten im Voraus festgelegt wird. Das Ergebnis ist eine Funktion, die mit den verbleibenden Argumenten aufgerufen werden kann. Mit ihr lässt sich auch die Komposition von Funktionen darstellen.

Wir demonstrieren die Funktionsweise in den folgenden Beispielen:

In [12]:
from functools import partial

def f(a1, a2):
    return a1 * a2 

partial(f, 3)(4)
Out[12]:
12
In [13]:
from functools import partial

def f(a1, a2, a3):
    return a1 * a2 * a3

partial(f, 3, 2)(4)
Out[13]:
24
In [14]:
partial(f, 3)(2, 4)
Out[14]:
24
In [15]:
res = partial(partial(f, 3), 2)(4)
print(res)
24
In [16]:
partial(f, 3)(2, 4)
Out[16]:
24
In [17]:
def multiply(x, y):
  return x*y
In [18]:
multiply.__code__.co_argcount
Out[18]:
2

Dekorator für currying

Die folgende Python-Funktion curry ist eine Funktion höherer Ordnung, die eine reguläre Funktion in eine curried-Funktion umwandelt. Die curried-Funktion gibt für jedes angegebene Argument eine neue Funktion zurück, und wir können diese Funktionen nacheinander anwenden, was in verschiedenen Szenarien nützlich sein kann. Innerhalb der curry-Funktion wird eine verschachtelte Funktion curried definiert. Sie akzeptiert mit *args eine variable Anzahl von Argumenten. Dadurch kann die curried-Funktion eine beliebige Anzahl von Argumenten akzeptieren.

print(f'{args}'): gibt einfach die aktuellen Argumente auf der Konsole aus. Das ist für die Funktionalität natürlich nicht notwendig, sondern dient nur der Fehlersuche und der Veranschaulichung.

if len(args) == func.__code__.co_argcount: Diese Bedingung prüft, ob die Anzahl der übergebenen Argumente (len(args)) mit der Anzahl der Argumente übereinstimmt, die die ursprüngliche Funktion func erwartet. Das Attribut "co_argcount" wird verwendet, um die Anzahl der Argumente der Funktion "func" zu ermitteln.

return func(*args): Wenn die Anzahl der Argumente übereinstimmt, wird die ursprüngliche Funktion func mit den angegebenen Argumenten aufgerufen und das Ergebnis zurückgegeben.

else: return lambda x: curried(*(args + (x,))): Wenn die Anzahl der Argumente nicht übereinstimmt, wird eine neue Lambda-Funktion zurückgegeben. Diese Lambda-Funktion nimmt ein einzelnes Argument x an und ruft die aufgerufenen Funktionen mit den vorhandenen Argumenten und dem neu hinzugefügten Argument x auf. Auf diese Weise können Sie die Argumente nacheinander aufbauen (akkumulieren) und eine Kette von unären Funktionen erstellen.

Das Unterattribut co_argcount gibt die Anzahl der Positionsargumente zurück (einschließlich der Argumente mit Standardwerten). Mehr über das __code__-Attribut kann man auf unserer Seite erfahren.

In [1]:
def curry(func):
    def curried(*args):
        print(f'{args}')
        if len(args) == func.__code__.co_argcount:
            return func(*args)
        else:
            return lambda x: curried(*(args + (x,)))
    return curried

@curry
def prod3(x, y, z):
    return x + y + z

prod3(3)(4)(5)
(3,)
(3, 4)
(3, 4, 5)
Out[1]:
12

Ein weiteres Beispiel für die Verwendung unserer Funktion curry. Wir wiederholen zunächst die Funktion "curry" ohne die Debugging-Prints:

In [15]:
def curry(func):
    def curried(*args):
        if len(args) == func.__code__.co_argcount:
            return func(*args)
        else:
            return lambda x: curried(*(args + (x,)))
    return curried

Weiteres Beispiel für Currying

Rabatte Currying

Betrachten wir eine Funktion zur Berechnung der Gesamtkosten eines Warenkorbs, mit der Möglichkeit, verschiedene Rabatte in verschiedenen Stufen anzuwenden. In diesem Beispiel werden mehrere Curried-Funktionen verwendet, um Artikelpreise, Mengen und Rabatte schrittweise anzuwenden.

Zunächst definieren wir eine Curried-Funktion zur Berechnung der Kosten für einzelne Artikel:

In [16]:
@curry
def calculate_item_cost(price, quantity):
    return price * quantity

Als nächstes erstellen wir eine Funktion, um einen prozentualen Rabatt auf die Kosten anzuwenden. Diese Funktion nimmt einen Prozentsatz und gibt eine Curried-Funktion zurück:

In [17]:
@curry
def subtract_percentage(discount_percentage, cost):
    #print(f"{cost=}, {discount_percentage=}")
    discount_amount = (cost * discount_percentage) / 100
    return cost - discount_amount

Für das Folgende könnte es nützlich sein, mit zwei einfachen Formen der Preisreduzierung in der Geschäftswelt vertraut zu sein:

Rabatte und Skonto sind zwei unterschiedliche Preisstrategien, die Unternehmen einsetzen, um Anreize für Kunden zu schaffen. Der Hauptunterschied zwischen Rabatt und Skonto liegt im Zeitpunkt der Preissenkung. Preisnachlässe oder Rabatte bieten sofortige Kosteneinsparungen an der Verkaufsstelle, während Skonti Einsparungen nach dem Kauf bieten, wenn bestimmte Kriterien erfüllt sind. Beide Strategien können das Kundenverhalten wirksam beeinflussen und den Umsatz steigern, eignen sich aber für unterschiedliche Geschäftsszenarien und Ziele.

Wir wollen nun mit Hilfe dieser Curried-Funktionen die Gesamtkosten der Bestellliste eines Kunden berechnen:

In [18]:
# Berechnung der Preise für individuelle Artikel
item1 = calculate_item_cost(10)(2)  # Item 1 costs € 10 with a quantity of 2
item2 = calculate_item_cost(5)(3)   # Item 2 costs € 5 with a quantity of 3

print(f"{item1=}, {item2=}")
calculate_discounted_price = subtract_percentage(12)
calculate_rebated_price = subtract_percentage(3)

#  Berchnung der Gesamtkosten mit %-Abzügen
total_cost = calculate_rebated_price(calculate_discounted_price(item1 + item2))

print(f"Total cost: €{total_cost:.2f}")
item1=20, item2=15
Total cost: €29.88

Wir hätten die Berechnung auch direkt für jeden einzelnen Posten durchführen können:

In [19]:
subtract_percentage(3)(subtract_percentage(12)(calculate_item_cost(10)(2)))
Out[19]:
17.072000000000003

Im vorigen Beispiel haben wir für jeden Artikel den gleichen Rabatt und den gleichen Skonto-Satz verwendet. Normalerweise unterscheiden sie sich von Produkt zu Produkt.

Nehmen wir an, dass wir das Dictionary als Einkaufsliste für Artikel haben. Jeder Artikel besteht aus einem Tupel mit (Menge, Preis, Skonto, Rabatt). Wir können die Summierung auf eine saubere und lesbare Weise durchführen:

In [20]:
shopping_list = [(2, 10, 12, 3), (3, 5, 12, 3)]

total = 0
sub_perc = subtract_percentage 
for quantity, price, discount, rebate in shopping_list:
    subtotal = sub_perc(rebate)(sub_perc(discount)((calculate_item_cost(price)(quantity))))
    #print(subtotal)
    total += subtotal

print(f"Total cost: €{total:.2f}")
Total cost: €29.88

Currying-Funktion mit einer beliebigen Anzahl von Parametern

Eine interessante Frage bleibt: Wie kann man "Currying" auf eine Funktion mit einer beliebigen und unbekannten Anzahl von Parametern anwenden?

Um das Currying durchzuführen, können wir eine verschachtelte Funktion verwenden. Wir brauchen aber einen Weg, um der Funktion mitzuteilen, dass sie den Wert berechnen und zurückgeben soll. Wenn die Funktion mit Argumenten aufgerufen wird, werden diese, wie bereits erwähnt, mit der Currying-Technik akkumuliert. Was aber, wenn wir die Funktion ohne Argumente aufrufen? Richtig, das ist eine Möglichkeit, der Funktion mitzuteilen, dass wir das Ergebnis haben wollen:

In [25]:
def arimean(*args):
    return sum(args) / len(args)

def curry(func):
    # to keep the name of the curried function:
    curry.__curried_func_name__ = func.__name__
    f_args, f_kwargs = [], {}
    def f(*args, **kwargs):
        nonlocal f_args, f_kwargs
        if args or kwargs:
            f_args += args
            f_kwargs.update(kwargs)
            return f
        else:
            result = func(*f_args, *f_kwargs)
            f_args, f_kwargs = [], {}
            return result
    return f
            
curried_arimean = curry(arimean)
curried_arimean(2)(5)(9)(5)
Out[25]:
<function __main__.curry.<locals>.f(*args, **kwargs)>

Wir müssen die Funktion erneut mit einem leeren Argument aufrufen, weil sie sonst nicht weiß, dass sie am Ende ist. Das ist kein "richtiges" Currying, aber es ist ein guter Weg:

In [26]:
curried_arimean(2)(5)(9)(5)()
Out[26]:
5.25
In [27]:
arimean(2, 5, 9, 5)
Out[27]:
5.25

Einige "prints" können helfen zu verstehen, was vor sich geht:

In [23]:
def arimean(*args):
    return sum(args) / len(args)

def curry(func):
    # to keep the name of the curried function:
    curry.__curried_func_name__ = func.__name__
    f_args, f_kwargs = [], {}
    def f(*args, **kwargs):
        nonlocal f_args, f_kwargs
        if args or kwargs:
            print("Calling curried function with:")
            print("args: ", args, "kwargs: ", kwargs)
            f_args += args
            f_kwargs.update(kwargs)
            print("Currying the values:")
            print("f_args: ", f_args)
            print("f_kwargs:", f_kwargs)
            return f
        else:
            print("Calling " + curry.__curried_func_name__ + " with:")
            print(f_args, f_kwargs)

            result = func(*f_args, *f_kwargs)
            f_args, f_kwargs = [], {}
            return result
    return f
            
curried_arimean = curry(arimean)


print(curried_arimean(2)(5)(9)(7)())
Calling curried function with:
args:  (2,) kwargs:  {}
Currying the values:
f_args:  [2]
f_kwargs: {}
Calling curried function with:
args:  (5,) kwargs:  {}
Currying the values:
f_args:  [2, 5]
f_kwargs: {}
Calling curried function with:
args:  (9,) kwargs:  {}
Currying the values:
f_args:  [2, 5, 9]
f_kwargs: {}
Calling curried function with:
args:  (7,) kwargs:  {}
Currying the values:
f_args:  [2, 5, 9, 7]
f_kwargs: {}
Calling arimean with:
[2, 5, 9, 7] {}
5.75
In [ ]:
def arimean(*args):
    return sum(args) / len(args)

def curry(func):
    f_args, f_kwargs = [], {}
    def f(*args, **kwargs):
        nonlocal f_args, f_kwargs
        if args or kwargs:
            f_args += args
            f_kwargs.update(kwargs)
            return f
        else:
            result = func(*f_args, **f_kwargs)
            f_args, f_kwargs = [], {}
            return result

    return f
            
curried_arimean = curry(arimean)
result = curried_arimean(2)(5)(9)(7)(5, 9)()
print("Result:", result)

Die vorige Funtion können wir auch als "Klasse" definieren, indem wir die __call__-Methode verwenden:

In [2]:
def arimean(*args):
    return sum(args) / len(args)

class Curry:
    def __init__(self, func, *args):
        self.func = func
        self.args = args

    def __call__(self, *args):
        print(args, self.args)
        if not args:
            return self.func(*self.args)
        return Curry(self.func, *(self.args + args))



curried_arimean = Curry(arimean)
result = curried_arimean(2)(5)(9)(7)(5, 9)
print("Result:", result, type(result))
(2,) ()
(5,) (2,)
(9,) (2, 5)
(7,) (2, 5, 9)
(5, 9) (2, 5, 9, 7)
Result: <__main__.Curry object at 0x7fc2878bc650> <class '__main__.Curry'>
In [3]:
c = curried_arimean(2)(5)(9)(7)(5)(9)()
c
(2,) ()
(5,) (2,)
(9,) (2, 5)
(7,) (2, 5, 9)
(5,) (2, 5, 9, 7)
(9,) (2, 5, 9, 7, 5)
() (2, 5, 9, 7, 5, 9)
Out[3]:
6.166666666666667