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

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

This single line is equivalent to:

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

You can even include conditions:

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

This feature is highly optimized and preferred for readable and concise code.

Generator Expressions

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)

With Yield

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

Equivalent expression

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

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.

Different length

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

x = zip(a, b)


print(list(x)) 

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

It’s handy when you need to work with multiple lists or tuples and want to process corresponding elements together.

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

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]
  • start is the index where the slice begins (inclusive).
  • stop is the index where the slice ends (exclusive).
  • 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]

Reversing a sequence:

reversed_sequence = sequence[::-1]

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"
#Tuple Reversaltpl = (1, 2, 3, 4, 5)
reversed_tpl = tpl[::-1]

print(reversed_tpl)  # Output: (5, 4, 3, 2, 1)
# Interger reversal

n = 1234
print(int(str(n)[::-1])) #4321

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.

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 = [[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: [[1, 1, 1], [2, 2, 2], [3, 3, 3]] , 4715228096
print("New list:", new_list,id(new_list))   # New list: [[1, 1, 1], [2, 2, 2], [3, 3, 3]] , 4715162880

print(id(old_list[0])) #4715227584
print(id(new_list[0])) #4715227584
  • Object old_list and new_list have different reference addresses 4715228096 and 4715162880
  • old_list[0] and new_list[0] have same reference address 4715227584

Example 2: Modifying the existing nested object, it will get reflected in both


old_list[1][1] = 'AA'

print("Old list:", old_list , id(old_list)) # Old list: [[1, 1, 1], [2, 'AA', 2], [3, 3, 3]] ,4715228096
print("New list:", new_list,id(new_list)) # New list: [[1, 1, 1], [2, 'AA', 2], [3, 3, 3]], 4715162880

Example 3: 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: [[1, 1, 1], [2, 'AA', 2], [3, 3, 3], [4, 4, 4]], 4715228096
New list: [[1, 1, 1], [2, 'AA', 2], [3, 3, 3]], 4715162880

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

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

Flatten a list of lists

Sum function

sum(iterable, start=0)
  • 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

Flattening

l1 = [[1, 2], [3, 4], [5, 6]]
result = sum(l1, [])
print(result) # [1, 2, 3, 4, 5, 6]
  • 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.

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

  • *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')

Output

first == Back
mid == end
last == Mess
Hello
Welcome
to
BackendMess
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)

Output

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

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

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

By declaring the message as nonlocal, we are telling Python to look for the message in the closest enclosing scope (i.e., outer_function) and modify it. 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()

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

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

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

Leave a Comment