Object-Oriented Python

Overview

Associate Professor Justin Dressel

Faculty of Mathematics, Physics, and Computation

Schmid College of Science and Technology

 

Trichotomy of Data

Modeling

Processing

Visualization

Mathematical objects

Physical states

CS types/structures

 Transformations/Morphisms

Evolution/state updates

Algorithms/programs

Plots/graphs/movies/arrays

Scientific problems are data-centric

Organizing data and its possible transformations is essential

Scientific Problem Solving

  1. Identify a concrete  question
  2. Identify the  data to be modeled/processed/visualized
  3. Decompose the question into smaller problems  
  4. Design unit tests to check each small problem in isolation
  5. Implement solutions, ensuring all unit tests pass correctly
  6. Compose solutions together to answer main question
  7. Check correctness of solutions with end-to-end tests
  8. Profile performance of solution
  9. Optimize:
    1. rethink data structures (2.)
    2. refactor common code (3.)
    3. redesign efficient algorithms (5.)
    4. reimplement slow parts as faster modules (5.)
    5. retest to catch and prevent bugs (7.)

Problem

(why)

Interface (what)

Test Cases (check)

Implementation (how)

  • Data structure choice
  • Algorithm choice
  • Language choice
  • Other optimizations

Modular Design

Each small problem is an encapsulated module

To answer the main question, many modules must work together

Changing the implementation should not affect the interface

Language Paradigms

"Object-oriented"

"Functional"

"Logical"

  • Structure primary (classes/objects)
  • Functions belong to data (object methods)
  • Data arranged in hierarchy (inheritance)
  • Examples:
    C++, Java, Python, Ruby, Julia, Rust
  • Process primary (functions)
  • Data belong to functions (closures)
  • Functions arranged in hierarchy (closures)
  • Examples:
    Mathematica, Python,
    Haskell, Julia, R
  • Constraints primary (rules/clauses)
  • Data and functions belong to clauses
  • Hierarchical clauses become unified
  • Examples:
    Prolog, Mercury, Curry, 

    Python (LogPy, Pyke)

"Procedural"

  • Instructions primary, limited types of data
  • ExamplesC, MATLAB, Python, Julia, Go, Rust

(Others:  concurrent, actor-based, data-flow, rule-based, symbolic, etc.)

Language Abstraction Levels

  • Hardware level (Binary, Machine code, Assembly)
    Code executed by the hardware processor directly as procedural instructions
  • Low-level native compilation ( C, Go, Rust)
    Cross-architecture abstraction, compiled to machine code before execution
  • High-level native compilation ( C++, Haskell, LISP)
    Sophisticated and expressive abstraction, still compiles to machine code first
  • Just-In-Time (JIT) compilation ( Julia, Java, MATLAB)
    Cross-platform compiled code, converted to machine code during execution
  • Interpreted bytecode ( Python, R, Mathematica)
    Code read and executed by an interpreter, which can use compiled libraries

Increasing abstraction away from hardware:

      increases portability, simplifies coding, decreases efficiency

High-level

Low-level

Rule of thumb

BUT: Efficiency is multi-faceted

  1. Rethink efficient algorithms and data structures
  2. Exploit modular design
    • solve problems at a high level, but use fast low-level libraries for speed
  3. Profile code, refactor, and reimplement libraries
    • isolate slow parts, and replace just those with faster low-level libraries
  4. Parallelize code
    • run independent pieces of code on many processors (or GPUs)
  5. Reimplement entire correct solution (rapid prototyping):
    1. Replace interpreters with Just-In-Time (JIT) compilers (e.g., JVM, .NET)
    2. Replace JIT with high-level native code-compilation (e.g., LLVM, C++)
    3. Abandon high-level code for low-level native compilation (e.g., C, Rust)

Order of Importance:

Low-level code is difficult to write correctly, and scientific problems are often  hard to solve

A more effective strategy: solve problem with high-level language, then...

Modular Design in Python

The first two steps toward modular design are the most important in Python:

  1. Organize code into functions
  2. Organize functions into (python) modules

Functions should do one thing well

Functions should choose one:

  1. Return a value
  2. Perform an action

Well-written functions can be tested individually, with unit tests

 

Modules can be imported, and their functions can be reused

Promote reuse of code. Your code is extending the Python language.

#!/usr/bin/env python

import functools as ft
import operator as op

"""Example Module factorial.py
This is the module docstring.
"""

def fac(x):
    """The factorial of x: x!
        This is product of all positive integers less
        than or including x.
    
    Parameters:
    -----------
    x : int > 0
        Integer argument for factorial
        
    Returns:
    --------
    fib : int
    """
    return ft.reduce(op.mul, range(1, x+1), 1)

def test_fac5_120():
    """Test function for factorial.
        Test case: 5! == 120
    """
    assert fac(5) == 120, "5! == "+str(fac(5)) 

Object-Oriented Design

The next step toward modular design in Python is to understand classes and objects

At its core, Python is an object-oriented language

This means we define new types of data (or classes),

which have specific instances of data (or objects)

that possess their own functions (or methods)

Why Use Classes?

Classes are useful in two cases:

  1. You wish to organize both functions and related data into logical groupings such that they remain tightly bound together
  2. You wish to incrementally add functionality by extending a base case into more elaborate variations of that base case

Class Breakdown

Consider this example code for a class of Bear objects:

class Bear(object):           # subclass of "object"
    """Bear as a data-type"""
    
    fur = "brown"             # class attribute
                              # (data stored by class)
    
    def __init__(self, name): # class constructor
        self.name = name      # instance attribute
                              # (data stored by object)
    
    def roar(self):           # instance method
        print("I, {}, do roar.".format(self.name))

Run this code one line at a time: what does it do?

Note: The last three lines are identical  in meaning.

   Python converts the first to the last automatically. 

   This is why the method roar has first argument self : when executing  b1.roar(), Python passes the function  Bear.roar the object b1 as the argument self.

   Automatic syntax conversion like this is called "syntactic sugar" - it's a convenience to save typing.

Bear.fur
b1 = Bear("Ronaldo")
type(b1)
b1.name
b1.fur
b2 = Bear("Napoleon")
b2.name
b2.roar()

b1.roar()
Bear.roar(b1)
type(b1).roar(b1)

A class is mostly a fancy way of organizing namespaces by type.

__init__ is a "constructor" method that is run only once, when the object is created.  The arguments of __init__ are the ones used to construct the object and set default properties.

A class defines a new type.  This type may extend an existing type, called "subclassing" a type.  In this case, we are subclassing the most generic type "object."

Example Class for Factorial

Here is an illustrative example of how one can use a class to store an already computed result to prevent redundant calculation: this is called memoizing

#!/usr/bin/env python

import functools as ft
import operator as op

"""Example Module file factorial.py
This is the module docstring.
"""

class Factorial(object):
    """Memoized version of the factorial of x: x!
       This stores the product of all positive integers less
       than or including x when called.
    
    Attributes:
    -----------
    data : {int : int}
        Stored results of factorial
    """
    
    def __init__(self):
        """Constructor for Factorial of x"""
        self.data = dict()
    
    def __call__(self, x):
        """Compute factorial of x"""
        assert (type(x)==int and x >= 0), "Not a positive integer"
        if x not in self.data.keys():
            self.data[x] = ft.reduce(op.mul, range(1,x+1), 1)
        return self.data[x]

fac = Factorial() # replaces previous function definition

def test_fac5_120():  # This test is unaltered : interface preserved!
    """Test function for factorial.
        Test case: 5! == 120
    """
    assert fac(5) == 120, "5! == "+str(fac(5)) 
In [1]: import factorial as fac
In [2]: f = fac.Factorial()
        # This runs fac.Factorial.__init__(f)
In [3]: f.data
        {} # Nothing is stored in output yet
In [4]: f(5)
        # This runs fac.Factorial.__call__(f, 5)
Out[4]: 120
In [5]: f.data
        # Now the output has been stored for future use
Out[5]: {5 : 120}

Basic ideas:

  • a class describes similar objects
  • an object is an instance of a class
  • data inside an object are attributes
  • functions inside an object are methods
  • the special parameter self describes one particular instance of the class
  • the special method __init__ constructs new instances of the class
  • the special method __call__ executes an instance of the class as a normal function

Extending Objects

Suppose we want to create variations of a type, while keeping most things the same.

This is known as subclassing, and is a fancy way of just copying and modifying another class without rewriting everything. (Note that self.name is not defined here!)

This creates a tree hierarchy :  object => class => subclass1 => ...

We say that a subclass inherits the properties further up the hierarchy.

All objects in Python eventually subclass the most basic type:  object.

class StuffedBear(Bear):
    def roar(self):
        print("I, {}, cannot roar.".format(self.name))

This extends the Bear class by modifying the roar method.

b = Bear("Ronaldo")
s = StuffedBear("George")
b.roar()
s.roar()

super(StuffedBear, s).roar()

What does this code do?

Note the function super retrieves the original ("parent") class instead. This is especially useful when modifying the __init__ method.

Magic Methods

Methods surrounded by underscores are special, and hidden from tab-complete

(use dir(instance) to see all hidden methods)

Examples:

  • __init__(self, *args, **kwargs)
        Constructor for a class
  • __call__(self, *args, **kwargs)
        Run if instance f is executed: f(*args, **kwargs)
  • __iter__(self)
        Required to indicate object is an iterator
  • __next__(self)
        Run for an iterable to retrieve the next iteration
In [1]: class Gen(object):
   .....:     def __init__(self, max):
   .....:         self.val = 1
   .....:         self.max = max
   .....:     def __iter__(self):
   .....:         return self
   .....:     def __next__(self):
   .....:         prev = self.val
   .....:         self.val += 2
   .....:         if prev > self.max:
   .....:             raise StopIteration
   .....:         else:
   .....:             return prev
   .....:     def __call__(self):
   .....:         return self.__next__()
   .....:         

In [2]: g = Gen(10)

In [3]: g()
Out[3]: 1

In [3]: [i for i in g]
Out[3]: [3, 5, 7, 9]

Idea: Everything in Python follows apparent structure

If it looks like a duck and quacks like a duck, it is a duck.

Magic methods are conventions used by the language to identify common structural features and use them

Further Reading

Practice makes perfect.

 

Keep references handy until you remember commands on command.