Object Oriented Python

The Class on Classes

Intro


What is object oriented programming?

  • An abstraction which encapsulates data (attributes) and behavior (methods)
  • Data model may fit real-world (digital twinning?)
  • Extensibility through inheritance

Intro


Warning

Object Oriented is not synonymous with “better”

Conceptual Framework


Start with a dictionary

my_dict = {'data_1': 1, 'data_2': 2}

my_data['data_1']   # access data_1
my_data['data_3'] = 3  # set new data with key data_3

Conceptual Framework


Now switch it for a simple name space

from types import SimpleNamespace

my_ns = SimpleNamespace(data_1=1, data_2=2)

my_ns.data_1   # access data_1
my_ns.data_3 = 3  # set new data with key data_3
my_ns.__dict__  # What's this?

Conceptual Framework


Why not attach a function too?

def some_function():
    return

my_ns.some_function = some_function

Conceptual Framework


classDiagram
    class my_ns
    my_ns : my_data_1
    my_ns : my_data_2
    my_ns : my_data_3
    my_ns : some_function()

Conceptual Framework


classDiagram
    class Class
    Class : class_data = 12
    class instance
    instance : instance_data=42
    instance --> Class

Conceptual Framework

classDiagram
    class Class1
    Class1 : class_1_data = 12
    class instance
    instance : instance_data=42
    class Class2
    Class2 : class_2_data = 13
    instance --> Class1
    Class1 --> Class2

Conceptual Framework

classDiagram
    class Class1
    Class1 : class_1_data = 12
    class instance_1
    instance_1 : instance_data=42
    class instance_2
    instance_2 : instance_data= 30
    class Class2
    Class2 : class_2_data = 13
    instance_1 --> Class1
    instance_2 --> Class1
    Class1 --> Class2

Conceptual Framework

classDiagram
    class Class1
    Class1 : data = 42
    class instance
    instance : data = 42
    class Class2
    Class2 : data = 13
    instance --> Class1
    Class1 --> Class2

Conceptual Framework


classDiagram
    class Class1
    Class1 : class_data = 10
    Class1 : some_func(param1, param2)
    class instance
    instance : data = 42
    instance --> Class1

Conceptual Framework


class Class1:
    class_data = 10

    def __init__(self, data):
        self.data = data  # data

    def some_func(param_1, param2):  # behavior
        ...

Conceptual Framework

Instance methods created from class functions

classDiagram
    class Class1
    Class1 : class_data = 10
    Class1 : some_func(param1, param2)
    class instance
    instance : data = 42
    instance --> Class1

Conceptual Framework

class Class1:
    class_data = 10

    def __init__(self, data):
        self.data = data  # data

    def some_method(self, parameter_2):  # behavior
        print(self.data, parameter_2)

instance = Class1(data=42)
instance.some_method(12)  # prints 42, 12
Class1.some_method(self, 12)  # identical to above

Nomenclature

  • class - the definition (blank form)
  • instance - the filled in data (filled in form)
  • self - conventional name for class instance
  • call - operator with “()” (e.g., Dog())
  • attribute - data accessed via getattr (object.name)
  • (instance) method - class functions (first parameter is self)
  • class method - class function (first parameter is cls)
  • static method - class function (no special first parameter)

Class Methods

from pathlib import Path

class Dog:
    def __init__(self, name):
        self.name = name 
    
    @classmethod
    def from_file(cls, path):
        with Path(path).open('r') as fi:
            name = fi.read()
        return cls(name=name)


maggie = Dog("Maggie")
new_dog_1 = maggie.from_file("dog_name.txt")
new_dog_2 = Dog.from_file("dog_name.txt")

Static Methods

class Dog:
    def __init__(self, name):
        self.name = name

    @staticmethod
    def add(arg1, arg2):
        return arg1 + arg2


maggie = Dog("Maggie")
new_dog_1 = maggie.add(1, 2)
new_dog_2 = Dog.add(2, 2)

Knowledge Check

class Customer:
    name = "default"
    bank = "Chase"

    def __init__(self, name):
        self.name = name
        self.balance = 0

    def withdraw(self, amount):
        self.balance -= amount
bob = Customer('bob')  # What is bob?
cust = Customer  # what is cust?
bob.balance  # what is balance (value and thing)
bob.withdraw  # what is withdraw?
bob.name, cust.name, bob.bank, cust.bank

Classes in the Wild


import numpy as np

array = np.random.random(20)
assert isinstance(array, np.ndarray)

array.mean()  # method or attribute?
array.shape  # method or attribute?

Dunders


Dunders

Names with double leading and trailing underscores. Many are part of the python language.

Dunders: Examples


pyfile.py
print(__name__)

if __name__ == "__main__":
    print('script mode')
else:
    print('module mode')


python pyfile.py  # __main__, script mode 
python -c "import pyfile"  # pyfile, module mode

Dunders: Examples


class Customer:
    def __init__(self, name):
        self.name = name
        self.balance = 0

Object creation

Python has an object creation step:

__new__

Which creates an empty object (sets up space for data)

and initialization step:

__init__

Which fills in data

Usually, avoid __new__

Dunders: Examples


import numpy as np

class Color:
    def __init__(self, rgb):
        self.rgb = np.array(rgb)
        assert len(self.rgb) == 3
        assert np.all(self.rgb < 256)

    def __add__(self, other):
        new_color = (self.rgb + other.rgb) / 2
        return Color(new_color)

Dunders: Examples


color1 = Color([0, 0, 0])
color2 = Color([100, 100, 100])

color3 = color1 + color2
assert np.all(color3.rgb == [50, 50, 50])

Dunders: Operator Overloading

  • __add__  (+)
  • __sub__  (-)
  • __mul__  (*)
  • __truediv__  (/)
  • __floordiv__  (//)
  • __pow__  (**)
  • __rshift__  (>>)
  • __lshift__  (<<)
  • __or__  (|)
  • __eq__ (==)
  • __ne__ (!=)
  • __gt__  (>)
  • __lt__  (<)
  • __neg__ (-)
  • __pos__ (+)
  • __invert__ (~)

Dunders: Operator Overloading

class PaddingString:
    def __init__(self, starting_string):
        self._data = starting_string

    def __rshift__(self, num):
        return PaddingString(' '*num + self._data)
        
    def __lshift__(self, num):
        return PaddingString(self._data + ' '*num)
        
    def __str__(self):
        return self._data 

Dunders: Operator Overloading


fs = PaddingString('bob')
print(fs >> 3)
print(fs >> 6)
print(fs >> 9)
print(fs << 10)
   bob
      bob
         bob
bob          

Getters and Setters

  • Sometimes side effects (or calcs) are needed when getting or setting an attribute.
  • For simple cases, just assign or access a value on the instance/class.
  • When more logic is needed consider getters/setters.
  • For many such attributes use descriptors.
  • If this doesn’t work/fit, then use get_/set_.

Getters and Setters

get_/set_ methods

class DynamicX(object):
    def __init__(self):
        self._x = None

    def get_x(self):
        print("getting x")
        return self._x

    def set_x(self, value):
        print("setting x")
        self._x = value

Getters and Setters

getter and setter

class DynamicY(object):
    def __init__(self):
        self._y = None

    @property
    def y(self):
        print("getting y")
        return self._y

    @y.setter
    def y(self, value):
        print("setting y")
        self._y = value

Getters and Setters


DynamicX
dynamic_x = DynamicX()

dynamic_x.set_x(42)
dynamic_x.get_x()


DynamicY
dynamic_y = DynamicY()

dynamic_y.y = 42
dynamic_y.y

Getters and Setters

import time

class Clock:
    @property
    def time(self):
        return time.ctime()


clock = Clock()
time_1 = clock.time    
time.sleep(1)  # sleep 1 second
time_2 = clock.time  # slightly larger than time_1
print(time_1, time_2) 
Thu Apr 18 19:29:29 2024 Thu Apr 18 19:29:30 2024

Inheritance


  • Inheritance is used to extend or modify classes

  • super lets you access the immediate parent class’ namespace

  • Python supports multiple inheritance, which can get confusing

  • MRO (__mro__) defines lookups (instance) -> (class) -> (parent) -> (grandparent) -> …

Super

super is used to skip the current class in the mro.

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

    def area(self):
        return self.length * self.width

class Square(Rectangle):
    def __init__(self, length):
        super().__init__(length, length)

Inheritance

You can check the MRO via the __mro__ dunder class attribute.


import pandas as pd

print(pd.DataFrame.__mro__)
(<class 'pandas.core.frame.DataFrame'>, <class 'pandas.core.generic.NDFrame'>, <class 'pandas.core.base.PandasObject'>, <class 'pandas.core.accessor.DirNamesMixin'>, <class 'pandas.core.indexing.IndexingMixin'>, <class 'pandas.core.arraylike.OpsMixin'>, <class 'object'>)

Mixins

  • mixin’s add limited, broadly applicable, functionality to potentially many classes.
class Animal:
    ...
    
class _Pickleable:
    @classmethod
    def to_pickle(cls, path):
        ...
        
    @classmethod
    def from_pickle(cls, path):
        ...
        
class Dog(Animal, _Pickleable):
    ...

isinstance and issubclass

Checking class relations is done with isinstance and issubclass

class Parent():
    pass

class Child(Parent):
    pass

child = Child()
isinstance(child, Child)
isinstance(child, Parent)
isinstance(Child, Parent)
issubclass(Child, Parent)
issubclass(child, Parent)

Dataclasses


the dataclasses module makes class definitions more ergonomic.

It can:

  • Create __init__, __equal__, order dunders, __hash__
  • Can specify frozen for faux-immutability
  • Control input modes, like keyword only

Dataclasses Origin


The dataclasses module (of the standard library) was inspired by attrs. It also serves similar purposes as pydantic but does not perform any runtime type checking. If you want typehints to be enforced, use pydantic.

Dataclasses

Traditional class definition

class OceanPatch:

    def __init__(self, time, dimension, velocity, temperature=20.0):
        self.time = time
        self.dimension = dimension
        self.velocity = velocity
        self.temperature = temperature

    def __str__(self):
        ...

    def __eq__(self, other):
        ...

    ...

Dataclasses

Equivalent dataclass

from dataclasses import dataclass

@dataclass
class OceanPatch:
    time: float
    dimension: float
    velocity: list
    temperature: float = 20.0

Protocols


Protocols (sometimes called interfaces):

  • indicate objects behave in some way
  • dunders often used to implement protocols
  • any number of protocols are possible

Protocols

Common Protocols (most found in types or typing module):

- Sequence
- Mapping
- Collection
- Hashable
- Reversible
- Sized
- Iterable
- ...

Protocols

A class which implements the Sized protocol

class SizedThing:
    def __init__(self, data):
        self._data = data
    
    def __len__(self):
        return len(self._data)
from typing import Sized

sized_thing_1 = SizedThing([1,2,3])
assert len(sized_thing_1) == 3
assert isinstance(sized_thing_1, Sized)

Protocols

A class which implements the Hashable protocol

class MyHashableClass:
    def __init__(self, seed: int):
        self.seed = seed
    
    def __hash__(self):
        return hash(self.seed)
from typing import Hashable

my_instance = MyHashableClass(42)
assert isinstance(my_instance, Hashable)
my_dict = {my_instance: 42}

Protocols

from dataclasses import dataclass

@dataclass
class MyCallableClass:
    secret_number: int = 123_456
    
    def __call__(self, arg1, arg2):
        return self.secret_number + arg1 + arg2
from typing import Callable

callable_instance = MyCallableClass(12)
assert isinstance(callable_instance, Callable)
secret = callable_instance(-1, 1)

Protocols

Defining protocols

  • Can define protocols with abc module and typing.Protocol
  • Protocol specifies required methods and their signatures
  • ABCs use inheritance (or registering virtual children)
    • Children of an ABC which don’t meet the requirements raise error
  • typing.Protocol is based on structure not inheritance
    • isinstance checks required.

Protocols

ABC example

import abc

class DeckABC(abc.ABC):
    """An ABC for a deck of cards."""
    @abc.abstractmethod
    def shuffle(self):
        ...
    
    @abc.abstractmethod
    def draw(self):
        ...

Protocols

ABC use

from random import choice, sample

class MyDeck(DeckABC):
    """A deck with cards 1-10"""
    def __init__(self, cards=None):
        if cards is None:
            cards = list(range(1,11))
        self.cards = cards
    
    def shuffle(self):
        cards = sample(self.cards, len(self.cards))
        return MyDeck(cards)
    
    def draw(self):
        card = choice(self.cards)
        self.cards.pop(card)
        return card
        
mydeck = MyDeck()

Protocols

typing.Protocol example

import typing

@typing.runtime_checkable
class DeckProtocol(typing.Protocol):
    """An ABC for a deck of cards."""
    def shuffle(self) -> typing.Self:
        ...
    
    def draw(self) -> str:
        ...

Protocols

typing.Protocol use

from typing import Self
from random import choice, sample

class MyDeck:
    """A deck with cards 1-10"""
    def __init__(self, cards=None):
        if cards is None:
            cards = list(range(1,11))
        self.cards = cards
    
    def shuffle(self) -> Self:
        cards = sample(self.cards, len(self.cards))
        return MyDeck(cards)
    
    def draw(self) -> str:
        card = choice(self.cards)
        self.cards.pop(card)
        return card

deck = MyDeck()
assert isinstance(deck, DeckProtocol)

OO Design

Possible Advantages

  • Expressive (intuitive interfaces)
  • Modularity (inheritance)
  • Common Paradigm (java/c++)

Possible Disadvantages

  • Expressive (sometimes too much freedom)
  • Shared State (who changed what and when?)
  • Slower Execution (objects vs arrays)

Others?

OO Design

OO Design

OO Design: Good Examples


A few well-loved python classes:

  • pathlib.Path
  • pandas.DataFrame
  • scikitlearn (Estimator, Predictor, Transformer, Model)
  • pydantic.BaseModel

OO Design: Good Examples


before pathlib

import os

data_path = '/home/user/data'

data_file = os.path.join(data_path, 'the_facts.csv')

OO Design: Good Examples


With pathlib

from pathlib import Path

data_path = Path('/home/user/data')

data_file = data_path / 'the_facts.csv'

OO Design: Not Good Examples


A few dreaded python OO libraries:

  • PyQT (Too many objects)
  • VTK (Too many objects)
  • Boto3 (dynamic loading objects, huge number of objects, etc.)

Most are wrappers around languages with less flexible type systems or overly dynamic.

Derrick’s OO Design Guidelines

  • Common uses shouldn’t require more than 3 classes
  • Classes are simplest when extensions of familiar protocols (ie list-like plus extra bits)
  • Class division at a high data level (DataFrame rather than dict of Series)
  • Take it easy on operator overloading
  • Avoid deep and multiple inheritance
  • Prefer to return new instances rather than modify in place
  • Functions are just fine too!