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 definesclass Base10 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]
✔squarefunction has been passed to themy_mapunexecuted (without brackets).
✔ It's being executed later inmy_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_funcbeing executed returnsinner_functhat waits for its execution.
✔inner_funcis assigned to variablewelcome_funcwhich can be executed as the same as every function.
✔inner_funcis 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_funccan accessmembervariable in the local scope ofouter_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_funis a closure that remembers passed argument todecorator_funeven thoughdecorator_funfinishes execution first.
✔ Inside thewrapper_funwe append additional functionality before running original function.
✔decorator_fun(display_info)returnswrapper_funthat is waiting to be executed and remembersdisplay_infofunction as a passed argument to the decorator.
✔ Executingdecorated_display_info()executeswrapper_funreturiningoriginal_funbeing 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_funmeans decorator applied that extend basicdisplay_infofunction. 
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 definesscreamas alias for the exisiting python'sprintfunction.
- Assignment ofscream = printintroduces the identifierscreaminto 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: assignmentx = 5within a function has no effect on the identifierxoutside. - 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
- objectuser_1is created using__init__method that populates object's attributes wiht passed arguments.
- objectuser_2is created usingcreate_user_from_stringclassmethod 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 ofgoldstays with 2000 as it was initialized with such value.
- However, the instance was created on the weeknd so the variablegold_with_bonusgets 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.settersetsself.diameterwith valueradius * 2while object creation.
- To prove setter's work, I can acccessdiameterattribute right away.
- With@propertyI accessdiameterattribute but only the wayradiusmethod allows.
- However, I don't have access toself.radiusdirectly as it's taken over by@propertygetter.
-self.radiusis accepted only during initialization butdiameteris being stored within a class or instances.
-@propertyand@radius.setterencapsulatesself.radiusand makesdiameterattribute 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 
@propertydecorator 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
- Functioncompany_emaildecorated with@propertyadds a special line of code to return a string in a proper form.
- We can access returned value ofcompany_emailjust like every instance's attributes.
- With this line of code:e.full_name = 'Johnny Smith'we make use of the setter that establishself.firstandself.lastdue to the logic it includes.
- When we setfull_namewith setter, this variable cannot be put in the__init__asself.full_name = ""orself.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 objectdis an insatnce ofDucktype so we get the permission to execute Duck's methods on the objet.
✔ We can see that objectris not an instance ofDucktype 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:
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_1andlist_2variables 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_1andstr_2variables 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 ofatags object10but further in the code the same name of the variable is then shifted to object20.
- Whenb = athen both variables points to the same object soid(a) == id(b)isTrue.
- Whena = 20then this line creates a new object such thatid(a) == id(b)isFalse.
- 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 initializestr1andstr2, they point to the same object.
- Oncestr2modified, 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
- Whent2 = t1then both variables tags the same object of(1,2,3).
- Whent1 = (3,4,5)then it creates a totally new object in different memeory slot and tahs ith witht1name. 
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:list1andlist2points 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.
- Functionadd_employeecreates 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 theadd_employeefunction checks if a list passed to the function, if not thenemp_list is NoneisTrueand 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
- Variables1within the function is in local scope.
- Variables1outside the function is in global scope.
- Passing a string intofunmakes a copy of the object and tags it withs1as a local variable.
- As a string is immutabele, codes1 = "abcd"creates a new object in the memory location withs1tag name.
- Doing change on a copy doesn't affect the original object tagged with global variables1.
- In the local scopes1taggs string object of "abcd".
- In the global scopes1taggs 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()infundoesn't change the original object but creates a copy instead.
- Copied object in the local scope is unchanged byupper()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 parametermy_listas 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
- Variablemy_listis in the local scope in thefun.
- Variablemy_listis in the global scope outside thefun.
- Bymy_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_listtags object:[10, 11, 12, 13, 14].
- In the global scope outside the function,my_listtags object:[1, 2, 3, 4, 5]. 
Setup
No specific installation required.