Object Oriented Python

Guidelines

  • Do you need custom classes?
  • Often easier to work with just functions and data structures
  • Favor composition over inheritance
  • Avoid inheriting from concrete classes

Object-oriented ideas

  • What is an object?
  • Classes and instances
  • Inheritance
  • Composition
  • Abstract base classes
  • Design patterns

Class creation basics

In [1]:
class A:
    """Class A"""
    def __init__(self, x):
        self.x = x

    def __repr__(self):
        return '{}({})'.format(self.__class__.__name__, self.x)

    def report(self):
        return self.x

class B(A):
    """Class B inherits from A."""
    def __init__(self, x, y):
        self.y = y
        # super() returns the first parent class with the a method called __init__
        super().__init__(x)

    def report(self):
        """Over-ridden method."""
        return self.x, self.y
In [2]:
a = A(1)
b = B(2,3)

This uses the ``__repr__`` method found only in A

In [3]:
a, b
Out[3]:
(A(1), B(2))

This uses the instance specific method ``report``

In [4]:
a.report()
Out[4]:
1
In [5]:
b.report()
Out[5]:
(2, 3)

Classes are factories for instances

In [6]:
# Create a bunch of instances of A with differnet values for x

[A(i) for i in range(5)]
Out[6]:
[A(0), A(1), A(2), A(3), A(4)]

Class attributes and methods

In [7]:
A.__class__.__name__
Out[7]:
'type'

Instance attributes and methods

In [8]:
a.__class__.__name__
Out[8]:
'A'

Special methods

See also Special Method Names for a great summary.

Generic special methods

You have seen some of these such as __init__, __repr__.

Special methods for containers

Example

In [9]:
def fib(max):
    """A Fibonacci generator."""

    a, b = 0, 1
    while b < max:
        a, b = b, a + b
        yield a
In [10]:
list(fib(100))
Out[10]:
[1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]

We make the class Fib a container for Fibonacci numbers up to a maximum.

In [11]:
class Fib:
    """A Fibonacci class."""

    def __init__(self, max):
        self.max = max
        self.count = 0

    def __repr__(self):
        return 'Fib({})'.format(self.max)

    def __iter__(self):
        self.a, self.b = 0, 1
        return self

    def __next__(self):
        if self.b > self.max:
            raise StopIteration()
        self.a, self.b = self.b, self.a + self.b
        return self.a
In [12]:
f1 = Fib(100)
In [13]:
f2 = Fib(20)
In [14]:
f1, f2
Out[14]:
(Fib(100), Fib(20))
In [15]:
list(f1)
Out[15]:
[1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]
In [16]:
list(f2)
Out[16]:
[1, 1, 2, 3, 5, 8, 13]

Special methods for numbers

In [17]:
import math
from functools import total_ordering

@total_ordering
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return 'Point({},{})'.format(self.x, self.y)

    def __add__(self, other):
        x = self.x + other.x
        y = self.y + other.y
        return Point(x, y)

    def __radd__(self, otther):
        return self.__add__(other)

    def __eq__(self, other):
        return (self.x, self.y) == (other.x, other.y)

    def __lt__(self, other):
        return (self.x, self.y) < (other.x, other.y)

    def mag(self):
        return math.sqrt(self.x**2 + self.y**2)
In [18]:
p1 = Point(2,3)
p2 = Point(1,2)
p3 = Point(3,5)
In [19]:
p1 + p2
Out[19]:
Point(3,5)
In [20]:
p2 + p1
Out[20]:
Point(3,5)
In [21]:
p1 == p2
Out[21]:
False
In [22]:
p3 == p1 + p2
Out[22]:
True
In [23]:
sorted([p1, p2, p3])
Out[23]:
[Point(1,2), Point(2,3), Point(3,5)]
In [24]:
p3.mag()
Out[24]:
5.830951894845301

Properties

Properties allow classes to use the convenient attribute access syntax while using a more flexible getter/setter approach under the hood.

In [25]:
class A:
    def __init__(self, cost, discount=False):
        self._cost = cost
        self.discount = discount

    @property
    def cost(self):
        if self.discount:
            return 0.9*self._cost
        else:
            return self._cost

    @cost.setter
    def cost(self, value):
        if value < 0:
            self._cost = 0
        else:
            self._cost = value
In [26]:
a1 = A(100)
a2 = A(100, True)
In [27]:
a1.cost
Out[27]:
100
In [28]:
a2.cost
Out[28]:
90.0
In [29]:
a1.cost = -10
In [30]:
a1.cost
Out[30]:
0

Inheritance

  • Use the super() syntax to call super class attributes and methods
  • Avoid redundant super() calls - undefined attributes and methods will be searched in the inheritance tree automatically
In [31]:
class A:
    """Base class."""

    def __init__(self, x):
        self.x = x

    def __repr__(self):
        """Flexible string representation for any number of initialized variables."""
        return '{}({})'.format(
            self.__class__.__name__,
            ', '.join('{}={}'.format(k, v)
                      for k, v in sorted(self.__dict__.items())))

class B(A):
    """B extends A."""

    def __init__(self, x, y):
        """__init__ over-rides __init__ in A."""
        self.y = y
        super().__init__(x)

class C(B):
    """C extends B."""

    def f(self):
        return self.x, self.y
In [32]:
a = A(1)

B uses its own __init__ that accepts two argumens.

In [33]:
b = B(2,3)

C uses the same __init__ as B since it does not define its own.

In [34]:
c = C(4,5)

All instances use the __repr__ from A.

In [35]:
a, b, c
Out[35]:
(A(x=1), B(x=2, y=3), C(x=4, y=5))

The f() method is onvly available in instances of C.

In [36]:
c.f()
Out[36]:
(4, 5)

Chaining methods

In [37]:
class Num:
    def __init__(self, val):
        self.val = val

    def incr(self):
        self.val += 1
        print(self.val)
        return self

    def decr(self):
        self.val -= 1
        print(self.val)
        return self

    def __repr__(self):
        return '{}({})'.format(self.__class__.__name__, self.val)
In [38]:
n = Num(10)
n = n.incr().incr().decr().decr().decr().decr().incr()
n
11
12
11
10
9
8
9
Out[38]:
Num(9)

Composition

In [39]:
import numpy as np

class Character:
    def __init__(self, name, health, strength, weapon, armor):
        self.name = name
        self.health = health
        self.strength = strength
        self.weapon = weapon
        self.armor = armor

    def attack(self, other):
        if self.health >= 0:
            damage = max(0, self.weapon.hit - other.armor.block +
                         np.random.randint(0, self.strength))
            other.health -= damage
            return damage

    def __repr__(self):
        return self.name

class Weapon:
    def __init__(self, damage, enchanted=False):
        self.damage = damage
        self.enchanted = enchanted

    @property
    def hit(self):
        if self.enchanted:
            return 2 * self.damage
        else:
            return self.damage

class Armor:
    def __init__(self, protection, enchanted=False):
        self.protection = protection
        self.enchanted = enchanted

    @property
    def block(self):
        if self.enchanted:
            return 2 * self.protection
        else:
            return self.protection
In [40]:
axe = Weapon(10)
magic_arrow = Weapon(5, enchanted=True)
loin_cloth = Armor(1)
chainmail = Armor(10)
enchanged_chainmail = Armor(10, )

In [41]:
# Here we use composition
orc = Character('Groo', health=50, strength=10, weapon=axe, armor=loin_cloth)
elf = Character('Legolas', health=20, strength=5, weapon=magic_arrow, armor=enchanged_chainmail)
dwarf = Character('Gimli', health=25, strength=10, weapon=axe, armor=chainmail)

players = [orc, elf, dwarf]

for p in  players:
    print('{} starts with {} health points'.format(p, p.health))

print('\n\nBattle starts\n\n')

while True:
    p_idx = list(range(len(players)))
    p1_idx = np.random.choice(p_idx)
    p_idx.remove(p1_idx)
    p2_idx = np.random.choice(p_idx)

    p1 = players[p1_idx]
    p2 = players[p2_idx]

    damage = p1.attack(p2)
    print('{} is attacking {} for {} damage!'.format(p1, p2, damage))

    for p in players:
        print('{}: {}'.format(p, p.health))
        if p.health < 0:
            print('@ {} has died!'.format(p))
            players.remove(p)
    if len(players) == 1:
        print('@ {} is the winnder!'.format(players.pop()))
        break
    print('-'*40)
Groo starts with 50 health points
Legolas starts with 20 health points
Gimli starts with 25 health points


Battle starts


Gimli is attacking Legolas for 0 damage!
Groo: 50
Legolas: 20
Gimli: 25
----------------------------------------
Groo is attacking Legolas for 7 damage!
Groo: 50
Legolas: 13
Gimli: 25
----------------------------------------
Gimli is attacking Legolas for 3 damage!
Groo: 50
Legolas: 10
Gimli: 25
----------------------------------------
Groo is attacking Gimli for 7 damage!
Groo: 50
Legolas: 10
Gimli: 18
----------------------------------------
Gimli is attacking Legolas for 6 damage!
Groo: 50
Legolas: 4
Gimli: 18
----------------------------------------
Groo is attacking Legolas for 8 damage!
Groo: 50
Legolas: -4
@ Legolas has died!
----------------------------------------
Gimli is attacking Groo for 15 damage!
Groo: 35
Gimli: 18
----------------------------------------
Gimli is attacking Groo for 16 damage!
Groo: 19
Gimli: 18
----------------------------------------
Gimli is attacking Groo for 9 damage!
Groo: 10
Gimli: 18
----------------------------------------
Gimli is attacking Groo for 12 damage!
Groo: -2
@ Groo has died!
@ Gimli is the winnder!

Abstract base classes

In [42]:
import abc
In [43]:
class A(abc.ABC):
    @abc.abstractclassmethod
    def f(self):
        """Abstract method. Must be over-ridden in derived classes."""

class A1(A):
    pass

class A2(A):
    def f(self):
        print('This is OK!')

We cannot instantiate an abstract class with abstract methods.

In [44]:
a = A()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-44-144b248f218a> in <module>()
----> 1 a = A()

TypeError: Can't instantiate abstract class A with abstract methods f

Since A1 does not provide an implementation of f(), it cannot be instantiated either.

In [45]:
a1 = A1()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-45-8a60f01df923> in <module>()
----> 1 a1 = A1()

TypeError: Can't instantiate abstract class A1 with abstract methods f

A2 can be instantiated because it provides a concrete implementation for f().

In [46]:
a2 = A2()
In [47]:
a2.f()
This is OK!
In [ ]: