0% found this document useful (0 votes)
6 views26 pages

Week-3

Uploaded by

Shashank S
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
6 views26 pages

Week-3

Uploaded by

Shashank S
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 26

1.

Poorly Designed & Well Designed Functions


A function is a reusable block of code that performs a specific task. You define a function
using the def keyword.
●​ Parameters: The names listed in the function definition. They are placeholders for
the values the function expects to receive.
●​ Arguments: The actual values that are passed to the function when it is called.

Let's look at an example of a poorly designed function:

Python

# This function is poorly designed.


def calc(x, y):
return x / y

# Here, 'x' and 'y' are the parameters.

# Calling the function with arguments.


result = calc(100, 10) # 100 and 10 are the arguments.
print(result) # Output: 10.0

While this function works, it's difficult to understand at a glance. What does calc do? What do
x and y represent? This lack of clarity is the hallmark of poor design.

2. Principles of Good Function Design


The calc(x, y) function violates several key principles of good design:
1.​ Descriptive Naming: The name calc is too generic. It could be calculating anything.
Function and variable names should clearly describe their purpose. x and y are
meaningless out of context.
2.​ Clear Documentation: The function has no documentation. A user (including your
future self) has to read the code to figure out what it does. It should have a docstring
explaining its purpose, parameters, and what it returns.
3.​ Robustness: The function is fragile. What happens if someone calls it with calc(100,
0)? It will crash with a ZeroDivisionError. A well-designed function should anticipate
potential problems and handle them gracefully.

Essentially, the function is not readable, maintainable, or safe to use without careful
inspection of its internal code.
3. Improvement
Let's refactor the poorly designed function to follow good design principles.

Choosing Better Names

First, we'll give the function and its parameters descriptive names. This immediately
improves readability.

Python

# Before (poor)
def calc(x, y):
return x / y

# After (good)
def calculate_ratio(numerator, denominator):
return numerator / denominator

Now, a reader can immediately guess that the function calculates a ratio and understands
the roles of numerator and denominator. The readability is dramatically improved without
even changing the logic.

Explaining the Purpose of a Function (Docstrings)

A docstring is a special string placed at the very beginning of a function definition that
explains what the function does.

●​ A poor docstring is lazy and unhelpful:


●​ Python

def calculate_ratio(numerator, denominator):


"""divides two numbers"""
return numerator / denominator
●​
●​
●​ A good docstring is structured and informative, explaining the purpose, arguments
(Args), and return value (Returns):
●​ Python

def calculate_ratio(numerator, denominator):


"""Calculates the ratio of two numbers.

Args:
numerator (int or float): The number to be divided.
denominator (int or float): The number to divide by.
Returns:
float: The result of the division, numerator / denominator.
"""
# We still haven't made it robust, but it's now well-documented.
return numerator / denominator
●​
●​

This good docstring serves as complete documentation for the function.

4. Example of Poorly designed function


Let's critique a function from "our friend," a hypothetical fellow learner.

Our Friend's Function:

Python

def check(n):
if n % 2 == 0:
return True
else:
return False

Critique:
1.​ Vague Name: The function name check is meaningless. What is it checking for? We
have to read the code to learn that it checks if a number is even. A better name
would be is_even.
2.​ Verbose Logic: The structure if condition: return True else: return False is
redundant. The expression n % 2 == 0 itself evaluates to either True or False. You
can just return the result of that expression directly.
3.​ No Documentation: There is no docstring to explain its purpose.
4.​ Not Robust: It assumes n will always be an integer. check("hello") would crash.

It's a working function, but it's not a well-designed one.

5. Further Improvements
Let's improve our friend's function using modern tools and techniques.

GenAI Suggestions and Assumptions

If we feed the check(n) function to a GenAI like ChatGPT with the prompt "Improve this
Python function," it would likely suggest:
Python

def is_even(n: int) -> bool:


"""Checks if a given integer is even.

Args:
n (int): The integer to check.

Returns:
bool: True if n is even, False otherwise.
"""
return n % 2 == 0

This is a huge improvement! It has a better name, a docstring, type hints (n: int), and
simplified logic. However, the AI made an assumption: it assumed the function was only for
integers and added a type hint to reflect that. It didn't add code to handle non-integer inputs.

Writing Test Cases

To ensure a function works as expected, we write test cases. These are simple checks that
verify the output for a given input.

Python

assert is_even(10) == True # A positive even number


assert is_even(7) == False # A positive odd number
assert is_even(0) == True # Zero is even
assert is_even(-4) == True # A negative even number

If any of these assert statements fail, the program will crash, telling us our function is buggy.

Prompt Engineering vs. Technical Prompting


●​ Prompt Engineering: The broad skill of crafting prompts to get desired text, images,
or summaries from any LLM.
●​ Technical Prompting: A specific subset of prompt engineering focused on
generating or refining code. It's more precise.
○​ Simple Prompt: "Improve this function."
○​ Technical Prompt: "Refactor this function to be more Pythonic. Add a
Google-style docstring and include a try-except block to handle non-integer
inputs by returning False."

The technical prompt gives the AI specific constraints, leading to a more robust and
predictable result.
6. Simple Conditional Statements
Conditional statements allow a program to make decisions and execute different blocks of
code based on whether a condition is true or false. The basic structure is if, elif (else if), and
else.

The condition must be a Boolean expression—something that evaluates to either True or


False. These expressions are typically formed using comparison and logical operators.
●​ Comparison Operators: == (equal to), != (not equal to), < (less than), > (greater
than), <= (less than or equal to), >= (greater than or equal to).
●​ Logical Operators: and, or, not.

Example: A function to classify a movie rating.

Python

def get_rating_category(rating: float) -> str:


"""Classifies a movie rating into categories."""
if rating >= 8.0:
return "Excellent"
elif rating >= 6.0:
return "Good"
else:
return "Average or below"

# Test cases
print(get_rating_category(9.1)) # Output: Excellent
print(get_rating_category(7.5)) # Output: Good
print(get_rating_category(4.2)) # Output: Average or below

7. Critiquing AI Gen Code


AI-generated code can have subtle bugs. A great way to find them is to check for a
mismatch between the code's behavior and its documentation or tests (doctests).

A doctest is an example written directly inside a docstring that shows how a function should
be used.

AI-Generated Function with a Bug

Let's say we asked an AI to write a function to check if a number is positive, and it produced
this:
Python

def is_positive(n):
"""
Checks if a number is positive.
A number is positive if it is strictly greater than 0.

>>> is_positive(10)
True
>>> is_positive(-5)
False
>>> is_positive(0) # This doctest will fail!
False
"""
# The bug is here: >= includes 0, but the docstring says "strictly greater".
return n >= 0

The Critique:
●​ The docstring correctly states that a positive number is "strictly greater than 0" and
that is_positive(0) should be False.
●​ The code, however, implements the logic as n >= 0, which means it considers 0 to be
positive and returns True for an input of 0.
●​ When we run the doctests, the test is_positive(0) will fail. The observed output
(True) does not match the expected output (False).

This mismatch is a clear signal that the code is wrong. Always trust the specification (the
docstring) over the implementation.

8. Complex Conditional Statements


We can write more complex conditional statements by combining multiple Boolean
expressions using and and or. Parentheses () can be used to group expressions and ensure
the correct order of evaluation.

Word Problem: A theme park offers a discount to a visitor if they meet one of the following
criteria:
1.​ They are a child (age 12 or under) and are visiting on a weekday.
2.​ They are a senior (age 65 or over), regardless of the day.
Python

def has_discount(age: int, is_weekday: bool) -> bool:


"""
Determines if a visitor gets a discount.

Args:
age (int): The visitor's age.
is_weekday (bool): True if it is a weekday, False otherwise.

Returns:
bool: True if the visitor gets a discount, False otherwise.
"""
# The conditions are grouped with parentheses for clarity.
is_child_on_weekday = (age <= 12 and is_weekday)
is_senior = (age >= 65)

if is_child_on_weekday or is_senior:
return True
else:
return False

# --- Tests ---


print(f"Child on weekday: {has_discount(10, True)}") # Expected: True
print(f"Child on weekend: {has_discount(10, False)}") # Expected: False
print(f"Adult on weekday: {has_discount(30, True)}") # Expected: False
print(f"Senior on weekend: {has_discount(70, False)}") # Expected: True

9. Introduction to Refute Problems


A Refute Problem is a common type of programming puzzle where you are given a function
that is claimed to be correct, but it contains a hidden bug. Your task is to find a specific input,
called a counterexample, that proves the function is wrong.

To solve a refute problem, you need to manually trace the function's execution for different
inputs, especially edge cases.

Example Buggy Function:

Python

def is_long_word(word: str) -> bool:


"""
Returns True if the word has MORE THAN 7 letters.

>>> is_long_word("python") # len is 6, 6 > 7 is False. Correct.


False
>>> is_long_word("excellent") # len is 9, 9 > 7 is True. Correct.
True
"""
# The bug: >= 7 is "7 or more", not "more than 7".
return len(word) >= 7

Manual Tracing to Find a Counterexample:


1.​ Analyze the spec: The docstring says "MORE THAN 7 letters," which means a
length of 8, 9, 10, etc. A word with 7 letters is not "more than 7."
2.​ Test an edge case: Let's try a word with exactly 7 letters, like "program".
3.​ Trace the code with the counterexample:
○​ word is "program".
○​ len(word) evaluates to 7.
○​ The code checks 7 >= 7. This is True.
○​ The function returns True.
4.​ Compare with the spec: The function returned True, but a 7-letter word is not "more
than 7 letters long." The expected output was False.
5.​ Conclusion: The input "program" is a counterexample that refutes the function.

10. Summary - Key Takeaways up to this point


1.​ Good Functions are Self-Explanatory: They use descriptive names for the function
and its parameters. They have a clear, single purpose and are thoroughly
documented with docstrings.
2.​ Docstrings are the Specification: A good docstring explains a function's purpose,
arguments, and return value. When the code's behavior disagrees with the docstring,
the code is wrong.
3.​ AI is a Tool, Not an Oracle: GenAI is excellent for refactoring code and suggesting
improvements, but it makes assumptions. The developer's job is to guide it with
precise technical prompts and verify its output with test cases.
4.​ Conditionals Control Flow: if/elif/else statements allow programs to execute
different code paths based on Boolean expressions. These can be simple or complex
combinations of conditions.
5.​ Refutation is Bug Hunting: To refute a function, you must find a
counterexample—a specific input where the function's actual output does not match
the expected output defined by its specification. This requires careful manual code
tracing, especially on edge cases.
11. Example_1 (is_multiple)
Here is a function that contains a mismatch between its type hints (the intended types for
parameters) and its doctests (the examples of its usage). This is a major red flag that often
points to a bug.

Python

def is_multiple(m: int, n: int) -> bool:


"""
Returns True if m is a multiple of n.

>>> is_multiple(10, 2)
True
>>> is_multiple(9, 2)
False
>>> is_multiple("10", "2") # Doctest uses strings!
True
"""
return m % n == 0

The Mismatch:
●​ The function signature def is_multiple(m: int, n: int) clearly states that the function is
designed to work with integers.
●​ However, the third doctest, is_multiple("10", "2"), passes strings as arguments.
●​ This suggests a conflict: either the type hints are wrong, or the doctest is wrong, or
the code itself has a bug when handling these unexpected types.

12. Visualizing Example_1


When Python runs a script, the def statement is an executable command. It does not run the
code inside the function. Instead, it does two things:
1.​ It creates a function object in memory. This object contains the function's code, its
name, its docstring, and other metadata.
2.​ It creates a variable with the function's name (in this case, is_multiple) and makes it
point to this newly created function object.

After the line def is_multiple(...) has been executed, memory looks something like this:
●​ Global Frame:
○​ is_multiple ---> [function object at memory address 0x...]

At this point, the expression m % n == 0 has not been evaluated. The function is simply
defined and waiting to be called.
13. Visualizing a Function Call
When you call a function, like is_multiple(10, 2), a new, temporary workspace is created in
memory called a frame.

Here's the step-by-step process:


1.​ A new frame is created for the is_multiple call.
2.​ The parameters from the function definition (m and n) are created as local variables
inside this new frame.
3.​ Parameters are initialized by assignment: This is a critical concept. Python
implicitly performs an assignment for each argument. It's as if you wrote:
○​ m = 10
○​ n = 2
4.​ The code inside the function body is now executed within this new frame.

The Importance of Type Checkers

When the problematic doctest is_multiple("10", "2") is called, the same process happens.
The assignments are m = "10" and n = "2". The Python interpreter itself does not check the
type hints at runtime. It happily assigns strings to m and n.

This is why tools called static type checkers (like mypy) are important. They can analyze
your code before you run it and warn you about this type mismatch, saying, "You called
is_multiple with strings, but it expected integers."

14. The error in example_1


The root cause of the error is a TypeError that occurs when the function is called with string
arguments, as in the doctest is_multiple("10", "2").

Let's trace the execution for that call:


1.​ A new frame is created for is_multiple.
2.​ The parameters are assigned: m = "10" and n = "2".
3.​ The function body attempts to execute the line: return m % n == 0.
4.​ Python tries to evaluate m % n, which is "10" % "2".
5.​ The modulo operator (%) is defined for numbers (it gives the remainder of a division).
However, it is not defined for two string operands.
6.​ This mismatch of operator and operand types causes Python to raise a TypeError,
and the program crashes.

The doctest is incorrect. It claims the function should return True, but in reality, the function
crashes. The bug is that the code cannot handle the string inputs that the doctest claims it
can.
15. Fixing the code
To fix the code so that it can pass the problematic doctest, we must ensure that the values
are integers before we use the modulo operator. We can do this by explicitly converting the
parameters to integers using the int() function.

Python

def is_multiple(m: int, n: int) -> bool:


"""
Returns True if m is a multiple of n.

>>> is_multiple(10, 2)
True
>>> is_multiple(9, 2)
False
>>> is_multiple("10", "2")
True
"""
# Fix: Convert parameters to integers before the operation.
return int(m) % int(n) == 0

With this fix, when we call is_multiple("10", "2"):


1.​ m is "10", n is "2".
2.​ int(m) becomes 10, int(n) becomes 2.
3.​ The expression 10 % 2 == 0 is evaluated. 10 % 2 is 0. 0 == 0 is True.
4.​ The function returns True.

The return statement does two things:


1.​ It immediately terminates the execution of the current function.
2.​ It sends the specified value (in this case, True) back to wherever the function was
called from.

16. Key Observation


The process of calling a function is fundamental to understanding Python's behavior. The
key observations are:
1.​ A New Frame is Created: Every function call gets its own private, temporary
workspace (a frame). This isolates its variables from the rest of the program.
2.​ Arguments are Passed by Assignment: This is the most crucial concept. When
you call func(x), Python behaves as if the first thing inside the function is parameter =
x. The parameter is a new variable that gets its value from the argument.
3.​ Type Hints are for Tools, Not for Python: The Python interpreter ignores type hints
at runtime. They do not prevent you from passing the "wrong" type of data to a
function. Their purpose is to help developers and static analysis tools.
4.​ return Ends the Function: The return keyword concludes the function call, destroys
the function's frame, and passes a value back to the caller.

17. Example 2 (modified is_multiple)


Let's examine a new version of is_multiple. This one doesn't have a type error, but it might
have a logical error. It tries to handle the edge case of n being zero.

Python

def is_multiple_v2(m: int, n: int) -> bool:


"""
Returns True if m is a multiple of n. Handles n=0 by returning False.

>>> is_multiple_v2(10, 2)
True
>>> is_multiple_v2(10, 3)
False
>>> is_multiple_v2(10, 0)
False
>>> is_multiple_v2(0, 5)
True
"""
if n == 0:
return False
# This check for m==0 is redundant.
if m == 0:
return True
return m % n == 0

Let's execute the doctests mentally or by running a tool.


●​ is_multiple_v2(10, 2) -> 10 % 2 == 0 is True. Passes.
●​ is_multiple_v2(10, 3) -> 10 % 3 == 0 is False. Passes.
●​ is_multiple_v2(10, 0) -> n == 0 is True, returns False. Passes.
●​ is_multiple_v2(0, 5) -> n is not 0. m == 0 is True, returns True. Passes.

The doctests all pass, so if there is a bug, it must be in a case not covered by the tests.
18. Fixing the error, Simplification 1
The previous function's doctests all passed, suggesting it might be correct. However, good
code is not just correct, it's also simple and clear. The is_multiple_v2 function can be
simplified.

Tracing Nested Conditionals

Let's trace the logic for m=0:

is_multiple_v2(0, 5)

1.​ n == 0 is false.
2.​ m == 0 is true. The function returns True.

Now let's see what happens without that special check for m=0. The expression 0 % 5
evaluates to 0. So 0 % 5 == 0 is True. The final line of the original function would have
handled this case correctly!

The if m == 0: return True check is completely redundant.

Counterexamples as Proof of Bugs

In this specific case, we didn't find a counterexample that proved the function was wrong, but
we found a redundancy that made the code unnecessarily complex. Removing redundant
code is a key part of refactoring and maintenance.

Simplification 1

By removing the redundant check, we get a simpler, cleaner function that has the exact
same behavior.

Python

# Simplified version
def is_multiple_v3(m: int, n: int) -> bool:
"""
Returns True if m is a multiple of n. Handles n=0 by returning False.
"""
if n == 0:
return False
# The final line correctly handles the m=0 case.
return m % n == 0

This version is easier to read and understand.


19. Elements of a Refute Problem
A standard "Refute Problem" has two distinct parts in its problem statement, and a correct
answer requires three parts.

The Two Parts of a Refute Problem:


1.​ The Code: The full source code of the function you need to analyze.
2.​ The Specification: The description of what the function is supposed to do. This is
usually found in the docstring, but can also include type hints and surrounding
explanatory text.

The Three Parts of the Answer:

To prove a function is buggy, you must provide a complete refutation, which consists of:

1.​ The Counterexample: A single, specific input (e.g., an argument or set of


arguments) that causes the function to fail.
2.​ The Observed (Actual) Output: What the buggy code actually produces for your
counterexample. This could be an incorrect value, a crash (e.g., TypeError), or an
infinite loop.
3.​ The Expected (Correct) Output: What the function should have produced for your
counterexample, according to the specification.

Example:
●​ Function: A buggy grading function.
●​ Python

def get_grade(score):
"""Returns 'A' for >= 90, 'B' for >= 80, 'C' for >= 70."""
if score >= 70:
return 'C'
elif score >= 80:
return 'B'
elif score >= 90:
return 'A'
●​
●​
●​ Answer:
○​ Counterexample: 95
○​ Observed Output: 'C'
○​ Expected Output: 'A'
20. Refuting the median function
Let's analyze a buggy function that claims to find the median of three numbers, along with
the kind of feedback a testing tool like "CodeCheck" might provide.

The Buggy Function

Python

def median_buggy(a, b, c):


"""
Returns the median of three numbers.
The median is the number that would be in the middle if they were sorted.
"""
# This logic is incomplete. It misses cases like (10, 1, 5) where 'a' is the median.
if (b < a < c) or (c < a < b):
return a
if (a < b < c) or (c < b < a):
return b
# It just assumes 'c' is the median in all other cases.
return c

Feedback Generated by CodeCheck

An automated testing tool would run the function against many inputs and report the first
failure it finds. The feedback would look like this:

TEST FAILED: Your function median_buggy produced an incorrect result.

●​ Counterexample / Input: (10, 1, 5)


●​ Your Code's Output (Observed): 5
●​ Correct Answer (Expected): 1

Trace: When called with a=10, b=1, c=5, the first if condition (1 < 10 < 5) is
false. The second if condition (10 < 1 < 5) is false. The function then incorrectly
returns c, which is 5. The sorted list would be [1, 5, 10], so the median is 1.

This feedback provides all three necessary parts of a refutation.


21. Simplification 2
One of the benefits of reading other people's code is discovering more elegant and effective
ways to solve problems. The buggy median function used a complex, imperative style (a
series of if checks).

A much simpler, more declarative approach is to use Python's built-in sorted() function.

Python

def median_correct(a, b, c):


"""
Returns the median of three numbers.
The median is the number that would be in the middle if they were sorted.
"""
# Create a list containing the three numbers.
numbers = [a, b, c]

# Sort the list.


sorted_numbers = sorted(numbers)

# The median is the element at index 1 (the middle element).


return sorted_numbers[1]

This style of code is often easier to verify as correct. Instead of tracing complex logical
branches, you just need to trust that sorted() works correctly. It clearly states what you want
to achieve (the middle element of a sorted list) rather than getting bogged down in the how
(a complex series of comparisons).

22. Feedback for refute problems


When you're trying to find a counterexample for a refute problem, keep these key principles
in mind:
1.​ Respect the Type Hints and Docstring: The specification is your contract. Don't try
to refute a function by providing an input it was never designed to handle. If a
function is specified to take integers (n: int), providing a string ("hello") is not a valid
refutation unless the docstring makes a claim about handling strings. The goal is to
find a failure within the specified domain.
2.​ First, Understand the Task: Before you even look at the code, make sure you fully
understand what the function is supposed to do based on its specification. If you
misunderstand the goal, you won't be able to identify incorrect behavior. For the
median function, you must first know what "median" means.
3.​ Master Mental Code Tracing: The most powerful bug-finding tool is your brain. You
must be able to simulate the computer's execution step-by-step for a given input. Pay
close attention to:
○​ Edge Cases: What happens with 0, negative numbers, empty lists/strings, or
the largest/smallest possible values?
○​ Boundaries: For a condition like score >= 90, test the values 89, 90, and 91.
Bugs often hide at the boundaries.

23. Attempting to refute


The process of refuting a function should be systematic.

Step 1: Verify the Given Doctests

First, always trace the doctests provided in the function's docstring. These are the "easy"
cases the author thought of.

Python

def is_valid_username(name: str) -> bool:


"""
Returns True if the name is between 4 and 10 chars, inclusive.
>>> is_valid_username("alex") # len 4. 4 <= 4 <= 10. True. Correct.
True
>>> is_valid_username("longusername") # len 12. Fails. Correct.
False
"""
return 4 <= len(name) < 10 # Bug here! Should be <= 10.

By tracing the doctests, we confirm they pass according to the code's logic. This tells us two
things:
1.​ The bug is not immediately obvious.
2.​ The bug must lie in an edge case not covered by the existing tests.

Step 2: Test the Boundaries

The specification is "between 4 and 10 characters, inclusive." This means 4 and 10 are valid
lengths. The doctests cover length 4, but not length 10. This is a prime place to look for a
bug.

●​ Counterexample: "abcdefghij" (a string of length 10).


●​ Observed: The code checks 4 <= 10 < 10. The 10 < 10 part is False. The function
returns False.
●​ Expected: According to the spec, a 10-character name is valid. The function should
have returned True.

We have successfully refuted the function.


24. Refuting the num_days function
Bugs in conditional logic often arise from the order of execution or from comments that
don't match the code.

A Buggy num_days Function

Python

def num_days_in_month(month: int, is_leap: bool) -> bool:


"""Returns the number of days in the given month."""
# This comment is misleading! It doesn't check for 31 first.
# Check for months with 31 days, then 30, then February.
if month in {4, 6, 9, 11}:
return 30
elif month == 2:
if is_leap:
return 29
else:
return 28
else:
# Bug: This assumes any month that isn't Feb or a 30-day month MUST have 31 days.
# What if the input `month` is 13?
return 31

Refutation via Order of Execution and Invalid Inputs

The primary bug here is that the function doesn't validate its input. The specification implies
month will be from 1-12.

●​ Counterexample: (13, False)


●​ Trace:
1.​ 13 is not in {4, 6, 9, 11}. First if is false.
2.​ 13 is not 2. The elif is false.
3.​ The code falls through to the final else block.
●​ Observed Output: 31.
●​ Expected Output: The function should have indicated an error. A better
implementation would be to raise a ValueError or return None. Returning 31 for a
non-existent month is incorrect behavior.

Even if the comment were correct, the logic would be flawed due to the final else being a
catch-all.
25. Critique our friend's function
Our friend has written a function to determine a person's status based on their age. It
contains a classic bug related to using a series of if statements instead of if/elif/else.

Friend's Buggy Code:

Python

def get_status(age):
# This logic is flawed.
if age > 18:
status = "Adult"
if age < 65:
# This will overwrite the "Adult" status for anyone under 65.
status = "Not Senior"
if age < 18:
status = "Minor"
return status

Critique:

The problem is that for a single input, multiple if conditions can be true, and the last one to
be evaluated will win.

Let's trace it with age = 30:


1.​ 30 > 18 is true. status is set to "Adult".
2.​ Execution continues. 30 < 65 is true. status is overwritten and set to "Not Senior".
3.​ 30 < 18 is false. This block is skipped.
4.​ The function returns "Not Senior".

This is clearly not the intended behavior. The fix is to use an if/elif/else structure, which
guarantees that only one block of code will be executed. It's often best to handle special
cases or the most restrictive conditions first.

Corrected Version:

Python

def get_status_correct(age):
if age < 18:
return "Minor"
elif age < 65:
return "Adult"
else: # age must be >= 65
return "Senior"
26. Boolean/logical operators
Complex boolean expressions can often be simplified to make code more readable and less
error-prone. De Morgan's Laws are a pair of rules that are very useful for simplifying
expressions involving not, and, and or.
●​ not (A or B) is equivalent to (not A) and (not B)
●​ not (A and B) is equivalent to (not A) or (not B)

Example: Simplifying a Confusing Condition

Imagine a condition for sending a promotional email:

Python

# Send email if it's NOT the case that (the user has unsubscribed OR their account is inactive)
if not (user_has_unsubscribed or is_account_inactive):
send_promo_email()

This is logically correct but a bit hard to parse with the nested not. Let's apply De Morgan's
Law (not (A or B) == (not A) and (not B)):
●​ A is user_has_unsubscribed
●​ B is is_account_inactive

The simplified condition becomes:

Python

# Simplified using De Morgan's Law


if (not user_has_unsubscribed) and (not is_account_inactive):
send_promo_email()

This version, which can be read as "if the user is subscribed AND their account is active," is
much more direct and easier to understand.

27. Simplification 3
Understanding the logical effect of a piece of code allows you to rewrite it in a much simpler
form.

Original Complex Code:

This code determines if a person should go outside.


Python

def should_go_outside(is_raining: bool, has_umbrella: bool) -> str:


if is_raining == True:
if has_umbrella == True:
# It's raining, but we have an umbrella
return "Go outside"
else:
# It's raining, and we have no umbrella
return "Stay home"
else:
# It's not raining
return "Go outside"

Understanding the Effect:

Let's analyze the conditions under which the function returns "Go outside":

1.​ When is_raining is False.


2.​ When is_raining is True AND has_umbrella is True.

This can be expressed as a single boolean condition: (not is_raining) or (is_raining and
has_umbrella). This is a bit complex. Let's think about the only condition under which you
"Stay home": when is_raining is True AND has_umbrella is False.

Simplified Code:

We can rewrite the entire function based on this single condition.

Python

def should_go_outside_simple(is_raining: bool, has_umbrella: bool) -> str:


# The only reason to stay home is if it's raining AND you don't have an umbrella.
if is_raining and not has_umbrella:
return "Stay home"
else:
# In all other cases, you can go outside.
return "Go outside"

This version is much shorter, has less nesting, and is easier to understand, but it has the
exact same logical effect as the original.
28. Identifying an unexpected behaviour
Bugs can arise when a general check is not specific enough for the subsequent code.
Tracing the buggy code with PythonTutor is an excellent way to find this kind of issue.

The Buggy Code

This function is supposed to log the first element of a data structure, but only if the data
exists.

Python

def log_first_element(data):
"""
Prints the first element of 'data' if 'data' is not None.
"""
# The check is not specific enough.
if data is not None:
# This line will crash if 'data' is an empty list [].
print(f"First element: {data[0]}")
else:
print("No data provided.")

Tracing with PythonTutor

Let's trace the call log_first_element([]).

1.​ Input: The argument data is an empty list [].


2.​ The if check: The condition data is not None is evaluated. [] is not the same object
as None, so the condition is True.
3.​ Entering the if block: Because the condition was true, the code proceeds to execute
print(f"First element: {data[0]}").
4.​ The Crash: The code tries to access data[0], which is the first element of an empty
list. This is an invalid operation.
5.​ Unexpected Behavior: The program crashes with an IndexError. This is
unexpected because the programmer thought the if data is not None check would
prevent this. The check was correct, but insufficient.
29. Understanding and fixing the error
The error in the previous example comes from a misunderstanding of Python's "Truthy" and
"Falsy" values.

In a boolean context (like an if statement), Python considers certain values to be "false" and
all others to be "true."
●​ Falsy Values:
○​ False
○​ None
○​ Numeric zero of all types (0, 0.0)
○​ Empty sequences and collections ("", [], (), {})
●​ Truthy Values: Everything else. Any non-empty string, non-zero number, or
non-empty list is considered True.

The Root Cause:

The bug in log_first_element occurred because the check if data is not None: is too specific.
An empty list [] is not None, so it passes this check. However, an empty list [] is Falsy.

The Fix:

The real intention of the programmer was not just to check for None, but to check if data was
a non-empty, usable collection. A more idiomatic and correct way to write this check is to rely
on truthiness directly.

Python

def log_first_element_fixed(data):
"""
Prints the first element of 'data' if 'data' exists and is not empty.
"""
# This check works for None, [], "", etc.
if data: # This checks for truthiness
print(f"First element: {data[0]}")
else:
print("No data provided or data is empty.")

# --- Tests ---


log_first_element_fixed([1, 2, 3]) # Prints 1
log_first_element_fixed(None) # Prints "No data..."
log_first_element_fixed([]) # Prints "No data..." (Correctly handled!)

Setting a Breakpoint: In a real debugger, you could set a breakpoint on the if line. The
program would pause there, and you could inspect the value of data to see exactly what it is
([]) and why your condition is behaving unexpectedly.
30. Short-Circuiting
Python's logical operators and and or use a behavior called short-circuiting to be more
efficient. This means they only evaluate the second operand if it's absolutely necessary to
determine the outcome.

Understanding from the Python Documentation:


●​ x or y: If x is truthy, the result is x, and y is not evaluated at all. If x is falsy, the result
is y.
●​ x and y: If x is falsy, the result is x, and y is not evaluated at all. If x is truthy, the
result is y.

Practical Example:

Imagine you want to check if a user is an admin and has access to a log file. Reading the log
file might be a slow operation.

Python

def is_admin(user):
# a quick check
return user.role == "admin"

def has_log_access(user):
# a slow operation, maybe reads a large file
print("...Checking log file access (slow)...")
return True

# --- Using short-circuiting ---


# If the user is not an admin, the second part is never run!
if is_admin(current_user) and has_log_access(current_user):
print("Access granted.")

If is_admin(current_user) returns False, the and expression already knows the final result
must be False, so it short-circuits and never even calls the slow has_log_access function.
This saves time and resources.
31. Inequivalence due to short-circuiting
While A and B is logically the same as B and A, they are not programmatically equivalent
if A or B has a side effect (like printing to the screen, modifying a variable, or calling another
function).

Example:

Let's create a function with a side effect.

Python

def log_and_return_false():
print("Function was called!")
return False

Case 1: True and log_and_return_false()

Python

# The first operand is True, so the second MUST be evaluated.


if True and log_and_return_false():
print("Inside if")
else:
print("Inside else")

Output:

Function was called!


Inside else

Case 2: log_and_return_false() and True

Python

# The first operand is a function call. It must be evaluated.


if log_and_return_false() and True:
print("Inside if")
else:
print("Inside else")

Output:

Function was called!


Inside else
Case 3 (The Important One): False and log_and_return_false()

Python

# The first operand is False. The 'and' expression short-circuits.


if False and log_and_return_false():
print("Inside if")
else:
print("Inside else")

Output:

Inside else

Notice that "Function was called!" was not printed. The log_and_return_false() function was
never executed. This demonstrates that changing the order of operands can change the
behavior of your program if side effects are involved.

32. Summary
1.​ Function Calls Create Frames: Each call gets a new, isolated workspace where
parameters are created and assigned the values of the arguments.
2.​ Refutation Requires Precision: To refute a function, you need three things: a
counterexample input, the observed incorrect output, and the expected output based
on the function's specification (its docstring).
3.​ Trace Code Mentally: The key to finding bugs is to trace the code's execution
step-by-step, paying special attention to boundaries and edge cases not covered in
the doctests.
4.​ Simplify and Refactor: Good code is simple code. Reading different solutions and
applying principles like De Morgan's Laws can help simplify complex conditional
logic.
5.​ Beware Truthiness: Relying on Python's "Truthy" and "Falsy" values can lead to
concise code (if my_list:), but be careful that the check is specific enough for the
code that follows (e.g., an empty list [] is Falsy, but it is not None).
6.​ Leverage Short-Circuiting: The and and or operators use short-circuiting, meaning
they only evaluate the second operand if necessary. This can be used for efficiency
and to prevent errors (e.g., if user is not None and user.is_admin:).
7.​ Order Matters with Side Effects: Because of short-circuiting, the order of operands
in a logical expression can change the program's behavior if the expressions have
side effects (like printing or modifying state).

You might also like