This is the first in a series of sessions concerning Object-Oriented Programming (OOP). So far we've been talking about the functional programming paradigm where everything is about functions, whereas with OOP everything is now about objects.
Note that Python is a multi-paradigm programming language meaning you're free to choose the paradigm best suited for the task at hand or even mix them as you see fit.
Object
¶The OOP programming paradigm is a modeling approach where the code is structured such that properties and behaviors are contained in individual objects.
Simple examples of objects we have already been using:
# 'float' object
my_float = -5.5
print( type(my_float) )
print( my_float.as_integer_ratio() )
print( my_float.is_integer() )
<class 'float'> (-11, 2) False
# 'str' object
my_string = 'Hello'
print( type(my_string) )
print( 'Uppercase:', my_string.upper() )
print( 'Lowercase:', my_string.lower() )
<class 'str'> Uppercase: HELLO Lowercase: hello
# 'list' object
my_list = [1, 2, 'k', 'h']
print( type(my_list) )
print( "Index of 'k':", my_list.index('k') )
<class 'list'> Index of 'k': 2
So when we define a string, float, integer, boolean, list etc. in Python we're actually initiating objects (instances of the respective classes). Beside storing information, these objects also contains a set of behavioral operations (methods) relevant for the specific object.
With functional programming you would have had to define all these methods as individual functions (polluting the namespace) and also name them so it's clear what type of variables they're intended for. With OOP you can integrate these into each of the specific object types, making them only accessible right where you may need them.
The method names can also be simpler as it is obvious which data type they're intended for and it can be made more readable (e.g. my_float.as_integer_ratio()
).
But most importantly, using OOP encourages you to split your problem into logical entities/objects and bind any behavioral function-like features to these objects which results in an intuitive structure that is easy to use and build upon by others.
Class
and Instances
¶When dealing with OOP there are four terms one needs to understand:
A class
is at its essence a user-defined data structure with fully customizable behaviors. It's the blueprint describing the information structure and any corresponding behavioral operations for any objects/instances created from it.
The float class
e.g. describes the data structure for being able to store a signed decimal number together with definition of any behavioral operations/method calls relevant for floats.
The class itself will never contain a specific floating number!
From any class an infinite number of instances
can be initiated. While the class contains the blueprint, instances are the actual objects created from a class.
'my_float', 'my_string', 'my_list' are all instances/actual objects made using the blueprint defined by the corresponding classes.
So while the float class
from above will never contain a specific floating number, instances
of the class can be created to represent specific floating point numbers.
A method
is a function-like operation defined by and tied to the class. Methods are available to be called from any instances initiated from the class. Just like with functions, methods are also executed by having a bracket after the method name containing possible input arguments.
Finally, an attribute
refers to some information/value (a property) stored in an object. To access an attribute of an object you do not have a bracket after the attribute name as it does not need to be executed.
How to define a new class:
class Rectangle:
'''Defining a 'Rectangle' class.'''
def __init__(self, height, width):
'''This is a special constructor method that initializes the object.'''
self.height = height
self.width = width
def calculate_area(self):
'''Return the area of the rectangle.'''
area = self.height * self.width
return area
The __init__
method is a special method that is executed when a new instance/object is initialized.
The self
is a placeholder object for the instance object not yet created during the class definition.
# initiate rectangle instance
rect = Rectangle(height=5, width=3)
# this is an object
print(rect)
<__main__.Rectangle object at 0x000002DF2C2F6708>
# you can get the area by executing the area method
print( 'reactangle area:', rect.calculate_area() )
reactangle area: 15
# you can get (read) and set (assign) attributes in an object
print( 'height attribute:', rect.height )
rect.height = 6 # attributes modify an attribute
print( 'height attribute:', rect.height )
height attribute: 5 height attribute: 6
Notice how you don't have to input the self
argument when the method is executed from an instance.
However, same result can also be obtained by executing the method directly from thee class definition, but this time giving the rectangle instance itself to be considered as the self
input.
Rectangle.calculate_area(rect)
18
Let's try to store the area found so it doesn't have to be recomputed everytime someone ask for the area. This also means we should try to prevent users from changing the height and width as the area is no longer recalculated.
class Rectangle:
''' Defining a 'Rectangle' class.'''
num_corners = 4 # this is a class variable that applies to all rectangles
def __init__(self, height, width):
self._height = height # this is a protected attribute
self._width = width # this is a protected attribute
self.__area = None # this is a private attribute
def get_area(self):
'''Return the area of the rectangle if present, if not compute it and return it.'''
# check if __area have been updated
if not self.__area:
self.__area = Rectangle.calculate_area(self._height, self._width)
return self.__area
# a static method is independent of an instance and is defined by the corresponding decorator
@staticmethod
def calculate_area(height, width):
'''Compute and return the area of the rectangle.'''
area = height * width
return area
Notice the class variable num_corners
, which is defined before the __init__
method.
Recall that the __init__
method is used to initialize each specific instance, i.e. all types of rectangles that might be created. By defining the class variable before the initialization of each unique instance, it will be assigned to all instances of a class that are created.
This makes sense since all rectangles are bound to have four corners.
# Initialize new rectangle instance
rect2 = Rectangle(height=10, width=3)
Java-style getters and setters are not nesessary in Python:
print('Number of corners:', rect2.num_corners) # ask for number of corners
print('the area is: ', rect2.get_area()) # get area
Number of corners: 4 the area is: 30
A static method can be used, directly from the class, without having to initialize an instance. This is useful if one wants to use a class also as a container for related functions which may even be used by the methods like here.
print('the area is: ', Rectangle.calculate_area(height=12, width=3))
the area is: 36
Protected attibutes are not actually protected but just a way to communicate to the users that the code don't support direct changes and/or reads from this attribute.
# protected attibutes (single underscore prefix)
rect2._height = 100
rect2._height
100
# the area do not support this height change
print('the area is: ', rect2.get_area())
the area is: 30
Private (or hidden) attributes are a stronger way of saying "Don't mess with me!"
# private attibutes (dunder prefix) are more defficult to mess with
print('__area attribute:', rect2.__area)
--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) <ipython-input-16-2aff7ea788b6> in <module> 1 # private attibutes (dunder prefix) are more defficult to mess with ----> 2 print('__area attribute:', rect2.__area) AttributeError: 'Rectangle' object has no attribute '__area'
As seen, they cannot be accessed directly from outside the class definition.
However, if you really want there's always a way:
# list all available attributes
print('available attributes:', dir(rect2))
available attributes: ['_Rectangle__area', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_height', '_width', 'calculate_area', 'get_area', 'num_corners']
rect2._Rectangle__area = 999
print('_Rectangle__area:', rect2._Rectangle__area)
_Rectangle__area: 999
If you're not sure if an attribute is available or not the getattr
(for "get attribute") allows for a default return on error:
# getattr allows for providing a default return when attribute does not exist
print('_Rectangle__private:', getattr(rect2, '__private', 'my default value'))
_Rectangle__private: my default value
Let's now try to build a rectangle class that supports get(s) and set(s) of all properties: height, width and area.
class Rectangle:
'''Defining a 'Rectangle' class.'''
__slots__ = ('height', 'width') # predefine allowable/possible attributes
def __init__(self, height, width):
self.height = height
self.width = width
# disguise a method as a property (getter method)
@property
def area(self):
return Rectangle.calculate_area(self.height, self.width)
# method for when someone tries to set the area (setter method)
@area.setter
def area(self, *args, **kwargs):
print('You cannot change the area! you must change the width or height instead.')
# a static method is independent of an instance and is defined by the corresponding decorator
@staticmethod
def calculate_area(height, width):
area = height * width
return area
# Initialize new rectangle instance
rect3 = Rectangle(height=12, width=6)
rect4 = Rectangle(height=20, width=4)
The area property is now available as if it were an actual attribute:
print('the area is: ', rect3.area)
rect3.width = 2
print('the area is: ', rect3.area)
the area is: 72 the area is: 24
rect3.area = 100
You cannot change the area! you must change the width or height instead.
# compare rectangle areas with high readability
rect3.area < rect4.area
True
Separation of Concerns read more Ideally, you should try to split any programming problem into a series of principal concerns for which you each design a cohesive and loosely coupled class:
When done successful the program is considered modular. Modular coding requires a bit extra coding for all the interfacing but typically pays off through simplification and maintenance of code.
Four Tenets of OOP read more
Build your own Triangle class that takes a height and width argument at initialisation and that is capable of providing the user with its area. Expose the area method as a property of the object.
Initiate two instances and compare their area.
Build a simple Counter class. When initiated it should default to zero but also allow for an optional starting value. Equip the class with both a count_up
and a count_down
method. Also, provide it with a get_value
method and make the attribute containing the value private, thus encouraging users to use the get_value method.
Now you realize that you need to be able to count up by an arbitrary value.
Reimplement the Counter class now also with a count_up_by(value)
method.
The cell below is for setting the style of this document. It's not part of the exercises.
# Apply css theme to notebook
from IPython.display import HTML
HTML('<style>{}</style>'.format(open('../css/cowi.css').read()))