OOP II: Inheritance and Applications

EE1021 Programming for Engineers - Lecture 8

Basri Erdogan

Lecture Goals

  1. Use getters and setters and explain why information hiding matters
  2. Build class hierarchies with inheritance: subclasses that add, extend, or override behavior
  3. Initialize parent classes correctly with super().__init__()
  4. Use class variables shared across all instances, and special methods such as __add__ and __eq__
  5. Choose between composition and inheritance, and use @property decorators

Part 1: Recap and Encapsulation

Last Time

  • Abstract data types through classes
  • Coordinate example
  • Fraction example

Today

  • More on classes:
    • getters and setters
    • information hiding
    • class variables
  • Inheritance

Class Definition vs Instance of a Class (Recap)

The class definition

class Coordinate(object):
  • The class name is the type.
  • The class is defined generically:
    • use self to refer to some instance while defining the class, e.g. (self.x - self.y)**2
    • self is a parameter to methods in the class definition
  • The class defines data and methods common across all instances.

An instance

coord = Coordinate(1, 2)
  • An instance is one specific object.
  • Data attribute values vary between instances:
c1 = Coordinate(1, 2)
c2 = Coordinate(3, 4)
  • c1 and c2 have different values of c1.x and c2.x because they are different objects.
  • An instance has the structure of the class.

How to Define a Class (Recap)

This lecture’s running example — the Animal class:

class Animal(object):
    def __init__(self, age):
        self.age = age
        self.name = None

myanimal = Animal(3)
  • Animal — class name; object — class parent
  • self — variable that refers to an instance of the class
  • age — the data that initializes an Animal
  • name is a data attribute even though an instance is not initialized with it as a parameter
  • myanimal = Animal(3) creates an instance; the 3 maps to self.age in the class definition

Getter and Setter Methods

class Animal(object):
    def __init__(self, age):
        self.age = age
        self.name = None
    def get_age(self):              # getter
        return self.age
    def get_name(self):             # getter
        return self.name
    def set_age(self, newage):      # setter
        self.age = newage
    def set_name(self, newname=""): # setter
        self.name = newname
    def __str__(self):
        return "animal:" + str(self.name) + ":" + str(self.age)

Getters and setters should be used outside of the class to access data attributes.

An Instance and Dot Notation (Recap)

  • Instantiation creates an instance of an object:
a = Animal(3)
  • Dot notation is used to access attributes (data and methods), though it is better to use getters and setters to access data attributes:
a.age        # access data attribute directly:
             #   allowed, but NOT recommended
a.get_age()  # access method:
             #   best to use getters and setters

Information Hiding

The author of a class definition may change data attribute variable names:

class Animal(object):
    def __init__(self, age):
        self.years = age       # replaced the age data
    def get_age(self):         # attribute by years
        return self.years
  • If you access data attributes outside the class and the class definition changes, you may get errors.
  • Outside of the class, use getters and setters instead: use a.get_age(), not a.age
    • good style
    • easy to maintain code
    • prevents bugs

Python Is Not Great at Information Hiding

Python allows you to:

  • access data from outside the class definition:
print(a.age)
  • write to data from outside the class definition:
a.age = 'infinite'
  • create data attributes for an instance from outside the class definition:
a.size = "tiny"

It’s not good style to do any of these!

Default Arguments

  • Default arguments for formal parameters are used if no actual argument is given:
def set_name(self, newname=""):
    self.name = newname

Default argument used here

a = Animal(3)
a.set_name()
print(a.get_name())   # prints ""

Argument passed in is used here

a = Animal(3)
a.set_name("charlie")
print(a.get_name())   # prints "charlie"

Part 2: Inheritance

Hierarchies

  • Parent class (superclass)
  • Child class (subclass):
    • inherits all data and behaviors of the parent class
    • adds more info
    • adds more behavior
    • overrides behavior

flowchart TD
    O[object] --> A[Animal]
    A --> C[Cat]
    A --> P[Person]
    A --> R[Rabbit]
    P --> S[Student]

Inheritance: Parent Class

class Animal(object):
    def __init__(self, age):
        self.age = age
        self.name = None
    def get_age(self):
        return self.age
    def get_name(self):
        return self.name
    def set_age(self, newage):
        self.age = newage
    def set_name(self, newname=""):
        self.name = newname
    def __str__(self):
        return "animal:" + str(self.name) + ":" + str(self.age)
  • Everything is an object; the class object implements basic operations in Python.
  • In Python 3, object is automatically inherited.

Inheritance: Subclass

class Cat(Animal):
    def speak(self):
        print("meow")
    def __str__(self):
        return "cat:" + str(self.name) + ":" + str(self.age)
  • Cat inherits all attributes of Animal: __init__(), age, name, get_age(), get_name(), set_age(), set_name(), __str__()
  • Adds new functionality with speak():
    • an instance of type Cat can be called with the new method
    • an instance of type Animal throws an error if called with Cat’s new method
  • Overrides __str__
  • __init__ is not missing — Cat uses the Animal version.

Which Method to Use?

  • A subclass can have methods with the same name as the superclass.
  • For an instance of a class, Python looks for the method name in the current class definition first.
  • If not found, it looks for the method name up the hierarchy (in parent, then grandparent, and so on).
  • Python uses the first method up the hierarchy that it finds with that method name.

The Person Subclass

class Person(Animal):                        # parent class is Animal
    def __init__(self, name, age):
        Animal.__init__(self, age)           # call Animal constructor
        self.set_name(name)                  # call Animal's method
        self.friends = []                    # add a new data attribute
    def get_friends(self):                   # new methods
        return self.friends
    def add_friend(self, fname):
        if fname not in self.friends:
            self.friends.append(fname)
    def speak(self):
        print("hello")
    def age_diff(self, other):
        diff = self.age - other.age
        print(abs(diff), "year difference")
    def __str__(self):                       # override Animal's __str__
        return "person:" + str(self.name) + ":" + str(self.age)

Initializing the Parent: super()

  • When a subclass overrides __init__, the parent’s __init__ is not called automatically.
  • You must call it explicitly to ensure the object is properly set up.
class Child(Parent):
    def __init__(self, name, toy):
        # Option 1 (old style - hardcoded parent name)
        # Parent.__init__(self, name)
        # Option 2 (modern style - recommended)
        super().__init__(name)
        self.toy = toy

The Student Subclass

import random

class Student(Person):
    def __init__(self, name, age, major=None):
        # Person.__init__(self, name, age)   # the OLD way
        super().__init__(name, age)          # the new way
        self.major = major
    def change_major(self, major):
        self.major = major
    def speak(self):
        r = random.random()
        if r < 0.25:
            print("i have homework")
        elif 0.25 <= r < 0.5:
            print("i need sleep")
        elif 0.5 <= r < 0.75:
            print("i should eat")
        else:
            print("i am watching tv")
    def __str__(self):
        return "student:" + str(self.name) + ":" + str(self.age) + ":" + str(self.major)

Polymorphism

  • The ability to treat different object types as the same parent type.
  • Key point: you don’t need to check the type — Python automatically calls the correct method.
  • Real-world use: writing functions that work on any object that has the required methods.
animals = [Cat(3), Dog(5), Rabbit(2)]
for animal in animals:
    animal.speak()   # each calls its own version

Part 3: Class Variables and Special Methods

Class Variables and the Rabbit Subclass

  • Class variables and their values are shared between all instances of a class.
class Rabbit(Animal):
    tag = 1                       # class variable
    def __init__(self, age, parent1=None, parent2=None):
        Animal.__init__(self, age)
        self.parent1 = parent1
        self.parent2 = parent2
        self.rid = Rabbit.tag     # instance variable
        Rabbit.tag += 1
  • tag is used to give a unique id to each new Rabbit instance.

Rabbit Getter Methods

class Rabbit(Animal):
    tag = 1
    def __init__(self, age, parent1=None, parent2=None):
        Animal.__init__(self, age)
        self.parent1 = parent1
        self.parent2 = parent2
        self.rid = Rabbit.tag
        Rabbit.tag += 1
    def get_rid(self):
        return str(self.rid).zfill(3)
    def get_parent1(self):
        return self.parent1
    def get_parent2(self):
        return self.parent2
  • zfill is a string method that pads the beginning with zeros — for example, 001 not 1.
  • These getter methods are specific to the Rabbit class; get_name and get_age are inherited from Animal.

Working with Your Own Types

    def __add__(self, other):
        # returning object of same type as this class
        return Rabbit(0, self, other)
  • Recall Rabbit’s __init__(self, age, parent1=None, parent2=None).
  • This defines the + operator between two Rabbit instances:
    • defines what r4 = r1 + r2 does, where r1 and r2 are Rabbit instances
    • r4 is a new Rabbit instance with age 0
    • r4 has self as one parent and other as the other parent
    • in __init__, parent1 and parent2 are of type Rabbit

Special Method to Compare Two Rabbits

  • Decide that two rabbits are equal if they have the same two parents:
    def __eq__(self, other):
        parents_same = self.parent1.rid == other.parent1.rid \
                       and self.parent2.rid == other.parent2.rid
        parents_opposite = self.parent2.rid == other.parent1.rid \
                           and self.parent1.rid == other.parent2.rid
        return parents_same or parents_opposite
  • Compare ids of parents, since ids are unique (thanks to the class variable).
  • Note that you can’t compare the parent objects directly:
    • e.g. with self.parent1 == other.parent1
    • this calls the __eq__ method over and over until it is called on None, and gives an AttributeError when it tries to do None.parent1

Part 4: Design Tools

Composition vs Inheritance

When to use each:

Relationship Use Example
IS-A Inheritance Cat is an Animal
HAS-A Composition Car has an Engine
class Engine:
    def start(self):
        print("Vroom")

class Car:
    def __init__(self):
        self.engine = Engine()   # Car owns an Engine
    def drive(self):
        self.engine.start()
        print("Moving")
  • Rule: favor composition over inheritance (more flexible, less brittle).

Property Decorators

The property decorator is built into Python and allows you to use class methods as data attributes.

class Individual:
    def __init__(self, name):
        self._name = name

    @property                # getter
    def name(self):
        return self._name

    @name.setter             # setter
    def name(self, value):
        if not value:
            raise ValueError("Name cannot be empty")
        self._name = value
# Usage
p = Individual("Alice")
print(p.name)    # accesses the getter
p.name = "Bob"   # accesses the setter
  • _name: the leading _ signals the user that this is a private attribute and should not be modified directly.

Property Decorators: Computed Attributes

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    @property
    def area(self):
        # Calculated on the fly! No self._area variable needed.
        return self.width * self.height

# Usage
r = Rectangle(5, 10)
print(r.area)    # 50
# r.area = 60    # AttributeError: can't set attribute

Object-Oriented Programming

  • Create your own collections of data.
  • Organize information.
  • Division of work.
  • Access information in a consistent manner.
  • Add layers of complexity.
  • Like functions, classes are a mechanism for decomposition and abstraction in programming.

Summary

  • Getters and setters keep the internal representation private — information hiding prevents bugs.
  • A subclass inherits all data and behavior from its parent, and can add or override attributes.
  • When a subclass defines __init__, call the parent’s with super().__init__(...).
  • Method lookup goes up the hierarchy: current class first, then parent, grandparent, and so on.
  • Class variables (like Rabbit.tag) are shared by all instances.
  • Prefer composition (HAS-A) over inheritance (IS-A) when there is no true subtype relationship.