<jupyter_start><jupyter_text>Lecture 10:-  Learn about "object oriented programming" (OOP)- Learn how to create a "class"- Learn more about namespaces - Learn more about copies     Object oriented programmingUntil now we haven't    mentioned object oriented programming (OOP), yet we have been using **objects**from the beginning.  Knowing how to create and use **objects** in Python is very powerful.  Examples of **objects** that we have already encountered are the various data containers we have been using and things like plots.  **Objects**  have **methods** that can be used to change an object and **attributes** that describe features of an object.  Now we will learn how to make our own objects with our own special blend of **attributes** and **methods**.  The trick is to make a **class** and define it to have the desired **attributes** and **methods**.   ClassesTo create an object with methods, we use a **class** definition,  which is a blueprint or recipe defining  the **attributes** and **methods** associated with the **class**.  When we call the class, we create an **instance** of the **class**, also known as an **object**.   Here is an example of a  **class** definition:<jupyter_code>class Circle:
    """
    This is an example of a class called Circle
    """
    import numpy as np # get some math power
    # define some attributes of the Circle class
    pi=np.pi # pi is now an attribute of this class too.  
    # initialize the class   with the attribute r (no parentheses when called)
    def __init__(self,r):
        self.r=r # define a variable, r
    # define some methods (these have parentheses when called)
    def area(self): 
        return (1./2.)*self.pi*self.r**2
    def circumference(self):
        return 2.*self.pi*self.r<jupyter_output><empty_output><jupyter_text>Now we can create an **instance** of the Circle **class** called C with a radius of $r$.<jupyter_code>r=3.0 # assign 3 to a variable r
C=Circle(r) # create a class instance with radius of 3.0<jupyter_output><empty_output><jupyter_text>We can use any of the attributes or methods of this class like this:<jupyter_code>print ("The value of pi is: ",C.pi) # no parentheses!
print ("The radius of this circle is: ",C.r)# no parentheses!
print ("The area of a circle with radius = ",r,'is: ',C.area()) # with parentheses!
print ("The circumference of that circle is: ",C.circumference()) # with parentheses!<jupyter_output>The value of pi is:  3.141592653589793
The radius of this circle is:  3.0
The area of a circle with radius =  3.0 is:  14.137166941154069
The circumference of that circle is:  18.84955592153876<jupyter_text>We can also save the Circle class in a module, just as we did  in earlier Lectures for functions.  Then we can import it into other notebooks of scripts as desired.<jupyter_code>%%writefile Shapes.py
class Circle:
    """
    This is an example of a class called Circle
    """
    import numpy as np # get some math power
    # define some attributes of the Circle class
    pi=np.pi # pi is now an attribute of this class too.  
    # initialize the class   with the attribute r (no parentheses when called)
    def __init__(self,r):
        self.r=r # define a variable, r
    # define some methods (these have parentheses when called)
    def area(self): 
        return (1./2.)*self.pi*self.r**2
    def circumference(self):
        return 2.*self.pi*self.r<jupyter_output>Writing Shapes.py<jupyter_text>Now we can use it!  Here is an example how:<jupyter_code>import Shapes as S
newCirc=S.Circle(6.0)
print (newCirc.pi)<jupyter_output>3.141592653589793<jupyter_text>Attributes and methodsYou might be wondering about some things by now. For example, you should have noticed is that when we asked for **C.pi** there were no parentheses, but both **C.area( )** and **C.circumference( )** did have parentheses.   Why?  The answer is that __r__ and **pi** are **attributes**,  and **area** and **circumference** are **methods**.  Did you notice that the method definitions look a lot like functions, but are inside the class definition.  A **method**  really is a function, but it is special in that it belongs to a **class** and works on the **instance** of the **class**.  They can only be called by using the name of the **instance**, followed by a dot, followed by the **method** (with parentheses).  More about classesClasses are not the same as functions.  Although our **Shape** module can be imported just the same as any other module, to use it, we first have to create a class **instance** (**C=Shapes.Circle(r)**). All _**methods** (parts that start with **def**),  have an **argument** list. The first **argument** has to be a reference to the class instance itself, which is always **self**, followed by any variables you want to pass into the **method**. The "**\_\_init\_\_**" method initializes the **instance** attributes.   In the Circle class, the **\_\_init\_\_** method defined the **attribute** **r**, which gets passed in when the class is first called.  Asking for any **attribute**, retrieves the current value of that **attribute**.  But.  Attributes can be changed:<jupyter_code>print (C.r)
C.r=7.
print (C.r)<jupyter_output>3.0
7.0<jupyter_text>To summarize:  The  **methods** (**area** and **circumference**) are defined just like any function except note the use of **self** as the first argument.  This is required in all class method definitions.  In our case, no other variables are passed in because the only one used is $r$, so the argument list consists of only **self**.  Calling these **methods**  requires no further arguments (the parentheses are empty) and the class returns the current values.<jupyter_code>C.area()<jupyter_output><empty_output><jupyter_text>You can make a subclass (child) of the parent class which has all the attributes and methods of the parent, but may have a few attributes and methods of its own.   You do this by setting up another class definition within a class.  So, the bottom line about classes is that they are  in the same category of things as variables, lists, dictionaries, etc. That is, they are  "data containers" but with benefits. They hold data, and they also hold the  methods to process those data.If you are curious about classes, there's lots more to know about them that we don't have time to get into. You can find useful tutorials online: https://www.python-course.eu/python3_object_oriented_programming.phpor http://www.sthurlow.com/python/lesson08/ [but be careful with this one as it is for Python 2.7, so the **print** statements won't work without  parentheses, e.g., **print ('this way')**, not,  **print 'not this way'**. ]   NamespacesAnother thing you might be wondering about is why did we import **NumPy** inside the class definition when it was imported into the notebook at the top?  The answer is we didn't have to.  The class definition works perfectly well without it in this case.  But if we don't import **Numpy** within in the Shape module, the module won't work at all because it doesn't "know" about **NumPy**.  So in the module, you have to import whatever you need to run the module.    CopiesAnother issue we have been tiptoeing around is the concept of a copy of an object and what that means.  In Python, this can be a bit confusing. When we define some simple variables, the behavior is pretty much what you might expect:<jupyter_code>x=3 # define x
y=x # set y equal to x
print (y) # print out y
x=4 # change the value of X
print (y) # and y is still equal to its first definition.<jupyter_output>3
3<jupyter_text>But if we define a list object (a _compound_ object with more than one variable), things get weird:<jupyter_code>L1=['spam','ocelot',42] # define the list
L2=L1  # make a copy of the list
print (L2) # print the copy
L1[2]='not an ocelot' # change the original
print (L2) # and oops - the copy got changed too!<jupyter_output>['spam', 'ocelot', 42]
['spam', 'ocelot', 'not an ocelot']<jupyter_text>This means that **L1** and **L2** refer to the SAME OBJECT.  So how do I make a copy that is its own object (doesn't change)?    For simple lists (that do not contain sublists), we already learned how to do this:<jupyter_code>L3=L1[:]
print (L3)
L1[2]=42
print (L3)<jupyter_output>['spam', 'ocelot', 'not an ocelot']
['spam', 'ocelot', 'not an ocelot']<jupyter_text>This approach breaks down if the object is more complicated.  The copies will sometimes  be subject to mutation. (Try this yourself!).   To avoid this problem, there is a module called **copy** with a function called **deepcopy**, which will make an independent copy of the object in question:<jupyter_code>from copy import deepcopy

L1=['spam','ocelot',42] # define the list
L2=deepcopy(L1)  # make a copy of the list
print ("L2: ",L2) # print the copy
L1[2]='not an ocelot' # change the original
print ("L1: ",L1)
print ("L2: ",L2) # and bingo, L2 didn't 
# clean up
os.remove('Shapes.py')<jupyter_output><empty_output>