Python Tutorial 3 : Evaluation, Strings, and Memory Management¶
1. Using Python as a Calculator and Variable Assignment¶
Python can evaluate mathematical operations directly within functions like print(), which simply outputs the value of the arguments it receives.
When assigning the result of a complex expression to a variable, keep in mind that the = (assignment) operator has very low precedence. For example, in the statement x = 5 / 2, Python evaluates the entire right-hand expression (5 / 2) first. Only after the math is calculated does it assign the resulting output to the variable x. Under the hood, the underlying C implementation directly evaluates the math, allocates the memory for the resulting object (like a float), and the = operator simply binds your variable name to that exact memory address.
Interactive Mode: The _ Variable¶
In interactive mode (like the Python REPL), the last printed expression is automatically assigned to a special variable named _.
Important: Treat this variable as strictly read-only. If you explicitly assign a value to _, Python creates an independent local variable with the exact same name. This completely masks the built-in variable and breaks its magic behavior, and you can't bypass this masking using the global keyword because the magic _ actually lives in the builtins namespace. (Though, if you really messed up and needed it, you could technically still access it via import builtins; builtins._).
2. Deep Dive: How Python Processes Escape Sequences¶
To really grasp how escape sequences work, you need to understand the difference between how Python stores data in RAM versus how it renders that data to the terminal. Python uses two distinct representations for strings: the Developer-Friendly View (__repr__) and the User-Friendly View (__str__).
Here is exactly what happens under the hood when a string with an escape sequence is assigned and printed:
Step 1: Assignment and Storage in RAM¶
When the interpreter reads source code from left to right and hits a backslash (\), it triggers an escape routine. It stops reading literally and combines the backslash with the next character to form a single, special Unicode code point.
In RAM, Python 3 doesn't store a literal \ followed by an n. Instead, it stores a single, invisible character: the Unicode Line Feed character (U+000A). Because of this, len(my_string) evaluates to 11, not 12.
Step 2: Inspecting the Variable (The Developer View: __repr__)¶
When you evaluate a variable directly in an interactive shell without using print(), Python invokes the string's internal __repr__() method.
The Developer View is designed to show exactly what resides in memory safely and unambiguously. If the REPL actually executed the line break, it would mess up your terminal formatting and hide the special character. To prevent this, the interpreter artificially reconstructs the \n visually.
Step 3: The print() Function (The Human View: __str__)¶
Passing the variable to print() invokes the string's __str__() method, shifting to the Human View.
print() accesses the Unicode characters from memory, encodes them into bytes (usually UTF-8), and streams them directly to standard output (sys.stdout):
- It streams
H,e,l,l,o. - It streams the encoded Line Feed character. The
print()function itself doesn't break the line; the terminal emulator does. When the terminal receives this byte, it physically moves the cursor down to the next line and resets it to the left edge. - It resumes streaming
W,o,r,l,d.
Step 4: Bypassing Escape Sequences with Raw Strings¶
When writing regular expressions, system file paths, or LaTeX formulas, Python's eagerness to turn \ into special characters will ruin your text. To bypass this, prefix the string with an r to create a Raw String.
Standard String: Python parses \n into a line-break character.
Raw String: Python treats \ and n as literal characters.
Note: There is one subtle quirk with raw strings—they cannot end with an odd number of backslash characters (e.g.,
r"\"will cause a syntax error). Why? Because even in a raw string, a backslash escapes the quote mark immediately following it, which prevents the string from properly closing.
3. Data Structures: Lists and Object Binding¶
Introduction to Lists¶
Lists are highly versatile, ordered, and mutable data types in Python. Because they are ordered, they support indexing and concatenation. You can use the built-in len() function to quickly check their element count.
(Note: There is also a concept called implicit string literal concatenation, which isn't directly related to lists but is an important syntax feature we'll cover in future topics.)
Assignment and Object Binding¶
A critical concept in Python is understanding how variables actually work. Assignment statements never copy data; instead, they create bindings (references or pointers) between a variable name and an existing object in memory.
When you assign an existing list to a new variable, the new variable just points to the exact same list in memory. In-place changes made through one variable will instantly reflect across all variables pointing to that object.
rgb = ["Red", "Green", "Blue"]
rgba = rgb
# Both variables reference the exact same object in memory
print(id(rgb) == id(rgba))
# Output: True
# Modifying 'rgba' also affects 'rgb'
rgba.append("Alph")
print(rgb)
# Output: ["Red", "Green", "Blue", "Alph"]
Slicing and Ranges¶
- Slicing: Sequential data types can be sliced using the syntax
sequence[start:stop]. The most important rule here is that the index at thestopposition is never included in the result. - Ranges: The built-in
range()function takes three inputs:range(start, stop, step). Just like slicing, thestopvalue is never included in the generated sequence.
Mutability vs. Immutability¶
After a variable is initialized, its mutability determines whether you are allowed to change the underlying memory it points to.
- Immutable data types: Strings, tuples, ints, floats, complex numbers, booleans, frozensets, bytes, and ranges.
When you declare immutable_var = 5, Python creates an integer object in memory and points the variable to it. Because it's immutable, you cannot change the contents of that specific memory block. If you apply a method or mathematical operation to it, Python returns a brand-new instance in memory, which you can then assign to a variable.
Compound Data Types: Mutability gets interesting with nested objects. An immutable data type (like a tuple) can contain a mutable data type (like a list). While you can't reassign the items in the tuple, you can modify the contents of the list inside it.
Garbage Collection and Reference Counting¶
Everything in Python is an object. Each object keeps track of how many variables are currently pointing to it using a reference counter. If this counter drops to 0, Python's garbage collector sweeps in and frees up that memory block automatically.
Hashable Objects and Dictionaries¶
Dictionary keys must be hashable. In simple terms, the value used as a key cannot be modified, or it would crash the data lookup process. An object is hashable if its hash value never changes during its lifetime (which usually means the object must be immutable).
To be considered hashable, an object must implement two methods:
__hash__(): Generates the object's hash integer.__eq__(): Compares the object to others to check for equality.
Sets also use hashes to ensure all elements are unique. The core rule is: If two objects are equal (a == b), their hash values must be identical.
However, the reverse isn't always true. Two completely different objects can occasionally generate the same hash value. This is called a hash collision. Python dictionaries automatically handle these collisions behind the scenes using a technique called open addressing, though it can cause slight performance slowdowns (closer to O(n) time complexity).
4. The Mechanics of Copying Data¶
For mutable collections, you often need to create an independent copy so modifying the new version doesn't alter the original.
Basic Copying (Shallow)¶
- Via Slicing: All slice operations return a new list. Slicing from start to finish (
[:]) acts as a quick shallow copy. - Via Built-in Methods: Standard collections have a
.copy()method.
original_list = [1, 2, 3]
new_list = original_list.copy() # or original_list[:]
new_list.append(4)
print(original_list) # Output: [1, 2, 3]
print(new_list) # Output: [1, 2, 3, 4]
(Note: If you are using advanced list subclasses, slicing or .copy() might accidentally return a basic list instead of your custom class type. For complete accuracy, use the copy module.)
The copy Module: Heavy-Duty Copying¶
By importing the copy module, you get access to dedicated duplication tools:
copy.copy(obj): Creates a shallow copy.copy.deepcopy(obj): Creates a deep copy.copy.replace(obj, **changes): (New in Python 3.13) Duplicates the object but swaps out specific fields using keyword arguments (e.g.,copy.replace(my_obj, name="Alice", age=30)).
Shallow vs. Deep Copy (The "Shoebox" Analogy)¶
This distinction only matters for compound objects (objects containing other objects, like a list of lists). Imagine a large cardboard box (the outer list) containing smaller shoeboxes (the inner lists).
- Shallow Copy (
copy.copy): Builds a brand new outer cardboard box, but places shortcuts (references) to the original inner shoeboxes inside it. Changing an item inside a shared inner list alters both the original and the copy. - Deep Copy (
copy.deepcopy): A 100% independent clone. It builds a new outer box and recursively builds brand-new, identical inner shoeboxes.
import copy
original = [[1, 2], [3, 4]]
# Shallow Copy behaves differently for nested items
shallow = copy.copy(original)
shallow[0][0] = "CHANGED"
print(original)
# Output: [['CHANGED', 2], [3, 4]] -> The original is compromised!
# Deep Copy protects the original
original = [[1, 2], [3, 4]]
deep = copy.deepcopy(original)
deep[0][0] = "CHANGED"
print(original)
# Output: [[1, 2], [3, 4]] -> The original is safe.
Deep Copy Challenges and Solutions¶
The deepcopy() function handles two major complex challenges automatically:
- Infinite Recursive Loops: If a list contains itself, a naive copy function would loop forever.
- Over-copying: Sometimes you want certain objects to remain shared across instances (like a single
ManufacturingPlantconnected to multipleCarcopies) rather than duplicated.
Python solves this by maintaining a memo dictionary—acting like a guestbook. As deepcopy travels through your data, it logs the ID of every item it copies. If it encounters an item already in the guestbook, it stops and simply points to the existing copy.
Customizing Copy Behavior for Custom Classes¶
You can dictate exactly how Python copies your custom classes (for example, to prevent it from trying to duplicate a live database connection) by defining "magic" dunder methods:
__copy__(self): Defines what happens duringcopy.copy(). Return your customized shallow copy here.__deepcopy__(self, memo): Defines what happens duringcopy.deepcopy(). You must pass thememoguestbook along when copying inner components, but treat it as a black box. Inside, explicitly define which pieces are deeply copied usingcopy.deepcopy(component, memo)and which pieces are left alone.__replace__(self, /, **changes): Defines what happens duringcopy.replace(). Generate a fresh version of your object, intelligently swapping out the attributes provided in the**changeskeyword arguments (commonly used in dataclasses and namedtuples).
Miscellaneous Notes¶
Assigning to List Slices
You can assign entirely new data to a specific slice of a list, modifying the original list in place.
letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
# Replace specific values
letters[2:5] = ['C', 'D', 'E']
# Remove a slice by assigning an empty list
letters[2:5] = []
# Clear the entire list
letters[:] = []
Multiple Assignments
Python allows you to assign multiple variables on a single line:
The print() End Keyword
By default, print() adds a newline at the end of its output. You can use the end keyword argument to change this behavior and avoid the line break: