Python is one of the most popular programming languages due to its simplicity, readability, and versatility. It’s used across various fields like web development, data science, machine learning, automation, and more.
Let’s dive into some of the most intriguing aspects of Python that you might not know about!
Table of Contents

Dynamic Typing
Variables in Python are dynamically typed, meaning you don’t need to declare their type upfront. Python will infer the type at runtime, which enhances flexibility and reduces boilerplate code.
- Type checking happens at runtime.
- You don’t have to declare variable types explicitly.
- Variables can hold values of different types at different times.
- More flexible but can lead to runtime errors.
- Examples: Python, JavaScript, Ruby
x = "John" # Python infers this as a string
x = 30 # Python infers this as an integer
PythonStatically Typed Languages
- Type checking happens at compile time.
- You must declare variable types explicitly.
- Variables cannot change types once declared.
- More strict but helps catch errors early.
- Examples: Java, C, C++
Java Example
int x = 10; // x can only hold integers
x = "Hello"; // This will cause a compile-time error
PythonInterpreted Language
Python code is executed line by line by an interpreter, rather than compiled into machine code first. This makes testing and debugging more straightforward and reduces development cycles.
- Code is executed line by line by an interpreter.
- No separate compilation step; execution happens in real-time.
- Slower compared to compiled languages because interpretation happens at runtime.
- Examples: Python, JavaScript, PHP, Ruby, Bash
Advantages:
- Easier debugging (errors stop execution immediately)
- Platform-independent (runs on any system with the appropriate interpreter)
- Faster development time (no need for compilation)
Disadvantages:
- Slower execution due to on-the-fly interpretation
- Requires an interpreter to run
Compiled Languages
- Code is translated into machine code by a compiler before execution.
- The compiled binary is executed directly by the CPU.
- Faster execution compared to interpreted languages.
- Examples: C, C++, Rust, Go
Advantages:
- Faster execution (since it’s precompiled)
- More optimised and efficient
- No dependency on an interpreter at runtime
Disadvantages:
- Debugging is harder (errors are found after compilation)
- Platform-dependent (requires recompilation for different architectures)
Hybrid Approach (Both Compiled & Interpreted)
Some languages use both compilation and interpretation:
- Java: Compiles to bytecode (JVM) and is interpreted by the Java Virtual Machine.
- Python (with PyPy): Can use Just-In-Time (JIT) compilation for performance.
- JavaScript (V8 engine): Uses JIT compilation to improve speed.
Ways format string
In Python, there are four main ways to format strings. Each has different use cases and levels of readability. Here’s a quick comparison with examples:
1. Old-Style Formatting (%)
This is inspired by C’s printf syntax.
- %s → string
- %d → integer
- %f → float
name = "Alice"
age = 30
print("Hello, %s. You are %d years old." % (name, age))
Python- Quick and short
- Not very readable with complex strings
2. str.format() Method
Introduced in Python 2.7 and 3.0 — more flexible and readable.
name = "Alice"
age = 30
print("Hello, {}. You are {} years old.".format(name, age))
PythonYou can use positional or named placeholders:
print("Hello, {0}. You are {1}.".format(name, age))
print("Hello, {name}. You are {age}.".format(name="Alice", age=30))
Python- More powerful than %
- Verbose, harder to manage for many variables
3. f-Strings
- Literal String Interpolation
- The most modern and preferred way in Python 3.6 and above.
name = "Alice"
age = 30
print(f"Hello, {name}. You are {age} years old.")
PythonYou can also embed expressions directly:
print(f"In 5 years, {name} will be {age + 5}.")
Python- Very readable and concise
- Supports expressions directly
- Not available in older Python versions (<3.6)
4. Template Strings (string.Template)
Useful when working with user input (safer from injection).
from string import Template
template = Template("Hello, $name. You are $age years old.")
print(template.substitute(name="Alice", age=30))
Python- Safe for untrusted input (e.g., user-defined templates)
- Less powerful, limited formatting options
Summary Table
Method | Introduced In | Pros | Cons |
---|---|---|---|
% formatting | Python 2+ | Short, familiar for C users | Less readable, outdated |
str.format() | Python 2.7/3+ | Powerful, flexible | Verbose |
f-Strings | Python 3.6+ | Clean, readable, supports expressions | Newer versions only |
string.Template | Python 2+ | Safe for user input | Limited formatting |
Type hinting
Type hinting in Python is a way to explicitly specify the data types of variables, function arguments, and return values using annotations. It helps improve code clarity, enables better tooling (like autocomplete and static analysis), and reduces bugs.
Type Hint Function Arguments and Return Values
def greet(name: str) -> str:
return f"Hello, {name}!"
Python- name: str tells us that name should be a string.
- -> str tells us that the function returns a string.
Type Hint Variables
age: int = 25
name: str = "backendmesh"
is_active: bool = True
PythonCommon Types in Type Hinting
- int, float, str, bool, None
- list, dict, tuple, set
- Optional, Union, Any, Callable, Iterable from typing module
Use typing
Module for Complex Types
from typing import List, Dict, Tuple, Optional, Union
def square_numbers(nums: List[int]) -> List[int]:
return [x * x for x in nums]
def get_user(user_id: int) -> Optional[Dict[str, str]]:
if user_id == 1:
return {"name": "Alice", "email": "alice@example.com"}
return None
def process(data: Union[str, int]) -> str:
return str(data)
PythonWhy Use Type Hinting?
- Makes your code self-documenting
- Enables linting tools (like mypy, pyright) to catch bugs before runtime
- Helps with IDE autocompletion and suggestions
- Makes collaboration easier in teams
List Comprehensions
Python’s list comprehensions offer a concise way to generate lists. They allow developers to write powerful, compact expressions.
squares = [x**2 for x in range(10)]
PythonThis single line is equivalent to:
squares = []
for x in range(10):
squares.append(x**2)
PythonYou can even include conditions:
# List of squares of even numbers
squares = [x**2 for x in range(10) if x % 2 == 0]
PythonApplication : Matrix initialization
matrix = [[0]*3 for _ in range(3)]
or
matrix = [[0 for _ in range(3)] for _ in range(3)]
print(matrix) #[[0, 0, 0], [0, 0, 0], [0, 0, 0]]
CRead more about Matrix
This feature is highly optimized and preferred for readable and concise code.
Dictionary Comprehensions
squares_dict = {x: x**2 for x in range(10)}
PythonOutput
{0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25, 6: 36, 7: 49, 8: 64, 9: 81}
Pythonunique_squares = {x**2 for x in range(10)}
print(unique_squares)
PythonOutput
{0, 1, 64, 4, 36, 9, 16, 49, 81, 25}
PythonGenerator Comprehension
Generator expressions are similar to list comprehensions but use parentheses instead of square brackets. Here’s a deeper look into generator expressions:
(generator_expression for item in iterable if condition)
PythonWith Yield
# Generator function
def generate_squares(n):
for x in range(n):
yield x**2
PythonEquivalent expression
squares_gen_expr = (x**2 for x in range(10))
PythonThe zip() Function
The zip()
the function allows you to iterate over multiple iterables in parallel. It pairs up elements from the provided iterable:
names = ["Alice", "Bob", "Charlie"]
ages = [25, 30, 35]
for name, age in zip(names, ages):
print(f"{name} is {age} years old.")
# Output:
# Alice is 25 years old.
# Bob is 30 years old.
# Charlie is 35 years old.
PythonDifferent length
a = ("John", "Charles", "Mike","Vicky")
b = ("Jenny", "Christy", "Monica")
x = zip(a, b)
print(list(x))
#[('John', 'Jenny'), ('Charles', 'Christy'), ('Mike', 'Monica')]
PythonIt’s handy when you need to work with multiple lists or tuples and want to process corresponding elements together.
Matrix
matrix = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
]
print(matrix) # [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
print(*matrix) # [1, 2, 3] [4, 5, 6] [7, 8, 9]
zip(*matrix) => zip([1, 2, 3], [4, 5, 6], [7, 8, 9])
'''
Output
(1, 4, 7)
(2, 5, 8)
(3, 6, 9)
'''
PythonShort Hand If … Else
a = 2
b = 330
print("A") if a > b else print("B") #B
PythonThis technique is known as Ternary Operators or Conditional Expressions.
Python’s else in Loops
An often-overlooked feature of Python is the else clause that can be used in loops. The else block after a for or while loop will only be executed if the loop completes normally (i.e., no break statement is encountered).
for num in range(10):
if num == 50:
break
else:
print("Loop completed without break")
# Loop completed without break
Pythoni = 1
while i < 6:
print(i)
i += 1
else:
print("i is no longer less than 6")
PythonLambda, Map, Filter
Slicing (::)
In Python,:: is used to slice lists and other sequences. It allows you to specify a step value in addition to the start and stop indices. Here’s the general syntax:
sequence[start:stop:step]
Python- start is the index where the slice begins (inclusive) i.e start index is include.
- stop is the index where the slice ends (exclusive) i.e stop index is not include.
- step is the stride or step size between each element in the slice.
If you leave out start and stop but include the step, it will slice the whole sequence with the specified step.
lst = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(lst[::2]) # Output: [0, 2, 4, 6, 8]
print(lst[1:7:2]) # Output: [1, 3, 5]
print(lst[::-1]) # Output: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
print(lst[::1]) # Output: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(lst[::-2]) #[9, 7, 5, 3, 1]
print(lst[6:0:-2]) #[6, 4, 2]
print(lst[3:]) # 3, 4, 5, 6, 7, 8, 9
print(lst[:5]) #0, 1, 2, 3, 4
PythonReversing a sequence:
reversed_sequence = sequence[::-1]
PythonExample
print(lst[6:0:-2]) #[6, 4, 2] Note start and end index
PythonHow it works?
- When you omit start and stop, the slice covers the entire sequence.
- By specifying a step of -1, you’re telling Python to move backwards through the sequence, starting from the end and ending at the beginning.
Example
text = "hello"
reversed_text = text[::-1]
print(reversed_text) # Output: "olleh"
Python#Tuple Reversaltpl = (1, 2, 3, 4, 5)
reversed_tpl = tpl[::-1]
print(reversed_tpl) # Output: (5, 4, 3, 2, 1)
PythonInteger Reversal
# Integer reversal
n = 1234
print(int(str(n)[::-1])) #4321
Python- Convert to string
- Again convert to result to int
Note
- When start and stop are omitted, the slice defaults to covering the entire sequence.
- Step of -1: The -1 step means the slice will take elements in reverse order.
sequence[start:stop:step] vs sequence[:]
sequence[:]
- A special case of slicing where no start, stop, or step is given.
- Returns a shallow copy of the entire sequence.
- Equivalent to copying the whole sequence.
Property?
In Python, a property is a special kind of attribute that allows you to customize access to instance variables. It lets you define getter, setter, and deleter methods for an attribute, while still using attribute-style access (i.e., obj.attr instead of obj.get_attr()).
Option 1: Simple getter/setter functions (NO @property)
class Person:
def __init__(self,name):
self._name = name
def get_name(self):
return self._name
def set_name(self, value):
if not value:
raise ValueError("Name cannot be empty")
self._name = value
person = Person("hello")
person.set_name("backendmesh")
print(person.get_name()) #backendmesh
PythonOption 2: @property (Cleaner and Pythonic)
class Person:
def __init__(self, name):
self._name = name
@property
def name(self): # This is the **getter**
return self._name
@name.setter
def name(self, value): #This is the **setter**
if not value:
raise ValueError("Name cannot be empty")
self._name = value
p = Person("Alice") # this will not calls the setter
print(p.name) # Calls the getter, Same as p.get_name()
p.name = "Bob" # Calls the setter Same as p.set_name("Bob")
Python- p = Person(“Alice”) # will not calls the setter
- print(p.name) # Calls the getter
- p.name = “Bob” # Calls the setter
- @property turns a method into a getter.
- @x.setter adds a setter for the property.
- @x.deleter adds a deleter for the property.
Option 3: property()
class Person:
def __init__(self, name):
self._name = name
def get_name(self):
return self._name
def set_name(self, value):
if not value:
raise ValueError("Name cannot be empty")
self._name = value
def del_name(self):
del self._name
name = property(get_name, set_name, del_name)
p = Person("Alice")
print(p.name) # 👉 Calls get_name() → prints "Getting name" then "Alice"
p.name = "Bob" # 👉 Calls set_name() → prints "Setting name"
del p.name # 👉 Calls del_name() → prints "Deleting name"
PythonIs property() and @property are same?
Yes, property (the decorator @property) and property() (the built-in function) refer to the same thing — the property class. They’re just used in two different ways.
Why use property?
- To control access to private attributes.
- To add logic during getting or setting a value.
- To avoid breaking existing code by changing methods to attributes.
Why @property, if we can use simple getter and setter functions
Yes, you can use plain methods, but:
- Use @property when you want clean syntax (obj.name instead of obj.get_name()).
- Use it to make your class look and feel like using attributes, even when logic is needed under the hood.
- It’s more Pythonic, flexible, and aligns with the principle of encapsulation.
deleter
A deleter is a method that runs when you delete an attribute using the del
keyword, like this:
del obj.attribute
PythonInput Validation
Option 1: Basic Validation in __init__
Just check the type or value inside the __init__ method and raise an exception if invalid.
class Person:
def __init__(self, name: str, age: int):
if not isinstance(name, str):
raise TypeError("name must be a string")
if not isinstance(age, int):
raise TypeError("age must be an integer")
if age < 0:
raise ValueError("age must be non-negative")
self.name = name
self.age = age
PythonOption 2 using setter
validation on attribute assignment after initialization too:
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if not isinstance(value, int):
raise TypeError("age must be an integer")
if value < 0:
raise ValueError("age must be non-negative")
self._age = value
PythonCommon Error: RecursionError: maximum recursion depth exceeded
class Person:
def __init__(self, name):
self.name = name # Triggers the setter
@property
def name(self):
print("Getting name")
return self.name
@name.setter
def name(self, value):
print("Setting name")
if not value:
raise ValueError("Name cannot be empty")
self.name = value
p = Person("Alice")
print(p.name)
p.name = "Bob"
PythonPython Properties: Behind the Scenes
When you define a property:
@property
def name(self): ...
@name.setter
def name(self, value): ...
PythonPython rewires attribute access:
- self.name → calls the getter
- self.name = value → calls the setter
Setter
Inside your setter:
@name.setter
def name(self, value):
print("Setting name")
self.name = value # ❌ This calls the setter again!
PythonPython sees that as:
- name is a property, and someone is assigning to it → call the setter.”
- So self.name = value inside the setter is not a normal variable assignment — it recursively calls the setter itself again, like this:
self.name = value # → self.name = value # → self.name = value ...
= Infinite recursion → RecursionError
PythonGetter
The getter tries to return self.name, which again calls the getter, causing infinite recursion.
def name(self):
print("Getting name")
return self.name # ❗️This calls the getter again (infinite loop)
Pythonreturn self.name --> self.name()
def name(self):
print("Getting name")
return self.name() # ❗️This calls the getter again (infinite loop)
PythonWhy not then change method name
class Person:
def __init__(self, name):
self.name = name
@property
def get_name(self): # property name is `get_name`
return self._name
@get_name.setter
def set_name(self, value): # ❌ Error: different name!
self._name = value
PythonThis will raise an error, AttributeError: ‘property’ object has no attribute ‘setter’
Solution
- self.name = value → Python looks for the @name.setter method (if @property exists)
- print(self.name) → Python looks for the @property getter (if @property exists))
class Person:
@property
def name(self):
print("Getting name")
return self.a
@name.setter
def name(self, value):
print("Setting name")
self.a = value
p = Person()
p.name = "Bob" # Calls the setter @name.setter method
print(p.name) # Calls the getter @property name method
Python- a is a regular instance variable (like self.a) used internally to store the value of the property name.
To avoid confusion, it better to use _name instead of a
class Person:
@property
def name(self):
return self._name
@name.setter
def name(self, value):
self._name = value
Pythonclass Person:
def __init__(self, name):
self.name = name # Triggers the setter
@property
def name(self):
print("Getting name")
return self._name
@name.setter
def name(self, value):
print("Setting name")
self._name = value
p = Person("Alice") # will not calls the setter
print(p.name) # Calls the getter
p.name = "Bob" # Calls the setter
PythonBelow part also trigger setter
def __init__(self, name):
self.name = name # this will not call the setter, this will directly save name variable
PythonProof : Setter is not called at __init__
class Person:
def __init__(self, value):
self.a = value # Triggers the setter
@property
def name(self):
print("Getting name")
return self._a
@name.setter
def name(self, value):
print("Setting name")
self._a = value
p = Person("Ram")
print(p.name) # give error
PythonHow will you return the value from __init__ method?
In Python, you cannot return a value from the __init__ method because it is a constructor initializer, not a function designed to return anything.
What types of keys are accepted in dictionaries?
In Python dictionaries, keys must be:
- Immutable (unchangeable) types.
- Hashable (have a consistent hash value during their lifetime).
Common types accepted as dictionary keys:
- Strings (str) — most common key type
- Numbers (int, float, complex) — integers and floats are fine (e.g., 42, 3.14)
- Tuples — but only if all elements inside the tuple are themselves immutable and hashable (e.g., (1, 2, ‘a’) is okay, (1, [2,3]) is not)
- Booleans (True, False) — since they are a subclass of integers
- frozenset — an immutable version of set
Types NOT accepted as dictionary keys:
- Lists — mutable and unhashable
- Sets — mutable and unhashable
- Dictionaries — mutable and unhashable
- Custom objects — unless they implement both __hash__() and __eq__() properly and are immutable
Deep and shallow copy
Shallow Copy
A shallow copy creates a new object, but it does not create copies of objects contained within the original object. Instead, it references the same objects in memory.
Example 1
import copy
old_list = [56,[1, 1, 1], [2, 2, 2], [3, 3, 3]]
new_list = copy.copy(old_list)
print("Old list:", old_list , id(old_list)) # Old list: [56,[1, 1, 1], [2, 2, 2], [3, 3, 3]] , 4455774592
print("New list:", new_list,id(new_list)) # New list: [56,[1, 1, 1], [2, 2, 2], [3, 3, 3]] , 4455774400
print(id(old_list[0])) #4317095808
print(id(new_list[0])) #4317095808
print(id(old_list[1])) #4456088320
print(id(new_list[1])) #4456088320
Python- Object old_list and new_list have different reference addresses 4455774592 and 4455774400
- old_list[1] and new_list[1] have same reference address 4456088320
- old_list[0] and new_list[0] have same reference address 4317095808
Example 2: Modifying the existing nested object, it will get reflected in both
old_list[0] = 99
old_list[1][1] = 'AA'
print("Old list:", old_list , id(old_list)) # Old list: [99,[1, 1, 1], [2, 'AA', 2], [3, 3, 3]] ,4455774592
print("New list:", new_list,id(new_list)) # New list: [56,[1, 1, 1], [2, 'AA', 2], [3, 3, 3]], 4455774400
Python- Even old_list[0] and old_list[1] has same address
- But changes (old_list[0]) if not reflecting in old_list[0], while changes of old_list[1] is reflecting in new_list[1]
Because
- In Python, integers are immutable, and values like
56
are often interned (shared) to save memory. - This does not mutate the existing value — it replaces the reference at index 0 in
old_list
with a new int object (99
).
Example 3 : Assigning new values to the existing nested object, it will not get reflected
old_list[1] = ['xyz']
print("Old list:", old_list , id(old_list[1])) # Old list: [99, ['xyz'], [2, 2, 2], [3, 3, 3]] ,4365810688
print("New list:", new_list,id(new_list[1])) # New list: [56, [1, 1, 1], [2, 2, 2], [3, 3, 3], 4364267200
Python- Initially we have same reference
- But once we assign new object, reference get modified
Example 4: The addition of a new nested object, will not reflect in other object
old_list.append([4, 4, 4])
print("Old list:", old_list , id(old_list))
print("New list:", new_list,id(new_list))
Old list: [99,[1, 1, 1], [2, 'AA', 2], [3, 3, 3],[4, 4, 4], 4455774592
New list: [56,[1, 1, 1], [2, 'AA', 2], [3, 3, 3]], 4455774400
PythonDeep Copy
A deep copy creates a new object and recursively adds the copies of nested objects present in the original elements. The deep copy creates an independent copy of the original object and all its nested objects.
import copy
old_list = [[1, 1, 1], [2, 2, 2], [3, 3, 3]]
new_list = copy.deepcopy(old_list)
print("Old list:",old_list,id(old_list)) #Old list: [[1, 1, 1], [2, 2, 2], [3, 3, 3]] 4715197120
print("New list:",new_list,id(new_list)) #New list: [[1, 1, 1], [2, 2, 2], [3, 3, 3]] 4715263616
old_list[1][0] = 'BB'
print("Old list:",old_list,id(old_list)) #Old list: [[1, 1, 1], ['BB', 2, 2], [3, 3, 3]] 4715197120
print("New list:",new_list,id(new_list)) #New list: [[1, 1, 1], [2, 2, 2], [3, 3, 3]] 4715263616
Pythonnew_list=old_list, Shallow or deep?
- The statement new_list = old_list in Python is neither a shallow copy nor a deep copy.
- Instead, it is a simple assignment where both new_list and old_list reference the same object in memory.
- It’s not a copy of the object, it’s just an alias for the same object. It is just a reference assignment.
old_list = [[1, 2, 3], [4, 5, 6], [7, 8, 'a']]
new_list = old_list
new_list[2][2] = 9
print('Old List:', old_list,id(old_list))
print('New List:', new_list,id(new_list))
old_list.append([4, 4, 4])
old_list[1][1] = 'AA'
PythonOutput
Old List: [[1, 2, 3], [4, 5, 6], [7, 8, 9]] 4715106240
New List: [[1, 2, 3], [4, 5, 6], [7, 8, 9]] 4715106240
Old List: [[1, 2, 3], [4, 'AA', 6], [7, 8, 9], [4, 4, 4]] 4715106240
New List: [[1, 2, 3], [4, 'AA', 6], [7, 8, 9], [4, 4, 4]] 4715106240
PythonFlatten a list of lists
Sum function
sum(iterable, start=0)
Python- iterable: The sequence (like a list, tuple, etc.) of numbers or objects you want to sum.
- start (optional): A starting value that is added to the sum. This defaults to 0.
numbers = [1, 2, 3, 4]
result = sum(numbers)
print(result) #10
result = sum(numbers, 10)
print(result) #20
PythonFlattening
l1 = [[1, 2], [3, 4], [5, 6]]
result = sum(l1, [])
print(result) # [1, 2, 3, 4, 5, 6]
Python- By default,
start
is set to 0, so if you pass a list of numbers, the sum starts from0
. - If the start is something else (like an empty list []), it affects how sum() behaves. When summing lists, it concatenates them instead of adding them numerically.
- The sum() function works best for numeric types. If you try to sum non-numeric objects (like strings), it will raise a TypeError.
if __name__ == “__main__”:
What’s happening internally?
- Every Python file (module) has a special built-in variable called __name__.
- When a Python file is run directly, the interpreter sets __name__ = “__main__” in that file.
- When the file is imported into another file, __name__ is set to the module name instead (i.e., the filename without .py).
Common use case
def main():
# main logic here
print("Running main logic")
if __name__ == "__main__":
main()
PythonWhy use this?
- It prevents certain code from running on import.
- Useful for writing reusable code: functions/classes at the top, and a test or main routine guarded by this check.
Case 1: Running the file directly
Suppose you have this Python file named example.py:
print("Start of example.py")
def greet():
print("Hello!")
if __name__ == "__main__":
print("example.py is being run directly")
greet()
PythonWhat happens if you run it directly?
> python example.py
#Output
Start of example.py
example.py is being run directly
Hello!
PythonWhy?
- Python runs the whole file top to bottom.
- __name__ is set to “__main__” because this file is run directly.
- The code inside the if __name__ == “__main__”: block runs.
Case 2: Importing the file as a module
Now, create another file called test_import.py:
import example
print("Imported example.py successfully")
example.greet()
PythonWhat happens if you run test_import.py?
> python test_import.py
#Output
Start of example.py
Imported example.py successfully
Hello!
PythonKey Points
- The line print(“example.py is being run directly”) inside if __name__ == “__main__”: did NOT run.
- Because when Python imports example.py, it sets __name__ to “example”, the module name.
- So the if condition is False and that block is skipped.
- But the top-level code (outside the if block) did run — like the print(“Start of example.py”).
- The function greet() can still be called from the imported module.
Situation | __name__ value | Runs the if block? |
---|---|---|
Run file directly | "__main__" | Yes |
Import file as a module | "module_name" | No |
Why is this useful?
- You can write reusable modules with functions/classes.
- Add some test code or script code that only runs when you want to run the file directly.
- Avoid running unwanted code during import.
*args and **kwargs
- “*” notation like this – *args OR **kwargs – as our function’s argument when we have doubts about the number of arguments we should pass in a function.
- It is used to handle a variable number of arguments:
- *args (Non-Keyword Arguments)
- **kwargs (Keyword Arguments)
def myFun(**kwargs):
for key, value in kwargs.items():
print("%s == %s" % (key, value))
# Driver code
myFun(first='Back', mid='end', last='Mess')
def myFun(*argv):
for arg in argv:
print(arg)
myFun('Hello', 'Welcome', 'to', 'BackendMess')
PythonOutput
first == Back
mid == end
last == Mess
Hello
Welcome
to
BackendMess
Pythondef myFun(arg1, arg2, arg3):
print("arg1:", arg1)
print("arg2:", arg2)
print("arg3:", arg3)
# Now we can use *args or **kwargs to
# pass arguments to this function :
args = ("Back", "end", "Mess")
myFun(*args)
kwargs = {"arg1": "Back", "arg2": "end", "arg3": "Mess"}
myFun(**kwargs)
PythonOutput
arg1: Back
arg2: end
arg3: Mess
arg1: Back
arg2: end
arg3: Mess
PythonUnpacking and Extended Unpacking
# Basic unpacking
a, b, c = [1, 2, 3]
print(a, b, c) # Output: 1 2 3
# Extended unpacking
a, *b, c = [1, 2, 3, 4, 5]
print(a, b, c) # Output: 1 [2, 3, 4] 5
PythonNonlocal
nonlocal is a keyword that indicates that a variable exists in an enclosing (but not global) scope. When working with nested functions, you may want to modify a variable defined in an outer (enclosing) function within an inner function. Using nonlocal allows you to access and reassign this variable, rather than creating a new local instance within the inner function.
With nonlocal
def outer_function():
message = "Hello, outer world!"
def inner_function():
nonlocal message
message = "Hello, inner world!"
inner_function()
print(message) #Hello, inner world!
outer_function()
PythonBy declaring the message as nonlocal, we are telling Python to look for and modify the message in the closest enclosing scope (i.e., outer_function). Thus, when we print the message in outer_function after calling inner_function, it will display “Hello, inner world!”
Without nonlocal
def outer_function():
message = "Hello, outer world!"
def inner_function():
# no 'nonlocal' keyword here
message = "Hello, inner world!" # this creates a new local variable inside inner_function
inner_function()
print(message). #Hello, outer world!
outer_function()
PythonIf we didn’t use nonlocal in inner_function, a new message variable would be created locally within inner_function. The change would not affect the message variable in outer_function
When to Use nonlocal
nonlocal is most useful when working with closures or when creating decorators and other functional-style constructs that rely on state sharing between nested functions.
Enumerate
enumerate() is a built-in function that adds a counter to an iterable (like a list, tuple, or string) and returns it as an enumerate object, which can be directly used in loops. This is useful when you need both the index and the value while iterating over an iterable.
Syntax
enumerate(iterable, start=0)
Python- iterable: The iterable you want to loop over (e.g., list, tuple).
- start: The starting value for the counter (default is 0).
Example 1
mylist = [0,1,2]
print(enumerate(mylist)) # <enumerate object at 0x14fafc4f92c0>
print(list(enumerate(mylist))) # [(0, 0), (1, 1), (2, 2)]
PythonExample 2
fruits = ['apple', 'banana', 'cherry']
for index, fruit in enumerate(fruits):
print(index, fruit)
PythonOutput
0 apple
1 banana
2 cherry
PythonCustom Start Index
for index, fruit in enumerate(fruits, start=1):
print(index, fruit)
PythonOutput
1 apple
2 banana
3 cherry
PythonWhy Use enumerate()?
- Cleaner code: Using enumerate() is cleaner and more Pythonic than manually maintaining a counter.
- Readability: It’s easier to read and understand the purpose of the loop with enumerate().
for _ in range(n)
The for _ in range(n): statement in Python is a common idiom used when you need to iterate n times, but you don’t need to use the loop variable.
# Print "Hello" 5 times
for _ in range(5):
print("Hello")
PythonOutput
Hello
Hello
Hello
Hello
Hello
Python_ (underscore): Used as a convention for a throwaway variable, indicating that the loop variable will not be used in the body of the loop.
Duck Typing
Duck typing means you don’t care what type or class an object is, as long as it behaves the way you need it to.
Duck Typing is a concept in dynamic programming languages like Python where the type of an object is determined by its behavior (methods and properties) rather than its class or inheritance.
Imagine you are at a park, and you see a bird. You don’t check its species or ID. If it quacks and swims like a duck, you call it a duck. Similarly, in programming, if an object can do what you expect (has the methods or attributes you need), you use it without worrying about its exact type.
# Classes without any inheritance
class RealDuck:
def quack(self):
return "Quack!"
class RobotDuck:
def quack(self):
return "Robot quack!"
class Dog:
def quack(self):
return "I am pretending to quack!"
# Function works with any object that has a quack() method
def make_it_quack(animal):
return animal.quack()
# Usage
real_duck = RealDuck()
robot_duck = RobotDuck()
dog = Dog()
print(make_it_quack(real_duck)) # Output: Quack!
print(make_it_quack(robot_duck)) # Output: Robot quack!
print(make_it_quack(dog)) # Output: I am pretending to quack!
Python- The function doesn’t check what type animal is.
- It works as long as the object has a quack method.
In Strictly Typed Languages: In many languages, you must declare the exact types of variables or function arguments. For example, in Java:
interface Quackable {
void quack();
}
class Duck implements Quackable {
public void quack() {
System.out.println("Quack!");
}
}
class Dog { // No explicit interface, won't work
public void quack() {
System.out.println("I'm a dog, but I can quack!");
}
}
public class Main {
static void makeItQuack(Quackable animal) {
animal.quack();
}
public static void main(String[] args) {
Duck d = new Duck();
makeItQuack(d); // ✅ Works
Dog dog = new Dog();
// makeItQuack(dog); ❌ ERROR: Dog does not implement Quackable
}
}
PythonEven though Dog has a quack() method, Java does not allow passing it to makeItQuack() unless it explicitly implements Quackable
Here, the argument to makeItQuack must be a Duck. If you pass something else, the program won’t compile.
Python removes this restriction by focusing on behaviour rather than type, which is why the concept of duck typing is emphasized
Note:
- Without Duck Typing: You force all objects to inherit from a base class or match a specific type.
- With Duck Typing: You don’t care about the object’s type. If it behaves the way you need, you use it.
Return from __init__
You cannot return anything meaningful from __init__ in Python. If you try to return a value, Python will raise a TypeError.
class MyClass:
def __init__(self):
return "something" # ❌ Not allowed
# Will raise:
# TypeError: __init__() should return None, not 'str'
PythonReturn type of __init__
The return type of the __init__ method in Python is always None.
class Cal:
def __init__(self):
self.a = ""
obj = Cal()
print(obj.__init__()) # None
PythonHow can we use C in Python
You can connect Python with C in several ways depending on your use case (e.g., performance, calling C libraries, embedding Python in C). Here are the main methods:
1. Using ctypes
- Simplest for calling C functions from Python
- You need a compiled C library (.so on Linux, .dll on Windows) and want to call its functions from
Example:
C Code (mylib.c)
// Compile with: gcc -shared -o mylib.so -fPIC mylib.c
int add(int a, int b) {
return a + b;
}
PythonPython Code
import ctypes
# Load the shared library
lib = ctypes.CDLL('./mylib.so')
# Call the C function
result = lib.add(3, 5)
print("Result:", result)
C2. Using cffi
- Another simple way for C bindings
pip install cffi
PythonPython Code
from cffi import FFI
ffi = FFI()
ffi.cdef("int add(int, int);")
C = ffi.dlopen("./mylib.so")
print(C.add(10, 20))
Python3. Using Python C API
- For advanced integration
- Write Python extensions in C (for performance)
C Code (mymodule.c)
#include <Python.h>
static PyObject* add(PyObject* self, PyObject* args) {
int a, b;
if (!PyArg_ParseTuple(args, "ii", &a, &b))
return NULL;
return PyLong_FromLong(a + b);
}
static PyMethodDef MyMethods[] = {
{"add", add, METH_VARARGS, "Add two numbers"},
{NULL, NULL, 0, NULL}
};
static struct PyModuleDef mymodule = {
PyModuleDef_HEAD_INIT,
"mymodule",
NULL,
-1,
MyMethods
};
PyMODINIT_FUNC PyInit_mymodule(void) {
return PyModule_Create(&mymodule);
}
CCompile:
python3 setup.py build_ext --inplace
Csetup.py:
from setuptools import setup, Extension
module = Extension('mymodule', sources=['mymodule.c'])
setup(
name='MyModule',
ext_modules=[module]
)
PythonPython Code:
import mymodule
print(mymodule.add(3, 4))
PythonUsing Python in C
Useful for apps written in C that need to script logic in Python.
#include <Python.h>
int main() {
Py_Initialize();
PyRun_SimpleString("print('Hello from Python')");
Py_Finalize();
return 0;
}
CSummary
Method | Direction | Best For |
---|---|---|
ctypes | C → Python | Calling existing C shared libs |
cffi | C → Python | Simpler than C API, more flexible than ctypes |
Python C API | C ↔ Python | Writing performance-critical extensions |
Embedding | Python ← C | Running Python from inside a C program |
Conclusion
Python’s simplicity hides a wealth of interesting features that make it a powerful language for both beginners and experienced programmers. Features like dynamic typing, list comprehensions, lambda functions, generators, and f-strings offer elegant solutions to common programming tasks. As you explore Python further, you’ll discover even more nuances that make it an incredibly versatile and enjoyable language to work with!
6 thoughts on “Exploring Fascinating Features of Python”