Introduction to Functional Programming in Python
Python facilitates different approaches to writing code, and while an object-oriented approach is common, an alternative and useful style of writing code is functional programming.
By Spiro Sideris, Dataquest.io
Image source.
Most of us have been introduced to Python as an object-oriented language; a language exclusively using classes to build our programs. While classes, and objects, are easy to start working with, there are other ways to write your Python code. Languages like Java can make it hard to move away from object-oriented thinking, but Python makes it easy.
Given that Python facilitates different approaches to writing code, a logical follow-up question is: what is a different way to write code? While there are several answers to this question, the most common alternative style of writing code is called functional programming. Functional programming gets its name from writing functions which provides the main source of logic in a program.
In this post, we will:
- Explain the basics of functional programming by comparing it to object-oriented programming.
- Cover why you might want to incorporate functional programming in your own code.
- Show you how Python allows you to switch between the two.
Comparing object-oriented to functional
The easiest way to introduce functional programming is to compare it to something we're already aware of: object-oriented programming. Suppose we wanted to create a line counter class that takes in a file, reads each line, then counts the total amount of lines in the file. Using a class, it could look something like the following:
class LineCounter: def __init__(self, filename): self.file = open(filename, 'r') self.lines = [] def read(self): self.lines = [line for line in self.file] def count(self): return len(self.lines)
While not the best implementation, it does provide an insight into object-oriented design. Within the class, there are the familiar concepts of methods and properties. The properties set and retrieve the state of the object, and the methods manipulate that state.
For both these concepts to work, the object's state must change over time. This change of state is evident in the lines
property after calling the read()
method. As an example, here's how we would use this class:
# example_file.txt contains 100 lines. lc = LineCounter('example_file.txt') print(lc.lines) >> [] print(lc.count()) >> 0 # The lc object must read the file to # set the lines property. lc.read() # The `lc.lines` property has been changed. # This is called changing the state of the lc # object. print(lc.lines) >> [['Hello world!', ...]] print(lc.count()) >> 100
The ever-changing state of an object is both its blessing and curse. To understand why a changing state can be seen as a negative, we have to introduce an alternative. The alternative is to build the line counter as a series of independent functions.
def read(filename): with open(filename, 'r') as f: return [line for line in f] def count(lines): return len(lines) example_lines = read('example_log.txt') lines_count = count(example_lines)
Working with pure functions
In the previous example, we were able to count the lines only with the use of functions. When we only use functions, we are applying a functional approach to programming which is, non-excitingly, called functional programming. The concepts behind functional programming requires functions to be stateless, and rely only on their given inputs to produce an output.
The functions that meet the above criteria are called pure functions. Here's an example to highlight the difference between pure functions, and non-pure:
# Create a global variable `A`. A = 5 def impure_sum(b): # Adds two numbers, but uses the # global `A` variable. return b + A def pure_sum(a, b): # Adds two numbers, using # ONLY the local function inputs. return a + b print(impure_sum(6)) >> 11 print(pure_sum(4, 6)) >> 10
The benefit of using pure functions over impure (non-pure) functions is the reduction of side effects. Side effects occur when there are changes performed within a function's operation that are outside its scope. For example, they occur when we change the state of an object, perform any I/O operation, or even call print()
:
def read_and_print(filename): with open(filename) as f: # Side effect of opening a # file outside of function. data = [line for line in f] for line in data: # Call out to the operating system # "println" method (side effect). print(line)
Programmers reduce side effects in their code to make it easier to follow, test, and debug. The more side effects a codebase has, the harder it is to step through a program and understand its sequence of execution.
While it's convienent to try and eliminate all side effects, they're often used to make programming easier. If we were to ban all side effects, then you wouldn't be able to read in a file, call print, or even assign a variable within a function. Advocates for functional programming understand this tradeoff, and try to eliminate side effects where possible without sacrificing development implementation time.
The Lambda Expression
Instead of the def
syntax for function declaration, we can use a lambda
expression to write Python functions. The lambda syntax closely follows the def
syntax, but it's not a 1-to-1 mapping. Here's an example of building a function that adds two integers:
# Using `def` (old way). def old_add(a, b): return a + b # Using `lambda` (new way). new_add = lambda a, b: a + b old_add(10, 5) == new_add(10, 5) >> True
The lambda
expression takes in a comma seperated sequences of inputs (like def
). Then, immediately following the colon, it returns the expression without using an explicit return statement. Finally, when assigning the lambda
expression to a variable, it acts exactly like a Python function, and can be called using the the function call syntax: new_add()
.
If we didn't assign lambda
to a variable name, it would be called an anonymous function. These anonymous functions are extremely helpful, especially when using them as an input for another function. For example, the sorted()
function takes in an optional key
argument (a function) that describes how the items in a list should be sorted.
unsorted = [('b', 6), ('a', 10), ('d', 0), ('c', 4)] # Sort on the second tuple value (the integer). print(sorted(unsorted, key=lambda x: x[1])) >> [('d', 0), ('c', 4), ('b', 6), ('a', 10)]