Exploring Fascinating Features of Python

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!

Features of Python

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
Python

Statically 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
Python

Interpreted 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))
Python

You 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.")
Python

You 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

MethodIntroduced InProsCons
% formattingPython 2+Short, familiar for C usersLess readable, outdated
str.format()Python 2.7/3+Powerful, flexibleVerbose
f-StringsPython 3.6+Clean, readable, supports expressionsNewer versions only
string.TemplatePython 2+Safe for user inputLimited 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
Python

Common 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)
Python

Why 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)]
Python

This single line is equivalent to:

squares = []
for x in range(10):
    squares.append(x**2)
Python

You can even include conditions:

# List of squares of even numbers
squares = [x**2 for x in range(10) if x % 2 == 0]
Python

Application : 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]]
C

Read 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)}
Python

Output

{0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25, 6: 36, 7: 49, 8: 64, 9: 81}
Python

unique_squares = {x**2 for x in range(10)}

print(unique_squares)
Python

Output

{0, 1, 64, 4, 36, 9, 16, 49, 81, 25}
Python

Generator 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)
Python

With Yield

# Generator function
def generate_squares(n):
    for x in range(n):
        yield x**2
Python

Equivalent expression

squares_gen_expr = (x**2 for x in range(10))
Python

Read about generators

The 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.
Python

Different length

a = ("John", "Charles", "Mike","Vicky")
b = ("Jenny", "Christy", "Monica")

x = zip(a, b)


print(list(x)) 

#[('John', 'Jenny'), ('Charles', 'Christy'), ('Mike', 'Monica')]
Python

It’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)
'''
Python

Application of Zip

Short Hand If … Else

a = 2
b = 330
print("A") if a > b else print("B") #B
Python

This 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
Python

i = 1
while i < 6:
  print(i)
  i += 1
else:
  print("i is no longer less than 6")
Python

Lambda, Map, Filter

Refer

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
Python

Reversing a sequence:

reversed_sequence = sequence[::-1]
Python

Example

print(lst[6:0:-2]) #[6, 4, 2] Note start and end index
Python

How 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)
Python

Integer 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.

Further read

Real usages

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

Python

Option 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"
Python

Is 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
Python

Input 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
Python

Option 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
Python

Common 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"       
Python

Python Properties: Behind the Scenes

When you define a property:

@property
def name(self): ...

@name.setter
def name(self, value): ...
Python

Python 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!
Python

Python 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
Python

Getter

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)
Python

return self.name --> self.name()

def name(self):
    print("Getting name")
    return self.name()  # ❗️This calls the getter again (infinite loop)
Python

Why 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
Python

This 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
Python

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")
        self._name = value


p = Person("Alice")   # will not calls the setter
print(p.name)         # Calls the getter
p.name = "Bob"        # Calls the setter
Python

Below part also trigger setter

def __init__(self, name):
    self.name = name  # this will not call the setter, this will directly save name variable     
Python

Proof : 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
Python

How 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
Python

Deep 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

Python

new_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'
Python

Output

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
Python

Flatten 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
Python

Flattening

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 from 0.
  • 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()
Python

Why 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()
Python

What happens if you run it directly?

> python example.py

#Output

Start of example.py
example.py is being run directly
Hello!
Python

Why?

  • 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()
Python

What happens if you run test_import.py?

> python test_import.py

#Output
Start of example.py
Imported example.py successfully
Hello!
Python

Key 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__ valueRuns 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')
Python

Output

first == Back
mid == end
last == Mess
Hello
Welcome
to
BackendMess
Python

def 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)
Python

Output

arg1: Back
arg2: end
arg3: Mess
arg1: Back
arg2: end
arg3: Mess
Python

Unpacking 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
Python

Nonlocal

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()
Python

By 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()
Python

If 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)]
Python

Example 2

fruits = ['apple', 'banana', 'cherry']
for index, fruit in enumerate(fruits):
    print(index, fruit)
Python

Output

0 apple
1 banana
2 cherry
Python

Custom Start Index

for index, fruit in enumerate(fruits, start=1):
    print(index, fruit)
Python

Output

1 apple
2 banana
3 cherry
Python

Why 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().

Check uses of 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")
Python

Output

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
    }
}
Python

Even 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'
Python

Return 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
Python

How 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;
}
Python

Python Code

import ctypes

# Load the shared library
lib = ctypes.CDLL('./mylib.so')

# Call the C function
result = lib.add(3, 5)
print("Result:", result)
C

2. Using cffi

  • Another simple way for C bindings
pip install cffi
Python

Python Code

from cffi import FFI

ffi = FFI()
ffi.cdef("int add(int, int);")

C = ffi.dlopen("./mylib.so")
print(C.add(10, 20))
Python

3. 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);
}
C

Compile:

python3 setup.py build_ext --inplace
C

setup.py:

from setuptools import setup, Extension

module = Extension('mymodule', sources=['mymodule.c'])

setup(
    name='MyModule',
    ext_modules=[module]
)
Python

Python Code:

import mymodule
print(mymodule.add(3, 4))
Python

Using 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;
}
C

Summary

MethodDirectionBest For
ctypesC → PythonCalling existing C shared libs
cffiC → PythonSimpler than C API, more flexible than ctypes
Python C APIC ↔ PythonWriting performance-critical extensions
EmbeddingPython ← CRunning 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!

Resource

6 thoughts on “Exploring Fascinating Features of Python”

Leave a Comment