Classes

In a very simplicistic view, Classes are just pre-defined storage containers with additional functionality that knows how to access the currently stored data. (=methods).

So, for example, the simplest class possible is actually an empty container you can just use to store stuff in, very similar to dictionaries, or structures in other languages:


In [1]:
class Mammal:
    pass

This is sometimes useful to just quickly store things together in the same object for logical separations:


In [2]:
mammal = Mammal()    # now I have an "object" or "instance" of a class

In [3]:
mammal


Out[3]:
<__main__.Mammal at 0x1155f13c8>

In [4]:
mammal.n_of_legs = 4   # this is a new data attribute.
# as usual, Python doesn't need it to be defined before.

In [5]:
mammal.noise = 'blarg'

In [6]:
mammal.__dict__     # this is a useful internal attribute listing all data attributes


Out[6]:
{'n_of_legs': 4, 'noise': 'blarg'}

In [7]:
print(mammal.n_of_legs)  # note this attribute becomes <tab>-complete-able


4

Now, for a mid-size to bigger project, we don't want to define things only on the go.

Creating a class with some structure and functionality will increase our efficiency when working with a lot of data that has a lot of sub-structure.


In [8]:
class Mammal:
    # these are data attributes of the class
    name = 'Mammal'
    n_of_legs = 0
    noise = None   # indicating non-functionality
    nutrition_status = 0
    
    # this is a method of the class
    # methods are just like functions, but they always refer back to the
    # current object with the first argument being 'self', and after that
    # can take other arguments for functionality.
    def make_noise(self):
        if self.noise is not None:
            print(self.noise)
        else:
            print("Not implemented yet.")
            
    def feed(self, units):
        "This is a minimalist docstring."
        
        self.nutrition_status += units
        print("Was fed {} units of nutrition.".format(units))

In [9]:
print(Mammal.n_of_legs)


0

Let's create an object of this class:


In [10]:
mammal = Mammal()  # this is called "instantiation of an object"

Coding standards The usual applied coding style is that classes are defined with capital letters, and the instantiated object is often called the same name but with small letters. (Unless the object becomes more specific, see later).

Now, let's use a method:


In [11]:
mammal.make_noise()


Not implemented yet.

The noise attribute isn't set yet, so that's what we get.


In [12]:
mammal.noise = 'snort'

In [13]:
mammal.noise = None

In [14]:
mammal.make_noise()


Not implemented yet.

"Under the hood" we have changed an attribute of the method being used. In other words, how a method works can be highly status dependent.

This status-like programming style is both hard to follow at times, but also creates great opportunities, for example, to write methods that just automatically do the right thing, because they would read-out its own status from class attributes that have been set when a status changed.

Note:

  1. instances are independent of each other
  2. You do not HAVE TO create an object to use things inside a class

First, # 1: Independence


In [15]:
mammal2 = Mammal()
mammal2.make_noise()


Not implemented yet.

In [16]:
mammal2.noise = 'burp'

In [17]:
for m in [mammal, mammal2]:
    m.make_noise()


Not implemented yet.
burp

In [18]:
mammal.feed(5)


Was fed 5 units of nutrition.

In [19]:
for m in [mammal, mammal2]:
    print(m.nutrition_status)


5
0

Now #2:

Classes can be used for accessing class-based data, that are NOT supposed to change between instances, like the name 'mammal' for example.


In [20]:
Mammal.name


Out[20]:
'Mammal'

In [21]:
Mammal.n_of_legs


Out[21]:
0

Using __init__ to initalize a class

It's a bit inconvenient to set up things after instantiating a new object, so here's how to do it in one go, using the special __init__ method:


In [22]:
import datetime as dt

class Mammal:
    # these are data attributes of the class
    name = 'Mammal'
    n_of_legs = 0
    noise = None
    nutrition_status = 0
    
    # Always refer to self first in class methods!
    # This 'self' is used to attach data to itself when being 
    # 'alive' as an instance later on!
    def __init__(self, noise, legs):
        """The initialization method. Always called __init__ ! """
        self.noise = noise     
        self.n_of_legs = legs  
        self.creation_time = dt.datetime.now().isoformat()
        
    # this is a method of the class
    # methods are just like functions, but they always refer back to the
    # current object with the first argument being 'self', and after that
    # can take other arguments for functionality.
    def make_noise(self):
        if self.noise is not None:
            print(self.noise)
        else:
            print("Not implemented yet.")
            
    def feed(self, units):
        "This is a minimalist docstring."
        
        self.nutrition_status += units
        print("Was fed {} units of nutrition.".format(units))

In [23]:
mammal = Mammal('bark', 4)

In [24]:
mammal.make_noise()


bark

In [25]:
mammal.n_of_legs


Out[25]:
4

Note the difference between class attributes and instance attributes.

Instance attributes only exist after instantiation of an object (an 'alive' version of the mere theoretical class).

While class attributes always exist.


In [26]:
Mammal.creation_time


---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-26-c8f2aed491c5> in <module>()
----> 1 Mammal.creation_time

AttributeError: type object 'Mammal' has no attribute 'creation_time'

In [27]:
mammal.creation_time


Out[27]:
'2016-06-24T13:44:29.945707'

In [28]:
Mammal.name
# i did not create this attribute with the __init__ method, yet it exists


Out[28]:
'Mammal'

The __init__ function is often used to execute something that takes a bit more time than standard operation, maybe like connecting to a remote database and read out some data.

By generating an instance of that class, the data that was read out then stays alive within that object and can be accessed whenever required later on.

For more applicability, let's leave the Mammals alone for now and talk about how to apply classes to Planets, but keep them in mind for later when we discuss inheritance.


In [29]:
#  Define the class.  Planet is the name of the class.
class Planet:
    # note how i can make an argument optional, just like for functions
    def __init__(self, name, diameter=5000):
        """The initialization of my class.
        This is a special method (function) that gets called every time you
        create a new class.  Every method in a class has "self" as the first
        parameter.  The additional parameters to __init__ are the class's
        input parameters (in this case name and diameter).  You can set a
        default value to each parameter (diameter has a default of 5000).
        """
        self.name = name
        self.diameter = diameter
        
    def __str__(self):
        s = "This is {} with a diameter of {}".format(self.name, self.diameter)
        return s
    
#     def __repr__(self):
#         return self.__str__()

Creating and Using a Class

To recap:

To use a class you've written, you first need to create an "instance" of the class. Very much like with lists or dictionaries (in fact, dictionaries and lists are classes!). Some classes have required input parameters. Some classes have optional input parameters. Some classes have no input parameters at all.


In [30]:
#  Create 2 planets.  The first by passing in as input the name and diameter.
#  With the second planet we just pass in the name so the diameter takes on
#  the default value (5000 in this case).
planet1 = Planet("Crypton", 13000)
planet2 = Planet("Eternia")

In [31]:
print(planet1)


This is Crypton with a diameter of 13000

In [32]:
planet1


Out[32]:
<__main__.Planet at 0x115b0b3c8>

In [33]:
#  Define the class.  Planet is the name of the class.
class Planet:
    # note how i can make an argument optional, just like for functions
    def __init__(self, name, diameter = 5000):
        """The initialization of my class.
        This is a special method (function) that gets called every time you
        create a new class.  Every method in a class has "self" as the first
        parameter.  The additional parameters to __init__ are the class's
        input parameters (in this case name and diameter).  You can set a
        default value to each parameter (diameter has a default of 5000).
        """
        self.name = name
        self.diameter = diameter
        
    def __str__(self):
        s = "This is {} with a diameter of {}".format(self.name, self.diameter)
        return s
    
    def __repr__(self):
        return self.__str__()

In [34]:
p = Planet('Rubycon', 10000)
p


Out[34]:
This is Rubycon with a diameter of 10000

Inheritance

One of the most powerful features of OO-programming is inheritance.

This means I can inherit features of class definitions in a so called sub-class:


In [35]:
class Mammal:
    n_of_legs = 0
    noise = None
    
    def make_noise(self):
        print(self.noise)
        
class Dog(Mammal):
    n_of_legs=4
    noise = 'bark'

In [36]:
dog = Dog()

In [37]:
dog.make_noise()


bark

The Dog class has inherited the method make_noise from the Mammal class, because we assume all mammals make some kind of noise.

The Dog classes fixes the noise to the pre-defined noise and from now on, we would not have to deal with setting the noise anymore, because the Dog class specizalized it for us.

Create and Use a Class Based on Planet (derived from the Planet class)

Now we want to create a class based on a Planet. Our new class, Earth, will have all the data and methods of a planet plus any new data/methods for the Earth class. Notice that "Planet" is in parenthesis. Also notice we've added new data to the class, self.oceanList and self.continentList. We have also added "getter" methods so we can retrieve the ocean and continent lists.

In [46]:
class Earth(Planet):
    def __init__(self):
        """  This is the initialization method for Earth.  This will run every time
        an Earth is created.  Notice there are no input parameters.
        """
        #  This is the initialization method for the mother class Planet. 
        # Notice we pass in "Earth" and "12700".
        # Now every Earth will have the name "Earth" and a diameter of "12700".
        super().__init__("Earth", 12700)
        print("THIS IS EARTH!")
        #  Here we are adding more variables (data).  These belong to Earth and
        #  not Planet.  Again, the "self" in front of the variable allows us to
        #  use these variables anywhere in this class.
        self.oceans = ["Pacific", "Atlantic", "Indian", "Southern", "Artic"]
        self.continents = ["North America", "South America", "Antarctica", \
                           "Africa", "Europe", "Asia", "Australia"]
Let's create Earth!

In [47]:
#  Create a new Earth
earth = Earth()


THIS IS EARTH!

In [48]:
earth.diameter


Out[48]:
12700

In [49]:
earth


Out[49]:
This is Earth with a diameter of 12700
Earth has more data and methods than just the basic Planet. We can get info about its oceans and continents!

In [50]:
#  Print the Earth's oceans list and continents list.  Note that calling the
#  methods found in the Earth class look the same as calling methods found in
#  the Planet class.
print("Ocean List:    ", earth.oceans)
print("Continent List:", earth.continents)


Ocean List:     ['Pacific', 'Atlantic', 'Indian', 'Southern', 'Artic']
Continent List: ['North America', 'South America', 'Antarctica', 'Africa', 'Europe', 'Asia', 'Australia']

In [51]:
earth.mass = 6e24

In [52]:
earth.__dict__


Out[52]:
{'continents': ['North America',
  'South America',
  'Antarctica',
  'Africa',
  'Europe',
  'Asia',
  'Australia'],
 'diameter': 12700,
 'mass': 6e+24,
 'name': 'Earth',
 'oceans': ['Pacific', 'Atlantic', 'Indian', 'Southern', 'Artic']}

In [ ]: