# LibraryBook is the name of the class
class LibraryBook:
  pass # pass indicates that the body/suit of the class definition is empty.
# This will create an instance of the class.
my_book = LibraryBook()
my_book
## <__main__.LibraryBook object at 0x1096ff730>
type(my_book) 

# Another way to check the type of some object
## <class '__main__.LibraryBook'>
isinstance(my_book, LibraryBook)
## True

Why use classes and when?? Objects simplify problems by providing an abstraction over certain data types and their functionality. Instead of thinking of the problem in terms of individual strings, integers, etc. we can now think in terms of LibraryBooks (or other objects).

Encapsulation

4.1: init, Self Parameter

Data fields - Each instance owns its own data (the class can define what names the data fields have).

The init(self, …) method is automatically called by Python when a new instance is created. This method is called the class constructor; it initialize the data values in the class.

class LibraryBook (object):   
  def __init__(self, title, author, pub_year, call_no, checked_out):
      self.title = title
      self.author = author
      self.year = pub_year
      self.call_number = call_no
      self.checked_out = checked_out 
          
# The self parameter is REQUIRED within the class, because it tells the program to retrieve/act on the instance object.
# Since we have already created my_book as a LibraryBook object, we could now manually add the title, author,... information associated with the book.

my_book.title = "The Nature and Origins of Mass Opinion"
my_book.author = ('Zaller', 'John')
my_book.year = 1992

# Retrieve a specific data field of an instance by calling instance name and the field name
my_book.title
## 'The Nature and Origins of Mass Opinion'
# We could pass all the information into the __init__ to set up the fields when creating the new instance.
new_book = LibraryBook("The Rationalizing Voter", ("Lodge","Milt"), 2013, "PZ7.R79999", True)
new_book.title
## 'The Rationalizing Voter'
new_book.checked_out
## True

4.2: Methods

Methods contain the functionality of the object.These are defined in the class.

4.2.1: Writing a Method

class LibraryBook(object):
         
    def __init__(self, title, author, pub_year, checked_out):
        self.title = title
        self.author = author
        self.year = pub_year
        self.checked_out = checked_out
        
    ### Methods for LibraryBook

    # Returns the title and author information of the book as a string
    def title_and_author(self):
        return "{} {}: {}".format(self.author[1], self.author[0], self.title) 
    
    # Prints all information associated with a book in this format
    def __str__(self): # make sure that __str__ returns a string!
        return "{} {} ({}): {}".format(self.author[1], self.author[0], self.year, self.title) 

    # Returns a string representation of the book with it' title and checked_out status   
    def __repr__(self): 
        return "<Book: {} ({})>".format(self.title, self.checked_out)
      
# Simply calling the instance itself is triggering __repr__()
new_book
## <__main__.LibraryBook object at 0x109724430>
# print is triggering the __string__()
print(new_book)
## <__main__.LibraryBook object at 0x109724430>
new_book = LibraryBook("The Rationalizing Voter", ("Lodge","Milt"), 2013, "True")
new_book.title_and_author()
## 'Milt Lodge: The Rationalizing Voter'

4.2.2: Internal/External Method Calls

The ONLY difference is:

4.3: Inheritance

Example of instance-of relationship.

nemo is an instance of ClownFish.

class ClownFish(object):
    pass

nemo = ClownFish()
type(nemo)
## <class '__main__.ClownFish'>
isinstance(nemo, ClownFish)
## True

But ClownFish is also a fish, a vertebrate, and an animal, and each could a separate class. In this case, we need to have relationships between class.

The ClownFish class could have the parent class Fish,

which could have a parent class Vertebrate,

which could have a parent class Animal…

This relationship holds between a child class and its parent class. Every class in Python has at least one parent class. Note that the is-a relationship is transitive, so every ClownFish is also an Animal.

There is a top-most class in Python called object. So far, when we defined classes, we always made object the direct parent of the class.

class Animal(object): pass

class Vertebrate(Animal): pass

class Fish(Vertebrate): pass

class ClownFish(Fish): pass

class TangFish(Fish): pass

nemo = ClownFish()
isinstance(nemo, ClownFish)
## True
# TangFish is not a parent class of ClownFish
isinstance(nemo, TangFish)

# the is-a relationship is transitive
## False
isinstance(nemo, Animal) 

# All classes have a parent class of Object.
## True
isinstance(nemo, object)
## True

4.3.1: Inherited Methods

Why use inheritance? Every class also has access to the class attributes of the parent class. In particular, methods defined on the parent class can be called on instances of their “decendants”.

class Fish(Animal):
    def speak(self): 
        return "Blub blub blub"
      
class ClownFish(Fish): pass

class TangFish(Fish): pass

TangFish is a child class of Fish, so it can access the speak() from Fish class. It will first look for the method call within its class, and if not found, then repeat the search for each parent level up.

dory = TangFish()
dory.speak()
## 'Blub blub blub'

ClownFish is a child class of Fish, so it can access the speak() from Fish class

nemo = ClownFish()
nemo.speak()
## 'Blub blub blub'

What if we want different functionality for a child class? We can override the method (by writing a new one with the same name).

class TangFish(Fish):
    def speak(self):
        return "Hello, I'm a TangFish instance."
      
dory = TangFish()
dory.speak() # this speak() is from the TangFish class
## "Hello, I'm a TangFish instance."
nemo = ClownFish()
nemo.speak()
## 'Blub blub blub'

4.3.2: Accessing Variable with Inheritance

In a is-a relationship, the child classe could access the parent class’s attributes if not defined in the child class, or override the attribute value of same attribute exists in the child class.

However, if an instance is defined at one of the parent class levels, then it could NOT access the attributes that are defined in any of the lower child class level.

class Fish(Vertebrate):
    # self.name is not defined in Fish class, but is defined in the ClownFish class.
    def __str__(self):
        return "Hello, my name is {}".format(self.name)
    
class ClownFish(Fish):
    def __init__(self, name):
        self.name = name
        
nemo = ClownFish("nemo")
# The self.name attribute for the __str__() is from the ClownFish class
# but the __str__() is from the Fish class
print(nemo)
## Hello, my name is nemo
class Fish(Vertebrate):
    def __init__(self, name):
        self.name = name
    # self.name is not defined in Fish class, but is defined in the ClownFish class.
    def __str__(self):
        return "Hello, my name is {}".format(self.name)
    
class ClownFish(Fish):
    def __init__(self, name):
        self.name = name
        
nemo = ClownFish("Nemo")
print(nemo) # __str__() is accessing the self.name from the child level
## Hello, my name is Nemo
nemo = Fish("clown_fish") # __str__ ia accessing the self.name attribute from Fish class
print(nemo)
## Hello, my name is clown_fish