Last week we looked at several example projects and the classes we might use to implement them.
Before we revisit and expand on these we will cover the remaining material from last week.
In [1]:
class Person(object):
"""A class definition for a person. The following attributes are supported:
Attributes:
name: A string representing the person's name.
age: An integer representing the person's age."""
mammal = True
def __init__(self, name, age):
"""Return a Person object with name and age set to the values supplied"""
self.name = name
self.age = age
def __str__(self):
return '{0} who is {1} years old.'.format(self.name, self.age)
person1 = Person('Alice', 25)
person2 = Person('Bob', 30)
print(person1, person2)
Before we go on a note of caution is needed for class attributes. Do you remember the strange fibonacci sequence function from our first class?
In [1]:
def next_fibonacci(status=[]):
if len(status) < 2:
status.append(1)
return 1
status.append(status[-2] + status[-1])
return status[-1]
print(next_fibonacci(), next_fibonacci(), next_fibonacci(), next_fibonacci(), next_fibonacci(), next_fibonacci())
The same issue can happen with classes, only this is a much more common source of bugs.
If only using strings and numbers the behaviour will likely be much as you expect. However, if using a list, dictionary, or other similar type you may get a surprise.
In [2]:
class Person(object):
"""A class definition for a person. The following attributes are supported:
Attributes:
name: A string representing the person's name.
age: An integer representing the person's age."""
friends = []
def __init__(self, name, age):
"""Return a Person object with name and age set to the values supplied"""
self.name = name
self.age = age
def __str__(self):
return '{0} who is {1} years old'.format(self.name, self.age)
person1 = Person('Alice', 25)
person2 = Person('Bob', 30)
person1.friends.append('Charlie')
person2.friends.append('Danielle')
print(person1.friends, person2.friends)
Both of our objects point to the same instance of the list type so adding a new friend to either object shows up in both.
The solution to this is creating our friends attribute only at instantiation of the object. This can be done by creating it in the __init__ method.
In [3]:
class Person(object):
"""A class definition for a person. The following attributes are supported:
Attributes:
name: A string representing the person's name.
age: An integer representing the person's age."""
def __init__(self, name, age):
"""Return a Person object with name and age set to the values supplied"""
self._name = name
self.age = age
self.friends = []
def __str__(self):
return '{0} who is {1} years old'.format(self.name, self.age)
def _pseudo_private(self):
pass
def __little_more_private(self):
pass
person1 = Person('Alice', 25)
person2 = Person('Bob', 30)
person1.friends.append('Charlie')
person2.friends.append('Danielle')
print(person1.friends, person2.friends)
Objects have their own namespace, although we have created variables called name, age, and friends they can only be accessed in the context of the object.
In [4]:
print('This works:', person1.friends)
print('This does not work:', friends)
We are not limited to special methods when creating classes. Standard functions, or in this context methods, are an integral part of object oriented programming. Their definition is identical to special methods and functions outside of classes.
In [6]:
class Person(object):
"""A class definition for a person. The following attributes are supported:
Attributes:
name: A string representing the person's name.
age: An integer representing the person's age."""
def __init__(self, name, age):
"""Return a Person object with name and age set to the values supplied"""
self.name = name
self.age = age
self.friends = []
def __str__(self):
"""Return a string representation of the object"""
return '{0} who is {1} years old'.format(self.name, self.age)
def add_friend(self, friend):
"""Add a friend"""
self.friends.append(friend)
person1 = Person('Alice', 25)
person2 = Person('Bob', 30)
person1.add_friend('Charlie')
person2.add_friend('Danielle')
print(person1.friends, person2.friends)
Some programming languages support hiding methods and attributes in an object. This can be useful to simplify the public interface someone using the class will see while still breaking up components into manageable blocks 'under-the-hood'. We will discuss designing the public interface in detail in future classes.
Python does not support private variables beyond convention. Names prefixed with a underscore are assumed to be private. This means they may be changed without warning between different versions of the package. For public attributes/methods this is highly discouraged.
Now we will revisit the laboratory inventory system from last week.
I would like to keep track of all the items in the laboratory so I can easily find them the next time I need them. Both equipment and consumables would be tracked. We have multiple rooms, and items can be on shelves, in refrigerators, in freezers, etc. Items can also be in boxes containing other items in all these places.
The words in bold would all be good ideas to turn into classes. Now we know some of the classes we will need we can start to think about what each of these classes should do, what the methods will be. Let's consider the consumables class:
For consumables we will need to manage their use so there should be an initial quantity and a quantity remaining that is updated every time we use some. We want to make sure that temperature sensitive consumables are always stored at the correct temperature, and that flammables are stored in a flammables cabinet etc.
The consumable class will need a number of attributes:
The consumable class will need methods to:
The consumable class might interact with the shelf, refrigerator, freezer, and/or box classes.
Reading back through our description of consumables there is reference to a flammables cabinet that was not mentioned in our initial description of the problem. This is an iterative design process so we should go back and add a flammables cabinet class.
If we expand our list to all the classes we plan to use we get the following:
Although this is a long list careful examination reveals that there is a lot of repetition.
Items and equipment are identical and consumables is similar, adding several extra attributes and methods.
Rooms, shelves, refrigerators, freezers, boxes and flammables cabinet are all similar, only differing in the occasional attribute.
Our three main groups are:
So much duplication is problematic, it is diffcult to maintain and subject to greater risk of bugs.
There is a better way - we can create a generic class with the shared functionality and then inherit from it when we create the other classes.
For example an Item class would contain the basic attributes and methods. The Equipment class could then inherit from this class without modification. The Consumable class would also inherit from Item and only add the extra attributes and methods uniquely needed by the Consumable class.
In [7]:
class Item(object):
def __init__(self, name, description, location):
self.name = name
self.description = description
self.location = location
def update_location(self, new_location):
pass
class Equipment(Item):
pass
class Consumable(Item):
def __init__(self, name, description, location, initial_quantity, current_quantity, storage_temp, flammability):
self.name = name
self.description = description
self.location = location
self.initial_quantity = initial_quantity
self.current_quantity = current_quantity
self.flammability = flammability
def update_quantity_remaining(self, amount):
pass
Each of the classes we create inheriting from our general class can be thought of as having an 'is-a' relationship with the general class. For example, Equipment is a Item, Consumable is a Item.
There is one other situation we should consider. Occasionally we will want a class of a particular type to always implement a particular method even though we are unable to implement that method in our parent class. We need some way of raising an error when the parent class is inherited and the method is not implemented.
As a simple example consider a class representing length. We might create classes for meters, miles, feet, etc. Keeping the original units when performing operations (adding, subtracting, etc) would prevent rounding errors but each class would need custom logic.
Returning to our laboratory inventory system one way we can implement this is below:
In [5]:
class Item(object):
def safely_stored(self):
raise NotImplementedError('override in subclass')
class Consumable(Item):
def safely_stored(self):
return True
In [8]:
a = Item()
In [9]:
a.safely_stored()
In [10]:
b = Consumable()
In [11]:
b.safely_stored()
Out[11]:
A disadvantage with this approach is we only see the error message when we call the method. The error is in the way we implemented the class so it would be more intuitive to get an error earlier, when we first create the object.
This can be achieved using the abstract method decorator.
In [16]:
from abc import ABCMeta, abstractmethod
class Item(object, metaclass=ABCMeta):
@abstractmethod
def safely_stored(self):
pass
def test(self):
pass
class Consumable(Item):
def safely_stored(self):
return True
In [17]:
a = Item()
In [10]:
b = Consumable()
b.safely_stored()
Out[10]:
Either of these approaches work well for adding new methods or completely changing the behaviour of a method. Often we only need to make a more subtle change. In this situation it can be useful to call a method from a parent class while only implementing our new functionality in the child class.
There are two approaches for this.
In [43]:
class A(object):
def a(self):
print('a in class A')
class B(A):
def a(self):
A.a(self)
print('b in class B')
a = A()
a.a()
b = B()
b.a()
In [44]:
class A(object):
def a(self):
print('a in class A')
class B(A):
def a(self):
super().a()
print('b in class B')
a = A()
a.a()
b = B()
b.a()
Using super() is usually the best approach, the reasons for this are covered in detail in this blog post
We are not limited to inheriting from a single class. It is possible to merge functionality from multiple different classes simply by inheriting from them.
When inheriting from multiple classes that contain a method or attribute with the same name there is a particular order in which the names are resolved.
In [11]:
class A(object):
def a(self):
print('A-a')
class A2(object):
def a(self):
print('A2-a')
class B(A, A2):
pass
a = A()
a.a()
a2 = A2()
a2.a()
b = B()
b.a()
In [12]:
class A(object):
def a(self):
print('A-a')
class A2(object):
def a(self):
print('A2-a')
class B(A):
pass
class C(B, A2):
pass
a = A()
a.a()
a2 = A2()
a2.a()
c = C()
c.a()
A simple rule-of-thumb is that search is depth first. The details are a little more complicated.
In [45]:
class Item(object):
def safely_stored(self):
raise NotImplementedError('override in subclass')
class Consumable(Item):
def safely_stored(self):
return True
a = Item()
b = Consumable()
print('a instance of Item:', isinstance(a, Item))
print('b instance of Consumable:', isinstance(b, Consumable))
print('b instance of Item:', isinstance(b, Item))
print('a instance of Consumable:', isinstance(a, Consumable))
A popular alternative in python is duck typing, an approach named after the idea that,
If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.
What this means for programming is that instead of checking for a particular class, instead the methods and attributes that are actually needed are checked for.
Let's switch to another example from last week. We looked at a cookbook application and were often unsure whether things like ingredients should be attributes on the recipe class or classes in their own right.
Often the answer is both.
These are the interactions that change a collection of different classes into a functioning program. This is called composition. The Recipe object is a composite object, it has ingredients, it has instructions, etc.
Let's look at how we can design our classes to be easy to use, for both programmer-class and class-class interactions.
In [18]:
class Ingredient(object):
"""The ingredient object that contains nutritional information"""
def __init__(self, name, carbs, protein, fat):
self.name = name
self.carbs = carbs
self.protein = protein
self.fat = fat
def get_nutrition(self):
"""Returns the nutritional information for the ingredient"""
return (self.carbs, self.protein, self.fat)
class Recipe(object):
"""The Recipe object containing the ingredients"""
def __init__(self, name, ingredients):
self.name = name
self.ingredients = ingredients
def get_nutrition(self):
"""Returns the nutritional information for the recipe"""
nutrition = [0, 0, 0]
for amount, ingredient in self.ingredients:
nutrition[0] += amount * ingredient.carbs
nutrition[1] += amount * ingredient.protein
nutrition[2] += amount * ingredient.fat
return nutrition
bread = Recipe('Bread', [(820, Ingredient('Flour', 0.77, 0.10, 0.01)),
(30, Ingredient('Oil', 0, 0, 1)),
(36, Ingredient('Sugar', 1, 0, 0)),
(7, Ingredient('Yeast', 0.3125, 0.5, 0.0625)),
(560, Ingredient('Water', 0, 0, 0))])
print(bread.ingredients)
print(bread.get_nutrition())
In [19]:
import requests
In [26]:
r = requests.get('https://api.github.com/repos/biof509/biof509/events')
print(r.status_code, r.ok)
print(r.headers['content-type'])
In [22]:
print(r.text[:1000])
In [24]:
print(r.json()[0]['payload']['commits'][0])
In [25]:
type(r)
Out[25]:
In [27]:
import pandas as pd
In [28]:
data = pd.DataFrame([[0,1,2,3], [4,5,6,7], [8,9,10,11]],
index=['a', 'b', 'c'],
columns=['col1', 'col2', 'col3', 'col4'])
data
Out[28]:
In [29]:
print(data.shape)
print(data['col1'])
print(data.col1)
In [30]:
import matplotlib.pyplot as plt
%matplotlib inline
data.plot()
Out[30]:
In [31]:
data.to_csv('Wk05-temp.csv')
data2 = pd.DataFrame.from_csv('Wk05-temp.csv', index_col=0)
data2
Out[31]:
The API documentation for the DataFrame object.
Some useful features:
__getitem__
__getattr__
In [32]:
class Ingredient(object):
"""The ingredient object that contains nutritional information"""
def __init__(self, name, carbs, protein, fat):
self.name = name
self.carbs = carbs
self.protein = protein
self.fat = fat
def __repr__(self):
return 'Ingredient({0}, {1}, {2}, {3})'.format(self.name, self.carbs, self.protein, self.fat)
def get_nutrition(self):
"""Returns the nutritional information for the ingredient"""
return (self.carbs, self.protein, self.fat)
class Recipe(object):
"""The Recipe object containing the ingredients"""
def __init__(self, name, ingredients):
self.name = name
self.ingredients = ingredients
def get_nutrition(self):
"""Returns the nutritional information for the recipe"""
nutrition = [0, 0, 0]
for amount, ingredient in self.ingredients:
nutrition[0] += amount * ingredient.carbs
nutrition[1] += amount * ingredient.protein
nutrition[2] += amount * ingredient.fat
return nutrition
bread = Recipe('Bread', [(820, Ingredient('Flour', 0.77, 0.10, 0.01)),
(30, Ingredient('Oil', 0, 0, 1)),
(36, Ingredient('Sugar', 1, 0, 0)),
(7, Ingredient('Yeast', 0.3125, 0.5, 0.0625)),
(560, Ingredient('Water', 0, 0, 0))])
print(bread.ingredients)
print(bread.get_nutrition())
Viewing the ingredients now looks much better. Let's now look at the get_nutrition method.
There are still a number of areas that could be improved
In [2]:
class Ingredient(object):
"""The ingredient object that contains nutritional information"""
def __init__(self, name, nutrition):
self.name = name
self.nutrition = nutrition
def __repr__(self):
return 'Ingredient({0}, {1}, {2}, {3})'.format(self.name,
self.nutrition['carbs'],
self.nutrition['protein'],
self.nutrition['fat'])
class Recipe(object):
"""The Recipe object containing the ingredients"""
def __init__(self, name, ingredients):
self.name = name
self.ingredients = ingredients
@property
def nutrition(self):
"""Returns the nutritional information for the recipe"""
nutrition = {}
for amount, ingredient in self.ingredients:
# use get_nutrition
for k,v in ingredient.nutrition.items():
nutrition[k] = amount * v + nutrition.get(k, 0)
return nutrition
bread = Recipe('Bread', [(820, Ingredient('Flour', {'carbs':0.77,
'protein':0.10,
'fat':0.01})),
(30, Ingredient('Oil', {'carbs':0,
'protein':0,
'fat':1})),
(36, Ingredient('Sugar', {'carbs':1,
'protein':0,
'fat':0})),
(7, Ingredient('Yeast', {'carbs':0.3125,
'protein':0.5,
'fat':0.0625})),
(560, Ingredient('Water', {'carbs':0,
'protein':0,
'fat':0})),
])
print(bread.ingredients)
print(bread.nutrition)
In [4]:
# Recipe and Ingredient now have a consistent interface and we can combine them.
# Recipe for toast - add butter to a 12th of a loaf
toast = Recipe('Toast', [(1/12, bread),
(25, Ingredient('Butter', {'carbs':0,
'protein':0,
'fat':1}))])
print(toast.nutrition)
In [5]:
# We can also add additional types of nutrients
bread = Recipe('Bread', [(820, Ingredient('Flour', {'carbs':0.77,
'protein':0.10,
'fat':0.01,
'sodium':0.00002})),
(30, Ingredient('Oil', {'carbs':0,
'protein':0,
'fat':1,
'sodium':0.00002})),
(36, Ingredient('Sugar', {'carbs':1,
'protein':0,
'fat':0,
'sodium':0})),
(7, Ingredient('Yeast', {'carbs':0.3125,
'protein':0.5,
'fat':0.0625,
'sodium':0.00050})),
(560, Ingredient('Water', {'carbs':0,
'protein':0,
'fat':0,
'sodium':0})),
])
print(bread.ingredients)
print(bread.nutrition)
In [3]:
# If we want to accept nutritional information either as a dictionary or the older style numerical values we have
# a few options
# First, an alternate entry point using the @classmethod decorator
class Ingredient(object):
"""The ingredient object that contains nutritional information"""
def __init__(self, name, carbs, protein, fat):
self.name = name
self.nutrition = {'carbs': carbs,
'protein': protein,
'fat': fat}
@classmethod
def from_dict(cls, name, nutrition):
ingredient = cls(name, 0, 0, 0)
ingredient.nutrition = nutrition
return ingredient
def __repr__(self):
return 'Ingredient({0}, {1}, {2}, {3})'.format(self.name,
self.nutrition['carbs'],
self.nutrition['protein'],
self.nutrition['fat'])
# Creating an ingredient instance using the from_dict method
a = Ingredient.from_dict('Flour', {'carbs':0.77,
'protein':0.10,
'fat':0.01,
'sodium':0.00002})
print(a)
# Creating an ingredient instance using the __init__ method
b = Ingredient('Flour', 0.77, 0.10, 0.01,)
print(b)
In [4]:
# Second we can alter the __init__ method to accept either numerical values or a dictionary
# Default values are used below. A similar solution could be implemented using the *args and/or **kwargs functionality
class Ingredient(object):
"""The ingredient object that contains nutritional information"""
def __init__(self, name, carbs, protein=None, fat=None):
self.name = name
if isinstance(carbs, dict):
self.nutrition = carbs
elif isinstance(carbs, float) or isinstance(carbs, int):
self.nutrition = {'carbs': carbs,
'protein': protein,
'fat': fat}
else:
raise Exception('Unexpected argument types supplied')
def __repr__(self):
return 'Ingredient({0}, {1}, {2}, {3})'.format(self.name,
self.nutrition['carbs'],
self.nutrition['protein'],
self.nutrition['fat'])
# Creating an ingredient instance using a dictionary
a = Ingredient('Flour', {'carbs':0.77,
'protein':0.10,
'fat':0.01,
'sodium':0.00002})
print(a)
# Creating an ingredient instance using numercal values
b = Ingredient('Flour', 0.77, 0.10, 0.01,)
print(b)
In [32]:
a = {'x':1, 'y':2}
a['x']
Out[32]:
In [33]:
a.get('x')
Out[33]:
In [34]:
a['z']
In [35]:
a.get('z', 0)
Out[35]:
In [ ]: