Class 2: Virtual Environments and Object-oriented Programming (OOP)#
Review#
Describe the function of the
break
andcontinue
statements.
Solution
Within the context of a loop, continue
is used to invoke an immediate progression to the next step, and break
is used to terminate the loop before the iterator is exhausted.
Briefly explain type hinting in Python and its significance.
Solution
Type hinting is implemented by adding : <type>
to a variable or function argument’s definition. Any type can be used, and Python’s built-in typing
module enables creating fine-grained type hints.
How would you recommend implementing a simple file I/O operation?
Solution
The recommended way to implement a simple file I/O operation in Python is by combining the open()
function with a context manager clause (with ... as file_object:
). This way we don’t have to concern ourselves with closing the file.
For example:
path = "/path/to/file.txt"
with open(path, "r") as file_object:
content = file_object.read()
...
Virtual Environments#
The basic idea is that every Python project you have should be contained within an isolated environment, which includes the interpreter and any libraries (i.e. external Python packages) that are needed for that specific project.
Why do we need virtual environments?#
Once a project accumulates a number of dependencies (external Python packages used in your code), asserting compatibility between all components become tricky (see “dependency hell”). Python libraries are updated regularly, and each released version may introduce new bug fixes and features, or remove functionality that was flagged as depracated. Code that was written using one version of some external package will not necessarily run successfully using a different version. Virtual environments allow us to create as many isolated environments as we’d like, each containing only the libraries we need, and in the exact versions that guarantee compatibility.
If this concept feels a little vague, don’t be too worried. Once you’ve created a couple of virtual environments you’ll get it in no time.
Creating a Virtual Environment#
There are numerous tools for creating and managing virtual environments with Python. In this course we will use venv
, which is one of Python’s built-in packages and is therefore a simple option that will always be available to you.
Creating a new virtual environment with venv
is as easy as running:
python3 -m venv /path/to/new/virtual/environment/
C:\Python38\python -m venv C:\path\to\new\virtual\environment\
We are calling <python binary> -m
to execute the venv
module as a script and pass it the path in which it should create the new virtual environment.
The Virtual Environment Directory#
Looking at the newly created virtual environment’s directory tree, please note the following files and folders:
venv
├── bin/
│ ├── python
│ ├── pip
│ ├── activate
│ ├── <Other binaries and CLI tools>
├── lib/
│ ├── python3.8/
│ │ ├── site-packages/
│ │ │ ├── <pip-installed packages>
├── <...>
venv/bin/python
: The copy of the Python interpreter used when the virtual environment is activated.venv/bin/pip
: The copy of thepip
CLI used when the virtual environment is activated.venv/bin/activate
: A script we will use to activate the virtual environment (making the prior two files the defaultpython
andpip
executables).lib/python3.8/site-packages/
: This directory will contain all external packages installed withpip install <package-name>
when our virtual environment is activated.
Activating the Virtual Environment#
Activating a virtual environment means modifying your working environment to use a particular, isolated installation of Python (created in Creating a Virtual Environment). This will normally also “override” some other related CLI tools with those installed within the virtual environment’s bin
directory (such as pip
), and sometimes also change some OS-related environment variables.
To activate the virtual environment and use the newly created isolated Python environment, simply run:
source
* Replace <venv>
with your virtual environment’s directory path.
C:>
* Replace <venv>
with your virtual environment’s directory path.
You should now see your virtual environment’s directory name used as a prefix ((venv)
) in your terminal window.
To validate the activation, run:
(venv) $ which python
(venv) C:> where python
The returned path should be the path of the Python interpreter within the virtual environment’s directory.
For the purposes of this course one virtual environment will probably be enough. Always remember to activate it before starting work, and be sure to create a new virtual environment for any new project you begin in the future.
Project Setup Exercise
Create a new
my_project
directory on your computer.Start VSCode and open your new project’s directory.
Initialize Git to add version control to your project.
Create a virtual environment for your project and activate it (this will also let VSCode know what Python executable is used in this project).
Create a
.py
file with a function that simply prints your name.Create another
.py
file thatimport
s your function and runs it.Install the
numpy
package usingpip
and make sure you’re able toimport
it.Add an MIT license to the repo, as well as a basic
README.md
.If the virtual environment’s directory is found under the project’s directory, create a
.gitignore
file and add its relative path to it. This will tell Git to ignore this directory (and not archive everything in it as part of our code repository, which would be an absolute mess).Create your first commit.
Publish this project to your GitHub account.
Object-Oriented Programming: Part 1#
Introduction#
There are three main programming paradigms in use in mainstream programming languages:
Procedural
Functional
Object-oriented
While the functional paradigm is very interesting, we will not be discussing it in this course. You can read about Haskell, OCaml, F# and other functional programming languages wherever you get your information from.
Procedural Programming#
The procedural paradigm is the most widely used paradigm… in the academia. And it’s probably the one you’re most familiar with from your work with MATLAB.
For example, if we wanted to write a naive script that multiplies the elements in two lists, we could write something like:
l1 = [1, 2, 3]
l2 = [4, 5, 6]
result = []
for item1, item2 in zip(l1, l2):
result.append(item1 * item2)
result
[4, 10, 18]
If we later run into more lists we need to multiply, we’ll again write:
l3 = [10, 20, 30]
l4 = [40, 50, 60]
result2 = []
for item3, item4 in zip(l3, l4):
result2.append(item3 * item4)
result2
[400, 1000, 1800]
At this point we’ll recognize a pattern and immediately be rememinded of the DRY (“Don’t Repeat Yourself”) principle, leading us to define a function and replace these two parts:
def list_multiplier(l1, l2):
"""
Multiply two lists element-wise.
Parameters
----------
l1 : list
First list
l2 : list
Second list
Returns
-------
list
Element-wise multiplication result
"""
result = []
for item1, item2 in zip(l1, l2):
result.append(item1 * item2)
return result
This new procedure does one thing, and one thing only. This is what’s so powerful about it.
Procedural programming allows us to group and order our code base into small units, called functions or procedures, that have a specific, defined task.
It usually contains a “wrapper” script that defines the order of running for these functions:
def run_pipeline(foldername):
"""Main data pipeline script."""
data = get_user_input(foldername)
data_without_fieldnames = extract_fieldnames(data)
columnar_data = generate_columns(data_without_fieldnames, num_of_columns)
# ...
# At the end of the file it will contain:
if __name__ == '__main__':
foldername = '/path/to/folder'
result = run_pipeline(foldername)
print(result)
You should decisively eliminate any repeating code. It’s perhaps the most common source for errors in scientific computing, and it may bite you any of these ways:
Encapsulation
# String concatenation first_string = 'abcd' second_string = 'efgh' concat = first_string + second_string[:-1] + 'zzz' # you suddenly remember that you wish to exclude # the last character in "second_string" and add the 'zzz' sequence at the end. # Program continues... # ... third_string = 'poiu' fourth_string = 'qwer' concat2 = third_string + fourth_string + 'zzz' # you wish to achieve the same goal in this # concatenation - but you forgot that you excluded the last character of the second string.
The moment you realized that you have a recurring operation on strings - you have to encapsulate it in a function. Be ruthless!
Parametrization
Instead of writing:
def process_data(data): scaled_data = data * 0.3 # what is 0.3 exactly? Parametrize it.
We might do:
def process_data(data, na_concentration=0.3): """Multiplies data by the Na concentration.""" scaled_data = data * na_concentration
But this is usually not enough. When calling the
process_data
, parameterize thena_concentration
variable as well. This will help you avoid a situation such as:data = b * c - 1 + a process_data(data, 0.4) # Script continues... process_data(data2, 0.5) # Perhaps you really did wish to call "process_data" with two different # parameters, but it's more likely that you decided that 0.5 was too high, so you changed it to 0.4 # in the first call, but forgot that you had a second call. This parameter should appear somewhere at # the top of your script.
Instead, we can specify module-wide constants, e.g.:
NA_CONCENTRATION = 0.4 def process_data(data, na_concentration=NA_CONCENTRATION): """Multiplies data by the Na concentration.""" scaled_data = data * na_concentration data = b * c - 1 + a process_data(data) # If we really do wish to process the data with some other na_concentration: process_data(data2, na_concentration=0.3)
While procedural programming works great for most simple tasks, it might be considered inferior when writing code that is meant to scale and be collaborated on.
Classes and Objects#
Classes are user-defined types. Just like str
, dict
, tuple
and the rest of the standard types, Python allows us to create our own types.
Objects are instances of classes, they’re an instance of a type we made. Actually, all instances of all types are objects in Python. It means that every variable and function in Python are, by themselves, an instance of a type. A function you make is an instance of the function
type, for example. We’ll get to this during later stages of the course.
Classes are a type of abstraction we create with our code. A variable is the most simple type of abstraction - it’s a thing that is closely tied to a “real value” in a very simple relationship: My variable \(x\) represents the value \(y\).
Classes are more abstract - they don’t relate to a specific value directly, but rather they try to convey an idea of an object.
Example I: The Point
Class#
To show what we mean by “our own type”, we’ll define the Point
type.
So, what is a point?
In a 2D space it’s a pair of values, \((x, y)\), specifying a location on a grid.
\(x\) and \(y\) are the coordinates of the point.
Points have special relations to other points and to the space they reside in.
From these three simple observations, we expect our Point
type to include both data about its coordinates, and functions, or methods, used to interact with the grid and\or other points.
An object usually bundles together data (attributes) and methods we wish to express as some abstract template in our code. It might seem like a lot to write at first, but it pays off tremendously in no time.
# Introducing the class keyword:
class Point:
"""Represents a point in a 2D space."""
pass
Point
# A new type is born in __main__
__main__.Point
The name Point
is now a factory to create new Point
instances. To make one, we have to call it like we do with a function:
blank = Point()
blank
<__main__.Point at 0x7efc481bcc10>
We call this instantiation (and blank
is now an instance of Point
).
# Assign the point's data in the form of coordinates
blank.x = 1.0
blank.y = 0.0
# x and y are now attributes of our class:
blank.x
1.0
The .
means x
is an attribute or method (callable) of blank
(and of course there’s no conflict between a variable named x
and blank.x
).
print(1 + blank.x)
2.0
f"A case of a pointy Point at {(blank.x, blank.y)}"
'A case of a pointy Point at (1.0, 0.0)'
def print_point(p: Point) -> str:
"""Print a Point object.
Parameters
----------
p : Point
The point instance to print
Returns
-------
str
Point coordinates
"""
print(f"{p.x, p.y}")
print_point(blank)
(1.0, 0.0)
Exercise: calculate_distance()
Write the calculate_distance()
function that takes two points (p1
and p2
) and returns the Cartesian distance between them.
Solution
import math
def calculate_distance(p1: Point, p2: Point) -> float:
"""
Returns the Cartesian distance between two points.
Parameters
----------
p1 : Point
One point
p2 : Point
Second point
Returns
-------
float
Cartesian distance
"""
dx = p1.x - p2.x
dy = p1.y - p2.y
return math.sqrt(dx ** 2 + dy ** 2)
Example II: Rectangles#
Take a minute to think how you would implement a Rectangle
class.
Here are a couple of options:
We can decide to define it with a point (corner or center) and two sides.
We can also use two opposing points.
We’ll go with the first option, with the point being the corner.
class Rectangle:
"""
Rectangle model.
Attributes
----------
corner : Point
Bottom left corner
height : float
Length of vertical side
width : float
Length of horizontal size
"""
pass
rect = Rectangle()
rect.width = 100.0
rect.height = 200.0
corner = Point()
corner.x = 0.0
corner.y = 0.0
rect.corner = corner
rect
<__main__.Rectangle at 0x7efc481bd270>
We can return instances of classes (just like we do with instances of dictionaries):
def find_center(rect: Rectangle) -> Point:
"""
Return a Point instance with coordinates pointing to the center of the Rectangle.
Parameters
----------
rect : Rectangle
Rectangle instance to calculate the center of
Returns
-------
Point
Rectangle center
"""
p = Point()
p.x = rect.corner.x + rect.width / 2
p.y = rect.corner.y + rect.height / 2
return p
center = find_center(rect)
print_point(center)
(50.0, 100.0)
Also, objects are mutable:
def grow_rectangle(rect: Rectangle, dwidth: float, dheight: float) -> None:
"""
Take a Rectangle instance and grow it by (*dwidth*, *dheight*).
Parameters
----------
rect : Rectangle
Rectangle instance to grow
dwidth : float
Width delta
dheight : float
Height delta
"""
rect.width += dwidth
rect.height += dheight
print(rect.width, rect.height)
100.0 200.0
grow_rectangle(rect, 100, 100)
print(rect.width, rect.height)
200.0 300.0
Methods#
We really haven’t done object-oriented programming yet. Our objects currently contain only data (as attributes), and we wrote independent functions to manipulate them as required. Methods are functions bound to objects, describing actions they can do, or that can be done to them.
For example, a real-world car can drive. So a Car
object should have a drive()
method. It should also have a park()
method, and a couple of attributes, like number_of_wheels
, manufacturer
and model
.
As we’ll see in a second, the only difference between methods and functions is that methods are a part of an object, and they only make sense the context of that object or an instance of it. A park()
method has no meaning when we try to run it on a Rectangle
.
We’ve already met many methods and used them successfully. For example, we used the append()
method of a list instance. In this case it’s clear why a method is always bound to a specific class - it’s irrelevant to “append” an item to an object which is not a list.
Let’s add a method to our Point object:
class Point:
"""A 2D point."""
def transpose(self): # The first argument to a method is always the calling instance itself (self)
"""Trasnposes by flipping x and y"""
self.x, self.y = self.y, self.x
p = Point()
p.x, p.y = 10, 20
print(f"transpose is now of type: {type(p.transpose)}")
transpose is now of type: <class 'method'>
print(f"Before:\tp.x: {p.x}, p.y: {p.y}")
p.transpose()
print(f"After:\tp.x: {p.x}, p.y: {p.y}")
Before: p.x: 10, p.y: 20
After: p.x: 20, p.y: 10
The conceptual change here is the following: The active agents here are the objects, not the functions. Instead of transpose(point)
we have the point transpose itself with p.transpose()
.
In general, most functions that take an instance of some object as one of their parameters should be a candidate for becoming a method, bound to that object, since you might need it later on for other instances as well.
Note
Even though methods have self
as their first argument, when we call them we don’t need to pass that first parameter. self
acts as a reference to the instance, or object, that we’re currently handling. This is what makes methods “special” - they work with the data “inside” the object they’re a part of, and can modify this data if needed. All methods must be defined with the self
parameter as their first parameter (self
isn’t actually a special keyword, rather it’s just the convention for the first argument in the method definition).
# This doesn't work, look at the number of arguments:
p.transpose(p)
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[18], line 2
1 # This doesn't work, look at the number of arguments:
----> 2 p.transpose(p)
TypeError: Point.transpose() takes 1 positional argument but 2 were given
Two arguments were given? We gave only one. The second was the self
argument that is implicitly passed.
One thing is still missing though, each time we create the point we have a three step process:
Create the instance:
p = Point()
Add the
x
attribute:p.x = 2
Add the
y
attribute:p.y = 3
First, it would be nice if we could make this process shorter. Second, the Point instance is really unusable unless it has both attributes (x, y)
set, so we want to make sure that we don’t have a Point without both x
and y
. This is accomplished by the __init__
method.
The __init__
Method#
Classes have several special methods attached to them. While most are out of the scope of this course, the __init__()
method is regularly used and we should definitely familiarize ourselves with it.
The __init__()
methods allows us to define our class attributes inside the class definition:
class Point:
"""A 2D point."""
def __init__(self, x: float, y: float) -> None:
"""
Initialize a new Point instance.
Parameters
----------
x : float
X-axis coordinate
y : float
Y-axis coordinate
"""
self.x = x
self.y = y
def transpose(self) -> None:
"""
Trasnposes by flipping *x* and *y*.
"""
self.x, self.y = self.y, self.x
Now, in order to create a Point
instance, we have to pass in the two arguments that the __init__()
method requires:
p = Point(10, 20)
print(f"p.x: {p.x}, p.y: {p.y}")
p.transpose()
print(f"p.x: {p.x}, p.y: {p.y}")
p.x: 10, p.y: 20
p.x: 20, p.y: 10
p2 = Point()
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[21], line 1
----> 1 p2 = Point()
TypeError: Point.__init__() missing 2 required positional arguments: 'x' and 'y'
As we said, this is better because we enforce our Point
user to initialize all attributes, which eases the use of the other methods the Point
has. Most chances are that the first method you’ll write for a newly defined class is the __init__()
method.
Let’s look at a broader example using the Rectange we defined earlier and the two other functions we also had.
class Rectangle:
"""
Representation of a rectangle in Cartesian space based on the
bottom left corner and the sizes of its sides.
"""
def __init__(self, corner: Point, height: float = 10, width: float = 10):
"""
Initialize a Rectangle instance.
Parameters
----------
corner : Point
Bottom left corner
height : float
Length of vertical side
width : float
Length of horizontal side
"""
self.corner = corner
self.height = height
self.width = width
def find_center(self) -> Point:
"""
Return a Point to the center of the Rectangle box.
Returns
-------
Point
This rectangle instance's center
"""
x = self.corner.x + self.width / 2
y = self.corner.y + self.height / 2
return Point(x, y)
def grow(self, dwidth: float, dheight: float) -> None:
"""
Change this instance's size by (*dwidth*, *dheight*).
Parameters
----------
dwidth, dheight : float
Change the first and second axes by +dwidth\dheight
"""
self.width += dwidth
self.height += dheight
def move_to_origin(self) -> None:
"""Moves the center of the rectangle to (0, 0)."""
center = self.find_center()
centered = center.x == 0 and center.y == 0
if not centered:
self.corner = Point(-self.width / 2, -self.height / 2)
Note
Class names are written in CamelCase.
The docstring of the entire class describes its general purpose.
The
__init__
method takes in three arguments, but two of them are optional.We added the two functions we defined earlier to the class as methods, since they only operate on rectangles in the first place.
rect = Rectangle(p)
print(f"rect.width: {rect.width}, rect.height: {rect.height}")
rect.width: 10, rect.height: 10
If we now wish to create a new Rectangle
instance and find its center, we can:
corner = Point(10, 10)
rect = Rectangle(corner, 4, 4)
center = rect.find_center()
print(f"The center of the rectange is {(center.x, center.y)}")
The center of the rectange is (12.0, 12.0)
Move it to origin:
rect.move_to_origin()
new_center = rect.find_center()
print(f"The center of the moved rectange is {(new_center.x, new_center.y)}")
The center of the moved rectange is (0.0, 0.0)
Note how the object modifies itself and acts upon itself using its methods. We’re not modifying the internal parts of the instance ourselves, we let the methods do it for us.
The __str__
Method#
Another interesting dunder (“double underscore”) method is the __str__()
method, which defines what an instance of the class will show when invoked with the print(class_instance)
command. For example:
class ShoppingList:
def __init__(self, vegetables=10, fruit=5, bread=1):
self.vegetables = vegetables
self.fruit = fruit
self.bread = bread
def __str__(self):
n_items = self.vegetables + self.fruit + self.bread
return f"""
Shopping List:
Vegetabels: {self.vegetables}
Fruits: {self.fruit}
Bread: {self.bread}
Total items: {n_items}
"""
shopping_list = ShoppingList()
print(shopping_list)
Shopping List:
Vegetabels: 10
Fruits: 5
Bread: 1
Total items: 16
Note
We can change the order of parameters when using keyword arguments, e.g.:
shopping_list_2 = ShoppingList(fruit=5, bread=1, vegetables=3)
The str Dunder Method
Implement a __str__()
method for the Point
class from earlier.
Solution
class Point:
"""A 2D point."""
def __init__(self, x: float, y: float) -> None:
"""
Initialize a new Point instance.
Parameters
----------
x : float
X-axis coordinate
y : float
Y-axis coordinate
"""
self.x = x
self.y = y
def __str__(self) -> str:
"""
Returns the string representation of this instance.
Returns
-------
str
String represetation
"""
return f"({self.x}, {self.y})"
def transpose(self) -> None:
"""
Trasnposes by flipping *x* and *y*.
"""
self.x, self.y = self.y, self.x
Operator Overloading#
One of the most interesting properties of Python (although it’s not unique to it) is operator overloading. It means that we can force our self-declared types (i.e. classes) to behave in a certain way with the standard mathematical operations.
We’ll use the ShoppingList class as an example. Say we want to add two different shopping lists. Naively, we might just try the following:
shopping_list_a = ShoppingList()
shopping_list_b = ShoppingList()
print(shopping_list_a + shopping_list_b)
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[28], line 3
1 shopping_list_a = ShoppingList()
2 shopping_list_b = ShoppingList()
----> 3 print(shopping_list_a + shopping_list_b)
TypeError: unsupported operand type(s) for +: 'ShoppingList' and 'ShoppingList'
To us, this expression seems completely fine - adding two shopping lists should just concatenate the items one after the other. The fact that it’s a very readable line of code makes it a good line of code, since you have to remember that we write code for humans to read, not computers.
Unfortunately, Python can’t add two shopping lists because it was never taught how to do that. Luckily, we can override the behavior of the addition operator, by defining the __add__()
method in the class definition:
class ShoppingList:
"""Represents a shopping list."""
def __init__(self, vegetables: int = 10, fruit: int = 5, bread: int = 1):
"""
Initialize a ShoppingList instance.
Parameters
----------
vegetables : int
Number of vegetable items
fruit : int
Number of fruit items
bread : int
Number of bread items
"""
self.vegetables = vegetables
self.fruit = fruit
self.bread = bread
def __str__(self) -> str:
"""
Return a string representation of this instance.
Returns
-------
str
String representation
"""
n_items = self.vegetables + self.fruit + self.bread
return f"""
Shopping List:
Vegetabels: {self.vegetables}
Fruits: {self.fruit}
Bread: {self.bread}
Total items: {n_items}
"""
# ----- New method below: ------
def __add__(self, other: ShoppingList) -> ShoppingList:
"""
Combines two shopping lists and returns the result.
Notes
-----
This method returns a new shopping list, meaning it doesn't modify
any of the existing instances it was given.
Parameters
----------
other : ShoppingList
Another shopping list
Returns
-------
ShoppingList
Combined shopping list
"""
return ShoppingList(
vegetables=self.vegetables + other.vegetables,
fruit=self.fruit + other.fruit,
bread=self.bread + other.bread,
)
Now we can safely add two ShoppingList
instances together:
shopping_list_a = ShoppingList()
shopping_list_b = ShoppingList()
shopping_list_c = shopping_list_a + shopping_list_b
print(shopping_list_c)
Shopping List:
Vegetabels: 20
Fruits: 10
Bread: 2
Total items: 32
Addition of something other than a ShoppingList
instance will result in an AttributeError
.
shopping_list_a + 1
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Cell In[31], line 1
----> 1 shopping_list_a + 1
Cell In[29], line 60, in ShoppingList.__add__(self, other)
40 def __add__(self, other: ShoppingList) -> ShoppingList:
41 """
42 Combines two shopping lists and returns the result.
43
(...)
57 Combined shopping list
58 """
59 return ShoppingList(
---> 60 vegetables=self.vegetables + other.vegetables,
61 fruit=self.fruit + other.fruit,
62 bread=self.bread + other.bread,
63 )
AttributeError: 'int' object has no attribute 'vegetables'
Exercise: Dunder Methods
Modify the __add__()
method so that if other is an integer, it will simply add that number to all items in the shopping list.
Solution
from typing import Union
class ShoppingList:
"""Represents a shopping list."""
...
def __add__(self, other: Union[ShoppingList, int]) -> ShoppingList:
"""
Combines two shopping lists or adds *other* to all item categories
(if an *int* is provided).
Notes
-----
This method returns a new shopping list, meaning it doesn't modify
any of the existing instances it was given.
Parameters
----------
other : Union[ShoppingList, int]
Another shopping list or number to increase all categories by
Returns
-------
ShoppingList
New shopping list
"""
if isinstance(other, ShoppingList):
return ShoppingList(
vegetables=self.vegetables + other.vegetables,
fruit=self.fruit + other.fruit,
bread=self.bread + other.bread
)
elif isinstance(other, int):
return ShoppingList(
vegetables=self.vegetables + other,
fruit=self.fruit + other,
bread=self.bread + other
)
else:
raise TypeError(
f"other must by either a ShoppingList instance or an integer, got: {type(other)}"
)
Note
To learn more about other Python operators, see this overview.
Summary#
OOP is the most important programming paradigm for you to master on your Python journey. Some problems fit this paradigm hand in glove, however, it’s not the “ultimate” answer to any design difficulty you have. Some problems can be solved by using intricate objects and multiple inheritance, but in reality they’re much simpler when solved using a procedural design. Remember to write code that humans, and especially your future self, can read and understand.
With that being said, throughout this course I prefer you write too many objects over writing too few. Whether you’ll be writing new objects every day or not, you will certainly be using them any time you write Python code, and creating classes will nurture your confidence when doing so.
Exercise: The Vector Class
Create a
Vector
class that simulates a 1D vector array. Assume the inputs to the class are valid. TheVector
instance should be initialized with at least two attributes.The
Vector
class should enable adding either an integer or a different vector to a vector.The
Vector
class should enable checking which of two vectors is bigger, element-wise. The output is another vector with the correspondingTrue
andFalse
values.
Solution
from typing import Iterable
class Vector:
"""
Models a 1D vector array, assuming the inputs are valid.
Notes
-------
Integers and other vectors can be added. Element-wise comparison with other vectors
is also supported.
"""
def __init__(self, data: Iterable):
"""
Initialize a new `Vector` instance.
Attributes
----------
data : Iterable
The actual data in a list
"""
self.data = list(data)
self.length = len(self.data)
def __len__(self) -> int:
"""
Allows us to call len() on the vector.
Returns
-------
int
Vector size
"""
return self.length
def __str__(self) -> str:
"""
Returns a string representation of the instance.
Returns
-------
str
String representation
"""
return f"{self.data} with {self.length} elements"
def __add__(self, other: Union[Vector, int]) -> Vector:
"""
Left-add a Vector to an integer or another vector.
Parameters
----------
other : Union[Vector, int]
New `Vector` instance if added with a `Vector`, this instance
otherwise
Returns
-------
Vector
New vector or `self`
Raises
------
AssertionError
If length of the supplied vectors doesn't match
TypeError
If other isn't a vector or an integer
"""
# Handle modifying this instance when provided an integer.
if isinstance(other, int):
self.data = [datum + other for datum in self.data]
return self
# Handle creating a new `Vector` instance when provided another vector.
elif isinstance(other, Vector):
assert len(self) == len(other)
new_vec = [
elem0 + elem1 for elem0, elem1 in zip(self.data, other.data)
]
return Vector(new_vec)
# Otherwise, raise exception.
else:
raise TypeError(
f"Other object was {type(other)}, expected int or Vector."
)
def __gt__(self, other: Vector) -> bool:
"""
Enables using `>` between two vectors.
Parameters
----------
other : Vector
Vector to compare size with
Returns
-------
bool
Left is greater than right or not
Raises
------
AssertionError
If other has a different length
TypeError
If other isn't a vector
"""
if not isinstance(other, Vector):
raise TypeError(
f"Other object was {type(other)}, expected Vector."
)
assert len(self) == len(other)
ans = [elem0 > elem1 for elem0, elem1 in zip(self.data, other.data)]
return Vector(ans)
# The list concatenation does the same thing as:
# ans = []
# for elem0, elem1 in zip(self.data, other.data):
# ans.append(elem0 > elem1)
# but better :)