-
Notifications
You must be signed in to change notification settings - Fork 0
Object Oriented Programming
In Python OOP is very simple, and follows the same basic principles of other languages, such as Java or C++:
- Inheritance: A process of using details from a new class without modifying existing class
- Abstraction/Encapsulation: Hiding the private details of a class from other objects
- Polymorphism: A concept of using common operation in different ways for different data input
This chapter will rely on many comparisons to Java and C++, but if you have no knowledge of OOP in these languages, this workshop will should still be able to guide you through it.
Methods are just functions associated to objects of a class. You've already used plenty of methods before, such as Str.split()
or List.insert()
.
You can create an empty class, a stub, by using the keyword pass
.
class EmptyClass:
pass
In Python all methods need to have at least one argument, the self
argument (the implicit parameter this
in other languages). This keyword is a reference to the specific object the method was called from, effectively making it like this:
class Person:
def printName(self):
print("Eduardo")
p = Person()
p.printName() # Eduardo
Person.printName(p) # Eduardo
This works because these calls are equivalent, with Classname working as a namespace:
object = Classname()
object.method(param1, param2)
Classname.method(object, param1, param2)
Just like other languages, you can initialize various parts of a class by using a constructor, in this case by overloading the __init__()
method.
Whenever the object is garbage collected, the method __del__
is called, so you can use it to do something when an object is deleted.
Warning: This does not mean the method will be called when using del object
, it is only called when it's garbage collected, this means its Reference Count reached 0
class Person:
def __init__(self, name):
self.name = name
def printName(self):
print(self.name)
def __del__(self):
print("Goodbye!")
p = Person("Eduardo")
p.printName() # Eduardo
# Goodbye!
In the previous example's constructor we initialized an attribute for the Person class, name
. In Python all attributes created inside a method are specific to that object. If you wish to have static
attributes, this is, attributes common to all instances of a class, they must be declared inside the class itself.
class Person:
people_created = 0 # Common to all instances
def __init__(self, name):
self.people_created += 1
self.name = name
def printName(self):
print(self.name)
self.aNumber = 0
p1 = Person("Eduardo")
p2 = Person("Daniel")
print()
print(Person.people_created) # 2
We also just accessed the class attributes directly, specifically name
and people_created
. By default, all attribute are "public", meaning they can be accessed from outside the class.
This violates the principle of Encapsulation. To prevent discourage an attribute/method from being accessed from outside the class, it must start with either _
or __
. The meaning between using one or two underscores is different:
- One underscore is usually used within a module
- Two underscores is usually used within Python itself
In Python there is no privacy model, so the underscores are used merely as a suggestion (that you should follow whenever possible). They are mainly used to prevent names from different classes clashing together.
The example below creates an attribute called __name
. In truth, this variable can still be accessed through p._Person__name
.
Note: This is quite more complex than what we explained here, so please do investigate on your own if you wish to know more information (and more accurate). Search name mangling
.
class Person:
def __init__(self, name):
self.__name = name
p = Person("Eduardo")
# print(p.__name) # Results in an error!
Note: When using the global
keyword some odd behaviour may arise
If your class has a method that does not depend on an instance of itself, it can be considered good policy to indicate it. Decorators are used for this distinction:
class Date():
self.epoch = 1970
def __init__(self, day, month, year):
pass
@staticmethod
def fromString(s):
# Logic to extract info from string
return Date(day, month, year)
@classmethod
def yearsFromEpoch(cls, year):
return year - cls.epoch
So what's the difference between static method and class method?
A static method doesn't need to pass any argument, be it cls
or self
. As such it does not rely on anything defined in the class itself (trying to access the epoch attribute would return an error). A very common use case is to implement methods that construct an instance of the class from other sources (imagine a Color class that would have a fromRBG, fromHSV or fromHex methods).
A class method requires the first argument to be cls
, which is a reference to the class itself. It can be used, for example, to retrive class constants or to later on create modules (just like Math or datetime, for example).
class Person:
def __init__(self, name):
self.name = name
def aux(self):
self.a = 1
p = Person("Eduardo")
# print(p.a) # Results in an error!
p.aux()
print(p.a) # 1
An attribute is only available when it is actually initialized (obviously), as such trying to access p.a
before initializing it results in an error. This is a very common and easy mistake to do.
Sometimes you need to hide some logic, or need to refactor a class but need to keep old behaviour. For this, Decorators come to the rescue again! You can interact with properties just as if you would an attribute, but they can work miracles behind the scenes. On the following example we use a property to get the user's email and full name as an attribute and do extra logic on them as well! We also provide an way to set fullname
, which will extract the first and last name automatically. We also support the del
statement to set both names to None
.
class Person():
def __init__(self, firstName, lastName, emailDomain):
self.firstName = firstName
self.lastName = lastName
self.emailDomain = emailDomain
@property
def email(self):
if (self.firstName != None and self.lastName != None):
return f"{self.firstName}.{self.lastName}@{self.emailDomain}"
else
return None
@property
def fullname(self):
if (self.firstName != None and self.lastName != None):
return f"{self.firstName} {self.lastName}"
else
return None
@fullname.setter
def fullname(self, name):
self.firstName, self.lastName = name.split(' ')
@fullname.deleter
def fullname(self):
print("Deleted name!")
self.firstName = None
self.lastName = None
p = Person("Tiago", "Silva", "fe.up.pt")
print(p.email) # Tiago.Silva@fe.up.pt
p.fullname = "Eduardo Correia"
print(p.email) # Eduardo.Correia@fe.up.pt
del p.fullname # Deleted name!
print(p.email) # None
The syntax for a getter property is @property
The syntax for a setter property is @propertyName.setter
The syntax for a deleter property is @propertyName.deleter
In Python if you were to extend the Person class you only have to do class Student(Person)
.
class Person:
def __init__(self, name):
self.name = name
def printName(self):
print(f"Person: {self.name}")
class Student(Person):
def __init__(self, name, grade):
super().__init__(name)
self.grade = grade
def printName(self):
print(f"Student: {self.name}")
def printStudent(self):
print(f"Grade: {self.grade}")
s = Student("Eduardo", 18)
s.printName() # Student: Eduardo
s.printStudent() # Grade: 18
When looking for a method/attribute, Python starts looking from the subclasses to the superclasses.
By default, all Python classes extend the Object class. That's why you've been overriding __init__()
and __del__()
so far.
Sometimes you need to inherit multiple classes at once, for example if you needed Student to extend Person and a either a new Worker class or the methods of a LibraryPass class (think Java Interfaces) as well.
For the following example the we will use a simple LibraryPass class which defines a method to borrow a book.
class Person:
def __init__(self, identifier, name):
self.name = name
self.identifier = identifier
def printName(self):
print(f"Person: {self.name}")
class LibraryPass():
def __init__(self, borrower_id):
self.borrower_id = borrower_id
def borrow(self, book_id):
print(f"Borrower {self.borrower_id} has taken book {book_id}")
class Student(Person, LibraryPass):
def __init__(self, identifier, name, grade):
# super().__init__(identifier, name)
# super().__init__(identifier)
Person.__init__(self, identifier, name)
LibraryPass.__init__(self, identifier)
self.grade = grade
def printName(self):
print(f"Student: {self.name}")
s = Student(12345678, "Eduardo", 18)
s.printName() # Student: Eduardo
s.borrow(42) # Borrower 12345678 has taken book 42
In the case of multiple inheritance trying to call both __init__
methods from super()
(the commented lines in Student.__init__()
) would spit out the following error:
Traceback (most recent call last):
File "c:\PythonWorkshop\test.py", line 266, in <module>
s = Student(12345678, "Eduardo", 18)
File "c:\PythonWorkshop\test.py", line 257, in __init__
super().__init__(identifier)
TypeError: __init__() missing 1 required positional argument: 'name'
As Python tries to find __init__()
methods in the superclasses, it starts looking in the order of the __bases__
attribute (Built-in Class Attributes). To find the exact order through every superclass (including itself), all the way to object
, you can call Student.mro()
. In this case it would return the following:
[<class '__main__.Student'>, <class '__main__.Person'>, <class '__main__.LibraryPass'>, <class 'object'>]
So, as you can see from that list, Python found a valid __init()__
method in Person, it tried to use it. Unfortunately the number of arguments didn't match, so it gave us that error.
To specify which superclass to use, you must specify it, such as Person.__init__(self, identifier, name)
and LibraryPass.__init__(self, identifier)
.
The word polymorphism means having many forms.
Polymorphism is a very important concept in programming. It refers to the use of a single type entity (method, operator or object) to represent different types in different scenarios.
In this example, the same method greet()
is being used differently depending on the class in question.
class American():
def greet(self):
print("Hello World!")
class Spanish():
def greet(self):
print("Hola Mundo!")
class Portuguese():
def greet(self):
print("Olá Mundo!")
people = (American(), Spanish(), Portuguese())
for person in people:
person.greet()
You might have noticed that the same built-in operator or function shows different behavior for objects of different classes, this is called operator overloading.
Operator overloading means giving extended meaning beyond their predefined operational meaning.
For example, the operator +
is used to add two integers as well as join two strings or merge two lists. It is achievable because it is overloaded by int
and str
classes.
class Integer():
def __init__(self, i):
self.i = i
# Adding two objects
def __add__(self, o):
return self.i + o.i
num1 = Integer(2)
num2 = Integer(4)
print(num1 + num2) # 6
Commonly overridden operators:
Built-in Method | Operator | Meaning |
---|---|---|
__add__() | + | add |
__sub__() | - | minus |
__mul__() | * | multiply |
__truediv__() | / | regular division |
__floordiv__() | // | integer division |
__mod__() | % | remainder/modulo |
__pow__() | ** | power |
__lt__() | < | less than |
__gt__() | > | greater than |
__le__() | <= | lesser than or equal to |
__ge__() | >= | greater than or equal to |
__eq__() | == | equality check |
__ne__() | != | inequality check |
Note: For more operators check the official documentation
There are two different methods that convert an object into a string, whose use case are usually opposite:
-
__str__()
Used whenever you callprint(myClassInstance)
, and is expected to be able to be seen by a user. As such try to make this method show only what the end user of your class should see. -
__repr__()
This method is supposed to give an accurate representation of your class object, so do try to give as many details as possible, as this can be used as an important debug tool.
A common technique is to give a constructor-like string that could replicate the object.
Every Python class keeps following built-in attributes and they can be accessed using dot operator like any other attribute.
-
__dict__
: Dictionary containing the class's namespace. -
__doc__
: Class documentation string or None, if undefined. -
__name__
: Class name. -
__module__
: Module name in which the class is defined. This attribute is__main__
in interactive mode (REPL). -
__bases__
: A possibly empty tuple containing the base classes, in the order of their occurrence in the base class list.
Previous: Decorators
Next: Modules