This document covers Python from the basics to advanced topics, complete with one‐liner definitions, detailed examples, and best practices.
- Definition: Print statements display output and the built-in
id()returns the memory address of an object. - Example:
name = "taha" print(id(name)) # Output: Memory address of the variable 'name'
- Single-line comment:
# This is a single-line comment - Multi-line comment:
""" This is a multi-line comment. It can span multiple lines. """
- Definition: Variables store data values; Python supports several built-in types.
- Common Types:
int,float,str,bool,list,tuple,set,dict - Type Hints & Naming Conventions (PEP-8, snake_case):
name: str = "Taha" age: int = 28 height: float = 5.9 is_student: bool = True
- Arithmetic:
+,-,*,/,**(exponent),//(floor division),%(modulus) - Comparison:
==,!=,>,<,>=,<= - Logical:
and,or,not - Membership:
in,not in - Identity:
is,is not - Example:
a, b = 5, 10 # Arithmetic Operators print(a ** 2) # Output: 25 (Exponentiation) print(b // a) # Output: 2 (Floor division) print(a % b) # Output: 5 (Modulus) # Comparison Operators print(a == b) # Output: False (Equal) print(a != b) # Output: True (Not equal) print(a > b) # Output: False (Greater than) print(a < b) # Output: True (Less than) print(a >= b) # Output: False (Greater than or equal to) print(a <= b) # Output: True (Less than or equal to) # Logical Operators print(a < b and a != b) # Output: True (Both conditions are true) print(a > b or a != b) # Output: True (One condition is true) print(not (a == b)) # Output: True (Negates the equality check) # Membership Operators my_list = [1, 2, 3, 4, 5] print(a in my_list) # Output: False (5 is not in the list) print(b not in my_list) # Output: True (10 is not in the list) # Identity Operators x = [1, 2, 3] y = [1, 2, 3] z = x print(x is y) # Output: False (Different objects in memory, even if they have the same content) print(x is z) # Output: True (Same object in memory) print(x is not y) # Output: True (x and y are different objects)
- Example:
name = input("Enter your name: ") print(f"Hello, {name}")
- Definition: A string is a sequence of characters enclosed in quotes.
- Common Methods:
.lower(),.upper(),.capitalize(),.title(),.strip(),.index(),.count(),.replace(),.split(),.join(),.isalnum() - Examples:
text = " Hello World " # .lower(): Converts all characters to lowercase print(text.lower()) # Output: " hello world " # .upper(): Converts all characters to uppercase print(text.upper()) # Output: " HELLO WORLD " # .capitalize(): Capitalizes the first character of the string print(text.capitalize()) # Output: " hello world " # .title(): Capitalizes the first letter of each word print(text.title()) # Output: " Hello World " # .strip(): Removes any leading and trailing spaces print(text.strip()) # Output: "Hello World" # .index(): Finds the first occurrence of a substring and returns its index print(text.index("World")) # Output: 6 # .count(): Returns the number of occurrences of a substring print(text.count("o")) # Output: 2 # .replace(): Replaces a substring with another substring print(text.replace("World", "Python")) # Output: " Hello Python " # .split(): Splits the string into a list of substrings based on a separator print(text.split()) # Output: ['Hello', 'World'] # .join(): Joins a list of strings into a single string with a separator words = ["Hello", "Python"] print(" ".join(words)) # Output: "Hello Python" # .isalnum(): Checks if all characters in the string are alphanumeric (letters or numbers) print(text.isalnum()) # Output: False (because of spaces)
- Definition: A list is an ordered, mutable collection of items.
- Common Operations & Methods:
- Creation & Access:
my_list = [1, 2, 3] print(my_list[0]) # Output: 1
- Modifying Lists:
fruits = ["apple", "banana", "cherry"] fruits.append("orange") # Add element at the end fruits.insert(1, "mango") # Insert at a specific index fruits.extend(["kiwi", "grape"]) # Merge lists fruits.pop() # Remove last element fruits.pop(1) # Remove element at index 1 fruits.remove("apple") # Remove by value fruits.sort() # Sort list fruits.reverse() # Reverse list print(fruits)
- Other Methods:
.count(value),.clear(),.copy(), slicing (list[start:end]), and repeating elements ([value] * number)
- Creation & Access:
- Definition: Iterates over items of a sequence.
- Example:
my_list = [1, 2, 3, 4, 5] for item in my_list: print(item) # Using enumerate to get index and value for index, value in enumerate(my_list): print(f"Index {index}: {value}")
- Definition: Repeats as long as a condition is true.
- Example:
count = 1 while count <= 5: print(count) count += 1
- Definition: The
elseblock executes if the loop completes normally (without abreak). - Example:
for i in range(3): print(i) else: print("Loop completed")
- Definition: A tuple is an ordered, immutable sequence of values.
- Example:
names: tuple[str, str, str] = ("Taha", "Ahmed", "Alex") print(names[0]) # Output: Taha # Tuples support slicing like lists. print(names[0:2]) # Output: ("Taha", "Ahmed")
A tuple is an immutable sequence, meaning you cannot change its content after creation. Because of its immutability, tuples have only a couple of built-in methods:
index(value)
Returns the first index of the specified value.count(value)
Returns the number of times a specified value appears in the tuple.
Since tuples are ordered, you can access values using indexing or slicing:
# Creating a tuple
my_tuple = (10, 20, 30, 40, 30)
# Accessing values using indexing
print(my_tuple[2]) # Output: 30
# Using the index() method to find the first occurrence of a value
first_index = my_tuple.index(30)
print(first_index) # Output: 2
# Counting the occurrence of a value
count_30 = my_tuple.count(30)
print(count_30) # Output: 2
# Slicing a tuple
print(my_tuple[1:4]) # Output: (20, 30, 40)- Definition: A set is an unordered collection of unique items.
- Example:
my_set: set[str] = {"a", "b", "c", "a"} print(my_set) # Output: {'a', 'b', 'c'} (duplicates removed) # Common methods: my_set.add("d") my_set.remove("b") print(my_set)
A set is an unordered collection of unique elements. Because it is unordered, you cannot access elements via indexing. Instead, you iterate over the set or use membership tests.
add(element)
Adds an element to the set.remove(element)
Removes the specified element. Raises aKeyErrorif the element is not present.discard(element)
Removes the specified element if it exists. Unlikeremove(), it does not raise an error if the element is missing.pop()
Removes and returns an arbitrary element from the set.clear()
Removes all elements from the set.update(other_set)
Adds elements from another set (or any iterable) to the set.union(other_set),intersection(other_set),difference(other_set), andsymmetric_difference(other_set)
Return new sets based on set operations.
Since sets do not support indexing, you typically iterate over them:
# Creating a set
my_set = {1, 2, 3, 4, 5}
# Iterating over a set to access its values
for value in my_set:
print(value)
# Adding an element
my_set.add(6)
print(my_set) # Output will include 6 (order is not guaranteed)
# Removing an element using remove() and discard()
my_set.remove(2) # Raises KeyError if 2 is not present
my_set.discard(3) # Does nothing if 3 is not present
# Popping an element (removes and returns an arbitrary element)
popped_value = my_set.pop()
print(f"Popped: {popped_value}")
print(my_set)Yes, because sets are unordered collections, they don’t support direct indexing like lists. However, sets have specific use cases where they are extremely useful:
-
Uniqueness Guarantee (No Duplicates)
- If you need to store unique values and automatically remove duplicates, sets are perfect.
my_set = {1, 2, 3, 3, 4} print(my_set) # Output: {1, 2, 3, 4} (Duplicates are removed)
-
Fast Membership Checking (
O(1)Complexity)- Checking if an element exists in a set is much faster than lists because sets use hashing.
my_set = {"apple", "banana", "cherry"} print("banana" in my_set) # Output: True (Fast lookup)
The key reason lists and sets have different searching times is their underlying data structures.
- Lists are implemented as dynamic arrays.
- Searching for an element requires checking each item one by one (linear search), making the time complexity O(n) in the worst case.
- Sets use a hash table (unordered collection).
- When searching for an element, Python computes its hash value and looks it up in constant time, making the average time complexity O(1).
- However, in the worst case (when many elements collide in the same hash bucket), lookup time can degrade to O(n).
Hashing is a technique used to map data (like strings or numbers) to a fixed-size value called a hash code or hash value using a hash function.
Imagine you are storing names in a dictionary. Instead of searching through the entire list, a hash function converts each name into a unique index:
| Name | Hash Value (Index) |
|---|---|
| "Alice" | 102 |
| "Bob" | 215 |
| "Charlie" | 178 |
When searching for "Charlie," instead of checking each name one by one, the system directly jumps to index 178, making it much faster.
Non-hashing refers to data structures that do not use hash functions for lookup, such as:
- Lists (
list) → Use linear search (O(n))
- Hashing is fast but requires extra memory.
- Lists (non-hashing) are slower but maintain order.
- If you need fast lookups, use a set or dictionary (
dict). - If order matters, use a list or tree-based structures.
A bucket is a storage location inside a hash table where multiple values can be stored if they share the same hash code.
- When inserting a value in a set or dictionary, Python computes a hash value using the built-in
hash()function. - This hash value determines which bucket the value will go into.
- Because hash values are mapped to a limited number of buckets, multiple values may end up in the same bucket, causing a hash collision.
Since the number of possible hash values is large but not infinite, and the number of buckets is fixed, different values can sometimes get the same hash value modulo the number of buckets.
Let's say our hash function maps values to only 5 buckets:
| Value | Hash Code | Bucket (Hash Code % 5) |
|---|---|---|
| "Alice" | 102 | 102 % 5 = 2 |
| "Charlie" | 178 | 178 % 5 = 3 |
| "Eve" | 157 | 157 % 5 = 2 |
| "Tom" | 45 | 45 % 5 = 0 |
- Here, "Alice" and "Eve" both go into bucket 2, creating a hash collision.
Python resolves hash collisions using chaining (linked lists inside buckets):
- Each bucket starts as empty.
- When inserting an item:
- If the bucket is empty, the item is placed directly.
- If the bucket is already occupied (collision), Python stores the new value in a linked list at that bucket.
- When searching for an item:
- Python computes the hash value and finds the correct bucket.
- If multiple values are in the same bucket, it checks each one sequentially inside the bucket.
Imagine we have 3 buckets, and we insert three names:
| Name | Hash Code | Bucket (Hash Code % 3) |
|---|---|---|
| "Alice" | 102 | 102 % 3 = 0 |
| "Bob" | 205 | 205 % 3 = 2 |
| "Charlie" | 306 | 306 % 3 = 0 |
Now, bucket 0 contains both "Alice" and "Charlie":
Bucket 0 → ["Alice", "Charlie"]
Bucket 1 → []
Bucket 2 → ["Bob"]
If we search for "Charlie":
- Compute the hash (
306). - Find bucket
0. - Check each item in the bucket to find "Charlie".
Python's hash table is optimized:
- It dynamically resizes when too many elements are added.
- It increases the number of buckets, reducing collisions.
- It uses a prime number of buckets for better distribution.
✔ Buckets store values based on their hash codes.
✔ Hash collisions happen when multiple values share the same bucket.
✔ Python resolves collisions using chaining (linked lists inside buckets).
✔ The more buckets available, the fewer collisions occur.
Would you like me to show a Python example of how a simple hash table with buckets works?
-
Set Operations (Union, Intersection, Difference)
- Sets allow powerful mathematical operations:
set1 = {1, 2, 3} set2 = {3, 4, 5} print(set1 | set2) # Union → {1, 2, 3, 4, 5} print(set1 & set2) # Intersection → {3} print(set1 - set2) # Difference → {1, 2}
-
Eliminating Duplicates from a List
- If you have a list with duplicates, you can convert it into a set to remove them:
my_list = [1, 2, 2, 3, 4, 4, 5] unique_values = set(my_list) print(unique_values) # Output: {1, 2, 3, 4, 5}
-
Efficient Data Storage When Order Doesn’t Matter
- If you don’t need ordering but require fast lookups and uniqueness, sets are ideal.
- If you need to keep elements in order, use a
list. - If you need indexed access, use a
listortuple.
Use a set when:
- You need unique values.
- You want fast lookups.
- You need set operations (union, intersection, difference).
-
Definition: A dictionary stores data in key-value pairs.
-
Examples:
# Creation and basic operations my_dict: dict[str, any] = {"name": "Taha", "age": 28} print(my_dict["name"]) # Access: Output -> Taha my_dict["name"] = "Ahmed" # Modify my_dict["city"] = "Cairo" # Add new key-value pair # Deletion del my_dict["age"] # or using pop: my_dict.pop("city", None) # Safe access using get() print(my_dict.get("name", "Default Name")) # Iterating over keys, values, and items for key, value in my_dict.items(): print(f"{key}: {value}")
my_dict = {"name": "Taha", "age": 22, "city": "Lahore"} for key in my_dict: # Default: Iterates over keys print(key)
Output:
name age cityBy default, iterating over a dictionary only gives the keys.
for value in my_dict.values(): print(value)
Output:
Taha 22 LahoreHere,
.values()returns the dictionary's values.
for key, value in my_dict.items(): print(f"{key}: {value}")
Output:
name: Taha age: 22 city: Lahore.items()is necessary when you want both the keys and values in one iteration.
- You can iterate over a dictionary without
.items(), but you'll only get the keys. - If you need both the keys and values, you must use
.items().
- You can iterate over a dictionary without
The line:
print(my_dict.get("name", "Default Name"))is using the .get() method of a dictionary to safely retrieve the value of the "name" key.
If the key exists, .get() returns its value:
my_dict = {"name": "Taha", "age": 28}
print(my_dict.get("name", "Default Name")) # Output: TahaIf the key does not exist, .get() does not raise an error (unlike my_dict["key"]); instead, it returns the default value:
my_dict = {"age": 28}
print(my_dict.get("name", "Default Name")) # Output: Default NameIf the key does not exist, Python will throw a KeyError:
my_dict = {"age": 28}
print(my_dict["name"]) # ❌ KeyError: 'name'Use .get(key, default_value) when:
- You want to avoid errors if the key is missing.
- You need a default fallback value when the key is not found.
- Definition: A concise way to create lists, sets, or dictionaries.
- Example:
squared = [x**2 for x in range(10)] print(squared)
squared = [x**2 for x in range(10)]even_numbers = [x for x in range(20) if x % 2 == 0]Output: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
words = ["hello", "world", "python"]
uppercase_words = [word.upper() for word in words]Output: ['HELLO', 'WORLD', 'PYTHON']
🔹 Yes, list comprehensions are frequently used in Python because they are fast, concise, and readable.
🔹 However, avoid using them when the logic is too complex, as it can reduce readability.
- Examples:
unique_nums = {x for x in range(10)} print(unique_nums) squared_dict = {x: x**2 for x in range(5)} print(squared_dict)
A set comprehension is useful when you need a collection of unique values.
unique_nums = {x for x in range(10)}
print(unique_nums)🔹 Use Case: Removing duplicates from a list.
numbers = [1, 2, 3, 4, 5, 5, 6, 6, 7]
unique_numbers = {x for x in numbers}
print(unique_numbers) Output: {1, 2, 3, 4, 5, 6, 7}
✅ Why use set comprehension?
- Removes duplicate values automatically.
- Faster than converting a list to a set manually.
A dictionary comprehension is useful when you need to create a key-value mapping.
squared_dict = {x: x**2 for x in range(5)}
print(squared_dict)Output: {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
🔹 Use Case: Counting word frequency in a sentence.
sentence = "hello world hello python"
word_count = {word: sentence.split().count(word) for word in sentence.split()}
print(word_count)Output: {'hello': 2, 'world': 1, 'python': 1}
✅ Why use dictionary comprehension?
- Makes key-value pairs efficiently.
- Useful for transforming or filtering data.
✔ Set Comprehension: When you need unique values.
✔ Dictionary Comprehension: When you need to create mappings efficiently.
- Definition: A small anonymous function defined with the
lambdakeyword. - Example:
add = lambda a, b: a + b print(add(2, 3)) # Output: 5
- Example:
numbers = [1, 2, 3, 4] squared = list(map(lambda x: x ** 2, numbers)) print(squared) # Output: [1, 4, 9, 16]
- Example:
even_numbers = list(filter(lambda x: x % 2 == 0, numbers)) print(even_numbers) # Output: [2, 4]
- Example:
from functools import reduce result = reduce(lambda x, y: x + y, numbers) print(result) # Output: 10
-
Definition: A block of reusable code that performs a specific task.
-
Example:
def greet(name: str) -> str: return f"Hello, {name}" # Calling the function print(greet("Taha")) # Output: Hello, Taha # Different types of arguments: def func(pos1, pos2, *args, **kwargs): print(pos1, pos2) print(args) print(kwargs) func("first", "second", "extra1", "extra2", key1="value1", key2="value2")
Here's a concise example demonstrating all parameter types:
def demo(a, b, *args, greeting="Hello", **kwargs): print("Positional:", a, b) # Required positional arguments print("Extra positional:", args) # Arbitrary positional parameter (*args) print("Greeting:", greeting) # Keyword argument with a default value print("Extra keyword:", kwargs) # Arbitrary keyword parameter (**kwargs) # Use case: mixing different argument types demo(10, 20, 30, 40, greeting="Hi", extra="value")
Output:
Positional: 10 20 Extra positional: (30, 40) Greeting: Hi Extra keyword: {'extra': 'value'}a, b: Required positional arguments.*args: Captures extra positional arguments.greeting: A keyword parameter with a default value.**kwargs: Captures extra keyword arguments.
This pattern is useful when you need flexibility in the number and type of arguments your function accepts.
-
Definition: Modules allow you to organize and reuse code across multiple files.
-
Examples:
# Importing a module import math print(math.sqrt(25)) # Output: 5.0 # Importing specific functions or using aliases from math import pow print(pow(2, 3)) # Output: 8 import datetime as dt print(dt.datetime.now())
-
Example of a Python script:
def main(): print("Hello from project-01!") if __name__ == "__main__": main()
Explanation:
def main():: Defines a function namedmainthat prints a message.if __name__ == "__main__":: This ensures themainfunction only runs when the script is executed directly, not when imported as a module.- Why use this? It makes your script more modular, allowing you to reuse functions in other scripts without automatically running the code.
Example of importing and using the script:
import project_01 # Nothing prints, but you can call project_01.main() project_01.main() # Output: Hello from project-01!
'''
- Definition: Handling errors gracefully using try-except blocks.
- Examples:
try:
result = 10 / 0
except ZeroDivisionError:
print("Caught ZeroDivisionError: Cannot divide by zero.")try:
lst = [1, 2, 3]
result = lst[5]
except IndexError:
print("Caught IndexError: List index is out of range.")try:
dct = {"a": 1, "b": 2}
result = dct["c"]
except KeyError:
print("Caught KeyError: The key does not exist in the dictionary.")try:
result = int("not a number")
except ValueError:
print("Caught ValueError: Invalid value for conversion.")try:
with open("non_existent_file.txt", "r") as file:
result = file.read()
except FileNotFoundError:
print("Caught FileNotFoundError: File not found.")class CustomError(Exception):
pass
try:
raise CustomError("This is a custom error message.")
except CustomError as e:
print(f"Caught CustomError: {e}")Object-Oriented Programming (OOP) is a programming paradigm that organizes code into “objects” and “classes,” making it easier to manage and scale complex programs. This guide is designed for beginners and intermediate learners, offering clear explanations, real-world examples, and best practices.
- Definition: OOP is a programming style that uses objects to represent data and methods to manipulate that data.
- Importance: It helps in structuring code, promotes reusability, and makes it easier to maintain and extend applications.
- Analogy: Think of a class as a blueprint for a house. The blueprint defines the structure, while each house built from that blueprint (object) can have unique characteristics like color or furniture.
- Objects & Classes: Just as houses built from the same blueprint share features but can differ in details, objects are instances of classes that share attributes and methods but can hold individual data.
A class is a template, and an object is an instance of that class.
class Person:
def __init__(self, name: str, age: int):
self.name = name # Instance attribute for name
self.age = age # Instance attribute for age
def greet(self):
# Instance method that returns a greeting message
return f"Hello, my name is {self.name} and I am {self.age} years old."
# Creating an object of Person
person1 = Person("Alice", 30)
print(person1.greet()) # Output: Hello, my name is Alice and I am 30 years old.Inheritance allows a class to inherit attributes and methods from another class.
class Student(Person):
def __init__(self, name: str, age: int, student_id: int):
# Call the parent class's __init__ method using super()
super().__init__(name, age)
self.student_id = student_id # Additional attribute for Student
# Overriding the greet method from Person
def greet(self):
return f"Hello, I am {self.name}, {self.age} years old, and my student ID is {self.student_id}."
student1 = Student("Bob", 20, 12345)
print(student1.greet()) # Output: Hello, I am Bob, 20 years old, and my student ID is 12345.Python supports multiple inheritance, where a class can inherit from more than one parent class. Use this feature cautiously to avoid complexity.
Multiple inheritance allows a class to inherit attributes and methods from more than one parent class. This can be useful to combine features from multiple classes.
class Father:
def __init__(self, father_name: str):
self.father_name = father_name
def show_father_name(self):
return f"Father's Name: {self.father_name}"
class Mother:
def __init__(self, mother_name: str):
self.mother_name = mother_name
def show_mother_name(self):
return f"Mother's Name: {self.mother_name}"
class Child(Father, Mother):
def __init__(self, father_name: str, mother_name: str, child_name: str):
# Initialize both parent classes
Father.__init__(self, father_name)
Mother.__init__(self, mother_name)
self.child_name = child_name
def show_child_name(self):
return f"Child's Name: {self.child_name}"
# Creating an instance of Child
child = Child("John", "Emma", "Liam")
# Accessing methods from both parents and the child class
print(child.show_father_name()) # Output: Father's Name: John
print(child.show_mother_name()) # Output: Mother's Name: Emma
print(child.show_child_name()) # Output: Child's Name: Liam- Method Resolution Order (MRO): Python follows a specific order to resolve methods when using multiple inheritance. You can check it with
Child.__mro__. - Initialization: Be sure to call the
__init__method of each parent class explicitly to initialize their attributes.
Method Resolution Order (MRO) determines the sequence in which base classes are looked up when searching for a method or attribute in a class hierarchy. In Python, this follows the C3 linearization algorithm.
The __mro__ attribute shows the order in which classes are resolved.
class Father:
def __init__(self, father_name: str):
self.father_name = father_name
def show_name(self):
return f"Father's Name: {self.father_name}"
class Mother:
def __init__(self, mother_name: str):
self.mother_name = mother_name
def show_name(self):
return f"Mother's Name: {self.mother_name}"
class Child(Father, Mother):
def __init__(self, father_name: str, mother_name: str, child_name: str):
Father.__init__(self, father_name)
Mother.__init__(self, mother_name)
self.child_name = child_name
def show_name(self):
return f"Child's Name: {self.child_name}"
# Creating an instance of Child
child = Child("John", "Emma", "Liam")
# Checking the MRO
print(Child.__mro__)(<class '__main__.Child'>, <class '__main__.Father'>, <class '__main__.Mother'>, <class 'object'>)
- Child class inherits from both
FatherandMotherclasses. - Python first looks for methods in the Child class, then proceeds to the Father class, then the Mother class, and finally the base object class.
Polymorphism allows methods to do different things based on the object calling them. This can be achieved through method overriding and dynamic behavior (duck typing).
Method overriding occurs when a subclass provides a specific implementation of a method that is already defined in its superclass.
class Animal:
def speak(self):
# Base method meant to be overridden
pass
class Dog(Animal):
def speak(self):
return "Woof!"
class Cat(Animal):
def speak(self):
return "Meow!"
dog = Dog()
cat = Cat()
print(dog.speak()) #Output: "Woof!"
print(cat.speak()) #Output: "Meow!"Duck typing is a concept where the object's suitability is determined by the presence of certain methods or properties, rather than the object's type itself.
class Animal:
def speak(self):
# Base method meant to be overridden
pass
class Dog:
def speak(self):
return "Woof!"
class Cat:
def speak(self):
return "Meow!"
class Bird:
def speak(self):
return "Chirp!"
# Function accepting any object with a 'speak' method
def animal_sound(animal: Animal):
print(animal.speak())
dog = Dog()
cat = Cat()
bird = Bird()
animal_sound(dog) # Output: Woof!
animal_sound(cat) # Output: Meow!
animal_sound(bird) # Output: Chirp!- Method Overriding uses inheritance and allows subclasses to modify behavior.
- Duck Typing relies on an object's behavior rather than its type.
class Person():
def __init__(self, name:str , age: int):
self.name = name
self._age = age
self.__balance = 0
def get_method(self):
return self.__balance
person = Person("Taha", 20)
print(person.name)
person.name = "Ahmed"
print(person.name)
print(person._age)
person._age = 100
print(person._age)
print(person.get_method())
person.__balance = 15
print(person.get_method())Let’s break this down!
In Python, attribute access control works like this:
- Public attributes (e.g.,
self.name): Can be accessed and modified directly. - Protected attributes (e.g.,
self._age): By convention (not enforced), attributes with a single underscore are considered "protected." This means you shouldn't access or modify them directly, but Python doesn't prevent you from doing so. It relies on developer discipline. - Private attributes (e.g.,
self.__balance): Python uses name mangling to make these harder to access, but not impossible.
Python trusts you as a developer. The single underscore (_) is a convention, not a strict rule. It signals to other developers: “Hey, this is meant for internal use. Don't touch it directly unless you really know what you're doing.” But Python won't stop you from modifying it.
Python applies name mangling to private attributes. It renames __balance to _Person__balance behind the scenes. So when you do:
person.__balance = 15You're not modifying the original private attribute — you're actually creating a new public attribute named __balance. That’s why the value doesn’t change when you call get_method().
If you try:
print(person._Person__balance) # Accessing the actual private attributeYou'll see the real balance!
Encapsulation involves bundling data and methods within a class and restricting direct access to some attributes.
class Person:
def __init__(self, name: str, age: int):
self.name = name # Public attribute
self._age = age # Protected attribute (conventionally private)
self.__ssn = "123-45-6789" # Private attribute
def get_ssn(self):
# Public method to access the private attribute
return self.__ssn
def set_age(self, age: int):
# Public method to modify protected attribute
if age > 0:
self._age = age
def get_age(self):
# Public method to access protected attribute
return self._age
person = Person("Alice", 30)
# Accessing public attribute
print(person.name) # Output: Alice
# Accessing protected attribute (possible but not recommended directly)
print(person._age) # Output: 30
# Accessing private attribute (through a getter method)
print(person.get_ssn()) # Output: 123-45-6789
# Modifying protected attribute using a public method
person.set_age(35)
print(person.get_age()) # Output: 35- Public Attributes: Accessible from anywhere (e.g.,
name). - Protected Attributes: Conventionally private, indicated by a single underscore (e.g.,
_age). Accessible but discouraged outside the class. - Private Attributes: Hidden from outside access, indicated by double underscores (e.g.,
__ssn). Access through public methods like getters and setters.
Encapsulation involves bundling data (attributes) and methods that work on the data within one unit, e.g., a class. It also restricts direct access to some of the object's components.
class BankAccount:
def __init__(self, balance: float):
self.__balance = balance # Private attribute using double underscore
def deposit(self, amount: float):
if amount > 0:
self.__balance += amount
def withdraw(self, amount: float):
if 0 < amount <= self.__balance:
self.__balance -= amount
def get_balance(self):
# Getter method to access the private attribute
return self.__balance
account = BankAccount(1000)
account.deposit(500)
account.withdraw(200)
print(account.get_balance()) # Output: 1300-
Abstraction: Abstraction is a programming concept that hides complex implementation details and shows only essential features to the user, making the code more modular and easier to manage.
-
Abstract Class: An abstract class in Python is a blueprint for other classes, defined using the
ABCmodule, that can have abstract methods (methods without implementation) which must be implemented by any subclass.
from abc import ABC, abstractmethod
class Vehicle(ABC):
@abstractmethod
def start(self):
# Abstract method that must be implemented by subclasses
pass
class Car(Vehicle):
def start(self):
return "Car engine started!"
# Uncommenting the following line would raise an error because Vehicle is abstract
# vehicle = Vehicle()
car = Car()
print(car.start()) # Output: Car engine started!Class variables are shared by all instances of a class.
Static methods do not operate on an instance but are related to the class.
class MathOperations:
pi = 3.14159 # Class variable
@staticmethod
def add(a: float, b: float) -> float:
# Static method for adding two numbers
return a + b
print(MathOperations.pi) # Output: 3.14159
print(MathOperations.add(10, 20)) # Output: 30The line "Static methods do not operate on an instance but are related to the class." means that a static method belongs to the class itself rather than any particular instance of the class. It does not have access to instance variables (self) or class variables (cls). Instead, it behaves like a regular function but is placed inside the class because it is logically related to the class.
-
Does not operate on an instance
- Static methods do not require an instance (
self) to be called. - They do not modify or access instance attributes.
- Static methods do not require an instance (
-
Related to the class
- Although they do not modify instance attributes, they belong to the class because they logically fit within its purpose.
class MathOperations:
pi = 3.14159 # Class variable
@staticmethod
def add(a: float, b: float) -> float:
# Static method for adding two numbers
return a + b
print(MathOperations.pi) # Output: 3.14159
print(MathOperations.add(10, 20)) # Output: 30piis a class variable, meaning it belongs to the class and can be accessed usingMathOperations.pi.add()is a static method because:- It does not use
selforcls. - It simply performs addition and returns a result.
- It is called directly on the class (
MathOperations.add(10, 20)) without needing an instance.
- It does not use
✅ Static methods are utility methods inside a class.
✅ They do not need an instance to be called.
✅ They are defined using @staticmethod.
✅ They do not modify instance or class attributes.
You can make an object behave like a function by defining the __call__ method.
class Counter:
def __init__(self):
self.count = 0
def __call__(self):
# Increment the counter each time the object is called
self.count += 1
return self.count
counter = Counter()
print(counter()) # Output: 1
print(counter()) # Output: 2The built-in dir() function lists the attributes and methods of an object.
print(dir(Counter)) # Lists all methods and attributes of the Counter classHere’s what’s happening in your code with dir()!
class Person():
def __init__(self):
self.name = "Taha"
self.age = 20
def walk(self):
print(f"{self.name} is walking")
# ----------------------------------------------
print(dir(Person)) # Inspecting the class itself
per = Person()
print(dir(per)) # Inspecting an instance of the class['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', ... , '__weakref__', 'walk']
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', ... , '__weakref__', 'age', 'name', 'walk']
dir(Person)→ Lists class attributes and methods. It won't show instance attributes likenameorage, because they are set when an object is instantiated. It shows built-in attributes and your class methodwalk.dir(per)→ Lists everything the instance has access to. This includes instance attributes likenameandage, along with the methods and built-in attributes.
If you want to print only the relevant attributes and methods, you can filter out the dunder (double underscore) methods:
print([attr for attr in dir(per) if not attr.startswith('__')])['age', 'name', 'walk']
This way, you only see what you explicitly defined! Let me know if you’d like me to dig deeper! 🚀
Organize your code by separating classes into different files.
- File:
person.pyclass Person: def __init__(self, name: str): self.name = name def greet(self): return f"Hello, {self.name}!"
- File:
main.pyfrom person import Person # Import the Person class from person.py person = Person("Alice") print(person.greet()) # Output: Hello, Alice!
class Counter:
def __init__(self):
self.count = 0
def __call__(self):
return self.count
def __str__(self):
return f"Counter value: {self.count}"
counter = Counter() # Create an instance of the Counter class
print(counter()) # Uses __call__
print(counter) # Uses __str__-
Naming Conventions:
- Use PascalCase for class names (e.g.,
Person,BankAccount). - Use snake_case for methods and variable names (e.g.,
greet,get_balance).
- Use PascalCase for class names (e.g.,
-
Encapsulation:
- Keep attributes private (using
__attribute) if they should not be accessed directly. - Provide getters and setters to control access when needed.
- Keep attributes private (using
-
Documentation:
- Use docstrings to document classes and methods for clarity.
- Inline comments help explain code snippets, especially for beginners.
-
Code Organization:
- Separate classes into different modules (files) to enhance code readability and reusability.
- Follow the Single Responsibility Principle: each class should have a single purpose.
- OOP Basics: Understand classes and objects as the fundamental building blocks.
- Inheritance & Polymorphism: Learn how to extend functionality and override methods.
- Encapsulation & Abstraction: Keep data safe and simplify complex systems.
- Best Practices: Use proper naming, document your code, and organize your project for maintainability.
OOP is particularly beneficial in large-scale projects where modularity, reusability, and maintainability are crucial. It also helps in modeling real-world entities and their interactions.
This guide provides a solid foundation for beginners, while the advanced topics and best practices will aid intermediate learners in mastering OOP in Python. Happy coding!
- Definition: Reading from and writing to files.
- Examples:
# Reading from a file with open("file.txt", "r") as f: content = f.read() print(content) # Writing to a file (this will overwrite if the file exists) with open("file.txt", "w") as file: file.write("Hello, Python!")
- Definition: A decorator is a function that wraps another function to extend its behavior.
- Example:
def decorator(func): def wrapper(): print("Before function execution") func() print("After function execution") return wrapper @decorator def my_function(): print("Inside function") my_function()
- Definition: A generator is a function that yields a sequence of values using the
yieldkeyword. - Example:
def my_gen(): for i in range(3): yield i gen = my_gen() print(next(gen)) # Output: 0 print(next(gen)) # Output: 1 print(next(gen)) # Output: 2
- Definition: Asynchronous programming in Python allows execution of tasks concurrently without blocking the main thread.
- Why Use It? Useful for tasks like network requests, I/O operations, and database queries.
asyncdefines an asynchronous function, allowing the use ofawaitinside it.awaitpauses execution until the awaited task completes.
import asyncio
async def say_hello():
await asyncio.sleep(2) # Simulating an async task (e.g., API request)
print("Hello, Async World!")
asyncio.run(say_hello())- Use
asyncio.gather()to run multiple tasks at the same time.
import asyncio
async def task_one():
await asyncio.sleep(2)
print("Task One Completed")
async def task_two():
await asyncio.sleep(1)
print("Task Two Completed")
async def main():
await asyncio.gather(task_one(), task_two())
asyncio.run(main())Output:
Task Two Completed
Task One Completed
🔹 Why is task_two faster? Since task_two only sleeps for 1 second, it completes before task_one (which sleeps for 2 seconds). Both tasks run concurrently.
- Good Use Cases: Network requests, reading/writing large files, interacting with databases, web scraping.
- Avoid: CPU-intensive operations (use multiprocessing instead).
- Definition: Unpacking allows extracting values from lists, tuples, or dictionaries into variables.
- Why Use It? It simplifies variable assignment and improves readability.
# Define a list
fruits = ["apple", "banana", "cherry"]
# Unpacking the list into variables
first, second, third = fruits
# Print the unpacked values
print(first) # apple
print(second) # banana
print(third) # cherry # Define a tuple
coordinates = (10, 20, 30)
# Unpacking the tuple into variables
x, y, z = coordinates
# Print the unpacked values
print(x) # 10
print(y) # 20
print(z) # 30 # Define a dictionary
person = {"name": "Alice", "age": 25, "city": "New York"}
# Unpacking the dictionary into variables
name, age, city = person.values()
# Print the unpacked values
print(name) # Alice
print(age) # 25
print(city) # New York age = 20
status = "Adult" if age >= 18 else "Minor"
print(status) # Output: AdultHappy coding!