Object Oriented Programming
Video
Theory
In python, there are two fundamental concepts - functions, which we have already discussed, and objects - which encapsulate everything else. This should be inherently familiar to category theorists - there is great power in defining what is and how it relates.
Languages with this model are called object-oriented, and allow for object oriented programming - a philosophical take on programs that ties data together with its representation and the things you can do with it.
Object oriented programming is particularly valuable in mathematics because of mathematicians' tendency to define new objects to contain our ideas. OOP allows us to define analogous objects in Python, and to describe their behaviors.
Objects
In Python, everything is an object. Even functions are objects - objects which contain behaviors. When we define a new integer or float, it is an object.
Objects have methods - a special kind of function that is tied to their behavior.
We have already seen an example of a method - lists have the .append
method, allowing you to add elements:
example_list = []
example_list.append(1)
assert example_list == [1]
To see what methods an object has, you can use the dir
command - for example,
a = 5
dir(a)
If you do that, you will see a large number of methods which begin and end with double underscores.
We have actually been using these methods the entire time - python defines what are called magic methods, which are methods that have certain behaviors. For example, if we add 2
to a value a
, that is actually converting it to:
a=3
assert a+2 == a.__add__(2)
Magic methods start and end with double underscores. You can look in the documentation for a full list, but here is a table of relevant magic:
method | name | example |
---|---|---|
__init__ |
Initialize | a=int(3.0) |
__add__ |
Addition | a+3 |
__sub__ |
Subtraction | a-3 |
__mul__ |
Multiplication | a*3 |
__truediv__ |
Division | a/3 |
__floordiv__ |
Floor Division | a//3 |
__mod__ |
Modulo | a%3 |
__pow__ |
Power | a%3 |
__eq__ |
Equality | a==3 |
__ne__ |
Nonequality | a!=3 |
__lt__ |
Inequality | a<3 |
__gt__ |
Inequality | a>3 |
__le__ |
Inequality | a<=3 |
__ge__ |
Inequality | a>=3 |
__neg__ |
Negation | -a |
__abs__ |
Absolute Value | abs(a) |
__str__ |
To String | str(a) |
__int__ |
To Integer | int(a) |
__float__ |
To Float | float(a) |
__repr__ |
Reproduction | repr(a) |
Note that in an operator, the magic is called on the left operand for infix operators. Each infix operator also has a right version - for example, a.__rmul__
- which is only called if the method of the left operand fails. (or in some complicated subclass shenanigans.)
This magic is particularly useful for mathematicians, as we often want to use our familiar symbols on our new imagined objects.
Classes
Another familiar concept to mathematicians is Classes. A more general concept than sets - you may imagine the class of all sets that contain themselves - we have a similar relationship with members: 3
is an object; int
is a class.
When you want to define new types of objects, you first define a new class.
In Python, this is done with the class
statement:
# A class starts with a class statement, and a name. In python, the convention is PascalCase - no separation between words, with the first letter capitalized.
class Rational:
# A class should contain an __init__ method, which tells Python how to make it.
#`self` refers to the object being created.
def __init__(self,numerator, denominator):
# By using the self keyword, we can store values as "properties."
self.numerator = numerator
self.denominator = denominator
Representing and Printing Classes
Try creating a Rational
, and you will see a very unhelpful string.
To spice it up, we will define the __repr__
- and eventually __str__
- magic.
def __repr__(self):
return "Rational({},{})".format(self.numerator, self.denominator)
Try it out and now you will see that it prints out a reproduction of our rational - that is, a Python statement that would create our object. That is what __repr__
is for.
Notice that I used the string method .format
. .format
is a quick python templating system, allowing you to fill in the {}
in a string. (to escape a {
, write {{
).
There are lots of customization options for string formatting, but the default is to use the __str__
of the object being formatted - or the __repr__
, if it doesn't have one.
Plain {}
are positional arguments, just like normal functions. We can make them named arguments by putting a variable name in the function, and passing a named parameter to .format
:
"{},{third},{}".format(1,2,third=3)
Attributes are stored in the __dict__
magic. With the **
Dictionary Unpacking magic, we can turn a dictionary into named arguments:
def __repr__(self):
return "Rational({numerator},{denominator})".format(**self.__dict__)
Methods to our Magic
Methods are functions defined within the class, that always take self
as an implied first element.
In fact, when we called __add__
earlier, that was still a shorthand:
a = 3
assert a+2 == int.__add__(a,2)
Remember we can get the type - the class that a value is an instance of - with the type
function. More generally, addition is implemented as:
a = 3
assert a+2 == type(a).__add__(a,2)
We are going to add a method - __add__
- to this example Rational
class.
def __add__(self,other):
new_numerator = self.numerator*other.denominator+self.denominator*other.numerator
new_denominator = self.denominator*other.denominator
return Rational(new_numerator, new_denominator)
This works great for adding rationals, but what if someone tries adding something else? Well, the first thing we can do is tell them we haven't written it yet:
def __add__(self,other):
if isinstance(other, Rational):
new_numerator = self.numerator*other.denominator+self.denominator*other.numerator
new_denominator = self.denominator*other.denominator
return Rational(new_numerator, new_denominator)
raise NotImplementedError("Cannot Add Types {} and {}".format("Rational",type(other)))
Now we're safer - we are honestly warning people about what we can and cannot do when they try to do it.
Let's add a bit of code to handle integers:
def __add__(self,other):
if isinstance(other, Rational):
new_numerator = self.numerator*other.denominator+self.denominator*other.numerator
new_denominator = self.denominator*other.denominator
return Rational(new_numerator, new_denominator)
if isinstance(other, int):
new_numerator = self.numerator + other*self.denominator
return Rational(new_numerator,new_denominator)
raise NotImplementedError("Cannot Add Types {} and {}".format("Rational",type(other)))
Now let's add subtraction. At its core, subtraction of rationals is shorthand for addition of the negation of the other term:
def __sub__(self,other):
return self+(-other)
Now we can subtract integers from our rationals! But to subtract other rationals, we need to tell it what the negation is.
def __neg__(self):
return Rational(-self.numerator,self.denominator)
This lets us do Rational(1,2)+5
, but what about 5+Rational(1,2)
? For that, we need to define the right version of the operators:
def __radd__(self,other):
return self+other
def __rsub__(self,other):
return -self+other
Inheritance
Sometimes, we have chains of classes that inherit behavior. For example, all fields are euclidean domains, but not all euclidean domains are fields.
What mathematicians call class inclusions, programmers call Inheritance.
For example, if we have already defined a Euclidean domain class EuclideanDomain
, we can define:
class Field(EuclideanDomain):
def __init__(self,*args,**kwargs):
super().__init__(*args,**kwargs)
Now any functionality we have created that works on Euclidean Domains will automatically work on Fields!
Note the super().__init__(*args,**kwargs)
. super
is another special function that allows you to call methods of parent objects - in this case, to do all the Euclidean domain interaction.
*args
and **kwargs
are catchalls - for the positional arguments, and keyword arguments, respectively. They store these values in a list args
and a dictionary kwargs
.
Multiple Inheritance
Just like in Mathematics, class inheritance isn't always a neat ascending chain.
We know that Dedekind Domains are Integral Domains, but not necessarily Unique Factorization Domains - nor are Unique Factorization Domains necessarily Dedekind Domains. They don't inherit behavior from eachother, even though they share a parent class.
However, Principal Ideal Domains are both Dedekind Domains and Unique Factorization Domains - and can (and should) inherit all the benefits and behaviors of both.
In Python, this is called Multiple Inheritance:
class PrincipalIdealDomain(DedekindDomain,UniqueFactorizationDomain):
def __init__(self, *args,**kwargs):
super().__init__(*args,**kwargs)
Note that we only need to call super
once; it will go through all the classes in the order you parented them. It will even continue up the tree - if you have super
in each parent, as you should - and only initialize once for each parent, even if it is inherited from multiple times.
isinstance
We have covered checking type using type
, but you'll notice doing that would be prohibitively expensive if all we want is to know if our ring is a Unique Factorization Domain.
Enter isinstance
, a versatile type-checker that scans the entire parent tree for the desired class.
For example,
isinstance(R,UniqueFactorizationDomain)
Would be true on any of our derived classes - EuclideanDomain
, PrincipalIdealDomain
, Field
and FiniteField
included.
That way we can check for the desired behavior - unique factorization, and any methods we have defined relying only on that - without worrying about all the extra stuff.
Worksheet
Today's worksheet has you finishing up the Rational
class, and defining some more class magic.