A Little Bit of OO Theory

Classes and objects are two of programming’s power tools. They let good programmers do a lot in very little time, but with them, bad programmers can create a real mess. This section will introduce some underlying theory that will help you design reliable, reusable object-oriented software.

Encapsulation

To encapsulate something means to enclose it in some kind of container. In programming, encapsulation means keeping data and the code that uses it in one place and hiding the details of exactly how they work together. For example, each instance of class file keeps track of what file on the disk it is reading or writing and where it currently is in that file. The class hides the details of how this is done so that programmers can use it without needing to know the details of how it was implemented.

Polymorphism

Polymorphism means “having more than one form.” In programming, it means that an expression involving a variable can do different things depending on the type of the object to which the variable refers. For example, if obj refers to a string, then obj[1:3] produces a two-character string. If obj refers to a list, on the other hand, the same expression produces a two-element list. Similarly, the expression left + right can produce a number, a string, or a list, depending on the types of left and right.

Polymorphism is used throughout modern programs to cut down on the amount of code programmers need to write and test. It lets us write a generic function to count nonblank lines:

 def​ non_blank_lines(thing):
 """Return the number of nonblank lines in thing."""
 
  count = 0
 for​ line ​in​ thing:
 if​ line.strip():
  count += 1
 return​ count

And then we can apply it to a list of strings, a file, or a web page on a site halfway around the world (see Files over the Internet). Each of those three types knows how to be the subject of a loop; in other words, each one knows how to produce its “next” element as long as there is one and then say “all done.” That means that instead of writing four functions to count interesting lines or copying the lines into a list and then applying one function to that list, we can apply one function to all those types directly.

Inheritance

Giving one class the same methods as another is one way to make them polymorphic, but it suffers from the same flaw as initializing an object’s instance variables from outside the object. If a programmer forgets just one line of code, the whole program can fail for reasons that will be difficult to track down. A better approach is to use a third fundamental feature of object-oriented programming called inheritance, which allows you to recycle code in yet another way.

Whenever you create a class, you are using inheritance: your new class automatically inherits all of the attributes of class object, much like a child inherits attributes from his or her parents. You can also declare that your new class is a subclass of some other class.

Here is an example. Let’s say we’re managing people at a university. There are students and faculty. (This is a gross oversimplification for purposes of illustrating inheritance; we’re ignoring administrative staff, caretakers, food providers, and more.)

Both students and faculty have names, postal addresses, and email addresses; each student also has a student number, a list of courses taken, and a list of courses he or she is currently taking. Each faculty member has a faculty number and a list of courses he or she is currently teaching. (Again, this is a simplification.)

We’ll have a Faculty class and a Student class. We need both of them to have names, addresses, and email addresses, but duplicate code is generally a bad thing; so we’ll avoid it by also defining a class, perhaps called Member, and keeping track of those features in Member. Then we’ll make both Faculty and Student subclasses of Member:

 class​ Member:
 """ A member of a university. """
 
 def​ __init__(self, name: str, address: str, email: str) -> None:
 """Create a new member named name, with home address and email address.
  """
 
  self.name = name
  self.address = address
  self.email = email
 
 class​ Faculty(Member):
 """ A faculty member at a university. """
 
 def​ __init__(self, name: str, address: str, email: str,
  faculty_num: str) -> None:
 """Create a new faculty named name, with home address, email address,
  faculty number faculty_num, and empty list of courses.
  """
 
  super().__init__(name, address, email)
  self.faculty_number = faculty_num
  self.courses_teaching = []
 
 
 class​ Student(Member):
 """ A student member at a university. """
 
 def​ __init__(self, name: str, address: str, email: str,
  student_num: str) -> None:
 """Create a new student named name, with home address, email address,
  student number student_num, an empty list of courses taken, and an
  empty list of current courses.
  """
 
  super().__init__(name, address, email)
  self.student_number = student_num
  self.courses_taken = []
  self.courses_taking = []

Both class headers—class Faculty(Member): and class Student(Member):—tell Python that Faculty and Student are subclasses of class Member. That means that they inherit all of the attributes of class Member.

The first line of both Faculty.__init__ and Student.__init__ call function super, which produces a reference to the superclass part of the object, Member. That means that both of those first lines call method __init__, which was inherited from class Member. Notice that we just pass the relevant parameters in as arguments to this call, just as we would with any method call.

If we import these into the shell, we can create both faculty and students:

 >>>​​ ​​paul​​ ​​=​​ ​​Faculty(​​'Paul Gries'​​,​​ ​​'Ajax'​​,​​ ​​'pgries@cs.toronto.edu'​​,​​ ​​'1234'​​)
 >>>​​ ​​paul.name
 Paul Gries
 >>>​​ ​​paul.email
 pgries@cs.toronto.edu
 >>>​​ ​​paul.faculty_number
 1234
 >>>​​ ​​jen​​ ​​=​​ ​​Student(​​'Jen Campbell'​​,​​ ​​'Toronto'​​,​​ ​​'campbell@cs.toronto.edu'​​,
 ...​​ ​​'4321'​​)
 >>>​​ ​​jen.name
 Jen Campbell
 >>>​​ ​​jen.email
 campbell@cs.toronto.edu
 >>>​​ ​​jen.student_number
 4321

Both the Faculty and Student objects have inherited the features defined in class Member.

Often, you’ll want to extend the behavior inherited from a superclass. As an example, we might write a __str__ method inside class Member:

 def​ __str__(self) -> str:
 """Return a string representation of this Member.
 
  >>> member = Member('Paul', 'Ajax', 'pgries@cs.toronto.edu')
  >>> member.__str__()
  'Paul​​\\​​nAjax​​\\​​npgries@cs.toronto.edu'
  """
 
 return​ ​'{}​​\n​​{}​​\n​​{}'​.format(self.name, self.address, self.email)

With this method added to class Member, both Faculty and Student inherit it:

 >>>​​ ​​paul​​ ​​=​​ ​​Faculty(​​'Paul'​​,​​ ​​'Ajax'​​,​​ ​​'pgries@cs.toronto.edu'​​,​​ ​​'1234'​​)
 >>>​​ ​​str(paul)
 'Paul\nAjax\npgries@cs.toronto.edu'
 >>>​​ ​​print(paul)
 Paul
 Ajax
 pgries@cs.toronto.edu

That isn’t quite enough, though: for class Faculty, we want to extend what the Member’s __str__ does, adding the faculty number and the list of courses the faculty member is teaching, and a Student string should include the equivalent student-specific information.

We’ll use super again to access the inherited Member.__str__ method and to append the Faculty-specific information:

 def​ __str__(self) -> str:
 """Return a string representation of this Faculty.
 
  >>> faculty = Faculty('Paul', 'Ajax', 'pgries@cs.toronto.edu', '1234')
  >>> faculty.__str__()
  'Paul​​\\​​nAjax​​\\​​npgries@cs.toronto.edu​​\\​​n1234​​\\​​nCourses: '
  """
 
  member_string = super().__str__()
 
 return​ ​'''{}​​\n​​{}​​\n​​Courses: {}'''​.format(
  member_string,
  self.faculty_number,
 ' '​.join(self.courses_teaching))

With this, we get the desired output:

 >>>​​ ​​paul​​ ​​=​​ ​​Faculty(​​'Paul'​​,​​ ​​'Ajax'​​,​​ ​​'pgries@cs.toronto.edu'​​,​​ ​​'1234'​​)
 >>>​​ ​​str(paul)
 'Paul\nAjax\npgries@cs.toronto.edu\n1234\nCourses: '
 >>>​​ ​​print(paul)
 Paul
 Ajax
 pgries@cs.toronto.edu
 1234
 Courses: