Abstract¶
Just as closures encapsulate the local environment in functional programming, object-oriented programming (OOP) encapsulates properties and methods within objects, mirroring entities in the physical world. The concepts of inheritance and polymorphism are introduced by demonstrating how to use the methods of file and string objects, and how operators can be overloaded by single dispatch on the types of the operands.
from __init__ import install_dependencies
await install_dependencies()
OOP¶
Object-oriented programming (OOP) is a programming paradigm based on the concept of objects, which has its associated properties and methods of interacting with the world.
Motivation¶
Why OOP? Let’s write the Hello-World program with OOP:
%%manim -ql --progress_bar=none --disable_caching --flush_cache -v ERROR HelloWorld
class HelloWorld(Scene):
def construct(self):
self.play(Write(Text("Hello, World!")))
The above code creates a video by simply defining
- a
Scene
calledHelloWorld
construct
ed byplay
ing an animation thatWrite
s theText
message'Hello, World!'
.
Complicated animations can be created without too many lines of code because
- OOP makes programming expressive by
- encapsulating different variables/functions by the objects they apply to and
- abstracting away the implementation details.
Manim
%%manim -ql --progress_bar=none --disable_caching --flush_cache -v ERROR InscribedCircle
class InscribedCircle(Scene):
def construct(self):
### BEGIN SOLUTION
self.play(FadeIn(Square()))
self.play(GrowFromCenter(Circle()))
### END SOLUTION
Classes and Objects¶
Python is a class-based object-oriented programming (OOP) language:
- Each object is an instance of a class/type, which can be a subclass of one or more base classes.
- An object is a collection of members/attributes, each of which is an object.
What is an object?
object
is a class/type like int
, float
, str
, and bool
.
isinstance(object, type)
Recall that a function is also a first-class object, or more precisely, an instance of type object
:
isinstance(print, object) and isinstance(range, object)
Almost everything in Python is an object:
(
isinstance(1, object)
and isinstance(1.0, object)
and isinstance("1", object)
and isinstance(True, object)
and isinstance(None, object)
and isinstance(__builtin__, object)
and isinstance(object, object)
)
While an object is a type, is a type an object? Check using isinstance
in the cell below.
### BEGIN SOLUTION
isinstance(type, object)
### END SOLUTION
%%ai chatgpt -f text
Explain in a paragraph the chicken-and-egg relationship between type and object.
Shouldn't we define the class first before its instance?
An object can be an instance of more than one types. For instance, True
is an instance of bool
, int
, and object
:
isinstance(True, bool) and isinstance(True, int) and isinstance(True, object)
issubclass(bool, int) and issubclass(int, object)
In paricular, True
and False
have the integer values 1
and 0
respectively.[2]
True == 1, False == 0
Check whether type
is a subclass of object
and vice versa using issubclass
.
(Is there a chicken-and-egg problem?)
### BEGIN SOLUTION
issubclass(object, type), issubclass(type, object)
### END SOLUTION
%%ai chatgpt -f text
Explain in one paragraph why making `type` a subclass of `object` in python
can lead to conflicts in inheritance.
Attributes¶
The structure and behavior of an object is governed by its attributes.
To check if an object has a particular attribute:
complex("1+j")
hasattr(complex("1+j"), "imag"), hasattr("1+j", "imag")
To list all attributes of an object:
print(dir(complex("1+j")))
Different objects of a class share the class attributes:[3]
dir(complex("1+j")) == dir(complex(1)) == dir(complex)
%%ai chatgpt -f text
Explain in a paragraph or two how an instance may have a different set of
attributes than its class in python.
Different objects of the same class can still behave differently because their attribute values can be different.
complex("1+j").imag, complex(1).imag, complex.imag
An attribute can also be a function, which is called a method or member function.
complex.conjugate(complex(1, 2)), type(complex.conjugate)
A method can be accessed by objects of the class:
complex(1, 2).conjugate(), type(complex(1, 2).conjugate)
callable(complex(1, 2).conjugate), complex(1, 2).conjugate.__self__
Why can we call conjugate()
without an argument?
conjugate()
without an argument?complex(1,2).conjugate
is a callable object:
- Its attribute
__self__
is assigned tocomplex(1,2)
. - When called, it passes
__self__
as the first argument tocomplex.conjugate
.
A subclass also inherits the attributes of its base classes:
dir(bool) == dir(int)
For instance, bool
inherit the methods for arithmetics from the base class int
:
True + 1, False * float('inf')
A subclass do not necessarily have the same set of attributes as that defined by its base class. A subclass can inherit attributes from multiple base classes and define its own attributes. Different implementations of the same method are resolved using the method resolution order (mro
):
bool.mro()
%%ai chatgpt -f text
Explain in a paragraph or two how a subclass inherits from multiple base classes
in python using the C3 linearization.
Object Aliasing¶
Now is the time to differentiate values from objects.
When are two objects identical?
The keyword is
checks whether two objects are the same object:
%%optlite -l -h 400
def i(i):
return i
i(i) is i
Is is
the same as ==
?
is
is slightly faster because:
is
simply checks whether two objects occupy the same memory, but==
calls the method (__eq__
) of the operands to check the equality in value.
To see this, we can use the function id
which returns an id number for an object based on its memory location.
%%optlite -h 400
x = y = complex(1, 0)
z = complex(1, 0)
print(x == y == z == 1.0)
x_id = id(x)
y_id = id(y)
z_id = id(z)
print(x is y) # id(x) == id(y)
print(x is not z) # id(x) != id(z)
As the box-pointer diagram shows:
x
isy
because the assignmentx = y
bindsy
to the same memory locationx
points to.y
is said to be an alias (another name) ofx
.x
is notz
because they point to objects at different memory locations,
even though the objects have the same type and value.
Can we use is
instead of ==
to compare integers/strings?
%%optlite -h 350
print(10**10 is 10**10)
print(10**100 is 10**100)
%%optlite -h 350
x = y = "abc"
print(x is y)
print(y is "abc")
print(x + y is x + "abc")
Indeed, we normally gets a SyntaxWarning
when using is
with a literal.
10 is 10, "abc" is "abc"
When using is
with a literal, the behavior is not entirely predictable because
- python tries to avoid storing the same value at different locations by interning but
- interning is not always possible/practical, especially when the same value is obtained in different ways.
Hence, is
should only be used for built-in constants such as None
because there can only be one instance of each of them.
Using Methods¶
Like a Swiss army knife, an object is a versatile tool that can perform a variety of tasks. By invoking its methods, you can manipulate the object’s data, perform computations, or interact with other objects.
File Objects¶
How to read a text file?
Consider reading a csv (comma separated value) file:
!more 'contact.csv'
To read the file by a Python program:
f = open("contact.csv") # create a file object for reading
print(f.read()) # return the entire content
f.close() # close the file
Why close a file?
If not, depending on the operating system,
- other programs may not be able to access the file, and
- changes may not be written to the file.
To ensure a file is closed properly, we can use the with
statement:
with open("contact.csv") as f:
print(f.read())
The with
statement applies to any context manager that provides the methods
__enter__
for initialization, and__exit__
for finalization.
with open("contact.csv") as f:
print(f, hasattr(f, "__enter__"), hasattr(f, "__exit__"), sep="\n")
f.__enter__
is called after the file object is successfully created and assigned tof
, andf.__exit__
is called at the end, which closes the file.f.closed
indicates whether the file is closed.
f.closed
We can iterate a file object in a for loop,
which implicitly call the method __iter__
to read a file line by line.
with open("contact.csv") as f:
for line in f:
print(line, end="")
hasattr(f, "__iter__")
Print only the first 5 lines of the file contact.csv
.
with open("contact.csv") as f:
### BEGIN SOLUTION
for i, line in enumerate(f):
print(line, end="")
if i >= 5:
break
### END SOLUTION
How to write to a text file?
Consider backing up contact.csv
to a new file:
destination = "private/new_contact.csv"
The directory has to be created first if it does not exist:
import os
os.makedirs(os.path.dirname(destination), exist_ok=True)
os.makedirs?
!ls
To write to the destination file:
with open("contact.csv") as source_file:
with open(destination, "w") as destination_file:
destination_file.write(source_file.read())
destination_file.write?
!more {destination}
- The argument
'w'
foropen
sets the file object to write mode. - The method
write
writes the input strings to the file.
We can also use a
mode to append new content to a file.
Complete the following code to append new_data
to the file destination
.
new_data = "Effie, Douglas,galnec@naowdu.tc, (888) 311-9512"
with open(destination, "a") as f:
### BEGIN SOLUTION
f.write("\n")
f.write(new_data)
### END SOLUTION
!more {destination}
How to delete a file?
Note that the file object does not provide any method to delete the file.
Instead, we should use the function remove
of the os
module.
if os.path.exists(destination):
os.remove(destination)
!ls {destination}
String Objects¶
How to search for a substring in a string?
A string object has the method find
to search for a substring.
E.g., to find the contact information of Tai Ming:
str.find?
with open("contact.csv") as f:
for line in f:
if line.find("Tai Ming") != -1:
record = line
print(record)
break
How to split and join strings?
A string can be split according to a delimiter using the split
method.
record.split(",")
The list of substrings can be joined back together using the join
methods.
print("\n".join(record.split(",")))
Print only the phone number (last item) in record
. Use the method rstrip
or strip
to remove unnecessary white spaces at the end.
str.rstrip?
### BEGIN SOLUTION
print(record.split(",")[-1].rstrip())
### END SOLUTION
Print only the name (first item) in record
but with
- surname printed first with all letters in upper case
- followed by a comma, a space, and
- the first name as it is in
record
.
E.g., Tai Ming Chan
should be printed as CHAN, Tai Ming
.
Hint: Use the methods upper
and rsplit
(with the parameter maxsplit=1
).
str.rsplit?
### BEGIN SOLUTION
first, last = record.split(",")[0].rsplit(" ", maxsplit=1)
print("{}, {}".format(last.upper(), first))
### END SOLUTION
Operator Overloading¶
Recall that adding str
to int
raises a type error. The following code circumvented this by creating a subclass of str
that overrides the its methods __add__
and __radd__
for addition:
%%optlite -l -h 400
class MyStr(str):
def __add__(self, a):
return MyStr(str.__add__(self, str(a)))
def __radd__(self, a):
return MyStr(str.__add__(str(a), self))
print(MyStr(1) + 2, 2 + MyStr(1))
How does the above code re-implements +
?
Recall that the addition operation +
behaves differently for different types.
%%optlite -h 300
for x, y in (1, 1), ("1", "1"), (1, "1"):
print(f"{x!r:^5} + {y!r:^5} = {x+y!r}")
- Having an operator perform differently based on its argument types is called operator overloading.
+
is called a generic operator.
Dispatch on type¶
The strategy of checking the type for the appropriate implementation is called dispatching on type.
A naive idea is to put all the different implementations together:
def add_case_by_case(x, y):
if isinstance(x, int) and isinstance(y, int):
# integer summation
...
elif isinstance(x, str) and isinstance(y, str):
# string concatenation...
...
else:
# Return a TypeError
...
%%optlite -h 500
def add_case_by_case(x, y):
if isinstance(x, int) and isinstance(y, int):
print("Do integer summation...")
elif isinstance(x, str) and isinstance(y, str):
print("Do string concatenation...")
else:
print("Return a TypeError...")
return x + y # replaced by internal implementations
for x, y in (1, 1), ("1", "1"), (1, "1"):
print(f"{x!r:^10} + {y!r:^10} = {add_case_by_case(x,y)!r}")
One issue is that supporting a new data type would requires the addition to be re-implemented.
from fractions import Fraction # non-built-in type for fractions
for x, y in ((Fraction(1, 2), 1), (1, Fraction(1, 2))):
print(f"{x} + {y} = {x+y}")
Another issue is that developers need to fight for a good spot in the conditional statement to implement additions for the new data type.
These issues can be alleviated in some programming languages such as C and Java that supports function overloading.
%%ai chatgpt -f text
Explain in a paragraph what function overloading is, and how it can avoid
writing conditional statement to implement a function for different types.
However, Python does not support function overloading by default, and even if it did, it would still be quite tedious to implement additions for all possible types and combinations:
for x, y in ((1, 1.1), (1, complex(1, 2)), ((1, 2), (1, 2))):
print(f"{x!r:^10} + {y!r:^10} = {x+y!r}")
Weaknesses of the naive approach:
- New data types require rewriting the addition operation.
- A programmer may not know all other types and combinations to rewrite the code properly.
Data-directed programming¶
The solution idea is to treat an implementation as a datum that can be returned by the operand types.
for x, y in (Fraction(1, 2), 1), (1, Fraction(1, 2)):
print(f"{x} + {y} = {type(x).__add__(x,y)}") # instead of x + y
- The first case calls
Fraction.__add__
, which provides a way to addint
toFraction
. - The second case calls
int.__add__
, which cannot provide any way of addingFraction
toint
. (Why not?)
NotImplemented
object instead of raising an error/exception?- This allows
+
to continue to handle the addition by - dispatching on
Fraction
to call its reverse addition method__radd__
.
%%optlite -h 500
from fractions import Fraction
def add(x, y):
"""Simulate the + operator."""
sum = x.__add__(y)
if sum is NotImplemented:
sum = y.__radd__(x)
return sum
for x, y in (Fraction(1, 2), 1), (1, Fraction(1, 2)):
print(f"{x} + {y} = {add(x,y)}")
x + y
is a syntactic sugarx + y
invokes the method of the first operand x
to do the addition:
x.__add__(y)
If NotImplemented
object is returned, it invokes the method radd
of the second operand y
to do the reverse addition:
y.__radd__(x)
- A method with starting and trailing double underscores in its name is called a dunder method.
- Dunder methods are not intended to be called directly. E.g., we normally use
+
instead of__add__
. - Other operators have their corresponding dunder methods that overloads the operator.
Explain how the addition operation for the class MyStr
behaves differently compared to that of the str
class:
class MyStr(str):
def __add__(self, a):
return MyStr(str.__add__(self, str(a)))
def __radd__(self, a):
return MyStr(str.__add__(str(a), self))
Solution to Exercise 8
Unlike str
which cannot be added to instances of other types such as int
, MyStr
can be added (concatenated) or reverse added to instances of other types. This is achieved by overloading the +
operation with the new implementations of the forward/reverse addition methods __add__
and __radd__
.
The OOP techniques involved are formally called:
- Polymorphism: Different types can have different implementations of the same method such as
__add__
and__radd__
. - Single dispatch: The implementation such as
+
operation is chosen based on one single type at a time.
%%ai chatgpt -f text
Explain the differences between polymorphism, inheritance, overloading,
overriding, and single dispatch in a paragraph or two.
PyBool_Type
(bool
) specifiesPyLong_Type
(int
) as thetp_base
(base class) in the source codeSee the source code defining
False
and the source code definingTrue
.Different objects of a class do not necessarily have the same set of attributes as that defined by the class. While they share the class attributes, each instance can have unique instance attributes.