<jupyter_start><jupyter_text>Bonus Content - Classes and Objects in PythonThese notes are an adaptation of the notes found https://python.swaroopch.com/oop.htmlNow it's possible that throughout the notebooks you were confused about what I meant when I wrote `list` object, or `Series` object, or `ndarray` object. What is an object precisely in python?Objects are data types that can contain both data (called attributes) and functions (also called methods). Objects are instances of a predefined class. A class is essentially an object recipe. In writing up a class we provide the computer a set of instructions as to what the object should be, should have, should be able to do, and how to create one.If this is confusing don't worry, right now we are dealing with somewhat abstract concepts. Everything should become more clear once we work through some examples.<jupyter_code>## We'll start by making an empty class
## to define a class you write 'class' followed by the name of the
## class and finished with a colon
class Empty:
    ## Normally classes are filled with attributes and methods
    ## however, this class is empty so we will just write pass
    pass
## Once we've defined an Empty class we can make an Empty object
## By calling Empty()
e = Empty()

print(e)<jupyter_output><empty_output><jupyter_text>In the above code chunk we defined `e` as an instance of the `Empty` class, thus `e` is an `Empty` object.However, empty objects are a little boring. Let's spice things up a bit. MethodsLet's make a class that can do some things. We'll define a class called `Dog`. This class will be just what it sounds like. All dogs have a name so when we make a `Dog` object let's make sure that we have to give it a name. `__init__`This is where the `__init__` method of a class comes into play. This method tells your computer how we need to initialize an instance of this class.<jupyter_code>## We're starting to make a Dog class
class Dog:
    ## We can define an __init__ method that will run anytime we define a new 
    ## instance of this class. Note that self has to be included in any object 
    ## method
    def __init__(self, name):
        ## Here we are saying that when we create the Dog object, it will have
        ## a name attribute given by the input name
        self.name = str(name)
    
    ## Let's also define a method named woof. See that we still must put in self
    def woof(self):
        print("Woof.")
## Let's make a Dog object!
## Call Dog(name)
Fido = Dog('Fido')

## What does Fido sound like?
## You can call the woof method with the 
## Dog object's name .woof()
Fido.woof()

print()

## We can access the Dog object's name attribute
## with object.name
print("Good boy " + Fido.name)
## You Code
## Define a Cat Class
## Make sure it has a name attribute
## and a meow method





## You Code
## Make a Cat object, print its name and make it meow<jupyter_output><empty_output><jupyter_text>Class Variables vs. Object VariablesAbove we saw that every `Dog` and `Cat` has a `name` variable. `name` is a variable that belongs to an instance of the `Dog` class. Anytime you make a `Dog` object it will have a `name` variable.`name` is an example of what is called an object variable. These variables are owned by each instance of the class.When we define a class we can also create what are known as class variables. These variables belong to the class itself. This may be confusing so let's return to our `Dog` class to see an example.Note that when we run the code below we will be overwriting the previous Dog class from above. So we will have to redefine Fido.<jupyter_code>## Redifining our Dog class to include a class variable
class Dog:
    ## Here we initialize a class variable, NumDogs
    ## NumDogs will keep track of the number of Dog objects
    ## that have been created
    NumDogs = 0
    
    ## We'll ammend our __init__ method to add 1 to NumDogs anytime we make a 
    ## new Dog object
    def __init__(self,name):
        ## Here we are saying that when we create the Dog object, it will have
        ## a name attribute given by the input name
        self.name = name
        
        ## When we make a new Dog we'll increase NumDogs by 1
        Dog.NumDogs = Dog.NumDogs + 1
    
    ## A method named woof. See that we put in self
    def woof(self):
        print("Woof.")
## Let's Check to make sure it worked
## You can check a class variable by doing
## class_name.class_variable
print("There are " + str(Dog.NumDogs) + " dogs.")

## Make Fido anew
Fido = Dog('Fido')
print("Now there is " + str(Dog.NumDogs) + " dog.")
## You Code
## Rewrite your Cat class to include a class variable 
## that tracks the number of meows by all the cats.




## You Code
## Make two cats. Have the first one meow twice, 
## how many total meows are there? 


## Now have the second one meow three times, how many total meows are there?<jupyter_output><empty_output><jupyter_text>Class Methods vs. Object MethodsIn a similar vein there are object methods and class methods. `Fido.woof()` and the `cat_name.meow()` method are two examples of object methods. These are methods that belong to each instance of a class, like `Fido`.On the flip side we have methods that belong to the class itself. We don't currently have an example of a class method so let's write one for the `Dog` class.<jupyter_code>## Redifining our Dog class to include a class method
class Dog:
    ## Here we initialize class variables
    NumDogs = 0
    ## We added a new class variable NumWoofs
    NumWoofs = 0
    
    ## We'll ammend our __init__ method to add 1 to NumDogs anytime we make a 
    ## new Dog object
    def __init__(self,name):
        ## Here we are saying that when we create the Dog object, it will have
        ## a name attribute given by the input name
        self.name = name
        
        ## When we make a new Dog we'll increase NumDogs by 1
        Dog.NumDogs = Dog.NumDogs + 1
    
    ## woof is an object method, we can tell because the argument is self
    def woof(self):
        ## Each time a dog woofs we'll count it
        Dog.NumWoofs = Dog.NumWoofs + 1
        print("Woof.")
        
    ## The following methods are class methods, class methods require @classmethod
    ## above them and cls as an arguement. Note that @classmethod is called a decorator
    @classmethod
    def HowMany(cls):
        ## HowMany tells us howmany Dog objects there are
        return Dog.NumDogs
    
    @classmethod
    def HowManyWoofs(cls):
        ## HowManyWoofs tells us how many woofs have happened
        return Dog.NumWoofs
# We can check that this worked
Fido = Dog('Fido')
Spot = Dog('Spot')
Millie = Dog('Millie')

Fido.woof()
Spot.woof()
Millie.woof()
Fido.woof()
Fido.woof()

print("There are " + str(Dog.HowMany()) + " dogs.")
print("These dogs have woofed " + str(Dog.HowManyWoofs()) + " times.")
print("Settle down a little bit dogs.")
## You Code
## Write a class method for Cat that returns the total number of meows.<jupyter_output><empty_output><jupyter_text>InheritanceThe last thing we'll mention in this notebook is the notion of inheritance. We've made a `Dog` class and a `Cat` class, however in the real world we know both of these animals are specific examples of pets.We can think of both of these classes as a subclass of a larger class, known as a base class or superclass, called `Pet`.We'll make this below.<jupyter_code>## Defining our base class Pet
class Pet:
    ## define class variables
    NumberOf = 0
    
    ## define how we intialize a Pet object
    def __init__(self,name,sex,age):
        self.name = str(name)
        self.sex = sex
        self.age = age
        Pet.NumberOf = Pet.NumberOf + 1
        print("Just made pet, " + self.name)
    
    ## This object method will print the name and age of the pet
    def NameAndAge(self):
        print("This pet's name is " + self.name + ".")
        if self.sex == 'M':
            print("He is " + str(self.age) + " years old.")
        else:
            print("She is " + str(self.age) + " years old.")
    

## Now we'll make a subclass Dog
## by inputing the Pet class as an input
## this tells python that Dog should inherit
## all the attributes and functions of the Pet class
class Dog(Pet):
    ## Define some class variables
    NumberOf = 0
    NumberOfBorks = 0
    
    ## Define __init__ for the Dog Class
    def __init__(self,name,sex,age,breed):
        ## Note we call the Pet __init__ method
        Pet.__init__(self,name,sex,age)
        Dog.NumberOf = Dog.NumberOf + 1
        self.breed = breed
        print(self.name + " is a dog.") 
        
    ## Define a bork method
    def bork(self):
        print("bork")
        Dog.NumberOfBorks = Dog.NumberOfBorks + 1
        
## Now we'll make a subclass Cat
class Cat(Pet):
    ## Define some class variables
    NumberOf = 0
    NumberOfMews = 0
    
    ## Define __init__ for the Cat Class
    def __init__(self,name,sex,age,breed):
        Pet.__init__(self,name,sex,age)
        Cat.NumberOf = Cat.NumberOf + 1
        self.breed = breed
        print(self.name + " is a cat.")
        
    def mew(self):
        print("mew")
        Cat.NumberOfMews = Cat.NumberOfMews + 1
Fido = Dog("Fido","M",4,"Yorkshire Terrier")
Frances = Dog("Frances","F",6,"Golden Retriever")
MrMittens = Cat("Mr. Mittens","M",4,"Tabby Cat")
MissButtons = Cat("Miss Buttons","F",7,"Siamese")
print("There are " + str(Pet.NumberOf) + " pets.")
print(str(Dog.NumberOf) + " are dogs.")
print(str(Cat.NumberOf) + " ar cats.")<jupyter_output><empty_output><jupyter_text>Because `Cat` and `Dog` are subclasses of `Pet` every instance of `Cat` or `Dog` inherits the object variables and object methods of `Pet`. This is why the `__init__` methods of `Cat` and `Dog` include `Pet.__init__(self,name,sex,age)`. So we can access `name`, `sex`, and `age` variables even though they weren't explicitly assigned in the `Dog` and `Cat` classes. Think of it as `Cat` and `Dog` inheriting traits from their 'Parent' class `Pet`.<jupyter_code>print(Fido.name + " is a " + Fido.breed + ".\n\n")

# We can also use a Pet object method
MrMittens.NameAndAge()
Frances.bork()
Frances.bork()
MrMittens.mew()
MissButtons.mew()
MissButtons.mew()

print()
print()
print()

Fido.bork()
print()

print("There have been " + str(Dog.NumberOfBorks) + " borks and " + str(Cat.NumberOfMews) + " mews.")
if (Dog.NumberOfBorks + Cat.NumberOfMews) > 5:
    print("You guys sure are chatty today!")
else:
    print("A normal ammount of borks and mews.")
## You code
## Make a fish subclass of Pets
## Give the fish a noise too!<jupyter_output><empty_output>