#!/usr/bin/env python # coding: utf-8 # # COMP 364: Object Oriented Programming (OOP) Pt. I # # ### FYI From Last Lecture # # If you want to try the Twitter example, here is how to do the authentication. # # 1. Register here https://apps.twitter.com/ # 2. Go to “Keys and Access Tokens” tab, and copy your “API key” and “API secret”. Scroll down and click “Create my access token”, and copy your “Access token” and “Access token secret”. # 3. `pip install requests_oauthlib` # 3. Include this in your code: # # ```python # >>> import requests # >>> from requests_oauthlib import OAuth1 # # >>> url = 'https://api.twitter.com/1.1/account/verify_credentials.json' # #replace placeholders with your keys from registering with Twitter # >>> auth = OAuth1('YOUR_APP_KEY', 'YOUR_APP_SECRET', # ... 'USER_OAUTH_TOKEN', 'USER_OAUTH_TOKEN_SECRET') # # >>> requests.get(url, auth=auth) # # ``` # # ## What is OOP? # # Let us turn to the Bible. # # "And God made the beast of the earth after his kind, and cattle after their kind, and every thing that creepeth upon the earth after his kind: and God saw that it was good." -- Genesis 1:25 (King James Bible) # # "God made all sorts of wild animals, livestock, and small animals, each able to **produce offspring of the same kind**. And God saw that it was good." -- Genesis 1:25 (New Living Translation) # # OOP is a programming paradigm that lets us organize/create data according its kind. # # Lets us think about programming in a very natural and intuitive way. # # [Useful reading](https://docs.python.org/3/reference/datamodel.html) # # For example, all lists can have objects appended to them. # In[32]: mylist = [1] mylist.append(2) print(mylist) # But we can't append to an object of type integer. # # That would be behaviour that is *not* according to its kind. God says that is not good. # In[33]: x = 2 x.append(3) # Different object **types** have different **attributes**, according to their kind. # We've gone pretty far in the course using types that Python has made for us. # # But the point of today is to learn how we can define our own classes. # # In Python, type $\iff$ class. # # # Defining a Class # # The statement `class` is used to create a class. # # Like a function definition, anything tabbed in belongs to the **class definition**. # # Here is the most basic class definition possible: # In[72]: class Foo: pass # We defined a class, or a type, that we called `Foo` and pretty much left the definition empty otherwise. # # Now we can create objects of type `Foo`. # # Once you defined a class, you can create an object of that type using the class name as a function. # # Doing this creates an **instance** of the class Foo. # # For example, each person is an **instance** of the imaginary class "Human". # In[98]: #create an instance of the type Foo myfoo = Foo() #create another instance of type Foo myotherfoo = Foo() # In[101]: print(f"My object is of type: {type(myfoo)}") print(f"The id of my object is: {id(myfoo)}") # In[102]: print(f"The other instance's id is: {id(myotherfoo)}") print(f"myfoo and myotherfoo are the same type: {type(myfoo) == type(myotherfoo)}") # Like an object's `id`, the `type` of an object can never change # Classes are also objects but let's not go down that rabbit hole. If you're interested, look up "Metaprogramming". # In[73]: type(Foo) # So we were able to create an object but we didn't actually *code* anything... # # Clearly there is some magic going on here. # # # Class Hierarchies # # We can often understand the world as a hierarchy of types. # # # ![](http://www.blueoakmountaintech.com/DLF_Book.html/images/DLF_Chapter_515.gif) # # More specifically, *derived* classes *inherit* the properties of *base* classes. # # For example: dog types have all the properties of the animal class which has all the properties of the 'living-thing' class, etc. # # # ________ # # Types in Python work the same way. # # In Python, the class hierarchy looks more like this: # # # ![](https://i.stack.imgur.com/33Zt8.png) # # # Whenever we create a new class, it *inherits* the attributes of the class **object** (the most generic class). # # This makes `object` the *base* class of `Foo`. # The **object** class contains some methods that let us create new objects. # # ### Creating a new object # # The `object` class has a method called `__new__()` which creates new objects of a given type and **returns** it. # # This is what is used to create new objects. # In[109]: myfoo = object.__new__(Foo) type(myfoo) # Is (partially) equivalent to .. # In[111]: myfoo = Foo() # ### Initializing an object # # Once an object is created, it is like a blank slate. # # In order for it to be useful we have to give it some attributes. # # This is done by the `__init__` method. Which is short for **initialization**. # # `__init__(self, *args, **kwargs)` takes as input an instance of the class, given the name `self` here and returns nothing. It just sets attributes. # # e.g. It lets us do something like this: # ```python # >>> x = 5 # #numerator is an attribute of integer objects # >>> s.numerator # 5 # >>> s.denominator # 0 # ``` # So what's really happening when we do `myfoo = Foo()` is the base class `__new__` method is called to create a new object. # # Then the `__init__()` method is called to set its attributes. # In[131]: myfoo = object.__new__(Foo) object.__init__(myfoo) #now the object is fully created # ### Overriding # # Since `Foo` is actually using the `__init__` method from the `object` class we get a very boring looking object that is the most generic type of data we can create. # # If we want to customize our object, we have to re-define or **override** the `__init__` method inside our class definition. # # Python will always use the definition of a method of the derived class before the base class if it is defined. # # So let's redefine our class. # In[126]: class Foo: def __init__(self, n): self.name = n # Now when I create an object, I can pass an argument and the `__init__` method I defined will be used instead of the generic `object` method. # # We have **overridden** the base method definition for our own. # In[127]: myfoo = Foo("Carlos") # In[128]: myfoo.name # ### Instance and Class Attributes # # What we've done is define something known as an **instance attribute**. # # All instances of the class `Foo` will have a `name` attribute (or instance variable) but their values are independent of each other. # In[132]: a = Foo("Plato") b = Foo("Socrates") print(a.name) print(b.name) # `a` and `b` are both **instances** of the class Foo so they both have a name but their `name` attributes are different. # # We can also define data that is common to all **instances** of a class. These are known as **class attributes**. # # **Class attributes** are defined outside any function definitions of a class. # In[136]: class Family(): address = "1234 Elm St." family_members = 0 def __init__(self, n): self.name = n Family.family_members += 1 # In[139]: alice = Family("Alice") bob = Family("Bob") # In[140]: print(alice.name) print(bob.name) print(Family.address) print(Family.family_members) # You are not limited to having attributes defined in `__init__(self)`. # # At any point later in your code you can create and set new attributes. # # The purpose of `__init__(self)` is to ensure that **all** instances of a class have some common data. # In[141]: alice.sex = "F" # In[142]: print(alice.sex) # In[143]: print(bob.sex) # As you know, object attributes can also be executable i.e. functions. # In[144]: class DNA: def __init__(self, seq): self.sequence = seq def compute_GC(self): return len([n for n in self.sequence if n in {"G", "C"}]) / len(self.sequence) # The function `dir(object)` tells us the accessible attributes of an object. # In[146]: d = DNA("AACCGG") dir(d) # In[147]: d.compute_GC() # In[148]: b = DNA("AAAAAAAA") b.compute_GC() # There are many other functions that are defined for us that we can override to do cool things. # In[149]: class WeirdNumber: def __init__(self, n): self.num = n def __add__(self, other): return self.num / other.num # In[150]: w = WeirdNumber(3) x = WeirdNumber(2) w + x # Now we know that the `+` operator calls the `__add__` **instance method** of the two objects being added. # # **Instance methods always** take as input an instance of the class. Hence, the mandatory `self` positional argument. # # `self` is implicitly passed through the `.` operator. # In[151]: class Foo: def print_self(self): print(self) # In[154]: f = Foo() f.print_self() # ## Summary # # Classes define types of objects that share some common attributes. # # A class can produce instance of itself using the `ClassName(*args, **args)` syntax. # # When a class instance is created, the `__new__()` and `__init__` methods are used to create the object, and set its attributes initially. # # If the class definition **overrides** the base class's methods then the **derived** class definition will be used. # In[155]: import datetime as dt class Book: def __init__(self, author, title, loaned): self.author = author self.title = title self.loaned = loaned def borrow(self): if not self.loaned: self.loaned = dt.date.today() def book_return(self, borrow_days=10, late_fee=0.25): date_loaned = self.loaned() self.loaned = False if self.loaned > dt.date.today() + 10: return (dt.date.today() - self.loaned) * late_fee else: return None class Library: def __init__(self): self.books = {} def add_book(self, title, author): b = Book(author, title, False) if title not in self.books: self.books[title] = b # In[156]: mylib = Library() mylib.add_book("How to Travel with a Salmon", "Umberto Eco") # In[157]: print(mylib.books) # In[159]: salmon = mylib.books["How to Travel with a Salmon"] # In[160]: salmon.borrow() # In[161]: salmon.loaned