Language Concepts

Language Concepts

Python aims to be simple and readable, as well as easy to learn. But with this focus on simplicity, there are some trade-offs and limitations that you should be aware of, as well as language idioms, conventions, and idiocyncracies that is important to understand in order to write idiomatic Python code (a.k.a pythonic code).

Data Types & Data Structures

Python supports all the standard data types, including integers, floats, and strings, which are used to store and manipulate individual pieces of data in your code.

Additionally, Python includes several built-in data structures, such as lists, tuples, dictionaries, and sets, which are used to organize and manage collections of data efficiently.

# List is a collection of items that are ordered and changeable. In other languages, it is called an slice or dynamic array.
my_list = [1, 2, 3]
# A tuple is a collection of items that are ordered and unchangeable.
my_tuple = (1, 2, 3)
# a dictionary is a collection of items that are unordered, changeable, and indexed. In other languages, it is called a hash table or associative array.
my_dict = {'a': 1, 'b': 2}
# a set is a collection of items that are unordered and unindexed. In other languages, it is called a hash set.
my_set = {1, 2, 3}

Indentation and Code Blocks

Python uses indentation to define blocks of code, rather than using curly braces {} or keywords like begin and end. This means that the structure of your code is determined by the level of indentation, and you need to be careful to use consistent indentation throughout your code. This can be a bit confusing at first, but it helps to make your code more readable and maintainable. Example:

def check_number(num):
    if num > 0:
        print("The number is positive.")
    elif num == 0:
        print("The number is zero.")
    else:
        print("The number is negative.")

# Main block of the script
if __name__ == "__main__":
    # Input: Enter a number
    number = int(input("Enter a number: "))
    # Call the function to check the number
    check_number(number)

Dynamic Typing and Duck Typing

Python is a dynamically typed language, which means that you don’t need to declare the type of a variable when you create it. The type of a variable is determined at runtime based on the value that is assigned to it. This can make the code easier to read, but as a trade-off, not only the code will be slower, but also bugs that in other language would be caught at compile time or static analysis, will instead be caught at runtime.

Duck typing is a concept that is related to dynamic typing. It means that the type of an object is determined by what it can do, rather than what it is. If an object has a certain method or attribute, you can use it as if it were of a certain type, even if it doesn’t explicitly inherit from that type. It comes from the saying “If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.”. This can be a powerful feature, but it can also lead to bugs if you’re not careful. Example:

# Duck typing example
class Duck:
    def quack(self):
        print("Quack!")

class Person:
    def quack(self):
        print("I can quack like a duck!")

def make_it_quack(duck):
    duck.quack()

# Dynamic typing example
def print_type_and_value(val):
    if isinstance(val, int):
        print(f"Type: int, Value: {val}")
    elif isinstance(val, str):
        print(f"Type: string, Value: {val}")
    elif isinstance(val, bool):
        print(f"Type: bool, Value: {val}")
    else:
        print("Unknown type")

if __name__ == "__main__":
    duck = Duck()
    person = Person()

    make_it_quack(duck)
    make_it_quack(person)

    print_type_and_value(42)
    print_type_and_value("Hello, World!")
    print_type_and_value(True)

Functions

In Python, function can be declared with the def keyword, followed by the function name, a list of parameters in parentheses, and a colon. The body of the function is indented, and can contain any number of statements. Functions can return a value using the return keyword. Example:

def add(a, b):
    return a + b

result = add(2, 3)
print(result)

A Python function supports different types of arguments, such as positional arguments, keyword arguments, default arguments, and variable-length arguments.

For example, take the following signature:

def my_function(positional, keyword=value, *args, **kwargs):
    pass
ℹ️
Have you noticed the pass keyword here? In Python, this is a common way to define an empty block of code. It is often used as a placeholder when you are defining a function or a class that you plan to implement later.
  • positional is required for this function.
  • keyword is optional, since it has a default value.
  • *args is a tuple of variable-length positional arguments.
  • **kwargs is a dictionary of variable-length keyword arguments.

Positional Arguments

Positional arguments are passed by position. This means that the order of the arguments matters, and you need to provide the correct number of arguments when calling the function. Example:

def greet(name, message):
    print(f"{message}, {name}!")

greet("John", "Hello")
greet("Jane", "Goodbye")

Note that positional arguments can also be passed as keyword arguments, which allows you to specify the name of the argument when calling the function. Example:

greet(name="Alice", message="Hi")

However, this is considered an anti-pattern, as it can make the code less readable and harder to maintain. You should typically positional arguments when the function does not have a large number of arguments.

Keyword Arguments

Python allows you to define functions that accept keyword arguments, which are arguments that are passed by name rather than by position. This can make your code more readable and maintainable, especially when dealing with functions that have a large number of arguments. Example:

def greet(name, message="Hello"):
    print(f"{message}, {name}!")

greet("John")
greet("Jane", "Goodbye")
greet(message="Hi", name="Alice")

Default Arguments

Python allows you to define functions with default arguments, which are arguments that have a default value if no value is provided when the function is called. This can be useful when you want to provide a default value for an argument, but still allow the caller to override it if necessary. Example:

def greet(name, message="Hello"):
    print(f"{message}, {name}!")

greet("John")
greet("Jane", "Goodbye")

Variable-Length Arguments

Python allows you to define functions that accept a variable number of arguments, using the *args and **kwargs syntax. This can be useful when you want to create a function that can accept any number of arguments, or when you want to pass a variable number of arguments to another function. Example:

def sum(*args):
    total = 0
    for arg in args:
        total += arg
    return total

print(sum(1, 2, 3, 4, 5))

def print_info(**kwargs):
    for key, value in kwargs.items():
        print(f"{key}: {value}")

print_info(name="Alice", age=30, city="New York")

def print_all(*args, **kwargs):
    for arg in args:
        print(arg)
    for key, value in kwargs.items():
        print(f"{key}: {value}")

print_all(1, 2, 3, name="Alice", age=30)

Functions and Variables scoping

Python uses lexical scoping, which means that the scope of a variable is determined by where it is defined in the code. Variables that are defined inside a function are local to that function, and can’t be accessed from outside the function. If you want to access a variable from outside a function, you need to declare it as a global variable.

Function and variable scoping follow the LEGB rule, which stands for Local, Enclosing, Global, and Built-in scopes. When you reference a variable in your code, Python will look for it in these scopes in the following order:

  • Local: Variables that are defined inside the current function.
  • Enclosing: Variables that are defined in the enclosing function (if any).
  • Global: Variables that are defined at the top level of the module.
  • Built-in: Variables that are built into Python (like len, range, etc.).

If the variable is not found in any of these scopes, Python will raise a NameError.

Example:

# Global scope
global_var = "global"

def outer_function():
    # Enclosing scope
    enclosing_var = "enclosing"

    # nested function
    def inner_function():
        # Local scope
        local_var = "local"
        print("Inner function:", local_var)

    inner_function()
    print("Outer function:", enclosing_var)

outer_function()
print("Global scope:", global_var)

# Modifying global variable
def modify_global():
    # here the keyword global is used to modify the global variable
    # without it, the variable would be treated as a local variable
    global global_var
    global_var = "modified global"

modify_global()
print("Modified Global scope:", global_var)

# Accessing a built-in scope
def access_builtin():
    print("Built-in scope (length of list):", len([1, 2, 3]))

access_builtin()

Python also has a nonlocal keyword that allows you to modify a variable in the enclosing scope. This can be useful if you want to modify a variable in a nested function without declaring it as a global variable.

def outer_function():
    outer_var = "I am outer"

    def inner_function():
        # here the keyword nonlocal is used to modify the variable in the enclosing scope
        nonlocal outer_var
        outer_var = "I have been modified by inner"
        print(outer_var)

    inner_function()
    print(outer_var)

outer_function()

Python also has a built-in scope that contains functions and variables predefined in the language (e.g., len, range). You can access these functions and variables from anywhere in your code without having to import them.

def print_builtin():
    # Using the built-in len function
    print("Length of the list:", len([1, 2, 3]))

print_builtin()

List Comprehensions

List comprehensions are a concise way to create lists in Python. They allow you to create a new list by applying an expression to each item in an existing list. List comprehensions are often more readable and more efficient than using a for loop to create a list. Example:

# Using a for loop
squares = []
for i in range(10):
    squares.append(i ** 2)

print("Squares using for loop:", squares)

# Using list comprehension
squares = [i ** 2 for i in range(10)]
print("Squares using list comprehension:", squares)

List comprehensions can also include conditional statements to filter the items in the list. Example:

# Using a for loop
even_squares = []
for i in range(10):
    if i % 2 == 0:
        even_squares.append(i ** 2)

print("Even squares using for loop:", even_squares)

# Using list comprehension
even_squares = [i ** 2 for i in range(10) if i % 2 == 0]
print("Even squares using list comprehension:", even_squares)

Iterators and Generators

Generators are a special type of iterator that can be used to create iterators in a more concise and readable way. They are similar to list comprehensions, but instead of creating a list, they create an iterator that can be used to iterate over the items one at a time. This can be more memory-efficient, especially when dealing with large datasets.

Generators are defined using a function that contains the yield keyword. When the function is called, it returns a generator object that can be used to iterate over the items. Example:

# Using a generator function
def squares(n):
    for i in range(n):
        yield i ** 2

# Using a generator expression
squares_gen = (i ** 2 for i in range(10))

print("Squares using generator function:")
for square in squares(10):
    print(square)

print("Squares using generator expression:")
for square in squares_gen:
    print(square)

Iterators are objects that implement the __iter__ and __next__ methods, allowing you to iterate over the items in a sequence. Generators are a convenient way to create iterators in Python, as they automatically implement these methods for you.

class UpperCaseIterator:
    def __init__(self, strings):
        self.strings = strings
        self.index = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self.index < len(self.strings):
            result = self.strings[self.index].upper()
            self.index += 1
            return result
        else:
            raise StopIteration

# List of strings to iterate over
strings = ["hello", "world", "python", "iterator"]

# Create an instance of the iterator
uppercase_strings = UpperCaseIterator(strings)

# Use the iterator
for string in uppercase_strings:
    print(string)

# Output:
# HELLO
# WORLD
# PYTHON
# ITERATOR

Decorators

Decorators are a powerful feature in Python that allow you to modify or extend the behavior of functions or methods. They are often used to add functionality to existing functions without modifying their code. Decorators are defined using the @ symbol followed by the name of the decorator function. Example:

# Decorator function
def uppercase_decorator(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper

# Using the decorator
@uppercase_decorator
def greet(name):
    return f"Hello, {name}!"

print(greet("John"))

Decorators can also take arguments, allowing you to customize their behavior. Example:

# Decorator function with arguments
def repeat_decorator(n):
    def decorator(func):
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            return result * n
        return wrapper
    return decorator

# Using the decorator with arguments
@repeat_decorator(3)
def greet(name):
    return f"Hello, {name}!"

print(greet("John"))

Context Managers

Context managers are a way to manage resources in Python, such as files, network connections, or database connections. They allow you to allocate and release resources automatically, without having to worry about cleaning up after yourself. Context managers are defined using the with statement, and can be created using the contextlib module or by defining a class with __enter__ and __exit__ methods.

Example of a built-in context manager. The built-in open function acts as a context manager, which automatically handles opening and closing the file.

# Here we are using the built-in open function as a context manager.
# This will automatically close the file when the block is exited.
# Notice the `with` keyword
with open("example.txt", "w") as f:
    f.write("Hello, World!")

But we can also create our own custom context manager:

class FileManager:
    def __init__(self, filename, mode):
        self.filename = filename
        self.mode = mode
        self.file = None

    def __enter__(self):
        self.file = open(self.filename, self.mode)
        return self.file

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.file:
            self.file.close()

# Using the custom context manager
with FileManager('example.txt', 'w') as file:
    file.write('Hello, World!')

Alternatively, you can use the contextlib module to create a context manager using a generator function:

from contextlib import contextmanager

@contextmanager
def file_manager(filename, mode):
    file = open(filename, mode)
    try:
        yield file
    finally:
        file.close()

# Using the context manager
with file_manager('example.txt', 'w') as file:
    file.write('Hello, World!')

Lambda Functions

Lambda functions are a way to create anonymous functions in Python. They are often used as a quick way to define simple functions without having to use the def keyword. Lambda functions can take any number of arguments, but can only have one expression. Example:

# Using a lambda function
add = lambda x, y: x + y
print(add(2, 3))

# Using a lambda function with a list comprehension
squares = [(lambda x: x ** 2)(i) for i in range(10)]
print(squares)

Lambda functions are often used in combination with built-in functions like map, filter, and reduce to perform operations on lists or other iterables. Example:

# Using the map function with a lambda function
numbers = [1, 2, 3, 4, 5]
squared = list(map(lambda x: x ** 2, numbers))
print(squared)

# Using the filter function with a lambda function
even = list(filter(lambda x: x % 2 == 0, numbers))
print(even)

# Using the reduce function with a lambda function
from functools import reduce
sum = reduce(lambda x, y: x + y, numbers)
print(sum)

Modules and Packages

Python code is organized into 📄 modules, which are files that contain Python code. Modules can define functions, classes, and variables that can be used in other modules. You can import a module using the import keyword, and access its functions and variables using dot notation. Example:

# Importing a module
import math

# Using a function from the math module
print(math.sqrt(16))

Modules can be organized into 📁 packages, which are directories that contain one or more modules. Packages can contain other packages, creating a hierarchical structure. You can import a module from a package using dot notation.

A package is essentially a directory that contains multiple modules (Python files) and an __init__.py file, which can be empty or contain package initialization code. Packages allow for a hierarchical structuring of the module namespace, making it easier to manage and reuse code.

my_package/
    # This file indicates that the directory should be treated as a package.
    __init__.py
    module1.py
    module2.py

Importing Modules from a Package:

from my_package import module1
from my_package import module2

# you can also import specific functions or variables from a module:
# from my_package.module1 import function1
# from my_package.module2 import function2

module1.function1()
module2.function2()

Object-Oriented Programming

Python is an object-oriented programming language, which means that it supports the creation of classes and objects.

class Dog:
    def __init__(self, name):
        self.name = name

    def bark(self):
        print(f"{self.name} says woof!")

my_dog = Dog("Buddy")
my_dog.bark()

Error Handling

Python make use of Exceptions to handle errors that occur during the execution of a program. It has built-in support for error handling using try, except, and finally blocks. You can use these blocks to catch and handle exceptions that occur during the execution of your code. Example:

try:
    # code that may raise an exception
    x = 1 / 0
except ZeroDivisionError:
    # handle the exception
    print("Cannot divide by zero!")
finally:
    # code that will always run
    print("Done!")

You can create your own custom exceptions by defining a new class that inherits from the Exception class. Example:

class MyError(Exception):
    pass

You can also raise your own exceptions using the raise keyword. This can be useful if you want to handle a specific error condition in your code. Example:

def divide(x, y):
    if y == 0:
        raise ZeroDivisionError("Cannot divide by zero!")
    return x / y

try:
    result = divide(1, 0)
except ZeroDivisionError as e:
    print(e)