Introduction to Currying
Have you ever wondered why some functions always require multiple parameters? Take the built-in function print()
for example. Each time we call it, we not only need to pass in the content to be output, but also choose which stream to output to, what encoding format to use, whether to add a newline character at the end, and so on. This makes the function cumbersome to use.
Fortunately, Python provides us with a technique to simplify function calls, called "currying". Currying allows us to transform a function with multiple parameters into multiple functions that each accept a single parameter. Sounds a bit confusing? No worries, let me give you an example.
Suppose we have a function like this:
def add(x, y):
return x + y
Now we can use the partial
function from the functools
module to create a new function that only needs to pass in the remaining parameter to be called:
from functools import partial
add_5 = partial(add, 5)
print(add_5(3)) # Outputs 8
Here, add_5
is a new function that remembers the first parameter 5
, so we only need to pass in the second parameter to get the result. Isn't that cool?
You might say, "What's the big deal? Can't we just write a lambda
function?" Well, you have a point. But currying is not just about simplifying function calls.
Implementing Currying
Python's built-in partial
function is very useful and can satisfy most scenarios. But if you want to understand the principles of currying in depth, why not implement a currying function yourself?
Let's look at this example:
def curry(func, *args):
def helper(*more_args):
combined_args = args + more_args
if len(combined_args) < func.__code__.co_argcount:
return curry(func, *combined_args)
else:
return func(*combined_args)
return helper
This curry
function takes a callable object func
and some initial parameters args
. It returns a new function helper
. Each time helper
is called, it combines the newly passed parameters with the previous ones.
If the number of combined parameters is still not enough, helper
returns a new curried function, continuing to wait for the remaining parameters to be passed in. Once the number of parameters is sufficient, it calls the original function func
and returns the result.
Let's try this curry
function:
def mult(a, b, c):
return a * b * c
curried_mult = curry(mult)
print(curried_mult(2)(3)(4)) # Outputs 24
Isn't it amazing? We pass in the parameters step by step, and finally get the result 24
.
The key to implementing currying is that the function needs to remember the previously passed parameters and only execute when there are enough parameters. This way, we transform the original multi-parameter function into a series of functions that each only need a single parameter.
Introduction to Generators
Besides currying, another cool functional programming feature in Python is generators. Generators look like functions that return iterators, but their working principle is actually more magical.
Generators are implemented by the execution process of functions, but unlike ordinary functions that terminate after completion, generators can be paused and re-entered during execution, and their state is preserved. This is achieved through the yield
keyword.
Let's look at a simple example:
def count_up_to(n):
i = 0
while i < n:
yield i
i += 1
This count_up_to
function will generate a generator. Each time the yield
statement is executed, it produces the current count value and pauses execution until it's awakened next time.
We can use it like this:
counter = count_up_to(3)
print(next(counter)) # Outputs 0
print(next(counter)) # Outputs 1
print(next(counter)) # Outputs 2
print(next(counter)) # Will raise a StopIteration exception
Each time next(counter)
is called, the generator resumes execution from where it last paused, continuing until the next yield
statement. When there are no more yield
statements, the generator terminates.
You see, the execution process of a generator can be paused and resumed, and its state is preserved. This feature makes generators very useful in many scenarios, such as generating infinite sequences, stream processing, and so on.
Advanced Usage of Generators
The example above is just the most basic usage of generators. In fact, generators can do more advanced things, such as using the yield from
statement to implement generator chaining.
Suppose we have two generators:
def gen1():
yield 1
yield 2
def gen2():
yield from gen1()
yield 3
Here gen2
uses the yield from
statement, which directly puts all the values produced by the gen1
generator into the output sequence of gen2
.
We can use them like this:
g = gen2()
print(list(g)) # Outputs [1, 2, 3]
You see, gen2
first produces 1
and 2
from gen1
, and then produces its own 3
. This way of chaining generators allows us to combine multiple generators to implement more complex control flows.
Another advanced usage is to define generator functions inside generator functions, forming nested generators. This approach looks a bit convoluted, but it's quite useful in certain situations.
def outer():
value = yield 1
yield (yield value)
g = outer()
print(next(g)) # Outputs 1
print(g.send(2)) # Outputs 2
print(next(g)) # Outputs None
In this example, the outer
generator first produces 1
, then pauses execution. When we call g.send(2)
, 2
is passed back into the generator as value
, and then produced by yield
. The last next(g)
terminates the generator.
Although this nested generator writing style is awkward, it demonstrates the powerful control flow capabilities of generators. When used correctly, generators can help us implement various complex control logics.
The Magic of Recursion
Another important concept in functional programming is recursion. Recursion might sound intimidating, but it's just a method of problem-solving.
The core idea of recursion is simple: break down a big problem into several similar smaller problems, solve them separately, and then combine the results to solve the big problem. This divide-and-conquer approach allows us to solve many complex problems in an elegant way.
Let's look at a typical example, calculating the value of 2^n - 1
:
def power(n):
if n == 0:
return 0
else:
return 2 * power(n - 1) + 1
print(power(3)) # Outputs 7
This power
function is a recursive function. It works like this:
- If
n
is 0, return 0 directly - Otherwise, calculate the value of
power(n - 1)
, multiply the result by 2 and add 1, which is the value ofpower(n)
As you can see, the calculation of power(n)
depends on the result of power(n - 1)
. This mutually recursive call eventually simplifies the problem to the most basic case (n=0), thus obtaining the solution.
The trick in designing recursive functions is to find a way to break down the original problem into similar subproblems, and provide conditions for terminating the recursion. Once you master this approach, you can use recursion to solve many seemingly complex problems.
However, it's worth noting that excessive use of recursion can lead to performance issues. Each layer of recursive call leaves a stack frame in memory, and if the recursion depth is too great, it may lead to memory exhaustion. So in actual programming, you need to weigh the pros and cons of recursive and iterative implementations.
The Enigma of Floating-Point Rounding
After discussing so many features of Python functional programming, let's look at an interesting phenomenon - the hash value of floating-point numbers.
In Python, if you calculate the hash value of the floating-point infinity float('inf')
, you'll get a strange result:
print(hash(float('inf'))) # Outputs 314159
Why is it this number? If you pay attention, you'll find that it's exactly the first 6 digits of pi (π)!
This is not a coincidence. In fact, when Python calculates hash(float('inf'))
, it first approximates infinity to an extremely large double
value, then calculates the hash of this double
value. And the first few binary digits of this extremely large double
value happen to be the same as the first few digits of π
.
The reason is that floating-point numbers are represented in binary scientific notation in computers. When the value of the exponent part exceeds a certain range, it will be regarded as infinity or negative infinity. However, before this happens, the mantissa part of the floating-point number has already used up all the effective bits and can only be an approximate value. And this approximate value is highly related to the decimal part of pi.
This seemingly mystical phenomenon is actually a natural result of how floating-point numbers are represented in computers. It reminds us to be extra careful about rounding errors when performing calculations with floating-point numbers. Sometimes, the results may not be as precise as we expect.
Summary
Functional programming provides us with many interesting concepts and techniques, such as currying, generators, recursion, and so on. Mastering these allows us to solve programming problems in more elegant and efficient ways.
However, practice makes perfect. It's recommended that you implement the examples mentioned in this article yourself to experience the charm of functional programming. The joy of programming often lies in the process of practice.
Finally, as a multi-paradigm language, Python has its own characteristics in functional programming. You may encounter some counter-intuitive phenomena in practice, such as the hash value of floating-point numbers. When encountering such situations, don't be confused, but try to understand the essential reasons behind them.
Here concludes our sharing on Python functional programming. If you have any questions or insights, feel free to leave a comment for discussion. Let's explore and discover more mysteries of functional programming in practice together!