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)]
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 [ ]: