Python None and Implicit Return Practice Problems & Exercises
Practice: None and Implicit Return
← Back to lessonEasy
Create three variables all assigned to None. Return a tuple proving they are the same object (identity check) and that their type is NoneType.
def prove_singleton():
a = None
b = None
c = None
return (a is b, b is c, type(a).__name__ == 'NoneType')
print(prove_singleton())Solution
def prove_singleton():
a = None
b = None
c = None
return (a is b, b is c, type(a).__name__ == 'NoneType')
None is a singleton — there is exactly one None object in the entire interpreter. Every variable assigned None holds a reference to the same object at the same memory address. type(None) returns <class 'NoneType'>, and you cannot create additional instances of NoneType.
def prove_singleton():
"""Create three variables assigned to None.
Return a tuple of three booleans:
(a is b, b is c, type(a).__name__ == 'NoneType')
All three should be True.
"""
# TODO: implement
passExpected Output
(True, True, True)Hints
Hint 1: Assign None to three separate variables: a, b, c.
Hint 2: Use the `is` operator to check identity — all None references point to the same object.
The function double_all builds a doubled list but never returns it. Fix the bug so the function returns the correct result instead of None.
def double_all(numbers):
result = []
for n in numbers:
result.append(n * 2)
return result
def test():
output = double_all([1, 2, 3])
assert output is not None, "Function returned None!"
assert output == [2, 4, 6]
return "All tests passed"
print(test())Solution
def double_all(numbers):
result = []
for n in numbers:
result.append(n * 2)
return result # was missing
This is the most common None bug in Python. The programmer builds a result but forgets to return it. The function exits without a return statement, so CPython inserts LOAD_CONST None; RETURN_VALUE automatically. The caller receives None and crashes when trying to use it as a list.
def double_all(numbers):
"""Return a new list where each element is doubled.
BUG: this function has a missing return.
Fix it so it returns the doubled list.
"""
result = []
for n in numbers:
result.append(n * 2)
# BUG: missing return
# Do not modify the test code below
def test():
output = double_all([1, 2, 3])
assert output is not None, "Function returned None!"
assert output == [2, 4, 6]
return "All tests passed"Expected Output
All tests passedHints
Hint 1: A function with no return statement implicitly returns None.
Hint 2: Add a return statement that sends the result list back to the caller.
Write classify_return_style that calls a given function and returns 'returns_none' if the result is None, or 'returns_value' otherwise. Test it against four functions that use different return styles.
def classify_return_style(func):
result = func()
if result is None:
return 'returns_none'
return 'returns_value'
def style_a():
return None
def style_b():
return
def style_c():
pass
def style_d():
return 42
print(f"style_a: {classify_return_style(style_a)}")
print(f"style_b: {classify_return_style(style_b)}")
print(f"style_c: {classify_return_style(style_c)}")
print(f"style_d: {classify_return_style(style_d)}")Solution
def classify_return_style(func):
result = func()
if result is None:
return 'returns_none'
return 'returns_value'
All three None-returning styles are semantically identical. return None, bare return, and no return statement all cause the function to return the same None singleton. The convention difference is purely about communicating intent to readers: return None means None is a meaningful value, bare return means early exit, and no return means procedure.
def classify_return_style(func):
"""Call the given function and return a string:
- 'returns_none' if the function returns None
- 'returns_value' if the function returns something else
"""
# TODO: implement
pass
def style_a():
return None
def style_b():
return
def style_c():
pass
def style_d():
return 42Expected Output
style_a: returns_none
style_b: returns_none
style_c: returns_none
style_d: returns_valueHints
Hint 1: Call the function and check the result with `is None`.
Hint 2: All three styles — return None, bare return, and no return — produce the same None value.
The function get_sorted_unique is supposed to return a sorted list of unique elements. But it assigns the result of list.sort() — which is None. Fix the bug.
def get_sorted_unique(items):
result = sorted(set(items))
return result
def test():
output = get_sorted_unique([3, 1, 2, 3, 1])
assert output == [1, 2, 3], f"Expected [1, 2, 3], got {output}"
return "All tests passed"
print(test())Solution
def get_sorted_unique(items):
result = sorted(set(items))
return result
# Alternative fix:
def get_sorted_unique_v2(items):
result = list(set(items))
result.sort() # sort in place, do NOT assign
return result
list.sort() returns None by design — this is the Command-Query Separation principle. Commands (mutations) return None to prevent you from assuming the original is unchanged. Use sorted() when you need a return value, or call .sort() on its own line when you want in-place mutation.
def get_sorted_unique(items):
"""Return a sorted list of unique items.
BUG: this function chains in-place methods
and returns None.
Fix it without changing the function signature.
"""
result = list(set(items)).sort()
return result
# Do not modify test code
def test():
output = get_sorted_unique([3, 1, 2, 3, 1])
assert output == [1, 2, 3], f"Expected [1, 2, 3], got {output}"
return "All tests passed"Expected Output
All tests passedHints
Hint 1: list.sort() mutates in place and returns None — you cannot assign its return value.
Hint 2: Use sorted() which returns a new sorted list, or call sort() on a separate line.
Medium
Write a function that compares is None vs == None across a list of values. Return the indices that match under each check. The results will differ when an object overrides __eq__.
def safe_none_check(values):
identity = []
equality = []
for i, v in enumerate(values):
if v is None:
identity.append(i)
if v == None:
equality.append(i)
return {'identity': identity, 'equality': equality}
class AlwaysEqual:
def __eq__(self, other):
return True
def test():
items = [None, 0, "", AlwaysEqual(), False, None]
result = safe_none_check(items)
assert result['identity'] == [0, 5]
assert result['equality'] == [0, 3, 5]
return "All tests passed"
print(test())Solution
def safe_none_check(values):
identity = []
equality = []
for i, v in enumerate(values):
if v is None:
identity.append(i)
if v == None:
equality.append(i)
return {'identity': identity, 'equality': equality}
== None gives a false positive for AlwaysEqual() at index 3 because its __eq__ returns True for everything. is None uses identity comparison (same memory address) and cannot be overridden. This is why PEP 8 mandates is None and is not None for all None checks.
def safe_none_check(values):
"""Given a list of values, return two lists:
- 'identity': indices where value is None (using 'is')
- 'equality': indices where value == None (using '==')
Return a dict with keys 'identity' and 'equality'.
The lists may differ when objects override __eq__.
"""
# TODO: implement
pass
class AlwaysEqual:
def __eq__(self, other):
return True
# Do not modify test code
def test():
items = [None, 0, "", AlwaysEqual(), False, None]
result = safe_none_check(items)
assert result['identity'] == [0, 5]
assert result['equality'] == [0, 3, 5]
return "All tests passed"Expected Output
All tests passedHints
Hint 1: Loop through with enumerate to track indices.
Hint 2: The AlwaysEqual class returns True for == None but False for is None — that is the whole point.
Build a function pipeline that chains transformations. If any function in the chain returns None, the pipeline short-circuits and returns None immediately, preventing NoneType errors downstream.
def pipeline(value, *functions):
current = value
for func in functions:
current = func(current)
if current is None:
return None
return current
def test():
double = lambda x: x * 2
add_one = lambda x: x + 1
explode = lambda x: None
to_str = lambda x: str(x)
assert pipeline(3, double, add_one) == 7
assert pipeline(3, double, explode, to_str) is None
assert pipeline(5) == 5
return "All tests passed"
print(test())Solution
def pipeline(value, *functions):
current = value
for func in functions:
current = func(current)
if current is None:
return None
return current
None propagation is a major source of production crashes. When a function returns None unexpectedly, the next function in the chain tries to operate on None and raises TypeError or AttributeError. This pipeline pattern — check after each step — is the manual equivalent of Optional chaining in other languages. The key is using is None (not if not current, which would also stop on 0, "", or False).
def pipeline(value, *functions):
"""Apply a chain of functions to a value.
If any function returns None, stop the pipeline
immediately and return None.
Otherwise return the final transformed value.
"""
# TODO: implement
pass
# Do not modify test code
def test():
double = lambda x: x * 2
add_one = lambda x: x + 1
explode = lambda x: None # breaks the chain
to_str = lambda x: str(x)
assert pipeline(3, double, add_one) == 7
assert pipeline(3, double, explode, to_str) is None
assert pipeline(5) == 5
return "All tests passed"Expected Output
All tests passedHints
Hint 1: Loop through the functions, applying each to the current value.
Hint 2: After each application, check if the result is None — if so, short-circuit and return None.
Fix the count_with_default function. The bug is using if not start: instead of if start is None:. When start=0 is passed, the falsy check incorrectly replaces it.
def count_with_default(items, start=None):
if start is None:
start = 0
return start + len(items)
def test():
assert count_with_default([1, 2, 3]) == 3
assert count_with_default([1, 2, 3], 10) == 13
assert count_with_default([1, 2, 3], 0) == 3
assert count_with_default([], 0) == 0
assert count_with_default([], None) == 0
return "All tests passed"
print(test())Solution
def count_with_default(items, start=None):
if start is None: # was: if not start
start = 0
return start + len(items)
if not x catches every falsy value — None, 0, 0.0, "", [], False, and any object whose __bool__ returns False. When you mean "was this argument omitted?", always use if x is None. The falsy trap is especially dangerous with numeric parameters where 0 is a perfectly valid input.
def count_with_default(items, start=None):
"""Count items in a list, starting from 'start'.
If start is not provided (None), begin from 0.
BUG: the current implementation uses 'if not start'
which treats 0 as missing. Fix it.
"""
if not start:
start = 0
return start + len(items)
# Do not modify test code
def test():
assert count_with_default([1, 2, 3]) == 3
assert count_with_default([1, 2, 3], 10) == 13
assert count_with_default([1, 2, 3], 0) == 3 # BUG: returns 3 correctly but for wrong reason
# The real test: start=0 should use 0, not replace it
assert count_with_default([], 0) == 0 # Must be 0, not 0
assert count_with_default([], None) == 0
return "All tests passed"Expected Output
All tests passedHints
Hint 1: `if not start` is True when start is 0, "", [], or False — not just None.
Hint 2: Use `if start is None` to only catch the "not provided" case.
Implement a cache lookup that correctly distinguishes between "key exists with value None" and "key does not exist." Use a sentinel object so that None can be a valid cached value.
_MISSING = object()
def smart_cache_get(cache, key, default=_MISSING):
result = cache.get(key, _MISSING)
if result is not _MISSING:
return result
if default is not _MISSING:
return default
raise KeyError(key)
def test():
cache = {"a": 1, "b": None, "c": 0, "d": False}
assert smart_cache_get(cache, "a") == 1
assert smart_cache_get(cache, "b") is None
assert smart_cache_get(cache, "c") == 0
assert smart_cache_get(cache, "d") is False
assert smart_cache_get(cache, "z", "fallback") == "fallback"
assert smart_cache_get(cache, "z", None) is None
try:
smart_cache_get(cache, "z")
assert False, "Should have raised KeyError"
except KeyError:
pass
return "All tests passed"
print(test())Solution
_MISSING = object()
def smart_cache_get(cache, key, default=_MISSING):
result = cache.get(key, _MISSING)
if result is not _MISSING:
return result
# Key is missing — check if caller provided a default
if default is not _MISSING:
return default
raise KeyError(key)
The sentinel pattern solves a fundamental ambiguity. With dict.get(key), both "key missing" and "key has value None" return None. By using _MISSING = object() as the fallback, we can distinguish the two cases. Each object() call creates a unique instance that equals nothing but itself. This is the same pattern used in Python's standard library (e.g., dataclasses.MISSING, inspect.Parameter.empty).
_MISSING = object()
def smart_cache_get(cache, key, default=_MISSING):
"""Look up key in cache dict.
Return semantics:
- If key exists, return its value (even if the value is None)
- If key is missing and default is provided, return default
- If key is missing and no default, raise KeyError
You must distinguish 'cached None' from 'missing key'.
"""
# TODO: implement
pass
# Do not modify test code
def test():
cache = {"a": 1, "b": None, "c": 0, "d": False}
assert smart_cache_get(cache, "a") == 1
assert smart_cache_get(cache, "b") is None # cached None
assert smart_cache_get(cache, "c") == 0
assert smart_cache_get(cache, "d") is False
assert smart_cache_get(cache, "z", "fallback") == "fallback"
assert smart_cache_get(cache, "z", None) is None # explicit default=None
try:
smart_cache_get(cache, "z") # no default, should raise
assert False, "Should have raised KeyError"
except KeyError:
pass
return "All tests passed"Expected Output
All tests passedHints
Hint 1: Use dict.get(key, _MISSING) to distinguish missing keys from keys with None values.
Hint 2: Check if the result is _MISSING — if so, either use the default or raise KeyError.
Hard
Implement optional chaining for Python objects. Given a dot-separated path like "user.profile.email", traverse the attribute chain safely. If any intermediate attribute is None or does not exist, return the default value.
_MISSING = object()
def safe_getattr(obj, path, default=None):
current = obj
for attr in path.split("."):
if current is None:
return default
current = getattr(current, attr, _MISSING)
if current is _MISSING:
return default
return current
class Profile:
def __init__(self, email):
self.email = email
class User:
def __init__(self, profile):
self.profile = profile
class Request:
def __init__(self, user):
self.user = user
def test():
req1 = Request(User(Profile("[email protected]")))
req2 = Request(User(None))
req3 = Request(None)
assert safe_getattr(req1, "user.profile.email") == "[email protected]"
assert safe_getattr(req2, "user.profile.email") is None
assert safe_getattr(req2, "user.profile.email", "n/a") == "n/a"
assert safe_getattr(req3, "user.profile.email") is None
assert safe_getattr(req1, "user") is req1.user
assert safe_getattr(None, "anything") is None
return "All tests passed"
print(test())Solution
_MISSING = object()
def safe_getattr(obj, path, default=None):
current = obj
for attr in path.split("."):
if current is None:
return default
current = getattr(current, attr, _MISSING)
if current is _MISSING:
return default
return current
This is Python's manual equivalent of optional chaining (?. in JavaScript/TypeScript). The function combines two None-handling patterns: checking for None at each step (preventing AttributeError on None objects) and using a sentinel with getattr (preventing AttributeError on missing attributes). This pattern is extremely common in web frameworks — Django's template engine, for example, does exactly this when resolving variable lookups.
def safe_getattr(obj, path, default=None):
"""Safely traverse a dot-separated attribute path.
If any attribute in the chain is None or missing,
return the default instead of raising an error.
Example:
safe_getattr(request, 'user.profile.email')
is equivalent to:
request?.user?.profile?.email (optional chaining)
"""
# TODO: implement
pass
# Do not modify test code
class Profile:
def __init__(self, email):
self.email = email
class User:
def __init__(self, profile):
self.profile = profile
class Request:
def __init__(self, user):
self.user = user
def test():
req1 = Request(User(Profile("[email protected]")))
req2 = Request(User(None)) # no profile
req3 = Request(None) # no user
assert safe_getattr(req1, "user.profile.email") == "[email protected]"
assert safe_getattr(req2, "user.profile.email") is None
assert safe_getattr(req2, "user.profile.email", "n/a") == "n/a"
assert safe_getattr(req3, "user.profile.email") is None
assert safe_getattr(req1, "user") is req1.user
assert safe_getattr(None, "anything") is None
return "All tests passed"Expected Output
All tests passedHints
Hint 1: Split the path on "." and traverse one attribute at a time.
Hint 2: At each step, if the current object is None, return the default immediately.
Hint 3: Use getattr with a sentinel to detect missing attributes without catching unrelated exceptions.
Write a function that audits list methods to classify them as "command" (returns None, mutates in place) or "query" (returns a value). This demonstrates the CQS principle in Python's standard library.
def audit_methods(obj, method_names):
results = {}
for name in method_names:
obj_copy = list(obj)
method = getattr(obj_copy, name, None)
if method is None:
results[name] = 'unknown'
continue
try:
ret = method()
if ret is None:
results[name] = 'command'
else:
results[name] = 'query'
except TypeError:
results[name] = 'unknown'
return results
def test():
methods = ['sort', 'reverse', 'copy', 'clear']
result = audit_methods([3, 1, 2], methods)
assert result['sort'] == 'command'
assert result['reverse'] == 'command'
assert result['copy'] == 'query'
assert result['clear'] == 'command'
return "All tests passed"
print(test())Solution
def audit_methods(obj, method_names):
results = {}
for name in method_names:
obj_copy = list(obj) # fresh copy each time
method = getattr(obj_copy, name, None)
if method is None:
results[name] = 'unknown'
continue
try:
ret = method()
if ret is None:
results[name] = 'command'
else:
results[name] = 'query'
except TypeError:
results[name] = 'unknown'
return results
The critical insight is copying before each call. sort() and reverse() mutate the list and return None (commands). copy() returns a new list (query). clear() empties the list and returns None (command). Without copying, the first sort() call would mutate the original, and clear() would empty it before subsequent tests. This audit confirms that Python's list API consistently follows CQS — every mutating method returns None.
def audit_methods(obj, method_names):
"""Test each method on a COPY of obj to determine
if it follows Command-Query Separation.
For each method_name, call it on a copy of obj
(to avoid mutation issues) and classify:
- 'command': method returns None (mutates in place)
- 'query': method returns non-None (returns new value)
Return a dict mapping method_name -> 'command' or 'query'.
Note: call methods with no arguments beyond self.
Some methods may need a dummy arg — skip those that
raise TypeError and classify as 'unknown'.
"""
# TODO: implement
pass
# Do not modify test code
def test():
methods = ['sort', 'reverse', 'copy', 'clear']
result = audit_methods([3, 1, 2], methods)
assert result['sort'] == 'command'
assert result['reverse'] == 'command'
assert result['copy'] == 'query'
assert result['clear'] == 'command'
return "All tests passed"Expected Output
All tests passedHints
Hint 1: Use list() or copy() to create a fresh copy before each method call — mutations persist.
Hint 2: getattr(obj_copy, method_name)() calls the method. Wrap in try/except for methods that need arguments.
Implement a Maybe monad that wraps potentially-None values and allows safe chaining. bind chains functions that return Maybe, map chains functions that return plain values, and or_else extracts the final result with a fallback.
class Maybe:
def __init__(self, value):
self._value = value
def bind(self, func):
if self._value is None:
return Maybe(None)
return func(self._value)
def map(self, func):
if self._value is None:
return Maybe(None)
return Maybe(func(self._value))
def or_else(self, default):
if self._value is None:
return default
return self._value
def is_nothing(self):
return self._value is None
def __repr__(self):
if self._value is None:
return "Nothing"
return f"Maybe({self._value!r})"
def test():
def safe_div(a, b):
if b == 0:
return Maybe(None)
return Maybe(a / b)
result = (Maybe(10)
.bind(lambda x: safe_div(x, 2))
.map(lambda x: x + 1)
.or_else(0))
assert result == 6.0
result = (Maybe(10)
.bind(lambda x: safe_div(x, 0))
.map(lambda x: x + 1)
.or_else(0))
assert result == 0
result = (Maybe(None)
.map(lambda x: x * 2)
.or_else(-1))
assert result == -1
assert Maybe(42).is_nothing() is False
assert Maybe(None).is_nothing() is True
assert repr(Maybe(5)) == "Maybe(5)"
assert repr(Maybe(None)) == "Nothing"
return "All tests passed"
print(test())Solution
class Maybe:
def __init__(self, value):
self._value = value
def bind(self, func):
if self._value is None:
return Maybe(None)
return func(self._value)
def map(self, func):
if self._value is None:
return Maybe(None)
return Maybe(func(self._value))
def or_else(self, default):
if self._value is None:
return default
return self._value
def is_nothing(self):
return self._value is None
def __repr__(self):
if self._value is None:
return "Nothing"
return f"Maybe({self._value!r})"
The Maybe monad eliminates nested None checks entirely. Instead of writing if x is not None: if y is not None: if z is not None:, you write Maybe(x).map(f).map(g).map(h).or_else(default). The key distinction: bind expects the function to return a Maybe (for operations that might fail), while map wraps the result automatically (for operations that always succeed on non-None input). This pattern is native in Haskell, Rust (Option), and Swift (Optional), but in Python you typically use explicit is None checks — the Maybe monad is a useful exercise for understanding why those languages built it in.
class Maybe:
"""A Maybe monad that wraps a value which might be None.
Enables safe chaining of operations without manual
None checks at every step.
Implement:
- Maybe(value): wrap a value
- .bind(func): apply func if value is not None,
func must return a Maybe
- .map(func): apply func if value is not None,
func returns a plain value (auto-wrapped)
- .or_else(default): return value if not None,
otherwise return default
- .is_nothing(): True if wrapped value is None
"""
def __init__(self, value):
# TODO: implement
pass
def bind(self, func):
# TODO: implement
pass
def map(self, func):
# TODO: implement
pass
def or_else(self, default):
# TODO: implement
pass
def is_nothing(self):
# TODO: implement
pass
def __repr__(self):
# TODO: implement
pass
# Do not modify test code
def test():
# Safe division
def safe_div(a, b):
if b == 0:
return Maybe(None)
return Maybe(a / b)
# Chain operations
result = (Maybe(10)
.bind(lambda x: safe_div(x, 2))
.map(lambda x: x + 1)
.or_else(0))
assert result == 6.0
# None propagation
result = (Maybe(10)
.bind(lambda x: safe_div(x, 0))
.map(lambda x: x + 1)
.or_else(0))
assert result == 0
# Start with None
result = (Maybe(None)
.map(lambda x: x * 2)
.or_else(-1))
assert result == -1
assert Maybe(42).is_nothing() is False
assert Maybe(None).is_nothing() is True
assert repr(Maybe(5)) == "Maybe(5)"
assert repr(Maybe(None)) == "Nothing"
return "All tests passed"Expected Output
All tests passedHints
Hint 1: In bind and map, check if self._value is None — if so, return Maybe(None) without calling func.
Hint 2: bind expects func to return a Maybe, while map wraps the result in Maybe automatically.
Hint 3: or_else should return the raw value, not a Maybe — it is the "escape hatch" from the wrapper.
