python-course.eu

11. Currying in Python

By Bernd Klein. Last modified: 08 Mar 2024.

General Idea

Curry mit Currying

In mathematics and computer science, currying is the technique of breaking down the evaluation of a function that takes multiple arguments into evaluating a sequence of single-argument functions. Currying is also considered to be a design pattern. Currying is also used in theoretical computer science, because it is often easier to transform multiple argument models into single argument models.

Live Python training

instructor-led training course

Enjoying this page? We offer live Python training courses covering the content of this site.

See: Live Python courses overview

Enrol here

Origin of the Name

The "Curry" in "Currying" has nothing to do with the spicy curry powder, even though it can make your Python code more spicy. The name is a reference to the logician and mathematician Haskell Brooks Curry, and he is quoted in "THE KLEENE SYMPOSIUM", (Proceedings of the Symposium held June 18-24, 1978 at Madison, Wisconsin. U.S.A.):

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.

This is why there is also - though seldomly - the name "Schönfinkelisation" being used. There are even further roots of the concept going back to the end of the 19th century and the mathematician Gottlob Frege.

Currying Functions

Currying is the technique of breaking down the evaluation of a function that takes multiple arguments into evaluating a sequence of single-argument functions.

In mathematical notation it looks like this: If we have a function $f$ which takes $n$ arguments, we can 'replace' it by a composition of $n$ functions $f_1, f_2, \ldots f_n$ where each takes only one argument:

$$let x = f(a_1, a_2, a_3)$$

we will get the same value x as if we call:

$$f_2 = f_1(a_1)$$
$$f_3 = f_2(a_2)$$

and

$$x = f_3(a_3)$$

An example implemented in Python could look like this:

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
for i in range(1, 10):
    print(f(i, i+1, i+2), f1(i)(i+1)(i+2))

OUTPUT:

6 6
24 24
60 60
120 120
210 210
336 336
504 504
720 720
990 990

Live Python training

instructor-led training course

Enjoying this page? We offer live Python training courses covering the content of this site.

See: Live Python courses overview

Upcoming online Courses

Enrol here

Example BMI

We illustrate this with the function BMI, which we had used in our chapter on functions. We write a curried version of BMI:

def BMI(weight, height):
    return weight / height**2

def bmi_curried(height):
    def bmi_weight(weight):
        return BMI(weight, height)
    return bmi_weight
bmi_curried(1.76)(72)

OUTPUT:

23.243801652892564

Using partial

The function partial of the module functools of Python allows you to create partially applied functions. A partially applied function is a new function that is derived from an existing function by fixing a certain number of arguments in advance. The result is a function that can be called with the remaining arguments. We can use it for the commposition of functions.

We demonstrate the way of working in the following examples:

from functools import partial

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

partial(f, 3)(4)

OUTPUT:

12
from functools import partial

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

partial(f, 3, 2)(4)

OUTPUT:

24
partial(f, 3)(2, 4)

OUTPUT:

24
partial(partial(f, 3), 2)(4)

OUTPUT:

24
partial(f, 3)(2, 4)

OUTPUT:

24

Live Python training

instructor-led training course

Enjoying this page? We offer live Python training courses covering the content of this site.

See: Live Python courses overview

Enrol here

Using partial

The function partial of the module functools of Python allows you to create partially applied functions. A partially applied function is a new function that is derived from an existing function by fixing a certain number of arguments in advance. The result is a function that can be called with the remaining arguments. We can use it for the commposition of functions.

We demonstrate the way of working in the following examples:

Decorator for currying

def f(x, y, z):
    return x * y * z

The following Python function curry is a higher-order function that transforms a regular function into a curried function. The curried function returns a new function for each argument provided, and you can apply these functions one at a time, which can be useful in various scenarios. Inside the curry function, a nested function curried is defined. It accepts a variable number of arguments using *args. This allows the curried function to accept any number of arguments.

print(f'{args}'): simply prints the current arguments to the console. It's not necessary for the functionality of course, it's included for debugging and illustration purposes.

if len(args) == func.__code__.co_argcount: This condition checks whether the number of arguments provided (len(args)) matches the number of arguments that the original function func expects. The co_argcount attribute is used to retrieve the number of arguments of the func function.

return func(*args): If the number of arguments matches, the original function func is called with the provided arguments, and the result is returned.

else: return lambda x: curried(*(args + (x,))) If the number of arguments doesn't match, a new lambda function is returned. This lambda function takes a single argument x, and it calls the curried function with the existing args and the new argument x appended. This effectively allows you to build up (accumulate) the arguments one by one and create a chain of unary functions.

The co_argcount sub-attribute returns the number of positional arguments (including arguments with default values). To learn more about the __code__-attribute you can consult our page Argument Count for Advanced Python Programmers

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)

OUTPUT:

(3,)
(3, 4)
(3, 4, 5)
12

Another exaple for using our curry function. We first repeat the curry without the debugging print:

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

Live Python training

instructor-led training course

Enjoying this page? We offer live Python training courses covering the content of this site.

See: Live Python courses overview

Enrol here

Another Example for Currying

Sales Currying

Let's consider a function for calculating the total cost of a shopping cart, with the ability to apply various discounts at different stages. This example will use multiple curried functions to apply item prices, quantities, and discounts incrementally.

First, we define a curried function for calculating the cost of individual items:

@curry
def calculate_item_cost(price, quantity):
    return price * quantity

Next, we create a function to apply a percentage discount to the cost. This function takes a percentage and returns a curried function:

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

For the following, it might be useful to be familiar with to simple forms of price reduction in the business world:

Rebates and discounts are two distinct pricing strategies that businesses use to incentivize customers. The main distinction between discounts and rebates lies in the timing of the price reduction. Discounts provide immediate cost savings at the point of sale, while rebates offer savings after a purchase has been made, contingent on meeting specific criteria. Both strategies can be effective in influencing customer behavior and boosting sales, but they are suited to different business scenarios and objectives.

Now, let's use these curried functions to calculate the total cost of a customers order list in the end:

# Calculate the cost of individual items
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=}")
# Apply percentage discounts
calculate_discounted_price = subtract_percentage(12)
calculate_rebated_price = subtract_percentage(3)

# Calculate the total cost with discounts
total_cost = calculate_rebated_price(calculate_discounted_price(item1 + item2))

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

OUTPUT:

item1=20, item2=15
Total cost: €29.88

We could have done the calculation directly on each item:

subtract_percentage(3)(subtract_percentage(12)(calculate_item_cost(10)(2)))

OUTPUT:

17.072000000000003

In the previous example we used the same rebate and discount on each item. Usually, they differ from product to product.

Let's assume that we have the following dictionary as a shopping list for items. Each item consists of a tuple with (quantity, price, discount, rebate). We can do the summation in a clean and readable way:

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

total = 0
sub_perc = subtract_percentage # just a shorter name
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}")

OUTPUT:

Total cost: €29.88

Currying Function with an Arbitrary Number of Arguments

One interesting question remains: How to curry a function across an arbitrary and unknown number of parameters?

We can use a nested function to make it possible to "curry" (accumulate) the arguments. We will need a way to tell the function calculate and return the value. If the funtions is called with arguments, these will be curried, as we have said. What if we call the function without any arguments? Right, this is a fantastic way to tell the function that we finally want to the the result. We can also clean the lists with the accumulated values:

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)

OUTPUT:

<function __main__.curry.<locals>.f(*args, **kwargs)>

We have to call the function again with an empty argument, because otherwise it doesn't know that it's at the end. this is not "proper" currying, but it works:

curried_arimean(2)(5)(9)(5)()

OUTPUT:

5.25
arimean(2, 5, 9, 5)

OUTPUT:

5.25

Including some prints might help to understand what's going on:

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)
curried_arimean(2)(5)(9)(7)
# it will keep on currying:
curried_arimean(5, 9)
print(curried_arimean())

OUTPUT:

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 curried function with:
args:  (5, 9) kwargs:  {}
Currying the values:
f_args:  [2, 5, 9, 7, 5, 9]
f_kwargs: {}
Calling arimean with:
[2, 5, 9, 7, 5, 9] {}
6.166666666666667
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)

We can write it also as a class, which uses the __call__ method:

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))

OUTPUT:

(2,) ()
(5,) (2,)
(9,) (2, 5)
(7,) (2, 5, 9)
(5, 9) (2, 5, 9, 7)
Result: <__main__.Curry object at 0x7fdfe3c103d0> <class '__main__.Curry'>
c = curried_arimean(2)(5)(9)(7)(5)(9)()
c

OUTPUT:

(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)
6.166666666666667

Live Python training

instructor-led training course

Enjoying this page? We offer live Python training courses covering the content of this site.

See: Live Python courses overview

Upcoming online Courses

Enrol here