Functional Programming in Python

Intro

In this, let's call it, post I put all my notes on functional programming in Python. However let's start with more general concepts in programming.

You can navigate to following concepts here:

Programming languages follow one of 2 paradigms:

Imperative Declarative
Supports procedural paradigm. Supports functional paradigm.
We list all the step-by-step instructions that tell the computer what to do with the program’s input. We write what we want and the implemented lanugage figures out how to perform the computation efficiently
C, PHP, all major languages. SQL, markup languages, CSS.

There are also two different ways of approaching problems in programming:

Object-oriented Programming Functional Programming
Program manipulates collections of objects. Program decomposes a problem into a set of functions.
Data and functions are groupped together in an object of a specific type. Function can change data of member variables. Data separated from functions. Functions don't modify any data.
Objects have internal state (attributes fed with data) that can be modified by object's methods. Functions accept inputs and produce outputs without any state that may affect a returned object.
Programs consist of making the right set of state changes. Programs avoid state changes as much as possible and works with data flowing between functions.
Java is object-oriented lanugage. C++ and Python support object-oriented programming, but don’t force the use it. Haksell, Scala.
How it goes in Python:
  • Python is multi-paradigm based.
  • We can write large procedural prgrams that executes code line by line from top to bottom. We can rely on object-oriented approach defining our own types or importing types from external libraries. We can either apply functional pragramming to put data intake throigh set of functions.
  • In large programs, different parts can be defined using different approaches like UI can be object-oriented while the whole logic is procedural or functinal way.
  • Functional style supports functions with no side effects that could modify internal state or make other changes (like print(), time.sleep()) that aren’t visible in the function’s return value. We define such functions as purely functional so they don't use any data structures that get updated as a program runs and the final returned output depens on an input solely.
  • Python provides a functional-appearing interface but will use non-functional features under the hood. For example, the implementation of a function will still use assignments to local variables, however it won't affect any global variables. Interestingly, this is in oposizton to a strictly functional languages which don't even have any assignment statements such as a = 3 or c = a + b. Another example is every functions that take and return instances representing objects.
Why to use Functional Programming:
  • Easiness of mathematical proof that a functional program is correct as functional prgramming is the closest to math possible style. However, mathematical proof doesn't mean unit testing where we give inputs and compare it with expected output. Can be used therotically, however in case of Python's program it's usually not in usage.
  • Modularity - this is a very practical rule of keepeing your code and thus breaking the problem apart into small pieces. Small functions with single responsibility is easier to read, debug, test and update. Each function is a unit for a test.
  • Reusability - all the small functions can act like sub-programs that can be used in different part of main program many times. We are able then to literally assemble new programs by arranging existing functions in a new configuration and writing a few functions specialized for the current task as needed.
Functional Programming constraints:
  • Functions are separated form data they operate on. Data must be passed into functions as arguments.
  • Functions should never make changes to data they touch. They should only return a modified copy.
  • In the functional programming we don't assign values in the way of naming the bucket in the memory that holds different values at different time. What we do instead, is we simply define a value. Here is what I mean:
    - when x = 3 then x is not a bucket or memory's container,
    - x is literally 3 like x is another name for 3,
    - analogously like pi means 3.14 by itself.
  • Conceptually, each value should be treated as a constant. However, Python doesn't have a dedicated constant key-word for preventing variables from being changed. Functional programming treats values as if they were concrete and unchangeable. For example the list my_list is defined once and whenever you want to update its values then you would need to create a new list and fill it up with modified original values.
  • This one is more like flexibility than constraint and it's called higher order functions. This kind of function can be freely used in Python where a function can accept other function as a parameter as well as a funciton can return anoter funcion as the output.

Features

App includes following features:

  • Python

Demo

Coding in OOP vs coding in FP:
  • OOP:
    class Person:
    
      def __init__(self, first, last):
        self.first = first
        self.last = last
        self.initials = f'{first[0]}.{last[0]}'
    
    
    person = Person('Artur', 'Skrzeta')
    person.first = 'Adam'
    
    - Updating first name of the object goes with accessint first attribute of person object.
  • FP:
    def create_person(first, last):
      return {
        'first': first,
        'last': last,
        'initials': f'{first[0]}.{last[0]}'
      }
    
    person = create_person('Artur', 'Skrzeta')
    updated_person = create_person('Adam'. person['last'])
    
    - We define a new constsnt updated_person and passing new value for first, however leaving last like it was with person['last'].
List of functions:
  • Wec can store functions in the list keeping their name without executing them:
    def double(x):
      return x*2
    
    def minus_one(x):
      return x-1
    
    def squared(x):
      return x*2
    
    my_number = 3
    function_list = [double, minus_one, squared]
    
    for func in function_list:
      print(func(my_number))
    
    ---------------
    # output
    # 6
    # 2
    # 9
    
Passing function as the argument:
  • We can pass function to another function:
    def combine_names(func):
      return func('Artur', 'Skrzeta')
    
    def append_wtih_space(str1, str2):
      return f'{str1} {str2}'
    
    def get_upper(first, last):
      return f'{first.upper()} {last.upper()}'
    
    print(combine_names(append_wtih_space))
    print(combine_names(get_upper))
    
    -----------
    # output
    # Artur Skrzeta
    # ARTUR SKRZETA
    
Returning a function by anoter function:
  • Every function can return another function:
    def create_multiplication(multiplier):
    
      def multiplication(number):
        return multiplier*number
    
      return multiplication
    
    
    double = create_multiplication(2)
    triple = create_multiplication(3)
    quadruple = create_multiplication(4)
    
    print(double(5))
    print(triple(5))
    print(quadruple(5))
    -------------
    # output
    # 10
    # 15
    # 20
    
    - Function multiplication is a type of closure, which is the inner function that remebera outer variable passed to outer function - in this case create_multiplication.
    - Function create_multiplication returns unexecuted function multiplication that is being executed further in the code passig number parameter tha is being multiplied by memorized multiplier parameter.
Wrapping function by another function:
  • Inner function bring some basic functionality and outer function extend this functionality with for example some logic.
  • What is the most important, outer function doesn't modify inner function's code. It simply appends the code.
  • This way, we can keep modularity of functions that are responsible only for one thing. It fulfil single responsibility rule and supports clean code approach.
  • Example:
    def divide(x,y):
      print(x / y)
    
    def check_if_divisor_isnt_zero(func):
      def safe_division(*args):
        if args[1] == 0:
          print('division by zero error')
          return
        return func(*args)
      return safe_division
    
    divide_with_check = check_if_divisor_isnt_zero(divide)
    
    divide_with_check(10, 0)
    # 'division by zero error'
    
    divide_with_check(10, 2)
    # 5
    
  • Function safe_division appends division by zero check and prevents from launching passed division function when second argument (divisor) is zero.
Map Function:
  • Map function takes each element of iterable (object that we can iterate through like a list) and passes through a given function.:
    def double(num):
      return num*2
    
    doubled_list = [map(double,[1,2,3,4,5])]
    print(doubled_list)
    ---------------------
    # output
    # [1,4,6,8,10]
    
  • Map functin doesn't change original sequence, it returns a modified copy due to function's code.
Filter function:
  • Takes each element of an iterable list and puts through a function that returns True/False. For every element that get True from a function is being copied to the new list filter returns.
  • def is_even(x):
      return x % 2 == 0
    
    numbs = [1,2,3,4,5,6,7,8]
    even_numbers = list(filter(is_even,[1,2,3,4,5,6,7,8]))
    
    print(numbs)
    print(even_numbers)
    -------------------
    # output
    # [1,2,3,4,5,6,7,8]
    # [2, 4, 6, 8]
    
  • According to above code, every even number gets True from is_even function.
  • Every elemetn that gets True is included in the filter result.
  • Every element that gets False is excluded from the filter result.
Lambda function:
  • Nameless one line function with no return key-word:
    add = lambda x,y: x+y
    
    print(add(2,3))
    ------------------
    # output
    # 5
    
  • We can define it inside other expression (here map function):
    doubled_numbers = list(map(lambda x: x*2, [1,2,3,4,5]))
    
    print(doubled_numbers)
    ------------------
    # output
    # [2, 4, 6, 8, 10]
    
Reduce function:
  • Reduces an iterable object (a list) to a single value:
    get get_sum(acc, val):
      return acc + val
    
    sum = reduce(get_sum, [1,2,3,4,5])
    
    print(sum)
    ------------
    # output
    # 15
    
    - reduce function reduces entire iterable due to passed function of get_sum.
    - First argument acc of get_sum is a value of accumulated values that grows as we go trough the iterable.
    - Second argument val of get_sum is a value that we want to add to accumulated values that we get so far.
Working with structured data:
  • Here I demonstrate how to work with data and get results without changing the status as I was explaining in the intro.
  • Code:
    employees = [
      {
        'name': 'Artur',
        'salary': 11000,
        'title': 'Data Analyst'
      },
      {
        'name': 'John',
        'salary': 15900,
        'title': 'Software Developer'
      }
    ]
    
    def id_developer(employee):
      return employee['title'] == 'Software Developer'
    
    def get_salary(employee):
      return employee['salary']
    
    developers_salary = [get_salary(e) for e in employees if id_developer(e)]
    print(developers_salary)
    ----------
    # output
    # 15900
    
    - We retrieve a needed information without amending employees list. So no changing of status requirement is fulfulled due to paradigm of functinal programming.
    - Getting developers_salary I apply the list comprehension which is Pythonic way of filtering, transformin or reducing data in the original list.
    - [get_salary(e) for e in employees if is_developer(e)] means get a list in which each element is the outcome of get_salary function that is being executed for every element of employees list that passes if condition of is_developer.
List comprehension:
  • Readable way of both filter and transform elements in a list.
  • We can transform each element getting transformed copy of original list.
  • We can get subset of original list under given condition that needs to be passed by each element of it.
  • It combines map and filter function successfully.
  • Code:
    def square(x):
      return x*x
    
    squared = [square(x) for x in [1,2,3,4,5,6,7,8,9,10] if x % 2 == 0]
    print(squared)
    ---------------
    # output
    # [4, 16, 36, 64, 100]
    
    - Final outcome of above code snippet is squared list.
    - List comprehension executes suqare function on each element in the original list as long as the element is even (the rest of devison by 2 eqauls 0).
Iterators and iterables:
  • Iterable:
    - an object that can be looped through,
    - there is a special method that makes it iterable and this is __iter__(),
    - a loop calls __iter__ on the iterable object returning iterator.
  • Iterator:
    - is an object with the state,
    - it remembers where it is during the iteration,
    - it knows how to get its next value with special method __next__(),
    - iterator can only go forward by calling __next__,
    - if there is no next value then raises stop iteration exception,
    - for example an object of list doesn't have __next__ method so it isn't the iterator:
    lst = [1,2,3]
    
    next(lst)
    # TypeError: 'list' object is not an iterator
    
    lst.__next__()
    # AttributeError: 'list' object has no attribute '__next__'
    
    - getting iterator from the iterable object:
    lst = [1,2,3]
    
    i_lst = iter(lst)
    
    next(i_lst)
    # 1
    
    next(i_lst)
    # 2
    
    next(i_lst)
    # 3
    
    next(i_lst)
    # StopIteration
    
    - Calling iter on the iterable list returns the iterator.
    - Calling next on the iterator object gives following values of iterable.
    - Iterator remebers where it stops between every single value yielding.
    - Between values yielding, iterator gives back control to the user's code.
    - Iterator gets values one by one until exceeding number of all values, then hits StopIteration exception.
Generators:
  • At first lets understand the difference between generators and regular functions:
Regular functions Generators
Computes all values and returns them at once. Returns an iterator that returns a stream of values.
Keeps storage for a resulting values. No additional storage as it provides values one by one when we ask for it.
You need to wait for the result till function execution done completely. You are getting values on the fly one by one.
Outside code gets control back when function's execution completed. Gives control back to the caller on every yield.
When reaching return statement, the local variables get removed When reaching yield statement, the execution state is frozen and local variables are preserved.
  • Regular function:
    - When function is being called, it gets a private namespace along with local variables. Once the function reaches a return, the local variables are destroyed and the value is returned to the caller.
    - Another call to the same function recreates a new private namespace with fresh set of local variables.
    - Here is the example of a regular function:
    def generate_integers(n):
      new_list = []
      for i in range(1,n+1):
        new_list.appned(i)
      return new_list
    
    - Above function gives entire result at once, with the same time of execution and with the same memory space taken.
    - Doesn't matter if I want to get first value or 5 first values, if execution time is 5 seconds, then I need to wait 5 seconds for the whole computation to be done.
    - And even though I might care about first value of 5 first values, I still get entire list as the result.
    - The bigger the loop, the more time I need to spend waiting to even start processing the very first element of the output.
    - I'm blocked for the time the function is being executed completely.
  • Generator:
    - Any function containing a yield keyword is a generator function what can be recognized by Python interpreter.
    - Generator implementation:
    def generate_integers(n):
      for i in range(1, n + 1):
        yield i
    
    gen = generate_integers(3)
    
    next(gen)
    # 1
    
    next(gen)
    # 2
    
    next(gen)
    # 3
    
    next(gen)
    # StopIteration
    
    - Generators don't hold the result in the memory, they provide one value at a time.
    - It doesn't have a storage for gathering results of each for loop iteration.
    - With every next we get a value yielded by generate_integers.
    - Between each call of next, generator gives control back to the outside code.
  • With generator we can set the workflow sequence:
    1. User's code runs.
    2. Entering generator looking for a value.
    3. Generator runs its code.
    4. Generator yields back to the user's code.
    5. User's code takes it and processes it at once.
    6. Going back to the generator asking for another value to yield.
  • Making a generator from the list comprehension:
    - with this you get all results in a list at once:
    my_nums = [x*x for x in[1,2,3,4]]
    - with this you get generator:
    my_nums = (x*x for x in[1,2,3,4])
    - notice, the difference is only in rhe outer brackets.
*args and **kwargs
  • Putting *arg in the function definition allows to provide as many positional arguments as you need at any given moment. This is very convenient way of passing varying number of arguments instead of passing them within one list whose size needs to be known up front.
    def count_sum(*args)
      result = 0
      for i in args:
        result += i
      return result
    
    count_sum(1,2)
    # 3
    
    count_sum(1,2,3,4,5,6,7)
    # 28
    
    - Function count_sum takes all the passed parameters (regardless of its count) and packs them all into a single iterable object named args.
    - However, args is not an obligatory name, it can be replaced with different names as *numbers or anything with unpacking operator: * preceding.
    - The iterable that function loops through is a tuple which is an immutable object and thus its values cannot be changed after assignment.
  • Putting **kwargs in the function definition we pass keywords (named) arguments. As the same for *args, the number of passed keyword arguments can vary.
    def introduce_person(**kwargs):
      for key, value in kwargs.items():
          print(f'{key}: {value}')
    
    introduce_person(firstname="Artur", lastname="Skrzeta", age=27)
    # firstname: Artur
    # lastname: Skrzeta
    # age: 27
    
    introduce_person(firstname="John", email="john@company.com")
    # firstname: John
    # email: john@company.com
    
    - Function introduce_person accepts keyword arguments in form of a regular dictionary.
    - We can replace kwargs with any name as long as it's preceded by unpacking operator of *.
    - To iterate dictionary's keys and values, we must to call .items() on it.

Setup

No specific installation required.

Source Code

You can view the source code: HERE