Object Oriented Programming

According to Wikipedia, "Object-oriented programming (OOP) is a programming paradigm based on the concept of 'objects', which may contain data, in the form of fields, often known as attributes; and code, in the form of procedures, often known as methods."

Classes & Objects

A class is a template for defining objects. It specifies the names and types of variables that can exist in an object, as well as "methods"--procedures for operating on those variables. A class can be thought of as a "type", with the objects being a "variable" of that type.

For example, when we define a Person class using the class keyword, we haven't actually created a Person. Instead, what we've created is a sort of instruction manual for constructing "person" objects.


In [3]:
class Person:
    pass

Here the class statement did not create anything, it just the blueprint to create "Person" objects. To create an object we need to instantiate the "Person" class.


In [5]:
P1 = Person()
print(type(P1))


<class '__main__.Person'>

Now we created a "Person" object and assigned it to "P1". We can create any number of objects but please note there will be only one "Person" class.


In [6]:
# Doc string for class
class Person:
    '''Simple Person Class'''
    pass

print(Person.__doc__)


Simple Person Class

Attributes & Methods

Classes contain attributes (also called fields, members etc...) and methods (a.k.a functions). Attributes defines the characteristics of the object and methods perfom action on the object. For example, the class definition below has firstname and lastname attributes and fullname is a method.


In [22]:
class Person:
    '''Simple Person Class
    
       Attributes:
           firstname: String representing first name of the person
           lastname: String representing last name of the person
    '''
    def __init__(self,firstname,lastname):
        '''Initialiser method for Person'''
        self.firstname = firstname
        self.lastname = lastname
    
    def fullname(self):
        '''Returns the full name of the person'''
        return self.firstname + ' ' + self.lastname

Inside the class body, we define two functions – these are our object’s methods. The first is called __init__, which is a special method. When we call the class object, a new instance of the class is created, and the __init__ method on this new object is immediately executed with all the parameters that we passed to the class object. The purpose of this method is thus to set up a new object using data that we have provided.

The second method is a custom method which derives the fullname of the person using the firstname and the lastname.

__init__ is sometimes called the object’s constructor, because it is used similarly to the way that constructors are used in other languages, but that is not technically correct – it’s better to call it the initialiser. There is a different method called __new__ which is more analogous to a constructor, but it is hardly ever used.

You may have noticed that both of these method definitions have self as the first parameter, and we use this variable inside the method bodies – but we don’t appear to pass this parameter in. This is because whenever we call a method on an object, the object itself is automatically passed in as the first parameter (as self). This gives us a way to access the object’s properties from inside the object’s methods.

Instance Attributes

All the attributes that are defined on the Person instance are called instance attributes. They are added to the instance when the __init__ method is executed.

Class Attributes

We can, however, also define attributes which are set on the class. These attributes will be shared by all instances of that class. In many ways they behave just like instance attributes, but there are some caveats that you should be aware of.

We define class attributes in the body of a class, at the same indentation level as method definitions (one level up from the insides of methods)


In [23]:
class Person:
    '''Simple Person Class
    
       Attributes:
           firstname: String representing first name of the person
           lastname: String representing last name of the person
    '''
    TITLES = ['Mr','Mrs','Master']
    
    def __init__(self,title,firstname,lastname):
        '''Initialiser method for Person'''
        if title not in self.TITLES:
            raise ValueError("%s is not a valid title.", title)
        self.firstname = firstname
        self.lastname = lastname
    
    def fullname(self):
        '''Returns the full name of the person'''
        return self.firstname + ' ' + self.lastname
    
John = Person('Mister','John','Doe') # this will create an error


---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-23-a6f2f6f9628f> in <module>()
     19         return self.firstname + ' ' + self.lastname
     20 
---> 21 John = Person('Mister','John','Doe')

<ipython-input-23-a6f2f6f9628f> in __init__(self, title, firstname, lastname)
     11         '''Initialiser method for Person'''
     12         if title not in self.TITLES:
---> 13             raise ValueError("%s is not a valid title.", title)
     14         self.firstname = firstname
     15         self.lastname = lastname

ValueError: ('%s is not a valid title.', 'Mister')

In [29]:
class Employee:
   '''Common base class for all employees'''

   empCount = 0
    
   def __init__(self, name, salary):
      self.name = name
      self.salary = salary
      Employee.empCount += 1
   
   def displayCount(self):
     print("Total Employee %d",Employee.empCount)

   def displayEmployee(self):
      print("Name : ", self.name,  ", Salary: ", self.salary)

"This would create first object of Employee class"
emp1 = Employee("Zara", 2000)
"This would create second object of Employee class"
emp2 = Employee("Manni", 5000)
emp1.displayEmployee()
emp2.displayEmployee()
print("Total Employee ", Employee.empCount)


Name :  Zara , Salary:  2000
Name :  Manni , Salary:  5000
Total Employee  2

Please note that when we set an attribute on an instance which has the same name as a class attribute, we are overriding the class attribute with an instance attribute, which will take precedence over it.

Class Decorators

Class Methods

Just like we can define class attributes, which are shared between all instances of a class, we can define class methods. We do this by using the @classmethod decorator to decorate an ordinary method.

A class method still has its calling object as the first parameter, but by convention we rename this parameter from self to cls. If we call the class method from an instance, this parameter will contain the instance object, but if we call it from the class it will contain the class object. By calling the parameter cls we remind ourselves that it is not guaranteed to have any instance attributes.

Class methods exists primarily for two reasons:

  1. Sometimes there are tasks associated with a class which we can perform using constants and other class attributes, without needing to create any class instances. If we had to use instance methods for these tasks, we would need to create an instance for no reason, which would be wasteful.

  2. Sometimes it is useful to write a class method which creates an instance of the class after processing the input so that it is in the right format to be passed to the class constructor. This allows the constructor to be straightforward and not have to implement any complicated parsing or clean-up code.


In [31]:
class ClassGrades:

    def __init__(self, grades):
        self.grades = grades

    @classmethod
    def from_csv(cls, grade_csv_str):
        grades = grade_csv_str.split(', ')
        return cls(grades)
    
class_grades = ClassGrades.from_csv('92, -15, 99, 101, 77, 65, 100')
print(class_grades.grades)


['92', '-15', '99', '101', '77', '65', '100']

Static Methods

A static method doesn’t have the calling object passed into it as the first parameter. This means that it doesn’t have access to the rest of the class or instance at all. We can call them from an instance or a class object, but they are most commonly called from class objects, like class methods.

If we are using a class to group together related methods which don’t need to access each other or any other data on the class, we may want to use this technique.

The advantage of using static methods is that we eliminate unnecessary cls or self parameters from our method definitions.

The disadvantage is that if we do occasionally want to refer to another class method or attribute inside a static method we have to write the class name out in full, which can be much more verbose than using the cls variable which is available to us inside a class method.


In [36]:
class ClassGrades:

    def __init__(self, grades):
        self.grades = grades

    @classmethod
    def from_csv(cls, grade_csv_str):
        grades = grade_csv_str.split(', ')
        cls.validate(grades)
        return cls(grades)


    @staticmethod
    def validate(grades):
        for g in grades:
            if int(g) < 0 or int(g) > 100:
                raise Exception()

try:  
    # Try out some valid grades
    class_grades_valid = ClassGrades.from_csv('90, 80, 85, 94, 70')
    print('Got grades:', class_grades_valid.grades)

    # Should fail with invalid grades
    class_grades_invalid = ClassGrades.from_csv('92, -15, 99, 101, 77, 65, 100')
    print(class_grades_invalid.grades)
except:  
    print('Invalid!')


Got grades: ['90', '80', '85', '94', '70']
Invalid!

The difference between a static method and a class method is:

  • Static method knows nothing about the class and just deals with the parameters.
  • Class method works with the class since its parameter is always the class itself.

Property

Sometimes we use a method to generate a property of an object dynamically, calculating it from the object’s other properties. Sometimes you can simply use a method to access a single attribute and return it. You can also use a different method to update the value of the attribute instead of accessing it directly. Methods like this are called getters and setters, because they “get” and “set” the values of attributes, respectively.

The @property decorator lets us make a method behave like an attribute.


In [38]:
class Person:
    '''Simple Person Class
    
       Attributes:
           firstname: String representing first name of the person
           lastname: String representing last name of the person
    '''
    def __init__(self,firstname,lastname):
        '''Initialiser method for Person'''
        self.firstname = firstname
        self.lastname = lastname
    
    @property
    def fullname(self):
        '''Returns the full name of the person'''
        return self.firstname + ' ' + self.lastname
    
p1 = Person('John','Doe')
print(p1.fullname)


John Doe

There are also decorators which we can use to define a setter and a deleter for our attribute (a deleter will delete the attribute from our object). The getter, setter and deleter methods must all have the same name


In [39]:
class Person:
    '''Simple Person Class
    
       Attributes:
           firstname: String representing first name of the person
           lastname: String representing last name of the person
    '''
    def __init__(self,firstname,lastname):
        '''Initialiser method for Person'''
        self.firstname = firstname
        self.lastname = lastname
    
    @property
    def fullname(self):
        '''Returns the full name of the person'''
        return self.firstname + ' ' + self.lastname
    
    @fullname.setter
    def fullname(self,value):
        firstname,lastname = value.split(" ")
        self.firstname = firstname
        self.lastname = lastname
        
    @fullname.deleter
    def fullname(self):
        del self.firstname
        del self.lastname
    
p1 = Person('John','Doe')
print(p1.fullname)

p1.fullname = 'Jack Daniels'
print(p1.fullname)


John Doe
Jack Daniels

Inspecting an Object


In [37]:
class Person:
    def __init__(self, name, surname):
        self.name = name
        self.surname = surname

    def fullname(self):
        return "%s %s" % (self.name, self.surname)

jane = Person("Jane", "Smith")

print(dir(jane))


['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'fullname', 'name', 'surname']

Built In Class Attributes


In [41]:
class Employee:
   'Common base class for all employees'
   empCount = 0

   def __init__(self, name, salary):
      self.name = name
      self.salary = salary
      Employee.empCount += 1
   
   def displayCount(self):
     print("Total Employee", Employee.empCount)

   def displayEmployee(self):
      print("Name : ", self.name,  ", Salary: ", self.salary)

print ("Employee.__doc__:", Employee.__doc__)
print ("Employee.__name__:", Employee.__name__)
print ("Employee.__module__:", Employee.__module__)
print ("Employee.__bases__:", Employee.__bases__)
print ("Employee.__dict__:", Employee.__dict__)


Employee.__doc__: Common base class for all employees
Employee.__name__: Employee
Employee.__module__: __main__
Employee.__bases__: (<class 'object'>,)
Employee.__dict__: {'__module__': '__main__', '__doc__': 'Common base class for all employees', 'empCount': 0, '__init__': <function Employee.__init__ at 0x1022c4840>, 'displayCount': <function Employee.displayCount at 0x1022c47b8>, 'displayEmployee': <function Employee.displayEmployee at 0x1022c4730>, '__dict__': <attribute '__dict__' of 'Employee' objects>, '__weakref__': <attribute '__weakref__' of 'Employee' objects>}

Overriding Magic Methods


In [42]:
import datetime

class Person:
    def __init__(self, name, surname, birthdate, address, telephone, email):
        self.name = name
        self.surname = surname
        self.birthdate = birthdate

        self.address = address
        self.telephone = telephone
        self.email = email

    def __str__(self):
        return "%s %s, born %s\nAddress: %s\nTelephone: %s\nEmail:%s" % (self.name, self.surname, self.birthdate, self.address, self.telephone, self.email)

jane = Person(
    "Jane",
    "Doe",
    datetime.date(1992, 3, 12), # year, month, day
    "No. 12 Short Street, Greenville",
    "555 456 0987",
    "jane.doe@example.com"
)

print(jane)


Jane Doe, born 1992-03-12
Address: No. 12 Short Street, Greenville
Telephone: 555 456 0987
Email:jane.doe@example.com

Create Class Using Key Value Arguments


In [81]:
class Student:
    def __init__(self, **kwargs):
        for k,v in kwargs.items():
            setattr(self,k,v,)
    
    def __str__(self):
        attrs = ["{}={}".format(k, v) for (k, v) in self.__dict__.items()]
        return str(attrs)
        #classname = self.__class__.__name__
        #return "{}: {}".format((classname, " ".join(attrs)))
            
s1 = Student(firstname="John",lastname="Doe")
print(s1.firstname)
print(s1.lastname)
print(s1)


John
Doe
['firstname=John', 'lastname=Doe']

In [64]:
def print_values(**kwargs):
    for key, value in kwargs.items():
        print("The value of {} is {}".format(key, value))

print_values(my_name="Sammy", your_name="Casey")


The value of my_name is Sammy
The value of your_name is Casey

Class Inheritance

Inheritance is a way of arranging objects in a hierarchy from the most general to the most specific. An object which inherits from another object is considered to be a subtype of that object.

We also often say that a class is a subclass or child class of a class from which it inherits, or that the other class is its superclass or parent class. We can refer to the most generic class at the base of a hierarchy as a base class.

Inheritance is also a way of reusing existing code easily. If we already have a class which does almost what we want, we can create a subclass in which we partially override some of its behaviour, or perhaps add some new functionality.


In [86]:
# Simple Example of Inheritance

class Person:
    pass

# Parent class must be defined inside the paranthesis

class Employee(Person): 
    pass

e1 = Employee()
print(dir(e1))


['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__']

In [ ]:
class Person:
    
    def __init__(self,firstname,lastname):
        self.firstname = firstname
        self.lastname = lastname
        
    def __str__(self):
        return "[{},{}]".format(self.firstname,self.lastname)
    
class Employee(Person):
    pass

john = Employee('John','Doe')
print(john)

In [101]:
class Person:
    
    def __init__(self, firstname, lastname):
        self.firstname = firstname
        self.lastname = lastname
        
    def __str__(self):
        return "{},{}".format(self.firstname, self.lastname)
    
class Employee(Person):
    
    def __init__(self, firstname, lastname, staffid):
        super().__init__(firstname, lastname)
        self.staffid = staffid
        
    def __str__(self):
        return super().__str__() + ",{}".format(self.staffid)

john = Employee('Jack','Doe','12345')
print(john)


Jack,Doe,12345

Abstract Classes and Interfaces

Abstract classes are not intended to be instantiated because all the method definitions are empty – all the insides of the methods must be implemented in a subclass.

They serves as a template for suitable objects by defining a list of methods that these objects must implement.


In [102]:
# Abstract Classes

class shape2D:
    def area(self):
        raise NotImplementedError()
        
class shape3D:
    def volume(self):
        raise NotImplementedError()
        
sh1 = shape2D()
sh1.area()


---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
<ipython-input-102-33b983e0d6d3> in <module>()
      8 
      9 sh1 = shape2D()
---> 10 sh1.area()

<ipython-input-102-33b983e0d6d3> in area(self)
      1 class shape2D:
      2     def area(self):
----> 3         raise NotImplementedError()
      4 
      5 class shape3D:

NotImplementedError: 

In [103]:
class shape2D:
    def area(self):
        raise NotImplementedError()
        
class shape3D:
    def volume(self):
        raise NotImplementedError()

class Square(shape2D):
    def __init__(self,width):
        self.width = width
        
    def area(self):
        return self.width ** 2
    
s1 = Square(2)
s1.area()


Out[103]:
4

Multiple Inheritance


In [104]:
class Person:
    pass

class Company:
    pass

class Employee(Person,Company):
    pass

print(Employee.mro())


[<class '__main__.Employee'>, <class '__main__.Person'>, <class '__main__.Company'>, <class 'object'>]

Diamond Problem

Multiple inheritance isn’t too difficult to understand if a class inherits from multiple classes which have completely different properties, but things get complicated if two parent classes implement the same method or attribute.

If classes B and C inherit from A and class D inherits from B and C, and both B and C have a method do_something, which do_something will D inherit? This ambiguity is known as the diamond problem, and different languages resolve it in different ways. In our Tutor class we would encounter this problem with the __init__ method.


In [105]:
class X: pass
class Y: pass
class Z: pass

class A(X,Y): pass
class B(Y,Z): pass

class M(B,A,Z): pass

# Output:
# [<class '__main__.M'>, <class '__main__.B'>,
# <class '__main__.A'>, <class '__main__.X'>,
# <class '__main__.Y'>, <class '__main__.Z'>,
# <class 'object'>]

print(M.mro())


[<class '__main__.M'>, <class '__main__.B'>, <class '__main__.A'>, <class '__main__.X'>, <class '__main__.Y'>, <class '__main__.Z'>, <class 'object'>]

Method Resolution Order (MRO)

In the multiple inheritance scenario, any specified attribute is searched first in the current class. If not found, the search continues into parent classes in depth-first, left-right fashion without searching same class twice.

So, in the above example of MultiDerived class the search order is [MultiDerived, Base1, Base2, object]. This order is also called linearization of MultiDerived class and the set of rules used to find this order is called Method Resolution Order (MRO).

MRO must prevent local precedence ordering and also provide monotonicity. It ensures that a class always appears before its parents and in case of multiple parents, the order is same as tuple of base classes.

MRO of a class can be viewed as the mro attribute or mro() method. The former returns a tuple while latter returns a list.


In [112]:
class Person:
    def __init__(self):
        print('Person')

class Company:
    def __init__(self):
        print('Company')

class Employee(Person,Company):
    def _init_(self):
        super(Employee,self).__init__()
        print('Employee')
        
e1=Employee()


Person

Mixins

If we use multiple inheritance, it is often a good idea for us to design our classes in a way which avoids the kind of ambiguity described above. One way of doing this is to split up optional functionality into mix-ins. A Mix-in is a class which is not intended to stand on its own – it exists to add extra functionality to another class through multiple inheritance.


In [114]:
class Person:
    def __init__(self, name, surname, number):
        self.name = name
        self.surname = surname
        self.number = number


class LearnerMixin:
    def __init__(self):
        self.classes = []

    def enrol(self, course):
        self.classes.append(course)


class TeacherMixin:
    def __init__(self):
        self.courses_taught = []

    def assign_teaching(self, course):
        self.courses_taught.append(course)


class Tutor(Person, LearnerMixin, TeacherMixin):
    def __init__(self, *args, **kwargs):
        super(Tutor, self).__init__(*args, **kwargs)

jane = Tutor("Jane", "Smith", "SMTJNX045")
#jane.enrol(a_postgrad_course)
#jane.assign_teaching(an_undergrad_course)

Now Tutor inherits from one “main” class, Person, and two mix-ins which are not related to Person. Each mix-in is responsible for providing a specific piece of optional functionality. Our mix-ins still have __init__ methods, because each one has to initialise a list of courses (we saw in the previous chapter that we can’t do this with a class attribute). Many mix-ins just provide additional methods and don’t initialise anything.

Composition

Composition is a way of aggregating objects together by making some objects attributes of other objects. Relationships like this can be one-to-one, one-to-many or many-to-many, and they can be unidirectional or bidirectional, depending on the specifics of the the roles which the objects fulfil.

The term composition implies that the two objects are quite strongly linked – one object can be thought of as belonging exclusively to the other object. If the owner object ceases to exist, the owned object will probably cease to exist as well. If the link between two objects is weaker, and neither object has exclusive ownership of the other, it can also be called aggregation.


In [117]:
class Student:
    def __init__(self, name, student_number):
        self.name = name
        self.student_number = student_number
        self.classes = []

    def enrol(self, course_running):
        self.classes.append(course_running)
        course_running.add_student(self)


class Department:
    def __init__(self, name, department_code):
        self.name = name
        self.department_code = department_code
        self.courses = {}

    def add_course(self, description, course_code, credits):
        self.courses[course_code] = Course(description, course_code, credits, self)
        return self.courses[course_code]


class Course:
    def __init__(self, description, course_code, credits, department):
        self.description = description
        self.course_code = course_code
        self.credits = credits
        self.department = department
        #self.department.add_course(self)

        self.runnings = []

    def add_running(self, year):
        self.runnings.append(CourseRunning(self, year))
        return self.runnings[-1]


class CourseRunning:
    def __init__(self, course, year):
        self.course = course
        self.year = year
        self.students = []

    def add_student(self, student):
        self.students.append(student)


maths_dept = Department("Mathematics and Applied Mathematics", "MAM")
mam1000w = maths_dept.add_course("Mathematics 1000", "MAM1000W", 1)
mam1000w_2013 = mam1000w.add_running(2013)

bob = Student("Bob", "Smith")
bob.enrol(mam1000w_2013)
  • A student can be enrolled in several courses (CourseRunning objects), and a course (CourseRunning) can have multiple students enrolled in it in a particular year, so this is a many-to-many relationship. A student knows about all his or her courses, and a course has a record of all enrolled students, so this is a bidirectional relationship. These objects aren’t very strongly coupled – a student can exist independently of a course, and a course can exist independently of a student.
  • A department offers multiple courses (Course objects), but in our implementation a course can only have a single department – this is a one-to-many relationship. It is also bidirectional. Furthermore, these objects are more strongly coupled – you can say that a department owns a course. The course cannot exist without the department.
  • A similar relationship exists between a course and its “runnings”: it is also bidirectional, one-to-many and strongly coupled – it wouldn’t make sense for “MAM1000W run in 2013” to exist on its own in the absence of “MAM1000W”.

Inheritance Methods


In [122]:
class Person:
    pass

class Employee(Person):
    pass

class Tutor(Employee):
    pass

emp = Employee()

print(isinstance(emp, Tutor))       # False
print(isinstance(emp, Person))      # True
print(isinstance(emp, Employee))    # True
print(issubclass(Tutor, Person))    # True


False
True
True
True