# Introduction to Object Oriented Programming (OOP)¶

Object-oriented programming (OOP) is a programming paradigm/technique that organizes software design around objects, rather than functions and logic. An object can be defined as a data field that has unique attributes and behavior. It is useful in big projects (above ~500 lines), it makes the writing and understanding of the code easier. (For smaller codes – like in our case – we deliberately pretend it to be complex to understand what is happening.)

## Motivation¶

Exercise: There are several courses with "hours to attend" and "credit" values. Choose your courses and calculate the sum of their hours and sum of their credits.

In [1]:
# Format of one entry: (name, hours to attend, credit)
all_courses = [
("Info1", 3, 4),
("Info2", 3, 3),
("Combi1", 4, 4),
("Combi2", 3, 3)]

my_courses = ["Info1", "Combi1"]

In [2]:
def hourstotal(mycourses, allcourses):
hours = 0
for course in allcourses:
if course[0] in mycourses:
hours += course[1]
return hours

def credittotal(mycourses, allcourses):
credits = 0
for course in allcourses:
if course[0] in mycourses:
credits += course[2]
return credits

print((hourstotal(my_courses, all_courses)))
print((credittotal(my_courses, all_courses)))

7
8


Problem: Modify a course entry: store the presense requirements also.

In [3]:
# Format: (name, hours, presence required, credit)
all_courses = [
("Info1", 3, 2, 4),
("Info2", 3, 2, 3),
("Combi1", 4, 2, 4),
("Combi2", 3, 1, 3)]

my_courses = ["Info1", "Combi1"]

In [4]:
def credittotal(mycourses, allcourses):
credits = 0
for course in allcourses:
if course[0] in mycourses:
credits += course[3]  # <<< modified here
return credits

print(hourstotal(my_courses, all_courses))
print(credittotal(my_courses, all_courses))

7
8


Even if you store the same values except one, you have to modify the credittotal function, even if the credit entry did not change.

This is problematic if you have complicated data in the entries. And also you can forget to rewrite some of the functions when you insert new data.

This is why the tuple is not sustainable. One solution is to use a dictionary:

In [5]:
# Format: (name, classes in hours, presence required, credit)
all_courses = [
{"name": "Info1", "hour": 3, "presence": 2, "credit": 4},
{"name": "Info2", "hour": 3, "presence": 2, "credit": 3},
{"name": "Combi1", "hour": 4, "presence": 2, "credit": 4},
{"name": "Combi2", "hour": 3, "presence": 1, "credit": 3},]

my_courses = ["Info1", "Combi1"]

In [6]:
def hourstotal(mycourses, allcourses):
hours = 0
for course in allcourses:
if course["name"] in mycourses:
hours += course["hour"]
return hours

def credittotal(mycourses, allcourses):
credits = 0
for course in allcourses:
if course["name"] in mycourses:
# <<< you don't have to modify any more
credits += course["credit"]
return credits

print(hourstotal(my_courses, all_courses))
print(credittotal(my_courses, all_courses))

7
8


This is not so bad, but there are still some problems.

• If you add a new course you have to fill the dictionary carefuly.
• you may forget to add a key, for example you have "name" but don't have "presence"
• If you handle entries in more than one place, then you have to do the same in several places
• this is problematic because you can do correctly in one place, but incorrectly in other places (code repetition).

You can solve this by adding a newcourse function which creates a correct course entry. In this case you have to use that function every time you create a new course entry.

In [7]:
# use this function when creating a new course
def newcourse(allcourses, name, hour, presence, credit):
x = {"name": name, "hour": hour,
"precense": presence, "credit": credit}
allcourses.append(x)

newcourse(all_courses, "Info3", 2, 2, 3)

my_courses = ["Info1", "Combi1"]

print(all_courses)
print(hourstotal(my_courses, all_courses))
print(credittotal(my_courses, all_courses))

[{'name': 'Info1', 'hour': 3, 'presence': 2, 'credit': 4}, {'name': 'Info2', 'hour': 3, 'presence': 2, 'credit': 3}, {'name': 'Combi1', 'hour': 4, 'presence': 2, 'credit': 4}, {'name': 'Combi2', 'hour': 3, 'presence': 1, 'credit': 3}, {'name': 'Info3', 'hour': 2, 'precense': 2, 'credit': 3}]
7
8


This solves the problem of incorrect or incomplete entries if you use the newcourse function. Since you cannot call that function without the proper parameters.

This solution is the closest to the concept of a class.

## Class and object¶

The class is similar to a type, like list and an object is an instance of a class.

Like 5 is an instance of int.

Let's make a class named Course. It is a custom to use capitalized names as class names.

In [8]:
class Course:
pass


This works, but does nothing. One can create an instance by the name of the class and a parenthesis:

In [9]:
c = Course()
c1 = Course()

print(c)
c

<__main__.Course object at 0x7fa39019b6d8>

Out[9]:
<__main__.Course at 0x7fa39019b6d8>

And you can add attributes to that instance:

In [10]:
c.name = "Info2"
c.hour = 3

print(c.name, c.hour)

Info2 3


A variable defined in the class definition refers to the class, that is for every instances of it, although it can be changed in every instances of it:

In [11]:
class Course:
year = "2nd"

c = Course()
c.year

Out[11]:
'2nd'
In [12]:
c.year = "3rd"
print(c.year)         # class variable value in an object
print(Course.year)    # class variable

3rd
2nd


You can access the attributes with the dot (.) operator. The instance is on the left-hand-side of the dot and the required attribute is on the right-hand-side. This can be a value of a variable or a method (like the append method of a list).

## Constructor¶

You can add attributes to an object (to an instance of a class) after creating that one, but it is better to add them during the creation.

This is the constructor:

In [13]:
class Course:
def __init__(self, name, hour, presence, credit):
self.name = name
self.hour = hour
self.presence = presence
self.credit = credit


The constructor is a special method called __init__.

That function is executed when an instance is created: write the name of the class and after that the parameters in paranthesis.

c = Course(x, y, ... )



The self parameter refers the object to be created, you can set the value attributes using the function parameters.

It’s a good habit to name the first argument self, so follow it! But it’s not required. One may use any name, but it always refers to the created object. The next class definition is equivalent to the previous one:

In [14]:
class Course:
def __init__(course, name_of_course, hour, presence, credit):
course.name = name_of_course
course.hour = hour
course.presence = presence
course.credit = credit


Let's see the course example:

In [15]:
all_courses = [
Course("Info1", 3, 2, 4),
Course("Info2", 3, 2, 3),
Course("Combi1", 4, 2, 4),
Course("Combi2", 3, 1, 3)]

my_courses = ["Info1", "Combi1"]

In [17]:
c = Course("Info3", 2, 2, 2)
c.name

Out[17]:
'Info3'
In [18]:
all_courses

Out[18]:
[<__main__.Course at 0x7fa390196080>,
<__main__.Course at 0x7fa390196f28>,
<__main__.Course at 0x7fa3901962b0>,
<__main__.Course at 0x7fa3901961d0>]
In [19]:
def hourstotal(mycourses, allcourses):
hours = 0
for course in allcourses:
if course.name in mycourses:
hours += course.hour
return hours

def credittotal(mycourses, allcourses):
credits = 0
for course in allcourses:
if course.name in mycourses:
credits += course.credit
return credits

print(hourstotal(my_courses, all_courses))
print(credittotal(my_courses, all_courses))

7
8


Exercise: Now let's make a class representing complex numbers, called Complex.

In [20]:
class Complex:
def __init__(self, real, imaginary):
self.re = real
self.im = imaginary

z = Complex(4, 3)
print(z.re, z.im)

4 3


In [21]:
class Complex:
def __init__(self, real, imaginary):
self.re = real
self.im = imaginary

new_re = z1.re + z2.re
new_im = z1.im + z2.im
return Complex(new_re, new_im)

z1 = Complex(4, 3)
z2 = Complex(-2, 1)

print(z3.re, z3.im)

2 4


## Method¶

It is better to put the add function inside the class definition, since it is used to add Complex numbers and nothing else.

In [22]:
class Complex:
def __init__(self, real, imaginary):
self.re = real
self.im = imaginary

new_re = z1.re + z2.re
new_im = z1.im + z2.im
return Complex(new_re, new_im)

z1 = Complex(4, 3)
z2 = Complex(-2, 1)

print(z3.re, z3.im)

2 4


Even better without writing the name of the class in front of the function. Now we change the local function into a method (the first argument of which refers to the object):

In [23]:
class Complex:
def __init__(self, real, imaginary):
self.re = real
self.im = imaginary

def add(self, z2):            # definition of a method
new_re = self.re + z2.re
new_im = self.im + z2.im
return Complex(new_re, new_im)

z1 = Complex(4, 3)
z2 = Complex(-2, 1)
z3 = z1.add(z2)     # call the method

print(z3.re, z3.im)

2 4


The method's first parameter is self, which is the object on the left-hand-side of the dot. The method's name is on the right-hand-side of the dot. After that come the other parameters.

In this case we say: add z2 to self (which refers now to z1).

### Special methods¶

It can be even nicer, as we would like to use the + sign:

In [24]:
class Complex:
def __init__(self, real, imaginary):
self.re = real
self.im = imaginary

new_re = self.re + z2.re
new_im = self.im + z2.im
return Complex(new_re, new_im)

z1 = Complex(4, 3)
z2 = Complex(-2, 1)
z3 = z1 + z2        # the __add__ method is called

print(z3.re, z3.im)

2 4


The special name __add__ marks an operator, namely + but you can call it by the exact name:

In [25]:
z4 = z1.__add__(z2)
print(z4.re, z4.im)

2 4


The special method __add__ is called when there is a + operator after a Complex instance.

The left-hand-side of the + becomes self and the right-hand- side is the parameter. The result will be the returned object.

A special method can be a number of things, like __sub__, __mul__, __div__. But the __init__ was that, too.

These are special methods which can be called not only by their names, but by operators. These special names are provided by Python. They always begin and end with a double underscore.

#### Printing¶

There is a small problem left:

In [26]:
print(z3)

<__main__.Complex object at 0x7fa3901c2d68>


This is not nice, but there is a special method used for printing:

In [27]:
class Complex:
def __init__(self, real, imaginary):
self.re = real
self.im = imaginary

new_re = self.re + z2.re
new_im = self.im + z2.im
return Complex(new_re, new_im)

# used by the print() and str() functions
def __str__(self):
return str(self.re) + " + " + str(self.im) + "i"

# used by the repr() function and by the interactive output
def __repr__(self):
return str(self.re) + " + " + str(self.im) + " i"

In [28]:
z1 = Complex(4, 3)
z2 = Complex(-2, 1)
z3 = z1 + z2

print(str(z1))
print(z2)
print(z3)       # calls __str__()
z3              # calls __repr__()

4 + 3i
-2 + 1i
2 + 4i

Out[28]:
2 + 4 i
In [29]:
str(Complex(3, 2))

Out[29]:
'3 + 2i'

The __str__ method should return a string, that is what will be printed. This method is called when the user wants to print the object or convert it to string.

However that is not perfect yet:

In [30]:
print(Complex(0, 0))    # 0
print(Complex(3, 0))    # 3
print(Complex(-2, 1))   # -2 + i
print(Complex(-2, -1))  # -2 - i

0 + 0i
3 + 0i
-2 + 1i
-2 + -1i

In [ ]: