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
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))
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
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 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.
*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)
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().
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!