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

Week 9 [Mon, Mar 13th] - Programming Topics

Guidance for the item(s) below:

You are strongly recommended to do the following when learning this week's topics:

  • Watch pre-recorded videos of this week (x1.25 or x1.5 speed is recommended) as they have more elaborate explanations and visualizations. Read the text after watching the video.
  • Learn this week's SE topics before starting the topic below, as the two are highly interlinked.

This week, we continue to learn more about Python classes. Let's start with class-level variables and methods.

Object-Oriented Programming

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



Guidance for the item(s) below:

Next, let us learn to to control access to variables and methods of an object.

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



Follow up notes for the item(s) above:

This video contains an example that covers some of the OOP topics covered so far.

Video

Guidance for the item(s) below:

As your project code gets bigger, it becomes cumbersome to have all code in one file. Let's learn how to organize Python code into files and folders.

Video

Appendix C: Organizing Python Code

You can organize the code as multiple files. Python considers each file as a module. To use a function/class in one file from another, you need to import it first.

Consider the following two files residing in the same directory.

[project_root]/student.py:

def describe(name):
    print(name, 'is a student')

[project_root]/course.py:

import student

student.describe('Adam')

Notice how the course.py imports the student module and uses the student.describe function defined in the student.py file.

You can organize files into sub-directories too.

Consider the following two files.

[project_root]/utils/print_utils/misc.py:

def print_as_list(text):
    print(list(text))

[project_root]/course.py:

from utils.print_utils import misc 

misc.print_as_list('Adam')

Notice how the import statement uses a slightly different syntax and uses . to indicate directory nesting (i.e., utils/print_utilsutils.print_utils). In fact, there are other variations of the import syntax.

When you import a module, Python will interpret and execute any code in it.

Consider this student module being imported into the course module.

[project_root]/student.py:

def describe(name):
    print(name, 'is a student')

describe('Betty')

[project_root]/course.py:

import student

student.describe('Adam')
 → 

Betty is a student
Adam is a student

Notice how the output contains Betty is a student. That is because the line describe('Betty') in student.py is being executed as the student module is being imported.

To prevent execution of statements in an imported module, nest such statements under a if __name__ == "__main__": block.

In the code below, the student module can be executed to get the following output.

[project_root]/student.py:

def describe(name):
    print(name, 'is a student')

if __name__ == "__main__":
    describe('Betty')
 → 

Betty is a student

When the student module is imported to another module, describe('Betty') line is no longer executed.

[project_root]/course.py:

import student

student.describe('Adam')
 → 

Adam is a student