My In Depth Python Comprehension

Intro

In this section, I describe all my knowledge on Python I have managed to gather so far. It contains Python pure architecture, concepts and language-specific properties.

You can navigate to following concepts here:

Python

  • Pyhon is the interpreted programming language and its interpreter runs code from top to bottom, from right to left linearly. Almost everything is executable during run-time of code. This is in opostion to Java or C where entire code is complied by the compiler to the machine's bucekt of bits before it runs.
  • For example Python's class is simply executable code during runtime:
    for _ in range(10)
      class Base:
        pass
    
    - The code above defines class Base 10 times as the for loop goes.
  • Everything in Python is an object:
    def add(x,y):
      return x + y
    
    add
    # <function add at 0x002F6850>
    
    - Python interpreter can tell us where the function as the object is located in the memeory (output above).
  • Python supports both Object-oriented Programming (OOP) and Functional Programming (FP):

    OOP FP
    Object creation and manipulation with specific methods. Function can be used as first class object.
    Object-oriented featured like inheritance, encapsultaion, polymorphism. Supports lambda function characteristic for functional paradigm.
  • First class object:
    - function can be treated like an object,
    - function can be passed as an argument to another function.
    def square(x):
      return x*x
    
    def my_map(func, lst)
      result = []
      for i in lst:
        result.append(func(i))
      return result
    
    squares = my_map(square, [1,2,3,4,5])
    
    squares
    # [1,4,9,16,25]
    
    square function has been passed to the my_map unexecuted (without brackets).
    ✔ It's being executed later in my_map's body for every item in the passed list.

    - function can be assigned to a variable:
    sream = print
    
    scream("Hello!")
    # Hello!
    

    - function can be returned by another function:
    def outer_func()
      message = "Hello!"
    
      def inner_func():
        print(message)
    
      return inner_func
    
    welcome_func = outer_func()
    welcome_func()
    
    -------------
    # output
    # Hello!
    
    outer_func being executed returns inner_func that waits for its execution.
    inner_func is assigned to variable welcome_func which can be executed as the same as every function.
    inner_func is also so-called closure which is every inner function in the body of outer function. Closure remembers and has access to variables in the local scope it was created. Closure has an access to that variable even after the outer function has finished executing. In this case, inner_func can access member variable in the local scope of outer_fun.

  • Decorators:
    - Decorator is a function that takes another functions as an argument, extends its functionality and returns another function without altering the original function's code that was passed in.
    def decorator_fun(original_fun):
        def wrapper_fun(*args,**kwargs):
            print(f'Wrapper executed this before {original_fun.__name__} runs.')
            return original_fun(*args,**kwargs)
        return wrapper_fun
    
    def display_info():
        print('Displaying basic info')
    
    decorated_display_info = decorator_fun(display_info)
    decorated_display_info()
    
    --------------
    # output
    # Wrapper executed this before display_info runs.
    # Displaying basic info
    
    wrapper_fun is a closure that remembers passed argument to decorator_fun even though decorator_fun finishes execution first.
    ✔ Inside the wrapper_fun we append additional functionality before running original function.
    decorator_fun(display_info) returns wrapper_fun that is waiting to be executed and remembers display_info function as a passed argument to the decorator.
    ✔ Executing decorated_display_info() executes wrapper_fun returining original_fun being executed as well.
  • Pythonic way to use decorator:
    @decorator_fun
    def display_info():
        print('Displaying basic info')
    
    display_info()
    
    -------------
    # output
    # Wrapper executed this before display_info runs.
    # Displaying basic info
    
    @decorator_fun means decorator applied that extend basic display_info function.

Namespace

  • There is a variable scope hierarchy:
    1. Local scope (within a function).
    2. Enclosing scope (local scope of outer (enclosing) function).
    3. Global scope (top level of a module).
    4. Built-in.
  • Local scope within a function saves us from using and, most important, from overwriting global variables.
  • Working with namesapces:
    sream = print
    scream("Hello!")
    
    - Above code defines scream as alias for the exisiting python's print function.
    - Assignment of scream = print introduces the identifier scream into the current namespace with the value being the object that represents built-in function.
    - When assignment of an identifier (variable) to a value (object) happens, the definition in being made in the current namespace.
    - Python implements namespaces with ist own dictionary that maps each identifier to associated object.
    - When top-level assignment - we assign variable to an object in the global scope.
    - When assignment within a function - we assign variable to an object in the local scope.
    - Example: assignment x = 5 within a function has no effect on the identifier x outside.
  • Namespace resolution:
    - Python searches three scopes for a called name:
    1. the local (L),
    2. then the global (G),
    3. the built-in (B)
    and stops at the first place the name is found.
    - In practice, when we want to access atribute using dot operator syntax, the Python interpreter runs a name resolution process that looks up for a called name, checking:
    1. local instance scope,
    2. class scope,
    3. supercalss within inheritance hierarchy,
    4. it keeps seraching, otherwise it raises AttributeError when not found.

Object-oriented programming

  • Object is an entity encapsulating data with methods for manipulating the data.
  • Object implementation goes with:
    - instance dictionary: holds state (data) and points to its class,
    - class dictionary: holds functions (methods) for manipulating the data.
  • Special method __init__:
    - Calling the class during instatiation creates instance that is being referred by the class with a key-word self.
    - In fact, __init__ is not a constructor, it is an initializer that takes an instance self and populates it with attributes (data) that __init__ receives.
  • class User:
    
      def __init__(self, name, surname):
        self.name = name
        self.surname = surname
        self.email = name + '.' + surname + '@myshop.com'
    
    
    - The goal of instance variable self is to take unique data for the specific instance.
    - The goal of __init__ is to populate the instance with that data.
  • Inheritance:
    - ...
  • Subclass:
    - A class that delegates work to another class (parentclass).
    - A subclass and its parent-class are just two different dictionaries that contain functions to manipulate the data.
    - A sublcass points to ist parent due to flow: instance dictionary - > subclass dictionary -> parent-class dictionary.
    - Using subclasses can be used as a techique for code re-use.
    - In fact, a sublcass is in charge. It decides what work gets delegated.

Regular methods, class methods, static methods

  • Mehtods:
    Regular methods within a class take self as the first parameter. Regular methods manipulates object's data.
    Class methods take cls as the first parameter. Class methods manipulates class's data. It can also play a role of alternatice constructor (more below).
    Static methods takes nor self neither cls parameter. Static methods behaves like a regular function but they are included in a class body becasue of its logical connection to that class.
  • Class methods:
    class User:
    
        def __init__(self, first, last, gold):
            self.first = first
            self.last = last
            self.email = f'{first.lower()}.{last.lower()}@company.com'
    
        @classmethod
        def create_user_from_string(cls, str):
            first, last, gold = str.split("-")
            return cls(first, last, int(gold))
    
    user_1 = User('Artur', 'Skrzeta', 1000)
    user_2 = User.create_user_from_string('Jan-Nowak-2000')
    
    user_1.email
    # artur.skrzeta@company.com
    
    user_2.email
    # jan.nowak@company.com
    
    - object user_1 is created using __init__ method that populates object's attributes wiht passed arguments.
    - object user_2 is created using create_user_from_string classmethod which is an alternative constructor that can instantiate a class object with proper attributes.
  • Static methods:
    from datetime import date
    
    class User:
    
        def __init__(self, first, last, gold):
            self.first = first
            self.last = last
            self.gold = gold
            self.email = f'{first.lower()}.{last.lower()}@game.com'
    
        @staticmethod
        def is_workday(day):
            if day.weekday() == 5 or day.weekday() == 6:
                return False
            return True
    
        @property
        def gold(self):
            if User.is_workday(date.today()):
                return self.gold_with_bonus - 100
            else:
                return self.gold_with_bonus - 1000
    
        @gold.setter
        def gold(self, gold):
            if User.is_workday(date.today()):
                self.gold_with_bonus = gold + 100
            else:
                self.gold_with_bonus = gold + 1000
    
    user_1 = User('Artur', 'Skrzeta', 2000)
    
    user_1.gold
    # 2000
    
    user_1.gold_with_bonus
    # 3000
    
    - Static method connects to the class logic of assigning the bonuses to the user's account.
    - When account created on sunday or saturday, it gets 1000 of additional gold.
    - When account created on working day, it gets 100 of additional gold.
    - The instance's variable of gold stays with 2000 as it was initialized with such value.
    - However, the instance was created on the weeknd so the variable gold_with_bonus gets 3000.

Getters and setters

  • Getters ('accessors') and setters ('mutators') are used in OOP for aplying the encapsulation principle. Encapsulation means bundling attributes (data within a class) with the methods that operate on them. According to encapsulation principle, the attributes of a class have to be set as private to hide and protect them from the external code.
  • Getters an setters simply dictate the way of accessing the class's private data. In short, the getter is used for retrieving the data and the setter for changing the data within a class.
  • In Python the encapsulation matterw is solved as following:
    - getter: the method which is used for getting a value is decorated with @property,
    - setter: the method which has to function as the setter is decorated with @x.setter.
  • Code:
    class Circle():
    
        def __init__(self, radius):
            self.radius = radius
    
        @property
        def radius(self):
            return self.diameter / 2
    
        @radius.setter
        def radius(self, radius):
            self.diameter = radius * 2
    
    
    def main():
    
        c = Circle(2)
        print(c.diameter)
        print(c.radius)
    
        c.diameter = 6
        print(c.diameter)
        print(c.radius)
    
    
    if __name__ == "__main__":
        main()
    
    ---------------------
    # output
    # 4
    # 2
    # 6
    # 3
    
    
    - @radius.setter sets self.diameter with value radius * 2 while object creation.
    - To prove setter's work, I can acccess diameter attribute right away.
    - With @property I access diameter attribute but only the way radius method allows.
    - However, I don't have access to self.radius directly as it's taken over by @property getter.
    - self.radius is accepted only during initialization but diameter is being stored within a class or instances.
    - @property and @radius.setter encapsulates self.radius and makes diameter attribute accessible for external code.
  • Going with decorators and getting rid of getters and setters traditional methods makes a class shorter and running faster. This is perfect solution for dynamic languages, however not allowed in a compiled language.
  • Here comes another example that with @property decorator we can access method as an attribute:
    class Employee:
    
      def __init__(self, first, last):
        self.first = first
        self.last = last
    
      @property
      def company_email(self):
        return f'{self.first.lower()}.{self.last.lower()}@company.com'
    
      @property
      def full_name(self):
        return f'{self.first} {self.last}'
    
      @full_name.setter
      def full_name(self, name):
        first, last = name.split(' ')
        self.first = first
        self.last = last
    
    
    e = Employee('John', 'Smith')
    e.full_name
    # John Smith
    
    e.company_email
    # john.smith@company.com
    
    e.full_name = 'Johnny Smith'
    e.first
    # Johnny
    
    e.company_email
    # johnny.smith@company.com
    
    - Function company_email decorated with @property adds a special line of code to return a string in a proper form.
    - We can access returned value of company_email just like every instance's attributes.
    - With this line of code: e.full_name = 'Johnny Smith' we make use of the setter that establish self.first and self.last due to the logic it includes.
    - When we set full_name with setter, this variable cannot be put in the __init__ as self.full_name = "" or self.full_name = f'{first} {last}'.

Easier to Ask Forgiveness than Permission (EAFP)

  • When you ask for a permission then you have to access object multiple tims as opposed to asking for forgiveness where you access object once.
  • Let's take two classes on which we will be asking first for permission and then for forgiveness:
    class Robot:
    
      def talks(self):
        print("I'm talking.")
    
      def walk(self):
        print("I'm walking.")
    
    
    class Duck:
    
      def quack(self):
        print("Quack, quack!")
    
      def fly(self):
        print("Flap, flap!")
    
    
  • At first we will be asking for permission with if statements. We call it 'Look Before You Leap (LBYL)':
    def quack_and_fly(thing):
    
      if isinstance(thing,Duck):
        thing.quack()
        thing.fly()
      else:
        print("This has to be a duck.")
    
    d = Duck()
    quack_and_fly(d)
    
    r = Robot()
    quack_and_fly(r)
    
    ----------
    # output
    # Quack, quack!
    # Flap, flap!
    # This has to be a duck.
    
    ✔ We can see that object d is an insatnce of Duck type so we get the permission to execute Duck's methods on the objet.
    ✔ We can see that object r is not an instance of Duck type so we don't get the permission to execute Duck's methods on it.
    ✔ Notice that we have to access an object each time to check its type, and once we get the permission then we access the same object one more time.
  • Let's try to ask for forgiveness with try except block:
    def quack_and_fly(thing):
      try:
        thing.quack()
        thing.fly()
        thing.walk()
        thing.bark()
      except AttributeError as e:
        print(e)
    
    d = Duck()
    quack_and_fly(d)
    
    r = Robot()
    quack_and_fly(r)
    
    ------------
    # output
    # Quack, quack!
    # Flap, flap!
    #'Duck' object has no attribute 'walk'
    # 'Robot' object has no attribute 'quack'
    
    ✔ The main assumption of EAFP is trying to execute the code to see what happens: if it works - great, if not, then raise an error.
    ✔ This way we accessing object only once, when trying to retieve attribute on this.
  • One more comparison EAFP vs LBYL:
    
    person = {'name':'Json', 'age':23, 'job':'Software Developer'}
    
    # LBYL
    if 'name' in person and 'age' in person and 'job' in person:
      print(f"I'm {person['name']}, I'm {person['age']} and I work as {person['job']}")
    else:
      print("Missing some data!")
    
    # EAFP
    try:
      print(f"I'm {person['name']}, I'm {person['age']} and I work as {person['job']}")
    except KeyError as e:
      print(f"Missing {e} key")
    
    

Features

App includes following features:

  • Python

Demo

Varaiables name objects in the memory

Naming different objects:
  • Naming lists:
    list_1 = [1,2,3]
    list_2 = [1,2,3]
    
    id(list_1) == id(list_2)
    # False
    
    - list_1 and list_2 variables point to two different objects even though both list has the same values and they are in the same sizes.
  • Naming strings:
    str_1 = "Artur"
    str_2 = "Artur"
    
    id(str_1) == id(str_2)
    # True
    
    - str_1 and str_2 variables point to the same object in the memory.
    - They are two different tags for the same object.
is operator:
  • Returns TRUE if two variables point to the same object:
    a = 'Hello World'
    c = a
    
    type(a)
    # <class 'str'>
    
    id(a) == id(c)
    # True
    
    a is c
    # True
    
    - type() returns type of the object that passed variable is pointing to.
    - id() returns unique id of the object that variable is pointing to.

Immutability

  • Immutability means with every change to variable, a new object is being created.
  • Integers are immutable:
    a = 10
    b = a
    
    id(a) == id(b)
    # True
    
    a = 20
    
    id(a) == id(b)
    # False
    
    - Variables are only tags for memory locations that holds an object.
    - At the beginning variable with the name of a tags object 10 but further in the code the same name of the variable is then shifted to object 20.
    - When b = a then both variables points to the same object so id(a) == id(b) is True.
    - When a = 20 then this line creates a new object such that id(a) == id(b) is False.
    - So, immutability means that there is no possibility to overwrite one memory location. With every try of overwriting there is a new memory location being reserved.
  • Strings are immutable:
    srt1 = 'welcome'
    str2 = 'welcome'
    
    id(str1) == id(str2)
    # True
    
    str2 = str2 + ' to python'
    
    id(str1) == id(str2)
    # False
    
    - At the beggining where we initialize str1 and str2, they point to the same object.
    - Once str2 modified, then a new object is created in the new memory location.
    - So basically, 'welcome' is one object in the memory but 'welcome to python' is another object in defferent place in the memory.
  • Tuples are immutable:
    t1 = (1,2,3)
    t2 = t1
    t1 = (3,4,5)
    
    id(t1) == id(t2)
    # False
    
    - When t2 = t1 then both variables tags the same object of (1,2,3).
    - When t1 = (3,4,5) then it creates a totally new object in different memeory slot and tahs ith with t1 name.

Mutability

  • When mutating a mutable object, we work with the same object all the time (in the same place of memory).
  • List are mutable:
    list1 = [1,2,3,4]
    list2 = list1
    list1.append(10)
    
    list1
    # [1,2,3,4,10]
    
    list2
    # [1,2,3,4,10]
    
    id(list1) == id(list2)
    # True
    
    - variables: list1 and list2 points to the same obejct,
    - because of mutable nature of Python list, when appending, we still append to the same object no matter which variable we use to refer to it.
  • Let see short comparison of mutability with immutability:

    Mutability Immutability
    List, Set, Dictionary String, Number, Tuples
    Updating one memory location when modifing variable. Creating new memory location when modifing variable.
    Modifing the same object. Creating a new object.
Mutable default arguments:
  • Passing empty list as a defualt argument
    def add_employee(emp, emp_list=[]):
      emp_list.append(emp)
    
    add_employee.__defaults__
    # ([],)
    
    add_employee('Artur')
    add_employee.__defaults__
    # (['Artur'],)
    
    add_employee('John')
    add_employee.__defaults__
    # (['Artur', 'John'],)
    
    
    - Function creates empty list by default when we don't pass a list during the function's call.
    - Function add_employee creates empty list by default only once. As the list is a mutable object, it's being mutated by the function on every call.
  • Setting default list as None:
    def add_employee(emp, emp_list=None):
      if emp_list is None:
        emp_list = []
      emp_list.append(emp)
      return emp_list
    
    add_employee.__defaults__
    # (None,)
    
    add_employee('Artur')
    add_employee.__defaults__
    # (None,)
    
    add_employee('John')
    add_employee.__defaults__
    # (None,)
    
    - if condition in the add_employee function checks if a list passed to the function, if not then emp_list is None is True and it creates a new empty object list instead

Passing parameters to a function

Passing by Value Passing by Reference
Makes a copy of passed object. Passes an address of actual object.
Original object doesn't get modified. Original object changes.
Creating own memory location for a copied object. Accessing original object via address.
When immutable object being passed like string, integer, tuple. When mutable object being passed like a list.
Passing by Value:
  • Here is the example of passing string object:
    def fun(s1):
      s1 = "abcd"
      print(s1)
      print(id(s1))
    
    s1 = "xxx"
    print(s1)
    print(id(s1))
    
    fun(s1)
    
    print(s1)
    print(id(s1))
    ----------------
    # xxx
    # 11111
    
    # abcd
    # 22222
    
    # xxx
    # 11111
    
    - Variable s1 within the function is in local scope.
    - Variable s1 outside the function is in global scope.
    - Passing a string into fun makes a copy of the object and tags it with s1 as a local variable.
    - As a string is immutabele, code s1 = "abcd" creates a new object in the memory location with s1 tag name.
    - Doing change on a copy doesn't affect the original object tagged with global variable s1.
    - In the local scope s1 taggs string object of "abcd".
    - In the global scope s1 taggs string object of "xxx".
  • Here is another example of passing by value:
    def fun(s1):
      print(s1.upper())
      print(id(s1.upper()))
      print(s1)
      print(id(s1))
    
    s1 = 'Artur'
    fun(s1)
    
    print(s1)
    print(id(s1))
    ---------
    # output
    
    # 'ARUTR'
    # 2222222
    
    # 'Artur'
    # 3333333
    
    # 'Artur'
    # 3333333
    
    - upper() in fun doesn't change the original object but creates a copy instead.
    - Copied object in the local scope is unchanged by upper() as well as the original object in the global scope stays unchanged.
Passing by Reference:
  • It means when a function modifies an object that passed parameter refers to, the change also reflects back outside:
    def fun(my_list):
      my_list[0] = 99
      print(my_list)
      print(id(my_list))
    
    my_list = [1, 2, 3, 4, 5]
    
    fun(my_list)
    
    print(my_list)
    print(id(my_list))
    --------------
    # output
    # [99, 2, 3, 4, 5]
    # 43576936
    
    # [99, 2, 3, 4, 5]
    # 43576936
    
    - Above function gets parameter my_list as a reference to the object in the memory.
    - Modifying first element of it affects object outside the function.
    - We can see that ids of the object inside and ouside the function are the same.
  • Here is the example where argument is passed by reference and the reference itself is being overwritten inside the called function:
    def fun(my_list):
      my_list = [10, 11, 12, 13, 14]
      print(my_list)
      print(id(my_list))
    
    my_list = [1, 2, 3, 4, 5]
    
    fun(my_list)
    
    print(my_list)
    print(id(my_list))
    --------------
    # output
    # [10, 11, 12, 13, 14]
    # 43522222
    
    # [1, 2, 3, 4, 5]
    # 43522123
    
    - Variable my_list is in the local scope in the fun.
    - Variable my_list is in the global scope outside the fun.
    - By my_list = [10, 11, 12, 13, 14] we create the local list object in the memory.
    - However, the object outside the function stays untouched.
    - In the local scope of the function, my_list tags object: [10, 11, 12, 13, 14].
    - In the global scope outside the function, my_list tags object: [1, 2, 3, 4, 5].

Setup

No specific installation required.

Source Code

You can view the source code: HERE