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."
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))
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__)
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.
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.
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
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)
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.
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:
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.
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)
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!')
The difference between a static method and a class method is:
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)
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)
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))
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__)
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)
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)
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")
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))
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)
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()
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]:
In [104]:
class Person:
pass
class Company:
pass
class Employee(Person,Company):
pass
print(Employee.mro())
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())
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()
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 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)
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