Contents:
- Integers 
- Floats 
 
Introduction
The Zen of Python emphasizes simplicity and clarity:
- Prefer beauty, clarity, and simplicity. 
- Choose flat over nested structures, and sparse over dense. 
- Readability and practicality are crucial. 
- Special cases shouldn't override general rules. 
- Silence errors only intentionally. 
- Avoid guessing in ambiguity. 
- Ideally, there's one clear way to do things. 
- Timeliness matters, but thoughtful delay can be prudent. 
- Solutions should be easy to explain to be considered good. 
- Embrace more namespaces as they are beneficial. 
A quick refresher - basics review
Multiline Statements: Understanding Physical vs. Logical Newlines
- Implicit Physical Newlines: - Automatically merged into a single logical line. 
- Applicable in lists, tuples, dictionaries, sets, and function arguments/parameters. 
- Support inline comments. 
 
- Explicit Physical Newlines: - Not automatically merged; require manual intervention. 
- Used to break statements using the backslash ( - \) character.
- Do not support inline comments. 
 
- Multiline String Literals: - Created using triple delimiters ( - '''or- """), suitable for spanning strings across multiple lines.
 
Variable Naming and Conventions
- Variable Names: - Begin with an underscore (_) or a letter (a-z, A-Z), but not a digit. 
- Can include any combination of numbers, underscores, letters, or digits. 
- Avoid reserved words. 
 
- Conventions: - Single Underscore (_my_var): Indicates internal use or private objects. Such objects are excluded from imports like - from module import *.
- One-Sided Double Underscore (__my_var): Mangles class attributes, beneficial in inheritance scenarios. 
- Double-Sided Double Underscores (dunder, my_var): Reserved for system-defined names with specific interpreter meanings. Use only predefined dunders. 
 
- Naming Standards (PEP8 Style Guide): - Packages: Short, all-lowercase, ideally without underscores (e.g., - utilities).
- Modules: Short, all-lowercase, may include underscores (e.g., - db_utils,- dbutils).
- Classes: Use CapWords or upper camel case (e.g., - BankAccount).
- Functions: Lowercase with underscores (snake_case, e.g., - open_account).
- Variables: Lowercase with underscores (snake_case, e.g., - account_id).
- Constants: All-uppercase with underscores (e.g., - MIN_APR).
 
Classes
Classes implement special methods to define custom behaviors:
- __str__: Provides a readable representation for end-users. If undefined, falls back to- __repr__.
def __str__(self): 
    return 'Rectangle (width={}, height={})'.format(self.width, self.height)- __repr__: Gives a detailed, unambiguous string for debugging that could recreate the object's state.
def __repr__(self): 
    return 'Rectangle({}, {})'.format(self.width, self.height)- __eq__: Defines equality comparison, considering two objects equal based on specific attributes.
def __eq__(self, other): 
    return isinstance(other, Rectangle) and (self.width, self.height) == (other.width, other.height)- __lt__: Specifies less-than comparison, often based on a computed attribute like area.
def __lt__(self, other): 
    return isinstance(other, Rectangle) and self.area() < other.area()- Getters & Setters: Encapsulate data access and modification, implemented with decorators for simplicity. 
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height
    @property
    def width(self):
        return self._width
    @width.setter
    def width(self, value):
        if value <= 0:
            raise ValueError('Width must be positive.')
        self._width = value
    @property
    def height(self):
        return self._height
    @height.setter
    def height(self, value):
        if value <= 0:
            raise ValueError('Height must be positive.')
        self._height = valuePython type hierarchy
Numbers
- Integral: Integers, Booleans 
- Non-Integral: Floats, Complex numbers, Decimals, Fractions 
Collections
- Sequences - Mutable: Lists 
- Immutable: Tuples, Strings 
 
- Sets - Mutable: Sets 
- Immutable: Frozen Sets 
 
- Mappings: Dictionaries 
Callables
- User-Defined Functions 
- Generators 
- Classes - Instance Methods 
- Class Instances (call()) 
 
- Built-in Functions: (e.g., len(), open()) 
- Built-in Methods: (e.g., list.append(x)) 
Variables and memory
Variables are memory references
Heap - a section of memory allocated for storing objects while a program runs.
- The - id()function provides the memory address of an object as a base-10 integer.
Reference counting
- Reference Counting: A memory management strategy where objects track the number of references to them. 
- Automatic Deletion: Memory is freed when an object’s reference count hits zero, helping prevent memory leaks and automate memory management in Python. 
- sys.getrefcount(my_var): Increases the reference count by creating an extra reference.
- ctypes.c_long.from_address(address).value: Directly accesses the memory address (using- id(my_var)) without affecting the reference count.
Garbage collection
Circular References:
- Occur when objects are mutually referenced in a cycle. 
- Standard reference counting fails as the involved objects’ counts never drop to zero due to their interdependencies. 
Garbage Collector:
- Manages memory by identifying and reclaiming memory from objects that are no longer in use. 
- Includes a cycle detector to resolve circular references, freeing up memory that cannot be reclaimed by reference counting alone. 
Garbage Collection Control:
- Enabled by default to handle memory efficiently and prevent memory leaks. 
- Can be manually controlled via the - gcmodule; allows turning off, forcing garbage collection runs, or custom cleanup.
- Turning it off is risky unless you’re certain your code lacks circular references. 
Dynamic vs Static typing
Static Typing:
- Languages like Java, C++, and Swift require explicit declaration of data types during variable declaration. This ensures type consistency throughout the program. 
Dynamic Typing:
- In dynamic languages like Python, data types do not need to be declared with the variable. The type is determined at runtime, allowing for greater flexibility in coding. 
Variable re-assignment
Variable Reassignment:
- Reassigning a variable to a new value updates its memory address, as it typically points to a new object in memory. 
Immutability of Integers:
- Integer values are immutable, meaning the actual numeric value at a given memory address cannot be changed. 
Memory Address Sharing:
- Two variables holding the same integer value often reference the same memory address to optimize memory usage, thanks to Python's internal optimizations. 
Object mutability
Memory Address Content:
- A memory address holds the data type and the state (the actual data). 
Mutable Objects:
- Objects whose internal state can be changed. Examples include: - Lists 
- Sets 
- Dictionaries 
- User-Defined Classes 
 
Immutable Objects:
- Objects whose internal state cannot be altered. Examples include: - Numbers (int, float, Booleans) 
- Strings 
- Tuples 
- Frozen Sets 
- Some User-Defined Classes 
 
- Immutable objects can contain mutable elements, such as a tuple containing lists. While the tuple itself cannot change its structure (the references it holds), the content of the mutable elements (lists) within can change. 
Impact of Mutability on Memory Addresses:
- Mutability methods influence memory addresses. For example, appending to a list does not change its memory address, but concatenating lists creates a new list at a new address. 
Function arguments and mutability
Variable Values in Functions:
- Changes to variable values within functions adhere to the same rules of mutability and reference assignment as elsewhere in the program. 
Function Attribute References:
- Attributes within a function refer to the same memory addresses as the variables passed to it, maintaining consistency with external references. 
Immutable Objects:
- Immutable objects prevent unintended side-effects in functions because their internal state cannot be altered. 
Mutable Objects:
- Mutable objects are susceptible to unintended side-effects within functions due to their changeable internal state. 
Shared references and mutability
- Immutable variables with the same value share a reference to a memory address. 
- Changing an immutable variable creates a new memory address for the changed value, ensuring safety. 
- This sharing mechanism doesn't apply to mutable variables. 
Variable equality
Memory Address
- is / is not:- Identity operator. 
- Compares memory addresses. 
 
Object State (data):
- == / !=:- Equality operator. 
- Compares object states (data). 
 
The None object:
- Is a real object managed by the Python memory manager. 
- The memory manager always uses a shared reference when assigning a variable to None. 
Everything is an object
- Any object can be assigned to a variable including functions 
- Any object can be passed to a function including functions 
- Any object can be returned from a function including functions 
Python Optimizations: Interning
CPython Implementation:
- Standard Python implementation (written in C). 
- When - a = 6and- b = 6, both variables reference the same memory address.
- But for - a = 500and- b = 500, they reference different memory addresses.
Interning:
- Reuses objects on-demand. 
- Python pre-loads a global list of integers in the range [-5; 256]. 
- Small integers are optimized since they appear frequently. 
- Any reference to an integer in that range uses the cached version. 
Singletons:
- Classes that can only be instantiated once. 
Python Optimizations: String interning
String Interning:
- Some strings are automatically interned, but not all. 
- During Python code compilation, identifiers (or those resembling identifiers) are interned. 
- Identifiers starting with _ or (a-z A-Z) and consisting of _, (a-z A-Z 0-9) are interned. 
- Strings with white spaces are not interned. 
- Not all strings are automatically interned, but you can force intern them using - sys.intern().
- However, it's generally unnecessary to intern strings manually unless required. 
- Comparing references with - is / is notis faster than character by character comparison with- == / !=.
# Example 1:
a = 'hello'
b = 'hello'
a is b -> True
a == b -> True
# Example 2:
a = 'hello world'
b = 'hello world'
a is b -> False
a == b -> True
Example 3:
a = 'hello_world'
b = 'hello_world'
a is b -> True
a == b -> TruePython Optimizations: Peephole
Constant Expressions:
- Used for immutable variables, numeric calculations, and short sequences with length < 20. 
- Examples: - 24 * 60 -> 1440 (1, 2) * 2 -> (1, 2, 1, 2) 'abc' * 2 -> 'abcabc' 'helo' + ' world' -> 'hello world' 'the quick brown fox' * 10 -> too many characters (> 20)
Membership Tests:
- Mutables are replaced by immutables for membership tests. 
- Lists are replaced by tuples, sets by frozen sets. 
- Set membership is much faster than list or tuple membership. 
- Use - {}for membership tests whenever possible.- Instead of - if e in [1, 2, 3]or- if e in (1, 2, 3), use- if e in {1, 2, 3}.
 
Numeric Types
5 main types of numbers
- Boolean Truth Values: Represented by - bool.
- Integer Numbers (Z): Represented by - int.
- Rational Numbers (Q): Represented by - fractions.Fraction.
- Real Numbers (R): Represented by - floator- decimal.Decimal.
- Complex Numbers (C): Represented by - complex.
Hierarchy:
- Integers (Z) < Rational Numbers (Q) < Real Numbers (R) < Complex Numbers (C). 
Integers: Data Types
How large an integer can be depends on how many bits are used to store the number
In some languages like Java and C, integer data types use a fixed number of bits.
- Examples in Java include - byte(8-bit),- short(16-bit),- int(32-bit), and- long(64-bit).
In Python, the int object uses a variable number of bits.
- It can use 4 bytes (32 bits), 8 bytes (64 bits), 12 bytes (96 bits), etc. 
- Theoretically, its size is limited only by the available memory. 
You can use sys.getsizeof() from the sys module to determine the amount of memory used by the attribute.
Integers: Operations
- Division always returns a float. 
- n = d * (n // d) + (n % d), where- nis the numerator and- dis the denominator.
- Floor division ( - //or- div) returns an integer.- The floor of a real number - ais the largest integer less than or equal to- a.
- For negative numbers, floor division returns the largest integer smaller than or equal to - a.- Example: - math.floor(-3.1) -> -4.
 
 
- Floor division is different from truncation. 
- Modulo operator ( - %or- mod) returns the remainder as an integer.
Integers: Constructors and Bases
- The - intclass provides multiple constructors:- int(10) -> 10
- int(10.9) -> truncation 10
- int(-10.9) -> truncation -10
- int(True) -> 1
- int(Decimal('10.9')) -> truncation 10
- int('10') -> 10
 
- Number Base: - When used with a string, the constructor has an optional second parameter: base (2<= base <= 36). 
- If the base is not specified, the default is base 10. 
- Examples (answers are in base 10): - int('1010', base=2) -> 10
- int('A12F', base=16) -> 41263
- int('a12f', base=16) -> 41263
- int('534', base=8) -> 348
- int('A', base=11) -> 10
- int('B', base=11) -> ValueError: invalid literal for int() with base 11: B
 
 
- Reverse Process: Changing an int from base 10 to another base: - bin()- Base 2 (binary).- bin(10) -> '0b1010'
 
- oct()- Base 8.- oct(10) -> '0o12'
 
- hex()- Base 16.- hex(10) -> '0xa'
 
 
- The prefixes in the string help document the base of the number to avoid confusion. 
- The prefixes are consistent with literal integers using a base prefix (no strings attached!). 
- type(0xa) -> int
Base Change Algorithm:
- Given a number - nand a base- b.
- If - bis less than 2 or- nis negative, raise an exception.
- If - nequals 0, return- [0].
- Initialize an empty list - digits.
- While - nis greater than 0:- Calculate the remainder - mwhen dividing- nby- b.
- Divide - nby- band assign the result back to- n.
- Append - mto the beginning of the- digitslist.
 
Encoding:
- Python's encoding utilizes characters ranging from 0 to 9 and a to z (case insensitive), limiting the base to 36. 
- However, the choice of characters to represent the digits is flexible and can be customized based on your encoding map. 
Encoding Algorithm:
- Given a list of - digitsand an encoding- map.
- Initialize an empty string - encoding.
- For each digit - din the- digitslist:- Append the character corresponding to - din the encoding- mapto the- encodingstring.
 
Here's the simplified version:
# Encoding Algorithm
digits = [...]  # List of digits
map = '...'     # Encoding map
encoding = ''
# Construct the encoding string
for d in digits:
    encoding += map[d]
# Or more simply:
encoding = ''.join([map[d] for d in digits])Rational numbers
Rational numbers, represented as fractions of integer numbers or as real numbers with a finite number of digits after the decimal point, can be conveniently handled in Python using the Fraction class from the fractions module. Here are some key points about using fractions:
Fraction Class:
- Rational numbers are represented using the - Fractionclass in Python.
- Fractions are automatically reduced to their simplest form. - Example: - Fraction(6, 10)becomes- Fraction(3, 5).
 
- Negative signs are attached to the numerator. - Example: - Fraction(1, -4)becomes- Fraction(-1, 4).
 
- Constructors: - Fraction(numerator=0, denominator=1)
- Fraction(other_fraction)
- Fraction(float)
- Fraction(decimal)
- Fraction(string)
- Example: - Fraction('0.125')becomes- Fraction(1, 8).
 
- Standard arithmetic operators ( - +, -, *, /) are supported and result in- Fractionobjects.
- Numerator and denominator can be accessed using - numeratorand- denominatorattributes.
Handling Irrational Numbers:
- The - Fractionclass can handle irrational numbers like- math.pior- math.sqrt(2), although the result is an approximation, not exact.
Constraining the Denominator:
- In computer representations, numbers like - 0.3may not be exact, leading to approximations.
- limit_denominator(max_denominator)method helps in finding an approximate equivalent fraction with a constrained denominator.- Finds the closest rational with a denominator not exceeding - max_denominator.
 
- Example: - For - x = Fraction(0.3), the result is- Fraction(5404319552844595, 18014398509481984).
- But - x.limit_denominator(10)gives- Fraction(3, 10), providing a more human-readable result.
 
Floats: Internal representations
The float class in Python is the default implementation for representing real numbers. Here are some key points about Python's float:
Implementation:
- Python's - floatis implemented using the C double type, which typically conforms to the IEEE 754 double-precision binary float standard, also known as binary64.
- It uses a fixed number of bytes: 8 bytes or 64 bits. 
Components:
- Sign: 1 bit (0 for positive, 1 for negative). 
- Exponent: 11 bits, giving a range of [-1022, 1023]. 
- Significant digits: 52 bits, representing 15-17 significant (base-10) digits. - For simplicity, all digits are included except leading and trailing zeros. 
 
Examples of 5 Significant Digits:
- 1.2345
- 1234.5
- 12345000000
- 0.00012345
- 12345e-50
Finite Decimal vs. Binary Representation:
- Some numbers that have a finite decimal representation do not have a finite binary representation, and vice versa. 
Floats: Equality testing
Working with floating-point numbers in Python, especially when testing for equality, can be tricky due to precision issues. Here's why:
- Some decimals with finite decimal representation cannot be represented with a finite binary representation, leading to precision discrepancies. 
pythonCopy code
x = 0.1 + 0.1 + 0.1 y = 0.3 x == y # Evaluates to False due to precision discrepancies
To test for equality, two common methods are:
- Rounding: - Round both sides of the equality expression to a certain number of significant digits. 
 
round(x, 5) == round(y, 5)- Using an Appropriate Range (Epsilon): - Define a function to check if the absolute difference between two numbers is less than a specified tolerance (epsilon). 
 
def is_equal(x, y, eps): 
    return math.fabs(x - y) < epsHowever, there are non-trivial issues with these methods:
- Absolute Tolerance (abs_tol): - Works well with numbers away from zero. 
- Setting an exact number as epsilon may not work well with numbers away from zero. 
 
- Relative Tolerance (rel_tol): - Works well with numbers close to zero. 
- Setting a percentage-based epsilon may not work well with numbers close to zero. 
- We can calculate the tolerance using - rel_tol * max(|x|, |y|).
 
To overcome these issues, it's recommended to use the math.isclose() function, which handles absolute and relative tolerances intelligently:
math.isclose(a, b, rel_tol=1e-09, abs_tol=0.0)If you omit abs_tol, it defaults to 0, potentially causing issues when comparing numbers close to zero.
In general, it's advisable to avoid using the == operator with floats. If precise equality comparison is necessary, consider converting numbers to fractions.
Floats: Coercing to integers
When converting a float to an integer, there's always some data loss. Here are several ways to perform this conversion:
Truncation:
- math.trunc(): Returns the integer portion of the number by ignoring everything after the decimal point.
- int(float): Using the- int()method also performs truncation.
Floor:
- math.floor(): Returns the largest integer less than or equal to the number.
- For positive numbers, floor and truncation are equivalent, but for negative numbers, they differ. 
- Floor division ( - //) is an example of this method.
Ceiling:
- math.ceil(): Returns the smallest integer greater than or equal to the number.
Rounding:
- Rounding can be performed using the - round()function or by using the- roundmethod of the- floatclass.
- It rounds to the nearest integer, with ties rounded away from zero by default. 
Floats: Rounding
The round() function in Python rounds a number x to the closest multiple of 10 to the power of -n, where n can be negative as well. If n is not specified, it defaults to 0, and the function returns an integer. Here are some key points:
Usage:
- round(x): Returns an integer.
- round(x, n): Returns a number of the same type as- x.
- round(x, 0): Returns a number of the same type as- x.
Exception:
- When rounding ties, such as - round(1.25, 1), the rounding method follows the Banker's rounding technique.
Banker's Rounding:
- Follows the IEEE 754 standard, rounding ties to the nearest value with an even least significant digit. 
- Example: - round(1.25, 1) -> 1.2
- round(1.35, 1) -> 1.4
- round(15, -1) -> 20
- round(25, -1) -> 20
 
Why Banker's Rounding?:
- It's less biased than rounding ties away from zero, especially when averaging numbers. 
Rounding Towards Positive Infinity:
- An incorrect method is - int(x + 0.5), which doesn't work for negative numbers.
- The correct method involves considering the sign of - xand adding 0.5:
def round_up(x): 
    from math import copysign 
    return int(x + copysign(0.5, x))This function ensures proper rounding behavior, even for negative numbers.
Decimals
The decimal module in Python, introduced in PEP 327, provides an alternative to using binary floats, helping to avoid approximation issues inherent in floating-point arithmetic. While the Fraction class is another option, it poses challenges when adding fractions due to the need to find a common denominator, which can be complex and memory-intensive. Here's why the decimal module is preferred:
Decimal Context:
- The - decimalmodule introduces a context mechanism that controls various aspects of working with decimals.
- Contexts can be either global or local: - Global Context: - Affects operations performed throughout the codebase. 
- Accessed and modified using - decimal.getcontext().
 
- Local Context: - Temporarily modifies settings without affecting the global settings. 
- Created using - decimal.localcontext()as a context manager.
 
 
Precision and Rounding:
- The context allows control over precision and rounding mechanisms: - ctx.prec: Sets or retrieves the precision (an integer).
- ctx.rounding: Sets or retrieves the rounding mechanism (a string).- Options include - ROUND_UP,- ROUND_DOWN,- ROUND_CEILING,- ROUND_FLOOR,- ROUND_HALF_UP,- ROUND_HALF_DOWN, and- ROUND_HALF_EVEN.
 
 
Using the decimal module with appropriate context settings provides more control over arithmetic operations involving decimals, ensuring precision and avoiding approximation issues.
Decimals: Constructors and contexts
The Decimal(x) constructor in Python's decimal module allows creating Decimal objects from various types of data. Here's a breakdown of the supported input types for x:
- Integers: - Example: - a = Decimal(10)creates a Decimal object representing the integer 10.
 
- Other Decimal Objects: - Example: - a = Decimal('0.1')creates a Decimal object representing the decimal number 0.1.
 
- Strings: - Example: - a = Decimal('0.1')creates a Decimal object representing the decimal number 0.1.
 
- Tuples: - Example: - a = Decimal((1, (3, 1, 4, 1, 5), -4))creates a Decimal object representing the decimal number -3.1415.
- Format: - (sign, (d1, d2, d3, ...), exp), where sign is 0 for positive and 1 for negative.
 
- Floats: - While floats can be used, it's not usually recommended due to precision issues. 
- Example: - Decimal(0.1)may result in a slightly imprecise representation like- 0.100000000000000005551.
- It's better to use strings or tuples instead for exact representation. 
 
Context Precision and the Constructor:
- Context precision affects mathematical operations but not the constructor. 
- Example: 
import decimal
from decimal import Decimal
decimal.getcontext().prec = 6
a = Decimal('0.12345')  # a -> 0.12345
b = Decimal('0.12345')  # b -> 0.12345
print(a + b)            # -> 0.24690
with decimal.localcontext() as ctx:
    ctx.prec = 2
    c = a + b
    print(c)             # -> 0.25
print(c)                 # -> 0.25In this example, the precision set in the local context affects the result of the addition operation (c), but it doesn't affect the constructor (a and b).
Decimals: Math operations
When working with Decimals, the // (floor division) and % (modulus) operators don't behave exactly the same as they do with integers, although they still satisfy the equation n = d * (n // d) + (n % d). Here's how they differ:
Floor Division (//):
- For integers: Performs floor division, resulting in the floor of the quotient. 
- For Decimals: Performs truncated division, resulting in the truncated quotient. 
Modulus (%):
- For integers: Returns the remainder after division. 
- For Decimals: The behavior changes for negative Decimals, but the usual equation still holds true. 
While the Decimal class defines various mathematical operations like square root and logarithms, not all functions from the math module are available. Using math functions with Decimal objects involves casting them to floats, which loses the precision mechanism provided by Decimal objects. It's recommended to use math functions defined in the Decimal class whenever possible to maintain precision.
Decimals: Performance considerations
When comparing the Decimal class to the float class, there are several drawbacks to using Decimal:
- Ease of Coding: - Decimalobjects are not as easy to construct compared to floats. They require construction via strings or tuples, which may be less intuitive for some developers.
 
- Limited Math Functions: - Not all math functions available in the - mathmodule have a counterpart in the- Decimalclass. This limits the functionality available when working with- Decimalobjects.
 
- Memory Overhead: - Decimalobjects typically have more memory overhead compared to floats. This can become a concern when dealing with large datasets or performance-critical applications.
 
- Performance: - Decimalarithmetic operations are generally slower compared to floats. This relative slowness can impact the performance of applications, especially those involving extensive numerical calculations.
 
Despite these drawbacks, the Decimal class offers precise arithmetic and is suitable for applications requiring exact decimal representation, such as financial calculations or where accuracy is paramount. However, for applications where performance is critical and exact precision is not required, floats may be preferred due to their simplicity and faster arithmetic operations.
Complex numbers
Complex Class:
- Constructor: - complex(x, y)for rectangular coordinates where- xis the real part and- yis the imaginary part.
- Literals: - x + yJ.
 
- Rectangular Coordinates: - Real and imaginary parts ( - xand- y) are stored as floats.
 
- Instance Properties and Methods: - .real: Returns the real part.
- .imag: Returns the imaginary part.
- .conjugate(): Returns the complex conjugate.
 
- Arithmetic Operators: - Standard arithmetic operators (+, -, *, /, **) work as expected. 
- Real and complex numbers can be mixed. 
- //and- %operators (divmod) are NOT supported.
 
- Other Operations: - ==and- !=are supported but may corrupt due to float approximation problems.
- Comparison operators are not supported. 
- Use the - cmathmodule instead of- math.
 
- Rectangular to Polar: - cmath.phase(x): Returns the argument (phase) of the complex number- xin [-pi, pi].
- abs(x): Returns the magnitude (r) of- x.
 
- Polar to Rectangular: - cmath.rect(r, phi): Returns a complex number (rectangular coordinates) equivalent to the complex number defined by (r, phi) in polar coordinates.
 
- Euler's Identity: - eiπ + 1 = 0
- RHS = cmath.exp(complex(0, math.pi)) + 1is very close to zero, not exact (due to float's approximation problems).
- cmath.isclose(RHS, 0, abs_tol=0.0001)returns 0, confirming the exactness of Euler's identity.
 
Booleans
Bool Class:
- Represents Boolean values. 
- Subclass of the int class. 
- Possesses properties and methods of integers. 
- Specialized methods like - and,- or, etc.
- Trueand- 1are not the same object (- True is 1is- False).
- True == 1evaluates to- True.
- Two constants: - Trueand- False.
- Singleton objects of type - bool.
- Retain the same memory address. 
- Equality can be assessed using - is/- is notor- ==/- !=.
- Many classes define how to cast instances to a Boolean, known as truth value. 
- bool(0)evaluates to- False.
- bool(x)evaluates to- Truewhen- xis not- 0.
Booleans: Truth values
Truth Value in Python:
- Every object in Python has an associated truth value. 
- Exceptions include: - None
- False
- 0in any numeric type
- Empty sequences 
- Empty mapping types 
- Custom classes that implement a - __bool__or- __len__method returning- Falseor- 0.
 
- Implementation: - Classes define their truth value by implementing a special instance method: - __bool__(self)or
- __len__(self).
 
- When - bool(x)is called, Python executes- x.__bool__()or- x.__len__(), if- __bool__is not defined.
- If neither method is defined, then - if my_list:is equivalent to- if my_list is not None and len(my_list) > 0.
 
Booleans: Precedence and Short Circuiting
Properties of Boolean Operators:
- Commutativity: - A or B == B or A
- A and B == B and A
 
- Distributivity: - A and (B or C) == (A and B) or (A and C)
- A or (B and C) == (A or B) and (A or C)
 
- Associativity: - A or (B or C) == (A or B) or C == A or B or C
- A and (B and C) == (A and B) and C == A and B and C
 
- De Morgan's Theorem: - not(A or B) == (not A) and (not B)
- not(A and B) == (not A) or (not B)
 
- Miscellaneous: - not(x < y) == x >= y
- not(x > y) == x <= y
- not(x <= y) == x > y
- not(x >= y) == x < y
- not(not A) == A
 
- Operator Precedence: - Highest precedence - ( )- < > <= >= == != in is- not- and- or
- Lowest precedence 
 
- Short-Circuiting: - If - Xis- True, then- X or Ywill be- Trueregardless of the value of- Y.
- If - Xis- False, then- X and Ywill be- Falseregardless of the value of- Y.
 
Booleans: Boolean operators
X or Y:
- If - Xis truthy, returns- X, otherwise evaluates- Yand returns it (Short-Circuiting).
X and Y:
- If - Xis falsy, returns- X, otherwise evaluates- Yand returns it (Short-Circuiting).
- Can avoid a division by zero error using the - andoperator and Short-Circuiting:- x = 0 and total/0evaluates to- 0(since- 0is- False, Python does not evaluate- total/0).
 
Examples:
- Computing an average: - avg = n and sum/n.
- Returning the first character of a string - s, or an empty string if the string is- Noneor empty:- return (x and s[0]) or ''.
not X:
- True if - Xis falsy.
- False if - Xis truthy.
AND Operator Orientation:
- Oriented on returning a False value. 
OR Operator Orientation:
- Oriented on returning a True value. 
Comparison operators
Binary Operators: Evaluate to a bool value.
Categories:
- Identity Operations: - is/- is not: Compares memory addresses (any type).
 
- Value Comparisons: - ==/- !=: Compares values (different types OK, but must be compatible).
 
- Ordering Comparisons: - </- <=/- >/- >=: Doesn't work for all types.
 
- Membership Operations: - in/- not in: Used with iterable types.
 
Numeric Types:
- Value comparisons work with all numeric types. 
- Mixed types (except complex) in value and ordering comparisons are supported. 
- Be careful with floats (equality approximation problems). 
Chained Comparisons:
- a == b == cis equivalent to- a == b and b == c.
- a < b < cis equivalent to- a < b and b < c.
- a < b > c < dis equivalent to- a < b and b > c and c < d.
- Supports Short-Circuiting. 
Function parameters
Argument vs Parameter
Semantics:
- Parameters: - Variables local to a function during function declaration. 
 
- Arguments: - Variables local to a function that are passed to a function. 
 
- Arguments are passed by reference, i.e., the memory addresses of arguments are passed. 
- Often used interchangeably. 
Positional and Keyword arguments
Positional Arguments:
- Most common way of assigning arguments to parameters: via the order in which they are passed, i.e., their position. 
- Defined in the function signature. 
Default Values:
- A positional argument can be made optional by specifying a default value for the corresponding parameter. 
- If a positional parameter is defined with a default value, every positional parameter after it must also be given a default value. 
Keyword Arguments (Named Arguments):
- Positional arguments can optionally be specified by using the parameter name, regardless of whether the parameters have default values. 
- Example: - my_func(a=2, c=2)assigns- a=2,- b=5(default),- c=2.
- Once you use a named argument, all arguments thereafter must be named too when calling the function. 
Unpacking iterables
- Tuple Definition: - What defines a tuple in Python is not - ()but- ,(comma).
 
- Packed Values: - Refer to values that are bundled together in some way, i.e., iterables. 
 
- Unpacking: - The act of splitting packed values into individual variables contained in a list or tuple. 
- Based on the relative positions of each element. - Example: - a, b, c = (a1, a2, a3)assigns- a = a1,- b = a2,- c = a3.
 
 
- Swapping Values of Two Variables: - Traditional approach: - tmp = a a = b b = tmp
- Using unpacking (parallel assignment): - a, b = b, a.- This works because in Python, the entire RHS is evaluated first (creating a tuple) and completely then assignments are made to the LHS. 
 
 
- Unpacking Sets and Dictionaries: - Dictionaries (and Sets) are unordered types. 
- They can be iterated, but there is no guarantee the order of the results will match your return. 
- In practice, we rarely unpack sets and dictionaries in precisely this way. 
 
Extended unpacking
Usage of * Operator with Ordered Types:
- The - *operator, when used in unpacking assignments, is employed to unpack the remaining values of an iterable into another variable (or list if multiple variables).
- Example: - a, *b, c = lunpacks the list- l, assigning the first value to- a, the middle values to- bas a list, and the last value to- c.
 
Concatenation using * Operator:
- The - *operator can also be used for concatenation.
- Example: - l = [*l1, *l2]concatenates two lists- l1and- l2into a single list- l.
 
Usage of * Operator with Unordered Types:
- While iterating sets and dictionaries may not guarantee the preservation of order, unpacking still works. 
- However, it's rarely used to unpack sets and dictionaries directly. 
Nested Unpacking:
- Nested unpacking allows for extracting values from iterables within iterables. 
- Example: - a, *b, (c, *d) = [1, 2, 3, 'abcd']assigns the first value to- a, the middle values to- b, the first value of the nested iterable to- c, and the remaining values to- d.
- Result: - a = 1
- b = [2, 3]
- c = 'a'
- d = ['b', 'c', 'd']
 
 
Unpacking vs Slicing:
- Unpacking returns a list, while slicing a string returns a string. 
- Unpacking works with all iterables, including mixed types, while slicing does not work with unordered iterables. 
- When slicing to multiple variables, the returned type is the same as the type of the iterable being sliced (e.g., list -> list, set -> set, etc.). 
*args
- Used to declare positional arguments passed to a function: - Example: - a, b, *c = 10, 20, 'a', 'b'
 
- In function definitions, - *argscollects extra positional arguments:- Example: - def func1(a, b, *c): # code
 
- When calling the function, unpacking is required: - Example: - func(10, 20, 'a', 'b')assigns- a = 10,- b = 20,- c = ('a', 'b')
- Note: This forms a tuple, not a list. 
 
- The name - *argsis a convention but not mandatory:- You can choose any name for the variable after the - *, like- *valuesor- *items.
 
- *argsexhausts positional arguments:- No additional positional arguments can be added after - *args.
 
- Example: - def func(a, b, c): # code l = [10, 20, 30] func(l) # will NOT work func(*l) # assigns a = 10, b = 20, c = 30
Keyword arguments
Positional Arguments:
- Can optionally be passed as named (keyword) arguments. 
- When a default value is used for a positional parameter, every positional parameter must have a default value. 
Keyword Arguments:
- Can be assigned a default value. 
- Unlike positional parameters, if a default value is assigned to a keyword parameter, subsequent keyword parameters do not need default values. 
- Can be made mandatory by creating parameters after the positional parameters have been exhausted using the - *argsmethod:- Example: - def func(a, b, *args, d): # code
- Here, - *argsexhausts all positional arguments, and- dmust be passed as a keyword (named) argument.
- func(1, 2)will not work since there is no keyword argument 'd'.
 
- Mandatory positional arguments can even be omitted: - Example: - def func(*args, d): # code
- func(d=100)assigns- args = ()and- d = 100.
 
- No positional arguments at all can be forced using - *:- Example: - def func(*, d): # code
- func(1, 2, 3, d=100)raises an Exception Error.
 
- Putting it All Together: - Example: - def func(a, b=1, *args, d, e=True): # code
- Parameters: - a: Mandatory positional argument (can be specified using a named argument).
- b: Optional positional argument (can be specified positionally, as a named argument, or not at all), defaults to 1.
- *args: Catch-all for any additional positional arguments (optional).
- *: No additional positional arguments allowed.
- d: Mandatory keyword argument.
- e: Optional keyword argument, defaults to True.
 
 
Function parameters
Argument vs Parameter
- Semantics - Parameteters are variables local to a function during function declaration 
- Arguments are variables local to a function that are passed to a function - arguments are passed by reference, i.e. the memory addresses of arguments are passed 
 
- Often used interchangebaly 
 
Positional and Keyword arguments
- Positional arguments: - Most common way of assigning arguments to parameters: via the order in which they are passed, i.e. their position - def my_func(a, b)
 
- Default values - A positional arguments can be made optional by specifying a default value for the corresponding parameter 
- If a positional parameter is defined with a default value every poistional parameter after it must also be given a default value - def my_func(a, b=100)
- Keyword arguments (named arguments) - Positional arguments can, optionally, be specified by using the parameter name whether or not the parameters have default values - my_func(a=2, c=2)-> a=1, b=5(default), c=2
- Once you use a named argument, all arguments thereafter must be named too (when calling the function) 
 
 
Unpacking iterables
- What defines a tuple in Python, is not (), but , (coma) 
- Packed values referes to values that are budled together in some way, i.e. iterables 
- Unpacking - is the act of splitting packed values into individual variables contained in a list or tuple 
- is based on the relative positions of each element - a, b, c = (a1, a2, a3)-> a = a1, b = a2, c = a3
- swapping values of two variables - Traditional approach 
 
 
tmp = a
a = b
b = tmp
- using unpacking (parallel assignment) - a, b = b, a- This worls because in Python, the entire RHS is evaluated first (creating a tuple) and completely then assignments are made to the LHS 
 
 
- Unpacking Sets and Dictionaries - Dictionaries (and Sets) are unordered types 
- They can be iterated, but there is no guarantee the order of the results will match your return 
- In practice, we rarely unpack sets and dictionaries in precisely this way 
 
 
Extended unpacking
- Operator is used to unpack the remaining values of an iterable into another variable (or list if multiple variables): 
- Usage of * operator with ordered types 
l = [1, 2, 3, 4, 5, 6]
a = l[0]
b = l[1:]
- or - a, b = l[0], l[1:] (aka parallel assignment)
 or- a, *b, c = l-> a = 1, b = [2, 3, 4,5], c = 6- Apart from cleaner sintax, it (* - asterix) also works with any iterable, not just sequence types! 
- The * operator can only be used once in the LHS an unpacking assignment 
- Concatination using * operator: 
 
l1 = [1, 2, 3]
l2 = [4, 5, 6]
l = [*l1, *l2] -> l = [1, 2, 3, 4, 5, 6]
- Usage of * operator with unordered types - Iterating sets and dictionaries will not guarantee the preservance of order upon creation, but still works 
- In practice, rarelt used to unpack sets and dictionaries directly 
- Useful in cases where the you need to see all the items, keys or values from multiple unordered iterables to a separate lst 
- ** operator - pass the contents of a dictionary as keyword arguments to a function or to create a new dictionary by merging two dictionaries. 
 
 
d1 = {'a': 1, 'b': 2}
{'a': 10, 'c': 3, **d1} -> {a: 1, 'b': 2, 'c': 3} (a got overwritten by the 'a' key in d1, provided later positionally)
{**d1, 'a': 10, 'c': 3} -> {a: 10, 'b': 2, 'c': 3} ('a' value got overwritte by the value provided the latest)
- nested unpacking - l = [1, 2, [3, 4]] a, b, (c, d) = l``` -> a = 1, b = 2, c = [3, 4]
- a, *b, (c, d, e) = [1, 2, 3, 'XYZ']-> a = 1, b = [2, 3], c = 'X', d = 'Y', e = 'Z'
- a, *b, (c, *d) = [1, 2, 3, 'abcd']-> a = 1, b = [2, 3], c = 'a', d = ['b', 'c', 'd']- Although this looks like we are using * twice in the same expression, the second * is actually in a nested unpacking - so that's OK 
 
 
 
- Unpacking vs slicing - unapcking of a string will return list 
- unpacking works with all iterable (can work with mixed types of iterables) 
- slicing a string will return string 
- slicing does not work with unordered iterables 
- if slicing to a multiple variables, it returns the same type of iterable it is slicing (list -> list, set -> set etc) 
 
*args
- also used to declare positional arguments passed to a function 
a, b, *c = 10, 20, 'a', 'b'
def func1(a, b, *c):
    # code
func (10, 20, 'a', 'b') -> a = 10, b = 20, c = ('a', 'b') -> this is a tuple, not a list
- The * parameter name is arbitrary - you can make it whatever you want - it is customary (but not required) to name it *args 
 
- args exhausts positional arguments - You can not add more positional arguments after *args 
 
- Example: 
def func(a, b, c):
    # code
    
l = [10, 20, 30]
func(l) -> will NOT work
func(*l) -> a = 10, b = 20, c = 30 
Keyword arguments
- Positional arguments - positional parameters can, optionally be passed as named (keyword) arguments 
- when default value is used for a positional parameter, every positional parameter has to have a default value 
 
- Keyword arguments - can be assigned a default value 
- As opposed to positional parameter, if default value is assigned to a keyword parameter, next keyword parameters do not have to have a default value 
- can be made mandatory - To do so, we create parameters after the positional parameters have been exhausted (with *args method) - def func(a, b, *args, d): #code
- in this case, *args effectively exhausts all positional arguments and d must be passed as a keyword (named) argument. - func(1, 2)will not work since there is no keyword argument 'd'
 
- we can even omit any mandatory positional arguments - def func(*args, d): #code- func(d=100)-> args = (), d = 100
- we can force no positional arguments at all - def func(*, d): #code* indicates the 'end' of positional arguments]- func(1, 2, 3, d=100)-> Exception Error!
 
- Putting all together - def func(a, b=1, *args, d, e=True): #code- def func(a, b=1, *, d, e=True): #code- a: mandatory positional argument (may be specified using a named argument) 
- b: optional positional argument (may be specified positionally, as a named argument, or not at all), defaults to 1 
- *args: catch-all for any (optional) additional positional arguments 
- *: no additional positional arguments allowed 
- d: mandatory keyword argument 
- e: optional keyword argument, defaults to True 
 
**kwargs
*args:
- Used to scoop up a variable amount of remaining positional arguments into a tuple. 
- The parameter name - argsis arbitrary;- *is the real performer here.
- If positional arguments are passed using parameter names, - *argscannot be used. This is because the first arguments become keyword-only arguments, and- *argsrequires positional arguments, which cannot be used after keyword arguments.
- If default values are used for the positional parameters before - *args, the ability to use their default values is lost.
**kwargs:
- Used to scoop up a variable amount of remaining keyword arguments into a dictionary. 
- The parameter name - kwargsis arbitrary;- **is the real performer here.
- **kwargscan be specified even if the positional arguments have not been exhausted, unlike keyword-only arguments.
- No parameters can come after - **kwargs.
Example:
def func(*args, **kwargs):
    # code- func(1, 2, a=10, b=20)assigns- args = (1, 2)and- kwargs = {'a': 10, 'b': 20}.
- func()assigns- args = ()and- kwargs = {}.
Putting it all together
Parameter defaults
- Function Object Creation: - The function object is created, and - funcreferences it.
- The integer object - 10is evaluated/created and is assigned as the default for- a.
 
- Function Execution: - func()is called.
- By the time this happens, the default value for - ahas already been evaluated and assigned. It is not re-evaluated when the function is called again.
 
- Re-evaluation with Default Value: - If you want the parameter to get re-evaluated every time the function is called (e.g., set a current datetime): - Set a default to - None.
- If - dtis- None, set it to the current date/time.
- If not, use the provided - dt.- dt = dt or datetime.utcnow().
 
- Beware of using a mutable object (or a callable) for an argument default, as it will not be re-evaluated. 
 
- Exception Example: - def factorial(n, cache={}): if n < 1: return 1 elif n in cache: return cache[n] else: print('Calculating {0}!'.format(n)) result = n * factorial(n-1) cache[n] = result return result- cachebeing a mutable default is fine in this case.
 
First-Class Functions
First-Class Objects:
- Can be passed to a function as an argument. 
- Can be returned from a function. 
- Can be assigned to a variable. 
- Can be stored in a data structure (e.g., list, tuple, dictionary, etc.). 
- Types such as - int,- float,- string,- tuple,- list, and many more are first-class objects.
- Functions ( - function) are also first-class objects.
Higher-Order Functions:
- Take a function as an argument and/or 
- Return a function. 
Docstrings and annotations
Docstrings:
- To document functions (and modules, classes, etc.), use docstrings. 
- If the first line in the function body is a string (not an assignment, not a comment, just a string by itself), it will be interpreted as a docstring. 
- Multi-line docstrings are achieved using multi-line strings ( - ''').
- Docstrings are stored in the function's - __doc__property.- def func(x): "This is a documentation" # Accessing docstring: func.__doc__ # Output: 'This is a documentation' help(func) # Output: func(x) This is a documentation
Function Annotations:
- Metadata attached to the parameters. 
- Annotations can be any expression. 
- Annotations are mainly used by external tools and modules, such as documentation generators like Sphinx. 
- Example: - def my_func(a: str = 1, b: 'int > 0' = 2) -> str: return a * b # Annotations containing functions: x = 3 y = 5 def my_func(a: str) -> 'a repeated ' + str(max(x, y)) + ' times': return a * max(x, y) Accessing annotations: my_func.__annotations__ # Output: {'a': 'info on a', 'b': int, 'return': float}
Docstrings and annotations are entirely optional and do not enforce anything in our Python code.
Lambda expressions
- aka anonymous functions 
- Definition: - Lambda functions are anonymous functions defined using the - lambdakeyword.
- They are useful for creating small, one-off functions without the need for a formal - defstatement.
 
- Usage: - Lambdas consist of a single expression, which gets evaluated and returned automatically (no need for a return statement). 
- They can be assigned to a variable or passed as an argument. 
- Example: - my_func = lambda x: x ** 2 type(my_func) # Output: function my_func(3) # Output: 9 my_func(4) # Output: 16
 
- Limitations: - The body of a lambda is limited to a single expression. 
- No assignments are allowed inside the lambda body. 
- No annotations can be used. 
- However, parameters in a lambda can be assigned default values. 
- Single logical lines of code are allowed, and line continuation is acceptable, but the lambda must still consist of just one expression. 
 
Lambdas and sorting
sorted() Function:
- Definition: - The - sorted()function returns a new sorted list from the elements of the given iterable.
- It takes the iterable as its first positional argument. 
- Optional keyword arguments include - keyand- reverse.
 
- Basic Usage: - If no - keyfunction is supplied,- sorted()sorts the elements based on their natural order (e.g., ASCII value for strings).
- Example: - l = ['B', 'a', 'c', 'D'] sorted(l) # Output: ['B', 'D', 'a', 'c']
 
- Custom Sorting with - key:- The - keyparameter allows custom sorting based on a function applied to each element.
- Example: - sorted(l, key=lambda s: s.upper()) # Output: ['a', 'B', 'c', 'D']
 
- Usage for Non-Comparable Types: - The - keyfunction parameter is especially useful for sorting elements that do not support relational operators (e.g., complex numbers).
 
- Preservation of Order: - If two elements in the iterable are equal, - sorted()retains their original order (stable sort).
- This means that the order in which equal elements are positioned in the original iterable is preserved in the sorted list. 
 
Function introspection
Attaching Attributes to Functions:
- Functions in Python can have attributes attached to them. 
- Example: - def my_func(a, b): return a + b my_func.category = 'math' my_func.sub_category = 'arithmetic' print(my_func.category) # Output: 'math' print(my_func.sub_category) # Output: 'arithmetic'
Using dir() Function:
- The built-in - dir()function returns a list of valid attributes for an object.
- Example attributes for functions: - __name__: Returns the name of the function.
- __defaults__: Returns a tuple containing positional parameter defaults.
- __kwdefaults__: Returns a dictionary containing keyword-only parameter defaults.
- __code__: Provides information about the function body.- __code__.co_varnames: Returns the names of parameters and local variables.
- __code__.co_argcount: Returns the number of positional parameters.
 
 
Function vs. Method:
- Functions and methods are both objects that can have attributes. 
- A callable attribute bound to a class or object is called a method. 
Using the inspect Module:
- The - inspectmodule provides functions for inspecting objects.
- Examples: - inspect.ismethod(obj): Returns True if an object is a method.
- inspect.isfunction(obj): Returns True if an object is a function.
- inspect.isroutine(obj): Returns True if an object is a function or method.
- inspect.getsource(my_func): Returns the entire- defstatement of a function.
- inspect.getmodule(print): Finds the module in which the function was created.
- inspect.getcomments(my_func): Retrieves flagged comments (e.g., with 'TODO:') attached to the function.
 
Callable Signatures:
- The - inspect.signature(my_func)function returns a- Signatureinstance.
- inspect.signature(my_func).parametersprovides metadata about the function parameters, including names, defaults, annotations, and kinds.
Callables
Callables are objects that can be invoked using the () operator. Here are the types of callables:
- Built-in Functions: Functions provided by Python, such as - print()or- len().
- Built-in Methods: Methods associated with built-in types or objects, like - str.upper()or- list.append().
- User-Defined Functions: Functions defined using the - defstatement or lambda expressions.
- Methods: Functions that are bound to objects, such as methods of classes. 
- Classes: Classes themselves can be callable. When called, they typically invoke their - __new__()and- __init__()methods to create and initialize objects.
- Class Instances: Instances of classes can also be callable if the class implements the - __call__()method.
- Generators, Coroutines, and Asynchronous Generators: Special kinds of callables used for generating sequences of values or handling asynchronous operations. 
To determine if an object is callable, you can use the built-in function callable(). It returns True if the object can be called, otherwise False.
Map, filter, zip and list comprehensions
Map Function:
- map(func, *iterables)
- funcis a function that takes as many arguments as there are iterable objects passed to- iterables.
- Returns an iterator that computes the function applied to each element of the iterables. 
- Stops when any iterable is exhausted. 
- Example: - l1 = [1, 2, 3] l2 = [10, 20, 30, 40, 50] list(map(lambda x, y: x + y, l1, l2)) -> [11, 22, 33]
Filter Function:
- filter(func, iterable)
- funcis a function that takes a single argument.
- Returns an iterator containing elements of the iterable for which the function call is Truthy. 
- If - funcis- None, returns Truthy elements of the iterable.
Zip Function:
- zip(*iterables)
- Returns a tuple of tuples positionally paired from each iterable. 
- Not a higher-order function. 
- Example: - l1 = [1, 2, 3] l2 = [10, 20, 30, 40] l3 = 'python' list(zip(l1, l2, l3)) -> [(1, 10, 'p'), (2, 20, 'y'), (3, 30, 't')]
List Comprehension:
- Alternative to - mapand- filter.
- [<expression> for <var_name> in <iterable>]for mapping.
- [<expression_1> for <var_name> in <iterable> if <expression_2>]for filtering.
- Example: - l1 = [1, 2, 3, 4] l2 - [10, 20, 33] [x + y for x, y in zip(l1, l2)] -> [11, 22, 33]
- Deferred calculation example: - result = (x**2 for x in range(10) if x**2 < 25)
Reducing functions
Definition: Functions that recombine iterables recursively into a single return value, also known as accumulators or aggregators.
Example: Finding the maximum value in an iterable:
l = [5, 8, 6, 10, 9]
def _reduce(fn, sequence):
    result = sequence[0]
    for e in sequence[1:]:
        result = fn(result, e)
    return result
max_func = lambda a, b: a if a > b else b
_reduce(max_func, l)  # maximumfunctools module: Python's implementation of reduce function, handling any iterable similarly to the custom function above.
from functools import reduce
l = [5, 8, 6, 10, 9]
max_func = lambda a, b: a if a > b else b
reduce(max_func, l)  # max -> 10Reduce Initializer: A third optional parameter in reduce, adding a value in front of the iterable. Often used to handle empty iterables.
- Example for summation: - reduce(lambda x, y: x+y, l, 0)returns- 0if- lis empty.
- Example for multiplication: - reduce(lambda x, y: x*y, l, 1)returns- 1if- lis empty.
Built-in Reducing Functions:
- min,- max,- sum
- any: Uses OR operator, returns bool.
- all: Uses AND operator, returns bool.
Partial functions
Reducing Function Arguments:
- Using - partial: Import- partialfrom- functoolsmodule.- from functools import partial def my_func(a, b, c): print(a, b, c) f = partial(my_func, 10) f(20, 30) # Output: 10, 20, 30
- partialfunction imports the second positional argument as the first argument to the specified function.
Handling Complex Arguments:
from functools import partial
def pow(base, exponent):
    return base ** exponent
square = partial(pow, exponent=2)
cube = partial(pow, exponent=3)
square(5)  # Output: 25
cube(5)  # Output: 125- Beware: - square(5, exponent=3)would result in- 125.
- When using variables, be cautious as they reference memory addresses, not their values. 
The operator module
Arithmetic Functions:
- add(a, b)
- mul(a, b)
- pow(a, b)
- mod(a, b)
- floordiv(a, b)
- neg(a)
Comparison and Boolean Operators:
- lt(a, b)
- le(a, b)
- gt(a, b)
- ge(a, b)
- eq(a, b)
- ne(a, b)
- is_(a, b)
- is_not(a, b)
- and_(a, b)
- or_(a, b)
- not_(a, b)
Sequence/Mapping Operators:
- concat(s1, s2)
- contains(s1, s2)
- countOf(s1, s2)
- getitem(s, i)
- setitem(s, i, val)(mutable objects only)
- delitem(s, i)(mutable objects only)
Item Getters:
- The - itemgetterfunction returns a callable.- f = itemgetter(1, 3, 4) s = [1, 2, 3, 4, 5, 6] f(s) # Output: 2, 4, 5
Attribute Getters:
- The - attrgetterfunction returns a callable that retrieves object attributes.- my_obj.a = 10 my_obj.b = 20 my_obj.c = 30 f = attrgetter('a') f(my_obj) # Output: 10
Calling Another Callable:
- attrgetter('upper')('python')returns the upper method of- s.
- To call the method: - attrgetter('upper')('python')()returns- 'PYTHON'.
- methodcaller('upper')('python')returns- 'PYTHON'.
- It can handle multiple arguments. 








