Using Objects
Poll: What gets printed?
class Cat:
def __init__(self, name: str, human: str):
self.name = name
self.human = human
print(name * 3)
mini: Cat = Cat('Mini', 'Rasika')
Mini
<__main__.Cat object at 0x10d380200>
MiniMiniMini
- (Nothing)
State and aliasing
When a variable holds an object, it actually holds a reference to that object. That object is stored somewhere in the memory of the computer, and the variable knows where to access it.
Multiple variables can hold references to the same object.
Alias vs. copy
Given an object, an alias is simply another reference to the same object (same place in the computer's memory). A copy is another object (different place in the computer's memory) that has the same elements and looks exactly the same.
original: List[int] = [1, 2, 3]
alias: List[int] = original
copy: List[int] = original.copy()
original[2] = 90
print(alias) # [1, 2, 90]
print(copy) # [1, 2, 3]
Passing mutable objects as arguments
If we pass an object as an argument to a function, an alias is created, not a copy.
def sum_with_bad_manners(in_list: List[int]) -> int:
sum: int = 0
while (len(in_list) > 0):
sum += in_list.pop()
return sum
my_list: List[int] = [1, 2, 3, 4]
print(f'Sum: {sum_with_bad_manners(my_list)}') # Sum: 10
print(my_list) # []
Good manners: Functions should leave their args unchanged (unless they clearly state otherwise in the documentation).
Here's an example using a class that we define ourselves:
class Shirt:
def __init__(self, size: int):
self.size = size
def main() -> None:
six_hundred: int = 600
subtract_one(six_hundred)
print(six_hundred) # 600
shirt: Shirt = Shirt(600)
shrink_shirt(shirt)
print(shirt.size) # 599
def subtract_one(num: int) -> None:
num -= 1
def shrink_shirt(shirt: Shirt) -> None:
shirt.size -= 1
Poll: Which of these makes it print 4?
def main() -> None:
shirt: Shirt = Shirt(600)
modify_shirt(shirt)
print(shirt.size)
if __name__ == '__main__':
main()
def modify_shirt(shirt: Shirt) -> None:
shirt.size = 4
def modify_shirt(shirt: Shirt) -> None:
shirt = Shirt(4)
def modify_shirt(shirt: Shirt) -> Shirt:
return Shirt(4)
The __str__()
function
Every class has a __str__()
method. We can overwrite it with our own __str__()
method.
When we print an object, it implicitly calls the __str__()
method.
The default __str__()
method is not helpful.
See what happens when we print a Cat.
mini = Cat('Mini', 'Rasika')
print(mini) # <__main__.Cat object at 0x1095ca790
Why is it different when we print a list (which is also an object)?
We usually write our own __str__()
method that returns a more helpful str
for that class.
class Cat:
...
def __str__(self):
return f'{self.name} meows to {self.human}'
mini = Cat('Mini', 'Rasika')
print(mini) # Mini meows to Rasika
Poll: What is printed?
class Cat:
def __init__(self, name: str, human: str):
self.name = name
self.human = human
def __str__(self) -> str:
print('MUAHAHAHAHHA')
return self.name
mini: Cat = Cat('Mini', 'Rasika')
print(mini)
Mini
MUAHAHAHAHHA
//Mini
<__main__.Cat object at 0x10d380200>
MUAHAHAHAHHA
//<__main__.Cat object at 0x10d380200>
Abstraction
We like to organize our lines of code into functions, and our functions into classes. This prodecure of grouping more granular things into less granular groups is called "abstraction." We saw the benefits of abstraction when we learned how functions allow us to reuse code without redundancy, hide implementation details, and break down a problem into smaller, more manageable pieces. The same applies to the idea of putting methods into classes.
For example, this code is hard to debug or modify:
length: int = 5
width: int = 3
print(get_area_of_rectangle(length, width))
length = 6
width = 4
print(get_perimeter_of_rectangle(length, width))
If we put all relevant perimeter and area methods into a class for each shape, we can instead do this:
table: Rectangle = Rectangle(5, 3)
print(table.area())
print(Rectangle(6, 4).perimeter())
chair: Square = Square(4)
print(chair.area())
Benefits of abstraction:
- We can use the same code multiple times without re-writing it
- It's easier to read
- It's easier to maintain and adapt the code later on
- It's easier to test behaviors in isolation
- Each variable, function, and class has a single, clear responsibility
The Single Responsbility Principle
A core principle for writing code is that every component of our code must have a single purpose. This makes our code easier to read, test, and maintain.
Component | Does not Follow Single Responsibility Principle |
---|---|
Variable | age_or_nonexistent: int = -1 # negative if nonexistent |
Function | def read_file_compute_average_print_score(filename: str) -> None: |
Class | class FileManagerAndOutputFormatter |
The Program Design and Implementation Process
This diagram depicts the systematic software Program Design and Implementation process. We will revisit it multiple times this semester, and dive deeper into it in CS 3100. There are five phases:
- Requirements
- Design
- Implementation
- Validation
- Operations These five phases are generally done in that order, though it is possible to revisit an earlier phase. Each phase also has an optional pathway to "Abandon Project" for various reasons.
Open-ended poll: What would happen if we directly jumped to the Implementation phase, skipping the Requirements and Design phases? I.e., what would happen if we started by writing code without first designing the project and planning out its pieces?