This site is from a past semester! The current version will be here when the new semester starts.

Object-Oriented Programming

Classes

You can define your own classes (i.e., object types) in Python, which can then be used alongside the built-in classes such as list, int, str, etc.

The syntax for defining a class:

class ClassName:
  # <statement-1>
  .
  .
  .
  # <statement-N>

It is customary to use Upper Camel Case for class names.

The syntax for creating (also called instantiating) an object of the class is ClassName().

The code below defines a class named Employee, creates two employee objects, and prints the class/type of each object.

class Employee:
  pass # an empty class

john = Employee() # create an Employee object
print(type(john)) # print type of the john object
alice = Employee()
print(type(alice))

Note how the type of each object is given as <class 'Employee'> i.e., a class with name Employee.

<class 'Employee'>
<class 'Employee'>

You can add methods to the class by defining them inside the class definition. Note that a method of a class always take self as the first parameter. self refers to the object itself. When calling the method, there is no need to supply an argument for the self parameter as the target object is implicitly taken as the argument for that parameter.

Consider the code below:

class Pen:
  
  def write(self, text):
    print('writing:', text)
    
p = Pen()
p.write('It was a dark night ...')
 → 

writing: It was a dark night ...

write(self, text) method is called as p.write('It was a dark night ...').
This is how the arguments are matched with the parameters:

  • selfp
  • text'It was a dark night ...'

You can specify how to initialize an object of a class by defining an __init__() method in the class. Here are the important things to note about the __init__() method:

  1. There are two underscores in front and two behind the word init.
    _init_(self)
    __init__(self)
  2. It will be called every time you create an instance of the class.
  3. If it has parameters, you need to provide arguments for those parameters when you instantiate an object of that class.

This example shows an __init__ method added to a Person class.

class Person:
  
  def __init__(self, name):
    print(name, 'initialized!')
    
tom = Person('Tom')
 → 

Tom initialized!

An object can have attributes i.e., values attached to the object, just as an object can have methods. Attributes and methods of an object can be accessed using the objectname. syntax, as you did with objects of built-in classes.

This example shows accessing the get_age() method and the birthday attribute of an object x.

print(x.get_age())
print(x.birthday)
 → 

12
01/11/2000

The code within a class needs to use the self. to refer to its own attributes and methods. Furthermore, the best place to initialize attributes is the __init__() method.

Note how the __init__() method of the Book class initializes its two attributes and calls another of its own methods i.e., self.describe().

class Book:
  
  def __init__(self, book_title, book_author):
    self.title = book_title # initialize attribute title
    self.author = book_author # initialize attribute author
    self.describe() # call another method of the class
    
  def describe(self):
    # print attributes of the object
    print('Book info:', self.title, '/by', self.author) 
    
book1 = Book('The Jungle Book', 'Leo Tolstoy')
book2 = Book('The Art of War', 'Sun Tzu')

print()  # print a blank line
print('Title:', book1.title) 
print('Author:', book1.author)
book1.describe()

Book info: The Jungle Book /by Leo Tolstoy
Book info: The Art of War /by Sun Tzu

Title: The Jungle Book
Author: Leo Tolstoy
Book info: The Jungle Book /by Leo Tolstoy

You can get your classes to work with each other.

In this example we define a ReadingList class that can store a list of Book objects.

class ReadingList:
  
  def __init__(self, initial_list):
    self.books = initial_list
    
  def add_book(self, book):
    self.books.append(book)
    
  def show_authors(self):
    for b in self.books:
      print(b.author)
      
my_list = ReadingList([book1]) # book1 defined in a previous example 
my_list.add_book(book2) # book2 defined in a previous example
my_list.show_authors()

Leo Tolstoy
Sun Tzu

Exercise: Create StockItem Class

Exercise : Create StockItem Class

Define the StockItem class missing in the code below so that the code produces the output given on the right.

# ADD StockItem class here
# It should have an attribute: name
# It should have a method: info()

a = StockItem('Apple')
print(a.info())
print(a.name)
b = StockItem('Bag of Beans')
print(b.info())
b.name = 'Bananas'
print(b.info())
 → 

Item info: Apple
Apple
Item info: Bag of Beans
Item info: Bananas

Partial solution



Exercise: StockItem with Price

Exercise : StockItem with Price

Enhance the StockItem class you wrote in the previous exercise so that each StockItem object can keep track of its own price and the price can be increased later.

# ADD StockItem class here
# It should have attributes: name, price
# It should have methods: info, increase_price

items = []
items.append(StockItem('Pen', 5))  # a pen worth 5 dollars
items.append(StockItem('Pencil', 3))
items.append(StockItem('Cup', 1))
for item in items:
    item.increase_price(1)  # increase price by 1
    print(item.info())

The price of Pen is 6
The price of Pencil is 4
The price of Cup is 2


Class-Level Members

Attributes initialized inside a class but outside of its methods are considered class-level attributes (attributes are also called data or variables) i.e., they are shared among all objects of that class whereas attributes initialized inside the __init__() method are instance-level attributes i.e., their values vary from instance-to-instance. A class-level attribute is accessed using the ClassName. syntax.

The Student class below has one class-level attribute total_students. As it is meant to track the total number of Student objects created, it is incremented in the __init__() method. course_count and name are instance-level attributes.

class Student:
  
  total_students = 0 # class-level attribute
  
  def __init__(self, student_name):
    self.name = student_name # instance-level attribute
    Student.total_students = Student.total_students + 1 # update class-level attribute
    self.course_count = 0 # instance-level attribute

The code below creates two Student objects. Note how the class-level attribute total_students,

  • can be accessed using the class name (i.e., Student.total_students) or the object (i.e., amy.total_students) and both give the same value.
  • is shared among all objects and correctly gives 2 after another Student object has been created.
amy = Student("Amy")
print('total students:', Student.total_students) # access via class name
print('total students:', amy.total_students) # access via object
print('Amy course count:', amy.course_count)

ben = Student("Ben")
print('total students:', Student.total_students)
print('total students:', ben.total_students) 
print('Ben course count:', ben.course_count)

total students: 1
total students: 1
Amy course count: 0
total students: 2
total students: 2
Ben course count: 0

It is possible to have class-level methods too. A class-level method definition should be annotated using a @classmethod annotation. In addition, a class-level method's first parameter should be cls which represents the class itself (similar to instance level methods' first parameter needing to be self).

The Printer class below has a class-level attribute enabled and class-level methods enable() and disable().

class Printer:

  enabled = True

  def __init__(self, new_text):
    self.text = new_text

  def print_text(self):
    if Printer.enabled:
      print(self.text)

  @classmethod
  def disable(cls):
    cls.enabled = False  # cls here is same as Printer
    print('All Printers disabled')

  @classmethod
  def enable(cls):
    cls.enabled = True
    print('All Printers enabled')

The code below uses the class-level method to enable/disable printing for all Printer objects.

Printer.disable()
p1 = Printer('blah blah')
p2 = Printer('yak yak')
p1.print_text() # will not print
p2.print_text() # will not print

Printer.enable() 
p1.print_text()
p2.print_text()
 → 

All Printers disabled
All Printers enabled
blah blah
yak yak

As you can see from the above code, class-level methods can be called even before any objects of that class have been created.

Exercise: Add Class-Level Members to Subject Class

Exercise : Add Class Level Members to Subject class

Add the following members to the Subject class in the code below so that the code produces the output given on the right.

  • a class-level attribute total (type: int) that tracks the total number of Subject objects created
  • a class-level method limit_reached(limit) that returns True (type: bool) if the total is greater than or equal to the specified limit.
class Subject:

  def __init__(self, code, name):
    self.name = name
    self.code = code

  def print_info(self):
    print(self.code, ':', self.name)

print('total subjects:', Subject.total)
s1 = Subject('TEE3201', 'Software Engineering')
print('total subjects:', Subject.total)
print(Subject.limit_reached(1))
print(Subject.limit_reached(2))
s2 = Subject('TEE3201', 'Software Engineering')
print('total subjects:', Subject.total)
print(Subject.limit_reached(2))
print(Subject.limit_reached(3))
Subject.total = 100
print(Subject.limit_reached(100))
 → 

total subjects: 0
total subjects: 1
True
False
total subjects: 2
True
False
True

Partial solution and hints




Visibility

The encapsulation aspect of OOP requires that an object should only allow controlled access to its members. For example, it should be able to make some of its attributes visible to other object while keeping others hidden. As a consequence of Python's aim to be a very flexible and versatile language, it does not enforce encapsulation as strictly as some other OOP languages. However, it recommends some conventions, if followed, will maintain a reasonable level of encapsulation in objects.

  • Private members: An attribute/method whose name has at least two leading underscores and at most one trailing underscore (e.g., __foo, __bar_()) cannot be accessed using its name by code outside the class.

  • Protected members: a name prefixed with a single underscore (e.g. _foo, _bar()) are -- by convention -- should not be accessed by code outside of the owner class as they are not meant to be part of the object's public interface.

  • Public members: other members are considered public and OK to be accessed by code outside the class.

Consider the Account class below.

class Account:
  currency = '$'   # public
  _min = 10.       # protected
  __max = 1000.0   # private
  
  def __init__(self, person, amount):
    self.__balance = amount # private
    self._owner = person    # protected
    self.status = 'OK'      # public
    
  def get_info(self):       # public
    return [self.status]
    
  def get_loan_limit(self): # public
    return self.__get_income()*5
    
  def _get_more_info(self): # protected
    return self.get_info() + [self._owner]
    
  def __get_income(self):   # private
    return self.__balance + 100

The following code works fine because the members being accessed are public.

print('currency:', Account.currency)
a = Account('Adam', 100)
print('status:', a.status)
print('info:', a.get_info())
 → 

currency: $
status: OK
info: ['OK']

The following code works too. But they access protected members which is not a recommended practice.

print('minimum allowed:', Account._min) 
a = Account('Adam', 100)
print('owner:', a._owner) 
print('more info:', a._get_more_info())
 → 

minimum allowed: 10.0
owner: Adam
more info: ['OK', 'Adam']

The following code will not work because they try to access private members.

print('maximum allowed:', Account.__max) # error
a = Account('Adam', 100)
print('balance:', a.__balance) # error
print('income:', a.__get_income()) # error

AttributeError: type object 'Account' has no attribute '__max'...
AttributeError: 'Account' object has no attribute '__balance'...
AttributeError: 'Account' object has no attribute '__get_income'...

Although this example does not have class-level methods, note that the same visibility conventions apply to them as well.

Exercise: Change Visibility of School Class Members

Exercise : Change Visibility of School Class Members

Change the visibility of the members of the School class from private to public in the code below so that the code produces the output given on the right.

class School:

  __target = 0.99

  def __init__(self, capital):
    self.__budget = capital

  def __max_expenditure(self):
    return self.__budget * School.__target

print(School.__target)
s = School(100000)
print(s.__budget)
print(s.__max_expenditure())
 → 

0.99
100000
99000.0

💡 Hint




Inheritance

You can make a class from another class. If you do, it is as if the already has all the attributes and methods of the .

Syntax:

class ChildClassName(ParentClassName):
  # statements of the class

Consider the Person class below:

class Person:
  def __init__(self, name):
    self.name = name

  def print_info(self):
    print('My name is', self.name)

amy = Person('Amy')
amy.print_info()
 → 

My name is Amy

The Teacher class below inherits from the Person class given above.

class Teacher(Person):

  def teach(self):
    print(self.name, 'is teaching')

dan = Teacher('Dan')
dan.print_info()
dan.teach()
print('the name is', dan.name)
 → 

My name is Dan
Dan is teaching
the name is Dan

Observe how,

  • a Teacher object can use the print_info() method defined in the parent class.
  • the statement dan = Teacher('Dan') invokes the __init__() method defined in the parent class too.
  • the statement print('the name is', dan.name) is accessing the attribute name from a Teacher object although the attribute is defined in the parent class.
  • the method teach accesses the attribute name using self.name although the attribute is defined in the parent class.

Exercise: Add Fish Class

Exercise : Add Fish Class

Add a Fish class that inherits from the Animal class so that the code below produces the given output.

class Animal:
  def __init__(self, name):
    self.name = name
    
  def info(self):
    print("I'm a", self.name)
    
# ADD YOUR CODE HERE
    
tuna = Fish('Tuna')
tuna.info()
tuna.move()
guppy = Fish('Guppy')
guppy.info()
guppy.move()
 → 

I'm a Tuna
I'm swimming
I'm a Guppy
I'm swimming

Partial solution



A child class can override a method defined in the parent class. That way, a child object can change a behavior defined in the parent class.

Note how the Student class below overrides the print_info() method of the parent class Person.

class Person:
  def __init__(self, name):
    self.name = name

  def print_info(self):
    print(self.name, 'is a person')
adam = Person('Adam')
adam.print_info()
ben = Student('Ben')
ben.print_info()
 → 

class Student(Person):

  def print_info(self):
    print(self.name, 'is a student')




Adam is a person
Ben is a student

When overriding methods, you can reuse the parent's definition of the same method using the super(). prefix.

class Person:
  def __init__(self, name):
    self.name = name

Given that Person class has the initializer method given above, the following two versions of the Student class are equivalent.

(a) Override without reusing parent's method

class Student(Person):
  def __init__(self, name, matric):
    self.name = name
    self.matric = matric

(b) Override but reuse parent's method (preferred)

class Student(Person):
  def __init__(self, name, matric):
    super().__init__(name) # reuse parent's method
    self.matric = matric

Exercise: Add FlightlessBird Class

Exercise : Add FlightlessBird Class

Do the following changes to the given code so that the code below produces the given output:

  1. update the info() method of the Bird to call the same method from the parent class
  2. add a FlightlessBird class that inherits from the Bird class, and override the move() method
class Animal:

  def __init__(self, name):
    self.name = name
    
  def info(self):
    print("I'm a", self.name)
    

class Bird(Animal):

  def move(self):
    print("I'm flying")
    
  def info(self):
    # CALL info() METHOD OF PARENT CLASS HERE 
    print("I have feathers")

# ADD FlightlessBird CLASS HERE

crow = Bird('Crow')
crow.info()
crow.move()
penguin = FlightlessBird('Penguin')
penguin.info()
penguin.move()
 → 

I'm a Crow
I have feathers
I'm flying
I'm a Penguin
I have feathers
I'm waddling

Partial solution



Note that all python classes automatically inherits from the built-in class object even if you did not specify it as the parent class. The object class has a __str__() method that you can ovrride in your classes to customize how the print function will print an object of your class.

The Book class below overrides the __str__() method so that Book objects can be printed in a specific format.

class Book:
  def __init__(self, title):
    self.title = title

  def __str__(self):
    return 'Book title: ' + self.title

book = Book('Python for Beginners')
print(book)
 → 

Book title: Python for Beginners

Exercise: Override __str__() Method

Exercise : Override __str__() Method

Override the __str()__ of the Person class so that the code below produces the given output.

class Person:
  
  def __init__(self, name, age):
    self.name = name
    self.age = age
  
  # OVERRIDE __str__ METHOD HERE
    
print(Person('Amy', 25))
print(Person('Ben', 26))
 → 

Amy (25years)
Ben (26years)

Partial solution



A class can inherit from multiple classes. If multiple parent classes have the same method, the one that is given first (in the order of inheritance) will be used.

  

The TeachingAssistant class above inherits from both Student class and the Teacher class both of which inherit from the Person class. That means a TeachingAssistant object can use methods from classes object, Person, Student, Teacher, and TeachingAssistant.

elsie = TeachingAssistant('Elsie', 'A223344')
elsie.print_info()
elsie.teach()
elsie.learn()
elsie.grade()
 → 

Elsie is a teacher
Elsie is teaching
Elsie is learning
Elsie is grading

As both Teacher and Student classes have the print_info() method, the method from the Teacher class will be used as it comes first in the inheritance order (Teacher, Student); that is why you see Elsie is a teacher in the output instead of Elsie is a student.

Exercise: Add SmartPhone Class

Exercise : Add SmartPhone Class

Add a SmartPhone class to inherits from the Camera class and the Phone class so that the code below produces the given output.

class Camera:
  
  def take_photo(self):
    print('taking photo')
    
class Phone:
  
  def make_call(self):
    print('making call')
    
# ADD SmartPhone CLASS HERE

iphone = SmartPhone('iPhone')
iphone.take_photo()
iphone.make_call()
iphone.play_game()
 → 

My model: iPhone
taking photo
making call
playing game

Partial solution



You need to use inheritance when you create user-defined exceptions because all such exceptions need to inherit from a built-in Exception class.

In the example below, EmptyCommandError and InvalidCommandError are user-defined exceptions. The latter has overridden the constructor to take additional parameters.

class EmptyCommandError(Exception):
    """Indicates a task has expired."""
    pass


class InvalidCommandError(Exception):
    """Indicates that the user entered an invalid command"""

    def __init__(self, command, explanation):
        self.command = command
        self.explanation = explanation


def execute_command(command):
    if command == '':
        raise EmptyCommandError()
    elif len(command) < 4:
        raise InvalidCommandError(command, "command too short")


def process(command):
    try:
        execute_command(command)
    except EmptyCommandError:
        print('empty command')
    except InvalidCommandError as e:
        print('invalid command:', e.command, '->', e.explanation)


process('')
process('HA')

empty command
invalid command: HA -> command too short