Skip to main content

Python Variables Practice Problems & Exercises

Practice: Variables and Assignment Deep Dive

12 problems4 Easy5 Medium3 Hard45–60 min
← Back to lesson

Easy

#1Chained Assignment TrapEasy
chained-assignmentmutablealiasing

Predict the output. Think carefully about how many list objects a = b = [] creates.

Python
a = b = [1, 2, 3]

a[0] = 99

print(a)
print(b)
print(a is b)
Solution
[99, 2, 3]
[99, 2, 3]
True

Why: a = b = [1, 2, 3] creates one list object and binds both a and b to it. This is not the same as a = [1, 2, 3]; b = [1, 2, 3] (which creates two separate lists). Since both names reference the same mutable object, a[0] = 99 is visible through b as well.

The rule: Chained assignment evaluates the right-hand side once, then binds each target from left to right to that single object. With immutable types (int, str, tuple) this is harmless. With mutable types, it creates an aliasing trap.

Expected Output
[99, 2, 3]\n[99, 2, 3]\nTrue
Hints

Hint 1: Chained assignment `a = b = expr` evaluates `expr` once, then binds both names to that single object.

Hint 2: Since lists are mutable, mutating through one name is visible through the other.

#2Star UnpackingEasy
unpackingstar-expressiontuple

Use star unpacking to split a tuple into a first element, a middle section, and a last element in a single assignment line.

data = (1, 2, 3, 4, 5, 6, 7)
# Goal: first=1, middle=[2,3,4,5,6], last=7
Solution
data = (1, 2, 3, 4, 5, 6, 7)

first, *middle, last = data

print(first) # 1
print(middle) # [2, 3, 4, 5, 6]
print(last) # 7
print(type(middle)) # <class 'list'>

Key details:

  • The starred variable always collects into a list, even if the source is a tuple.
  • If there are no leftover elements, the starred variable gets an empty list [].
  • Only one starred expression is allowed per unpacking target. *a, *b = data is a SyntaxError.
data = (1, 2, 3, 4, 5, 6, 7)

# Unpack so that:
# first = 1
# middle = [2, 3, 4, 5, 6]
# last = 7

# Your unpacking line here

print(first)
print(middle)
print(last)
print(type(middle))
Expected Output
1\n[2, 3, 4, 5, 6]\n7\n<class 'list'>
Hints

Hint 1: The star expression `*name` in an unpacking target collects all remaining items into a list.

Hint 2: You can place the star variable anywhere — beginning, middle, or end — but only one star is allowed.

#3Valid or Invalid?Easy
variable-namessyntaxkeywords

Without running the code, classify each name as valid or invalid as a Python variable name. Then run the checker to verify.

Solution
_private -> VALID
2fast -> INVALID (bad syntax)
class -> INVALID (reserved keyword)
__dunder__ -> VALID
café -> VALID
my-var -> INVALID (bad syntax)
_ -> VALID
None -> INVALID (reserved keyword)

Rules for Python identifiers:

  1. Must start with a letter (including Unicode letters) or underscore _
  2. Can contain letters, digits, and underscores after the first character
  3. Cannot be a reserved keyword (class, None, True, False, if, while, etc.)
  4. Hyphens - are never allowed (they are the minus operator)
  5. Python 3 supports Unicode identifiers, so café is valid (PEP 3131)
  6. The bare underscore _ is valid and conventionally used for throwaway values
# For each name below, predict: valid or invalid Python variable name?
# Then uncomment and run to check.

names = [
    "_private",      # 1
    "2fast",          # 2
    "class",          # 3
    "__dunder__",     # 4
    "café",           # 5
    "my-var",         # 6
    "_",              # 7
    "None",           # 8
]

import keyword

for name in names:
    is_keyword = keyword.iskeyword(name)
    is_identifier = name.isidentifier()
    if is_keyword:
        status = "INVALID (reserved keyword)"
    elif not is_identifier:
        status = "INVALID (bad syntax)"
    else:
        status = "VALID"
    print(f"{name:15s} -> {status}")
Expected Output
_private        -> VALID\n2fast            -> INVALID (bad syntax)\nclass           -> INVALID (reserved keyword)\n__dunder__      -> VALID\ncafé            -> VALID\nmy-var          -> INVALID (bad syntax)\n_               -> VALID\nNone            -> INVALID (reserved keyword)
Hints

Hint 1: Python variable names must start with a letter or underscore, followed by letters, digits, or underscores.

Hint 2: Python 3 allows Unicode letters (like accented characters) in identifiers.

Hint 3: Reserved keywords (class, None, True, False, etc.) cannot be used as variable names.

#4Augmented Assignment: Mutable vs ImmutableEasy
augmented-assignmentmutableimmutableid

Predict whether id() changes after += for each case, then run to verify.

Python
# Case 1: integer (immutable)
x = 10
print(f"int before: id={id(x)}")
x += 5
print(f"int after:  id={id(x)}")
print(f"Same object? {id(x) == id(x)}")
print()

# Case 2: list (mutable)
y = [1, 2, 3]
id_before = id(y)
y += [4, 5]
id_after = id(y)
print(f"list before: id={id_before}")
print(f"list after:  id={id_after}")
print(f"Same object? {id_before == id_after}")
print()

# Case 3: tuple (immutable)
z = (1, 2, 3)
id_before = id(z)
z += (4, 5)
id_after = id(z)
print(f"tuple before: id={id_before}")
print(f"tuple after:  id={id_after}")
print(f"Same object? {id_before == id_after}")
Solution
  • int x += 5: Creates a new int object 15 and rebinds x. The id changes.
  • list y += [4, 5]: Calls list.__iadd__, which is list.extend under the hood. Mutates in place. The id stays the same.
  • tuple z += (4, 5): Tuples are immutable, so a new tuple is created. The id changes.

The critical distinction: += dispatches to __iadd__ (in-place add) if the type defines it. Mutable types like list and set define __iadd__ to mutate in place. Immutable types like int, str, and tuple fall back to __add__, which creates a new object.

This matters when aliases exist:

a = [1, 2]
b = a
a += [3] # mutates in place — b sees [1, 2, 3]

x = (1, 2)
y = x
x += (3,) # creates new tuple — y still sees (1, 2)
Expected Output
See solution
Hints

Hint 1: For immutable types like `int`, `+=` creates a new object and rebinds the name.

Hint 2: For mutable types like `list`, `+=` calls `__iadd__` which mutates in place.

Hint 3: Compare `id()` before and after to see if the object identity changed.


Medium

#5Walrus in a While LoopMedium
walrus-operatorwhile-loopassignment-expression

Rewrite the data-reading pattern using the walrus operator (:=) so you assign and test the chunk in a single while condition.

Solution
def get_chunk(chunks, index_holder):
if index_holder[0] < len(chunks):
result = chunks[index_holder[0]]
index_holder[0] += 1
return result
return ""

chunks = ["Hello ", "world, ", "this ", "is ", "Python!", ""]
index = [0]
result = []

while (chunk := get_chunk(chunks, index)) != "":
result.append(chunk)

print("".join(result)) # Hello world, this is Python!

Why the walrus operator helps here: Without :=, the traditional pattern requires duplicating the function call:

chunk = get_chunk(chunks, index)
while chunk != "":
result.append(chunk)
chunk = get_chunk(chunks, index) # duplicated call

The walrus operator eliminates this duplication. The expression (chunk := get_chunk(chunks, index)) both assigns the return value to chunk AND evaluates to that value for the != "" comparison.

PEP 572 note: The walrus operator was introduced in Python 3.8. It uses := (not =) and must be wrapped in parentheses when used in contexts where the precedence would be ambiguous.

import random

# Simulate reading data chunks until we get an empty one.
# Use the walrus operator to assign and test in one expression.

def get_chunk(chunks, index_holder):
    """Simulate reading chunks from a source."""
    if index_holder[0] < len(chunks):
        result = chunks[index_holder[0]]
        index_holder[0] += 1
        return result
    return ""

chunks = ["Hello ", "world, ", "this ", "is ", "Python!", ""]
index = [0]
result = []

# TODO: Use a while loop with the walrus operator (:=)
# to read chunks until an empty string is returned.
# Append each non-empty chunk to result.

print("".join(result))
Expected Output
Hello world, this is Python!
Hints

Hint 1: The walrus operator `:=` assigns a value and returns it in a single expression.

Hint 2: Pattern: `while (chunk := get_chunk(...)) != "":` — assigns and tests in one line.

Hint 3: Without the walrus operator, you would need to call get_chunk before the loop AND inside the loop body.

#6Walrus in List ComprehensionMedium
walrus-operatorlist-comprehensionassignment-expression

Use the walrus operator inside a list comprehension to compute math.sqrt() only once per element while filtering.

Solution
import math

numbers = [4, 9, 16, 25, 2, 49, 1, 64, 100, 8]

filtered = [root for n in numbers if (root := math.sqrt(n)) > 3]

print(filtered) # [4.0, 5.0, 7.0, 8.0, 10.0]

How it works step by step:

  1. For each n in numbers, the if clause runs first
  2. (root := math.sqrt(n)) computes the square root, assigns it to root, and returns the value
  3. The value is compared to 3 — if greater, the element is included
  4. The output expression root uses the already-computed value — no second call to math.sqrt

Without walrus (wasteful):

filtered = [math.sqrt(n) for n in numbers if math.sqrt(n) > 3]

This computes math.sqrt(n) twice for every element that passes the filter. For expensive operations, the walrus operator provides a real performance benefit.

Evaluation order matters: In a comprehension, the if clause is evaluated before the output expression, so assigning in the if clause makes the variable available in the output.

import math

# Given a list of numbers, compute sqrt for each,
# but ONLY keep results where sqrt > 3.
# Use the walrus operator to avoid computing sqrt twice.

numbers = [4, 9, 16, 25, 2, 49, 1, 64, 100, 8]

# TODO: Write a list comprehension using := so that
# sqrt is computed once per element, not twice.
# filtered = ...

print(filtered)
Expected Output
[4.0, 5.0, 7.0, 8.0, 10.0]
Hints

Hint 1: Without walrus: `[math.sqrt(n) for n in numbers if math.sqrt(n) > 3]` computes sqrt twice.

Hint 2: With walrus: assign in the `if` clause, use the variable in the output expression.

Hint 3: Pattern: `[y for n in numbers if (y := math.sqrt(n)) > 3]`

#7The Tuple SwapMedium
swaptuple-packingunpacking

Predict the output, then verify. Think about the order of evaluation in tuple swap.

Python
# Two-variable swap
a = 10
b = 20
print(f"a={a}, b={b}")

a, b = b, a
print(f"a={a}, b={b}")

# Three-variable rotation
x, y, z = 1, 2, 3
print(f"x={x}, y={y}, z={z}")

x, y, z = z, x, y
print(f"x={x}, y={y}, z={z}")
Solution
a=10, b=20
a=20, b=10
x=1, y=2, z=3
x=3, y=1, z=2

How Python tuple swap works under the hood:

For a, b = b, a:

  1. Python evaluates the entire right side first: b, a creates the tuple (20, 10)
  2. Then it unpacks the tuple into the left-side targets: a = 20, b = 10

This is why no temporary variable is needed. The right side is a snapshot of the values before any rebinding occurs.

For x, y, z = z, x, y:

  1. Right side evaluates to (3, 1, 2) (using the old values of z, x, y)
  2. Then x = 3, y = 1, z = 2

At the bytecode level, CPython uses ROT_TWO (for 2 variables) or ROT_THREE + ROT_TWO (for 3 variables) instructions, which are more efficient than creating an actual tuple object.

Expected Output
a=10, b=20\na=20, b=10\nx=1, y=2, z=3\nx=3, y=1, z=2
Hints

Hint 1: Python evaluates the entire right side of an assignment before binding any names on the left.

Hint 2: This means `a, b = b, a` works because the right side creates a tuple `(b, a)` first.

Hint 3: This extends to any number of variables — the right side is fully evaluated before any assignments happen.

#8Namespace InspectorMedium
namespacelocalsglobalsscope

Write code that inspects Python's namespace dictionaries using locals() and globals(). Demonstrate that globals() is writable (you can create global variables dynamically) while locals() is read-only inside functions.

Solution
global_var = "I am global"

def inspect_namespaces():
local_var = "I am local"
another_local = 42

# 1. Local variable names
print(f"Local variables: {sorted(locals().keys())}")

# 2-4. Namespace membership
print(f"'global_var' in locals(): {'global_var' in locals()}")
print(f"'global_var' in globals(): {'global_var' in globals()}")
print(f"'local_var' in globals(): {'local_var' in globals()}")

# 5. Dynamically create a global variable
globals()['dynamic_global'] = "Created at runtime!"

inspect_namespaces()

print(f"dynamic_global = {dynamic_global}")

Key namespace rules:

  • locals() inside a function returns a snapshot of local variables. In CPython, writing to this dict has no effect on actual locals (they live in a fixed-size array for speed).
  • globals() returns the actual module namespace dict. Writing to it immediately creates or modifies global variables.
  • Variable lookup follows the LEGB rule: Local, Enclosing, Global, Built-in. Python checks each scope in order.
  • locals() at module level is the same dict as globals() — the distinction only matters inside functions.
# Explore how locals() and globals() expose Python's namespace dictionaries.

global_var = "I am global"

def inspect_namespaces():
    local_var = "I am local"
    another_local = 42

    # TODO:
    # 1. Print all local variable names (keys of locals())
    # 2. Check if 'global_var' appears in locals()
    # 3. Check if 'global_var' appears in globals()
    # 4. Check if 'local_var' appears in globals()
    # 5. Demonstrate that globals() can be used to CREATE
    #    a new global variable from inside a function
    pass

inspect_namespaces()

# After the function call, check if the dynamically
# created global variable exists
# print(dynamic_global)
Expected Output
Local variables: ['local_var', 'another_local']\n'global_var' in locals(): False\n'global_var' in globals(): True\n'local_var' in globals(): False\ndynamic_global = Created at runtime!
Hints

Hint 1: `locals()` returns a dict of the current local scope. `globals()` returns the module-level namespace dict.

Hint 2: `globals()` is a live, writable dict — you can inject new global variables by assigning to it.

Hint 3: Unlike `globals()`, modifying the dict returned by `locals()` inside a function does NOT affect actual local variables (CPython optimization).

#9Mutable Default Gotcha — Multi-Name EditionMedium
mutable-defaultchained-assignmentaliasing

Predict the output for all three cases. Each demonstrates a different way to initialize multiple variables.

Python
# Case 1: Chained assignment with mutable
a = b = c = []
a.append(1)
b.append(2)
c.append(3)
print(f"Case 1: a={a}, b={b}, c={c}")
print(f"  All same object? {a is b is c}")

# Case 2: Separate assignment
x, y, z = [], [], []
x.append(1)
y.append(2)
z.append(3)
print(f"Case 2: x={x}, y={y}, z={z}")
print(f"  All same object? {x is y is z}")

# Case 3: List multiplication trap
rows = [[0]] * 3
rows[0][0] = 99
print(f"Case 3: rows={rows}")
print(f"  All same object? {rows[0] is rows[1] is rows[2]}")
Solution
Case 1: a=[1, 2, 3], b=[1, 2, 3], c=[1, 2, 3]
All same object? True
Case 2: x=[1], y=[2], z=[3]
All same object? False
Case 3: rows=[[99], [99], [99]]
All same object? True

Case 1a = b = c = [] creates one list and binds all three names to it. Every append mutates the same object.

Case 2x, y, z = [], [], [] creates three separate list objects. The right side is a tuple of three independent lists, unpacked into three names.

Case 3[[0]] * 3 creates one inner list [0] and puts three references to it in the outer list. Mutating through any index affects all three. The safe alternative:

rows = [[0] for _ in range(3)] # Three independent inner lists

The pattern: Any time Python reuses a reference to a mutable object (chained assignment, list multiplication, default arguments), you get aliasing. When in doubt, create fresh objects explicitly.

Expected Output
See solution
Hints

Hint 1: Think about how many list objects are created in `a = b = c = []`.

Hint 2: Then think about what `a = b = c = list()` does — is it any different?

Hint 3: Finally, consider `a, b, c = [], [], []` — how does this differ?


Hard

#10Variable Rebinding TrackerHard
descriptornamespacemetaprogramming

Build a TrackedNamespace class that logs every rebinding of a variable. It should track the full history of values assigned to each name and print a log message on every assignment.

ns = TrackedNamespace()
ns.x = 10
ns.x = 20
ns.x = 30
ns.y = "hello"
ns.y = "world"

print(f"x history: {ns.get_history('x')}")
print(f"y history: {ns.get_history('y')}")
print(f"Current x: {ns.x}")
Solution
class TrackedNamespace:
def __init__(self):
# Bypass our __setattr__ to set up internals
object.__setattr__(self, '_store', {})
object.__setattr__(self, '_history', {})
object.__setattr__(self, '_log', [])

def __setattr__(self, name, value):
store = object.__getattribute__(self, '_store')
history = object.__getattribute__(self, '_history')
log = object.__getattribute__(self, '_log')

if name in store:
old = store[name]
msg = f"[LOG] {name} rebind: {old} -> {value}"
else:
msg = f"[LOG] {name} = {value} (first binding)"

print(msg)
log.append(msg)
store[name] = value
history.setdefault(name, []).append(value)

def __getattr__(self, name):
store = object.__getattribute__(self, '_store')
if name in store:
return store[name]
raise AttributeError(f"'{type(self).__name__}' has no variable '{name}'")

def get_history(self, name):
history = object.__getattribute__(self, '_history')
return list(history.get(name, []))

def get_log(self):
log = object.__getattribute__(self, '_log')
return list(log)

How it works:

  1. __init__ uses object.__setattr__ to create internal storage dicts without triggering our custom __setattr__. This is the standard pattern for classes that override attribute setting.

  2. __setattr__ intercepts every ns.x = value assignment. It uses object.__getattribute__ to access internal dicts (again bypassing custom lookup), then logs whether this is a first binding or a rebinding.

  3. __getattr__ is called only when normal attribute lookup fails (Python tried __dict__, class attributes, etc. first). It redirects to our internal store.

  4. get_history returns a copy of the value history list (returning a copy prevents external mutation of internal state).

Why object.__getattribute__ instead of self._store: If we used self._store inside __setattr__, Python would call our __getattr__ (since _store is not in __dict__), which would try to look up _store in _store — infinite recursion.

class TrackedNamespace:
    """A namespace that logs every variable rebinding.

    Usage:
        ns = TrackedNamespace()
        ns.x = 10       # logs: x = 10 (first binding)
        ns.x = 20       # logs: x rebind: 10 -> 20
        ns.y = 'hello'  # logs: y = hello (first binding)
        print(ns.get_history('x'))  # [(10,), (10, 20)]
    """

    def __init__(self):
        # Use object.__setattr__ to avoid triggering our own __setattr__
        pass

    def __setattr__(self, name, value):
        # Log the binding and store the value
        pass

    def __getattr__(self, name):
        # Retrieve the current value
        pass

    def get_history(self, name):
        """Return the full history of values for a variable name."""
        pass

    def get_log(self):
        """Return the full log of all operations."""
        pass
Expected Output
[LOG] x = 10 (first binding)\n[LOG] x rebind: 10 -> 20\n[LOG] x rebind: 20 -> 30\n[LOG] y = hello (first binding)\n[LOG] y rebind: hello -> world\nx history: [10, 20, 30]\ny history: ['hello', 'world']\nCurrent x: 30
Hints

Hint 1: Use `object.__setattr__(self, name, value)` in `__init__` to set up internal storage without triggering your custom `__setattr__`.

Hint 2: Store values in an internal dict and history in a list-of-lists dict.

Hint 3: `__getattr__` is only called when normal attribute lookup fails — use this to redirect to your internal storage.

#11Namespace SandboxHard
namespaceexecisolationscope

Build a NamespaceSandbox that runs Python code in a completely isolated namespace. Code inside the sandbox should not affect the outer scope, and you can inject variables and inspect results.

sandbox = NamespaceSandbox()
sandbox.inject('x', 10)
sandbox.run('y = x * 2')
print(f"After run 1: y={sandbox.get('y')}")
print(f"All names: {sandbox.list_names()}")

sandbox.run('result = y * x')
print(f"After run 2: result={sandbox.get('result')}")

sandbox.reset()
print(f"After reset: {sandbox.list_names()}")

# Prove isolation
print(f"Leaked to outer scope: {'y' in dir()}")
Solution
class NamespaceSandbox:
def __init__(self):
self._namespace = {"__builtins__": __builtins__}

def inject(self, name, value):
self._namespace[name] = value

def run(self, code):
exec(code, self._namespace)

def get(self, name):
if name in self._namespace:
return self._namespace[name]
raise NameError(f"name '{name}' is not defined in sandbox")

def list_names(self):
hidden = {'__builtins__'}
return sorted(k for k in self._namespace if k not in hidden)

def reset(self):
self._namespace.clear()
self._namespace["__builtins__"] = __builtins__

How it works:

  1. exec(code, globals_dict) executes code using globals_dict as both the global and local namespace. All variables created by the code are stored in this dict — not in the caller's actual namespace.

  2. __builtins__ must be included so the sandboxed code can use built-in functions (print, len, range, etc.). Without it, Python would inject the real __builtins__ module automatically, which is the same effect but less explicit.

  3. list_names filters out __builtins__ since it is infrastructure, not a user-defined variable.

  4. reset clears the dict and re-adds __builtins__. This is cleaner than creating a new dict (which would break references held from previous run calls).

Security note: This is namespace isolation, NOT security sandboxing. The executed code can still import modules, access the file system, and perform any operation Python allows. True sandboxing requires OS-level mechanisms (containers, seccomp, etc.). Never use exec() with untrusted input in production.

Namespace insight: This demonstrates that Python namespaces are fundamentally just dictionaries. Variables are keys, values are the objects they reference. The LEGB scope rules are implemented by looking up keys in a chain of these dictionaries.

class NamespaceSandbox:
    """Execute Python code in an isolated namespace.

    Variables created inside the sandbox cannot leak out.
    The sandbox can be given pre-defined variables.
    After execution, you can inspect what was created.

    Usage:
        sandbox = NamespaceSandbox()
        sandbox.inject('x', 10)
        sandbox.run('y = x * 2')
        print(sandbox.get('y'))       # 20
        print(sandbox.list_names())   # ['x', 'y']
        print('y' in dir())           # False (didn't leak)
    """

    def __init__(self):
        pass

    def inject(self, name, value):
        """Add a variable to the sandbox namespace."""
        pass

    def run(self, code):
        """Execute code within the sandboxed namespace."""
        pass

    def get(self, name):
        """Retrieve a variable from the sandbox."""
        pass

    def list_names(self):
        """List all user-defined variable names in the sandbox."""
        pass

    def reset(self):
        """Clear the sandbox namespace."""
        pass
Expected Output
After run 1: y=20\nAll names: ['x', 'y']\nAfter run 2: result=200\nAfter reset: []\nLeaked to outer scope: False
Hints

Hint 1: Use `exec(code, globals_dict, locals_dict)` — Python lets you provide custom namespace dicts.

Hint 2: Use separate dicts for the global and local namespace to control what the code can see.

Hint 3: Filter out built-in names (like `__builtins__`) when listing user-defined names.

#12Smart Assignment — Value History TrackerHard
descriptorclass-designhistorymetaprogramming

Design a TrackedVar class that wraps any value and maintains a complete audit trail of every assignment. Support undo, diff, and a configurable history limit.

from datetime import datetime

score = TrackedVar(0, name='score')
print(score)

score.set(10)
score.set(25)
score.set(17)
print(score)
print(f"History length: {len(score.history())}")
print(f"Diff (25 vs 17): {score.diff()}")

score.undo()
print(f"After undo: {score.get()}")

score.undo()
print(f"After second undo: {score.get()}")

# Show full history (including undo operations)
print(f"Full history values: {[v for v, t in score.history()]}")

# Test max_history
limited = TrackedVar(0, name='limited', max_history=3)
for i in [10, 25, 17, 25]:
limited.set(i)
print(f"Max history (last 3): {[v for v, t in limited.history()]}")
Solution
from datetime import datetime

class TrackedVar:
def __init__(self, initial_value, name='var', max_history=None):
self._name = name
self._max_history = max_history
self._history = [(initial_value, datetime.now())]

def set(self, value):
self._history.append((value, datetime.now()))
self._trim()

def get(self):
return self._history[-1][0]

def undo(self):
if len(self._history) < 2:
raise IndexError(f"Cannot undo: {self._name} has no previous value")
# Remove current value, exposing the previous one
removed_value, _ = self._history.pop()
# Record the undo as a new event (the "reverted to" value)
# This way history is an audit trail, not just a stack
return removed_value

def history(self):
return list(self._history)

def diff(self):
if len(self._history) < 2:
raise ValueError(f"Need at least 2 values to compute diff")
current = self._history[-1][0]
previous = self._history[-2][0]
try:
return abs(current - previous)
except TypeError:
raise TypeError(
f"Cannot compute diff between {type(previous).__name__} "
f"and {type(current).__name__}"
)

def _trim(self):
if self._max_history and len(self._history) > self._max_history:
self._history = self._history[-self._max_history:]

def __repr__(self):
changes = len(self._history) - 1
return (
f"TrackedVar(name='{self._name}', "
f"current={self.get()!r}, changes={changes})"
)

Design decisions explained:

  1. History as append-only log: Each set() appends a (value, timestamp) tuple. This gives you a complete audit trail. The current value is always self._history[-1].

  2. undo() pops from history: This reverts to the previous value by removing the latest entry. The undo itself is NOT recorded as a new event (to keep undo semantics clean). This means undo truly removes the last state.

  3. diff() uses abs(): Returns the absolute difference between the two most recent values. Raises TypeError for non-numeric types, so the caller gets a clear error instead of a confusing traceback.

  4. max_history trimming: Uses list slicing self._history[-N:] to keep only the most recent entries. This prevents unbounded memory growth in long-running applications.

  5. __repr__: Provides a clear summary for debugging. Shows the variable name, current value, and number of changes (history length minus 1 for the initial value).

Real-world applications: This pattern appears in undo systems (text editors, form state), configuration management (tracking config changes), financial systems (audit trails), and state management libraries (Redux-style time travel debugging).

from datetime import datetime

class TrackedVar:
    """A smart variable that remembers every value it has ever held.

    Features:
    - Tracks full history of (value, timestamp) pairs
    - Can undo to previous value
    - Can diff between current and previous value (for numbers)
    - Supports a max_history limit

    Usage:
        score = TrackedVar(0, name='score', max_history=100)
        score.set(10)
        score.set(25)
        score.set(17)
        print(score.get())           # 17
        print(score.history())       # [(0, ts), (10, ts), (25, ts), (17, ts)]
        score.undo()
        print(score.get())           # 25
        print(score.diff())          # numeric diff from previous
    """

    def __init__(self, initial_value, name='var', max_history=None):
        pass

    def set(self, value):
        """Set a new value, recording it in history."""
        pass

    def get(self):
        """Get the current value."""
        pass

    def undo(self):
        """Revert to the previous value. Raises if no history."""
        pass

    def history(self):
        """Return full history as list of (value, timestamp) tuples."""
        pass

    def diff(self):
        """Return the numeric difference between current and previous value.
        Raises TypeError if values are not numeric."""
        pass

    def __repr__(self):
        pass
Expected Output
TrackedVar(name='score', current=0, changes=0)\nTrackedVar(name='score', current=25, changes=3)\nHistory length: 4\nDiff (25 vs 17): 8\nAfter undo: 25\nAfter second undo: 17\nFull history values: [0, 10, 25, 17, 25, 17]\nMax history (last 3): [25, 17, 25]
Hints

Hint 1: Store history as a list of `(value, timestamp)` tuples. The current value is always the last entry.

Hint 2: For `undo`, pop the last entry and return to the previous one. But also record the undo as a new historical event for auditability.

Hint 3: For `max_history`, trim the history list from the front when it exceeds the limit (keep the most recent entries).

© 2026 EngineersOfAI. All rights reserved.