Skip to content
Snippets Groups Projects
Commit 9c2d4b49 authored by Ulrich Kerzel's avatar Ulrich Kerzel
Browse files

dedicated notebook for inheritance / classes

parent f5d88d06
No related branches found
No related tags found
No related merge requests found
%% Cell type:markdown id: tags:
# Classes
So far, we have approached Python in a procedural way: Starting from a long list of statements, we have introduced functions to make our code more efficient, more reusable and also cleaner.
The functions took common datatypes, from single variables to dicionaries, as input and we could also return such entities.
Classes take this concept further and allow us to define our own entities (or objects). We distinguish between the "class" itself, this describes how this object looks like, which information it stores, or which functionality (defined by functions) it has. We can, informally, understand the class as a construction plan. If we want to make use of classes, we create "instances" of the class that then represent the actual objects.
For example, we may define a class "dog": This class allows us to describe the dog (e.g. fur colour, it's name or race), and what it can do (e.g. bark). The instances of this class then refer to specific dogs.
The general setup of a class is:
```
class class_name():
def __init__(self, <optional parameters>):
#... any initialisations we need to do....
def my_function_1(self, <optional parameters>):
# ... define the function ....
# with or without return value
#... define more functions if needed ...
```
**We notice:**
* The class is defined by the keyword ```class```, followed by the name of the class
* Each class needs to be initialised, this is done in the function defined with ```___init___()```. The function with a double underscore before and after the keyword is called a "dunder" or "magic" method in python. These methods are not intended to be called directly (by us), but they are typically called internally by some other method or action. In our case, this "magic" method is automatically called when we create an instance of this class. This initialisation function can take one or more optinal arguments. Typically, we define variables here that define the properties of our class.
* The indicator ```self``` indicates the instance of this class. It is not a keyword as such (we could use some other word) - but pretty much everyone uses *self* to avoid confusion. We use this to refer to, e.g., the variables or functions that are part of this class.
* A class can have one or more functions that define the functionality. They can (or not) have return values, they may operate on internal variables of the class, etc.
A simple class for dogs could be:
%% Cell type:code id: tags:
``` python
class Dog():
def __init__(self):
pass
def bark(self):
print('Woof')
```
%% Cell type:markdown id: tags:
Note that here we do not have anything to initialise - but we still need the magic ```__init___()``` function. We can use the keyword ```pass``` to tell python to "pass over" this function and move on.
We also note that the function "bark" needs to take ```self``` as an argument - we need to tell Python, even though the code is indented below the class, that this method is part of the class.
> We also used upper case letters to define the class. This is a common convention.
We can then create an instance of the class to refer to a specific dog and let it bark:
%% Cell type:code id: tags:
``` python
# create an instance
dog = Dog()
# bark
dog.bark()
```
%% Output
Woof
%% Cell type:markdown id: tags:
This is not the most exciting dog... Let's add some variables
%% Cell type:code id: tags:
``` python
class Dog():
def __init__(self, name, fur_color, clean = True):
self.name = name
self.fur_color = fur_color
self.clean = clean
def bark(self):
print('Woof')
```
%% Cell type:markdown id: tags:
Here, we added a few variables, making the last one optional with a default argument.
The variables we created are associated with the class again by using ```self``` beforehand.
**Private and public variables**
> All variables in python are public.
All variables that are part of the class are public, i.e. we cannot prevent someone to access them directly an change them. For example:
%% Cell type:code id: tags:
``` python
# create an instance of the class
dog = Dog('Rocky', 'brown')
dog.bark()
print('The name of the dog is {}'.format(dog.name))
```
%% Output
Woof
The name of the dog is Rocky
%% Cell type:markdown id: tags:
However, this is not really considered good practice - we should avoid to temper with the internals of the class directly but instead write "setter" and "getter" methods.
The "setter" methods take an argument and set the respective variables to the new value, for example "set_name(new_name)" should update ```self.name``` to ```new_name```.
The "getter" methods should return the value of the variable, e.g. ```get_name()``` or just ```name()```.
### Exercise
Extend the class definition by a "getter" method ```name()``` that returns the name of the dog, so that we can write:
```print('The name of the dog is {}'.format(dog.name()))```
> Trying to avoid accesing class variables avoids potential bugs a little as we interact with the variables in a controlled way.
%% Cell type:code id: tags:
``` python
class Dog():
def __init__(self, name, fur_color, clean = True):
self.name = name
self.fur_color = fur_color
self.clean = clean
def bark(self):
print('Woof')
# ... your code here ...
```
%% Cell type:code id: tags:
``` python
dog = Dog('Rocky', 'brown')
#print('The name of the dog is {}'.format(dog.name()))
```
%% Cell type:markdown id: tags:
**Getting help**
Python has an inbuilt function that provides more information about objects and classes. You can access it anytime using
> help(object)
%% Cell type:code id: tags:
``` python
help(dog)
```
%% Output
Help on Dog in module __main__ object:
class Dog(builtins.object)
| Dog(name, fur_color)
|
| Methods defined here:
|
| __init__(self, name, fur_color)
| Initialize self. See help(type(self)) for accurate signature.
|
| bark(self)
|
| is_clean(self)
|
| play(self)
|
| wash(self)
|
| ----------------------------------------------------------------------
| Data descriptors defined here:
|
| __dict__
| dictionary for instance variables (if defined)
|
| __weakref__
| list of weak references to the object (if defined)
%% Cell type:markdown id: tags:
### Exercise
Imagine we are a bank and need to establish a way for us to track the transactions with customers.
In a very simple scenario, we need an account for each customer, that has the following properties:
* It is associated with a customer with their name.
* For simplicity, we only consider euros (i.e. no cents, just integer values).
* Customers can put money into the account, withdraw money and ask for the balance.
* The bank does not offer credit or overdraft.
Implement this as a class.
First, think about why the above requirements are ambiguous and not sufficient (and then come up with a way to mitigate this.)
Then, think about which cases you need to consider in your implementation.
*Hints*
* The function ```int(number)``` converts number to an integer.
* If we need to return "nothing", we can use the keyword ```None```.
%% Cell type:code id: tags:
``` python
# ... your code here ...
```
%% Cell type:code id: tags:
``` python
# Test your account
account = Account('Smith')
print('Opened account for {}'.format(account.name()))
print('Current balance: {}'.format(account.balance()))
# make a deposit
account.deposit(10)
print('Current balance: {}'.format(account.balance()))
# try to make an invalid deposit -- should produce an error message
account.deposit(-10)
# try to make an invalid deposit -- should produce an error message
account.deposit(10.5)
# try to make an invalid deposit -- should produce an error message
account.deposit('ten')
# withdraw some money
my_money = account.withdrawal(5)
print('I have now {} euros in my hand'.format(my_money))
print('Current balance: {}'.format(account.balance()))
# try to make an invalid withdrawal -- should produce an error message and we have "nothing" in our hand
my_money = account.withdrawal(-10)
print('I have now {} euros in my hand'.format(my_money))
# try to make an invalid withdrawal -- should produce an error message and we have "nothing" in our hand
my_money = account.withdrawal(10.5)
print('I have now {} euros in my hand'.format(my_money))
# try to make an invalid withdrawal -- should produce an error message and we have "nothing" in our hand
my_money = account.withdrawal('ten')
print('I have now {} euros in my hand'.format(my_money))
# try to make an invalid withdrawal -- should produce an error message and we have "nothing" in our hand
my_money = account.withdrawal(500)
print('I have now {} euros in my hand'.format(my_money))
```
%% Cell type:markdown id: tags:
***Note***
In our implementation so far, we have resorted to a rather crude error handling by printing out an error statement.
This is already quite good as we test for unexpected behaviour - but it would be better to deal with the erronous cases in a different way. This is called "exception handling" which we will focus on later.
%% Cell type:markdown id: tags:
# Classes
## Inheritance
Classes are a powerful way to define objects with certain properties (variables) and capabilities (functions).
Previously, we have defined a simple class describing a dog. We could do the same for cats - but that would replicate a lot of code.
It would be much nicer to have a way to describe cats and dogs in more general terms and then specialise this for cats and dogs.
Here we can make use of a feature called "inheritance". This allows us to create a relationship between classes. We can start with a more generic "high-level" class and then define more specialised classes that inherit the properties of the parent class.
Designing such relationship between classes is quite tricky - on one hand, we want to have a generic setup that we can re-use several times and that is quite flexible. On the other hand, if we are too flexible, we run the risk at becoming too generic and then it becomes more difficult to derive the proper approach for our problem at hand.
Whenever we find ourselves in the situation to define a set of classes and their relationship, we should take a step back, think about the problem we are trying to solve and choose the "right" level of complexity while trying to avoid to "over-engineer" the setup.
(It does sound a bit vague and tricky - because the design is typically not easy and straightforward.)
In our case, we want to have a base class that defines what is common between cats and dogs. We could call this class "pets" - but immediately we run into issues with this: While cats and dogs are pets, not all dogs and cats are pets (think of wild or feral ones). Further, other people would have birds, rabbits, etc. as pets.
We could take several approaches now:
* We take "pets" as our base class and specify it such that it would also work for birds, rabbits, etc. - at least in principle.
* We limit ourselves to cats and dogs (not necessarily pets)
Let's take the second approach. Then we take the taxonomy of animals as a starting point, which we can see here for [Cats](https://en.wikipedia.org/wiki/Cat) and here for [Dogs](https://en.wikipedia.org/wiki/Dog). Cats and dogs start to differ at the level of Order: Both belong to the order of "Carnivora", but then for cats we go into "Felidae", and for dogs to "Canidae". The order of [Carnivora](https://en.wikipedia.org/wiki/Carnivora) includes a lot of other animals that we do not want to describe in our derived classes for cats and dogs - but we could define classes for them as well based on this base-class.
The base class could look like this:
%% Cell type:code id: tags:
``` python
class Carnivora():
def __init__(self, name=''):
self._name=name
def name(self):
return self._name
def set_name(self, new_name):
self._name = new_name
def speak(self):
pass
```
%% Cell type:markdown id: tags:
Like this, we have defined a quite generic class class. Since we mainly want to use it for cats and dogs, we assume that we will mostly assign a name to the animals - but we provide a an empty string as default argument so we do not have to name the animal. For example, if we were later to describe a wild bear, we could assign it some specific identifier - but we do not have to.
We have also replaced the function ```bark()``` with a mere generic ```speak()``` as neither cats, nor bears or others bark. However, we notice that we have not implemented anything here. This will depend if we are, for example, to describe cats or dogs. For now, the function does not do anything - it would be better though to raise an error message using methods from exception handling (e.g. raise the ```NotImplementedError```).
Since we do not want to use this class directly, but describe cats and dogs, let us start with implementing the class for dogs.
This class inherits the properties from the ```Carnivora``` class and we indicate this by adding this in the definition of the class. In general:
```class derived_class(base_class):```
Python also supports multiple inheritance, i.e.
```class derived_class(base_class_1, base_class_2, ...):```
> ... but beware, dragons be here:
>
> Doing so can be very beneficial - but it also increases the complexity of the design very quickly. If you find yourself in this position, think very carefully if you really want to do this.
%% Cell type:code id: tags:
``` python
class Dog(Carnivora):
def __init__(self, name=''):
super().__init__(name)
def speak(self):
print('Woof')
```
%% Cell type:markdown id: tags:
Here we notice the following:
* We define the class Dog (and name it as per convention with capital D), which inherits from our base-class Carnivora.
* When we initialise an instance of the derived class Dog, we also implicitly need to initialise the base-class.
* Initialising the base class is done by the call to ```super().__init__```. Here, ```super()``` refers to the class we inherit from and Python figures out for us, which class that is.
* Because the initalisation of the base-class takes an argument with default value, we need to add the same argument to the derived class Dog and pass this along to the initialisation of the base-class.
* Note that in this case we *do* call the magic function ```__init__``` of the base-class directly!
* Because we did not implement the function ```speak()``` in the base class, we need to **override** it here by a function with the same name (and arguments, if any)
* This is called an **abstract** method in the base class - one that needs to be overwritten in every sub-class that inherits from this class.
* Because the base-class is not intended to be used directly, we call it an **abstract class** (In some other programming languages: virtual class)
%% Cell type:code id: tags:
``` python
dog = Dog('Rocky')
dog.speak()
print('The name of the dog is: {}'.format(dog.name()))
```
%% Output
Woof
The name of the dog is: Rocky
%% Cell type:markdown id: tags:
## Exercise
Implement a class "Cat" that inherits from the base-class "Carnivora" and override the ```speak()``` method accordingly ("meow").
%% Cell type:markdown id: tags:
# Classes
## Private Variables
Other programming languages offer "public" and "private" variables for classes, where private variables cannot be accessed from outside. Python does not allow this, but we can use the following convention:
> Variables or functions in a class with a leading underscore (e.g. ```_name```) are intended to be private and should not be used or called directly.
For example, if we do not want users to interact with the cleanliness of the dog directly, we could use:
%% Cell type:code id: tags:
``` python
class Dog():
def __init__(self, name, fur_color):
self.name = name
self.fur_color = fur_color
self._clean = True
def bark(self):
print('Woof')
def is_clean(self):
return self._clean
def wash(self):
self._clean = True
def play(self):
self._clean = False
```
%% Cell type:markdown id: tags:
Now, the variable ```_clean``` is indicated to be used internally only. Therefore, we do not pass a value in the initialising function, but set an initial state. Then, we can ask if the dog is clean, wash it or let it play to change the state.
Note that here we have used the simplest way to change the state of the variable and always update it - we could, for example, first check if the dog is clean before cleaning it.
%% Cell type:code id: tags:
``` python
dog = Dog('Rocky', 'brown')
dog.play()
print('Is the dog clean? {}'.format(dog.is_clean()))
dog.wash()
print('Is the dog clean? {}'.format(dog.is_clean()))
```
%% Output
Is the dog clean? False
Is the dog clean? True
%% Cell type:markdown id: tags:
However, this is just convention. We could access the variable directly - the leading underscore just tells us, not to:
%% Cell type:code id: tags:
``` python
dog = Dog('Rocky', 'brown')
dog._clean = False
print('Is the dog clean? {}'.format(dog.is_clean()))
```
%% Output
Is the dog clean? False
%% Cell type:markdown id: tags:
Python does have a way to make variables and functions a bit more private by using a double underscore instead of a single one. This tells python to "mangle" the name in a special way - but if you know how this is done, you can get around it.
%% Cell type:code id: tags:
``` python
class Dog():
def __init__(self, name, fur_color):
self.name = name
self.fur_color = fur_color
self.__clean = True
def bark(self):
print('Woof')
def is_clean(self):
return self.__clean
def wash(self):
self.__clean = True
def play(self):
self.__clean = False
```
%% Cell type:code id: tags:
``` python
# Now we cannot change the internal state of the variable.
dog = Dog('Rocky', 'brown')
dog.__clean = False
print('Is the dog clean? {}'.format(dog.is_clean()))
```
%% Output
Is the dog clean? True
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment