Object-Oriented Programming in Python

Gregory M. Kapfhammer

January 27, 2025

What are the main goals of object-oriented programming?

  • Write code that reflects thought and the world
  • Enable code reuse and sharing among programmers
  • Encourage the creation of well-designed software
  • Ensure that software is easy to maintain and extend

What is a class? What is an object?

  • A class is a blueprint for creating objects
  • An object is an instantiation of a class
  • A class defines the attributes and methods of an object
    • Attributes are the data that an object holds
    • Methods are the behaviors that an object can perform

Find an example of a class and an object in a Python program that you previously wrote! After identifying the attributes and the methods, assess the overall quality of the object-oriented design.

Type Inference in Python

mylist = []
print(type(mylist))
print(isinstance(mylist, list))
print(isinstance(mylist, str))
<class 'list'>
True
False
  • Key insights about this source code:
    • mylist = [] creates an empty list
    • type(mylist) returns the type of the list
    • isinstance returns True or False for provided type
    • isinstance(mylist, list) checks if mylist is a list
    • Same approach works for other data types!

Existing types available in Python

def example_function():
  return 0
print(type(example_function))
<class 'function'>

def generator_example(n):
  for i in range(n):
    yield i
print(type(generator_example))
print(type(generator_example(5)))
<class 'function'>
<class 'generator'>
  • type shows that functions and generators have their own types
  • What are the similarities and differences for these two types?
  • How do return and yield influence a function’s behavior?

Explore use of the type function

  • Key Task: Add other types, including those that contain different types!

What if we don’t create objects?

u = (3,4)
v = (3,6)
def add(a, b):
  return (a[0] + b[0], a[1] + b[1])
def subtract(a,b):
  return (a[0] - b[0], a[1] - b[1])
def dot(a, b):
  return (a[0] * b[0] + a[1] * b[1])
def norm(a):
  return (a[0] * a[0] + a[1] * a[1]) ** 0.5
def isvertical(a):
  return a[0] == 0
print(norm(u))
print(add(u,v))
print(u + v)
print(isvertical(subtract(v, u)))
5.0
(6, 10)
(3, 4, 3, 6)
True

Explore tuple for encoding state

  • Key Questions: What are the strengths and weaknesses of this approach to representing vectors? How could this approach lead to program defects?

An object-oriented alternative

class Vector:
  def __init__(self, x, y):
    try:
      self.x = float(x)
      self.y = float(y)
    except ValueError:
      self.x = 0.0
      self.y = 0.0
  def norm(self):
    return (self.x ** 2 + self.y ** 2) ** 0.5
  def __add__(self, other):
    newx = self.x + other.x
    newy = self.y + other.y
    return Vector(newx, newy)
  def __str__(self):
    return "({:.2f}, {:.2f})".format(self.x, self.y)
u = Vector(3,4)
v = Vector(3,6)
print(u + v)
(6.00, 10.00)

Explore an object-oriented approach

  • Key Questions: After trying different instances of Vector, what is the state and behavior? What are the strengths and weaknesses of this approach to representing vectors? How could this approach lead to program defects?

What are the principles of object-oriented programming?

  • Abstraction: Hide implementation details
  • Inheritance: Create new classes from existing classes
  • Encapsulation: Group related data and methods together
  • Polymorphism: Allow objects to take on multiple forms

Encapsulation with Triangle

class Triangle:
    def __init__(self, points):
        self._sides = 3
        self._points = list(points)
        if len(self._points) != 3:
            raise ValueError("Wrong number of points.")

    def sides(self):
        return 3

    def __str__(self):
        return "I’m a triangle."
  • What state does a Triangle object have?
  • How can we access the state of a Triangle object?
  • What are the benefits and limitations of encapsulation?

Encapsulation with Square

class Square:
    def __init__(self, points):
        self._sides = 4
        self._points = list(points)
        if len(self._points) != 4:
            raise ValueError("Wrong number of points.")

    def sides(self):
        return 4

    def __str__(self):
        return "I’m so square."
  • What state does a Square object have?
  • How can we access the state of a Square object?
  • Wait, what is the purpose of __init__ and __str__?

Wait, what is the relationship between a Square and a Triangle?

class Polygon:
    def __init__(self, sides, points):
        self._sides = sides
        self._points = list(points)
        if len(self._points) != self._sides:
            raise ValueError("Wrong number of points.")

    def sides(self):
        return self._sides
  • The Polygon class is a superclass of Triangle and Square
  • The Triangle and Square classes are subclasses of Polygon
  • The Polygon class is a generalization of Triangle and Square

Connecting Triangle and Square to the Polygon Superclass

class Triangle(Polygon):
    def __init__(self, points):
        Polygon.__init__(self, 3, points)

    def __str__(self):
        return "I’m a triangle."

class Square(Polygon):
    def __init__(self, points):
        Polygon.__init__(self, 4, points)

    def __str__(self):
        return "I’m so square."
  • Forms an “is-a” relationship between:
    • Triangle and Polygon and also Square and Polygon

What is duck typing? How does it work in the Python language?

  • Python is a dynamically typed language

  • Python uses duck typing to determine types

  • Remember the silly adage of “If it walks like a duck and quacks like a duck, then it must be a duck”

  • Inheritance is not only way to create an “is-a” relationship!

  • Yet, inheritance makes the “is-a” relationship explicit

  • Let’s explore an example of duck typing in Python!

Connecting Triangle and Square to the Polygon Superclass

class PolygonCollection:
    def __init__(self):
        self._triangles = []
        self._squares = []

    def add(self, polygon):
        if polygon.sides() == 3:
            self._triangles.append(polygon)
        if polygon.sides() == 4:
            self._squares.append(polygon)
  • The polygon parameter can be any object with a sides method
  • This is an example of parametric polymorphism in Python
  • Again, inheritance is not the only way to create an “is-a” relationship!

Using composition in Python

class MyLimitedList:
    def __init__(self):
        self._L = []

    def append(self, item):
        self._L.append(item)

    def __getitem__(self, index):
        return self._L[index]

limited = MyLimitedList()
limited.append(1)
limited.append(10)
limited.append(100)
print(limited[2])
100
  • Composition means that one object is part of another object
  • The MyLimitedList class is composed of a list called _L

Performance of object-oriented programming?

  • Costs to consider in object-oriented programming:
    • Create a new instance of a class
    • Access an object’s attribute
    • Call a method on an object
    • Store an object in memory

What is the expected performance overhead of a function call?

  • Invocation of a function in Python has overhead
  • Function call overhead in Python is reputed to be high
  • Use the Python scripts in jni/performance-tests

Time overhead of function calls

import timeit, f
n = int(1e6); num_exec = 1
for func in filter(lambda f: f.startswith('loop'), sorted(dir(f))):
    print('====== Timing function called:', func)
    print(getattr(f, func).__doc__)
    t = timeit.timeit(stmt='f.%s(n)' % func,
                      number=num_exec, globals=globals())
    per_loop = round(t / n * 1e9)
    print('Time: %ins, Theoretical max bandwidth: %.2fMB/s' %
          (per_loop, 1000 / per_loop))
====== Timing function called: loop_0_empty
Run empty loop n times.
Time: 14ns, Theoretical max bandwidth: 71.43MB/s
====== Timing function called: loop_1_f
Run loop with empty function n times.
Time: 37ns, Theoretical max bandwidth: 27.03MB/s
====== Timing function called: loop_2_f_twice
Run loop calling empty function twice per loop, n times.
Time: 61ns, Theoretical max bandwidth: 16.39MB/s

Review the source code of f.py

"""f.py: A module to benchmark Python function call overhead."""

def f():
    pass

def loop_0_empty(n):
    """Run empty loop n times."""
    for i in range(n):
        pass

def loop_1_f(n):
    """Run loop with empty function n times."""
    for i in range(n):
        f()

def loop_2_f_twice(n):
    """Run loop calling empty function twice per loop, n times."""
    for i in range(n):
        f()
        f()

More timings for function calls

import timeit, g
n = int(1e6); num_exec = 1
for func in filter(lambda g: g.startswith('loop'), sorted(dir(g))):
    print('====== Timing function called:', func)
    print(getattr(g, func).__doc__)
    t = timeit.timeit(stmt='g.%s(n)' % func,
                      number=num_exec, globals=globals())
    per_loop = round(t / n * 1e9)
    print('Time: %ins, Theoretical max bandwidth: %.2fMB/s' %
          (per_loop, 1000 / per_loop))
====== Timing function called: loop_3_g
Run loop with empty function taking args, n times.
Time: 62ns, Theoretical max bandwidth: 16.13MB/s
====== Timing function called: loop_5_g_arg
Run loop with empty function passing an arg, n times.
Time: 80ns, Theoretical max bandwidth: 12.50MB/s
====== Timing function called: loop_6_g_kwarg
Run loop with empty function passing a kwarg, n times.
Time: 84ns, Theoretical max bandwidth: 11.90MB/s

Review the source code of g.py

"""g.py: A module to benchmark Python function call overhead."""

def g(*args, **kwargs):
    pass

def loop_3_g(n):
    """Run loop with empty function taking args, n times."""
    for i in range(n):
        g()

def loop_5_g_arg(n):
    """Run loop with empty function passing an arg, n times."""
    for i in range(n):
        g(n)

def loop_6_g_kwarg(n):
    """Run loop with empty function passing a kwarg, n times."""
    for i in range(n):
        g(n=n)

Let’s run this experiment on your laptop! What are the results?

  • Clone this Github repository: jni/performance-tests
  • Change into the directory called function-calls
  • Run the program called timer.py and observe the results
  • Carefully review the Python code in f.py and g.py
  • What trends did you observe in the performance results?
  • What are the implications of these experimental results?
  • Share (a) your results and (b) a one-sentence summary!

What are the performance trade-offs associated with using objects?

  • Questions to consider when designing an experiment:
    • Cost of calling a method or a function?
    • Cost of accessing an object’s attribute?
    • How quickly can your program process data?
    • What is the space overhead of using objects?
  • Questions to consider when building a program:
    • What data structures are best for your application?
    • How do data structures influence algorithm performance?