<jupyter_start><jupyter_text>Classes (and some Python theory)In this lesson, we'll take a deep dive into object-oriented programming with Python.  Lesson ObjectivesBy the end of this lesson, you will be able to:- Understand namespaces and scope hierarchies in Python- Create your own classes to represent abstract data types- Understand and apply the four pillars of object-oriented programming- Differentiate between dynamically and statically typed languages- Differentiate between strongly and weakly typed languages- Understand and apply duck typing- Know when to use private as opposed to public variables  Table of Contents - [Namespaces & Scope](namespaces) - [Creating A Class](classes)     - [Class Definition](classDefinition)     - [Class Objects](classObjects)     - [Instance Objects](instanceObjects)     - [Class and Instance Variables](classInstanceVariables) - [The Four Pillars of Object-Oriented Programming](pillars)     - [Encapsulation](encapsulation)     - [Abstraction](abstraction)     - [Inheritance](inheritance)     - [Polymorphism](polymorphism) - [Dynamically Typed vs. Statically Typed](dynamically) - [Strongly Typed vs. Weakly Typed](strong) - [Duck Typing](duck) - [Private vs. Public Variables](private) - [Takeaways](takeaways)  Namespaces & ScopeBefore we cover classes, we need to cover **namespaces** and **scope**. That's because class definitions add another level of complexity to how we reference objects by name.  NamespaceA namespace is a system for managing all of the object names in your program. In Python, this system is implemented as a dictionary of dictionaries, with namespaces as the primary keys and then the names within each namespace (the sub-keys) mapped to their objects (the sub-values). Here's an example to help you visualize this:> `namespace1 = {'name_1':object_1, 'name_2':object_2, ...}`> `namespace2 = {'name_3':object_3, 'name_4':object_4, ...}`As an example, think about what happens whenever you define a function. Behind the scenes, Python creates a namespace for all of the names that you defined within that function. That way the function knows to use the objects you defined within it before using another object of the same name that might exist elsewhere in your program. As you can imagine, the moment you start writing larger, more complex programs, you'll have multiple *independent* namespaces. When I say that namespaces are independent, I mean names can be reused across different namespaces. Only the objects that map to the names will be unique. That means our little example above could look like this:> `namespace1 = {'name_1':object_1, 'name_2':object_2, ...}`> `namespace2 = {'name_1':object_3, 'name_2':object_4, ...}`In Python the primary namespaces include: - built-in names (like `while`, `for`, `in`, `def`, etc.) - names imported from another module - the global names of the current module - local names defined within a function or loop  Lifetime of a NamespaceNamespaces are born and then they die. This means that your program's namespaces aren't always accessible (i.e. alive). That's chiefly because they are often created at different points in time. For example: - The built-in namespace is immortal. Names like `in`, `for`, `assert` and `del` are always available.  - The global namespace of a module is accessible once the module is imported. These normally last until the intrepreter quits. - A local namespace is created whenever the function or loop that it's connected to is invoked. If it's a function's namesapce, the namespace is deleted when the function ends.  ScopeNamespaces exist hierarchically, and it's **scope** that determines the order in which Python searches namespaces for objects with a given name. More concretely, a scope is a textual region of a program where a namespace can be directly accessed without using a namespace prefix (we'll see this prefix in action later, when we work with imported modules).Fortunately for us, there's actually a handy acronym to help you remember Python's scoping rules: LEGB. - **L**ocal:  names that are assigned within functions - **E**nclosing:  names that are assigned within a closure (i.e. a nested function) - **G**lobal:  names that are assigned for the whole module (i.e. objects created at the top-level of a program without indentation) - **B**uilt-In:  names such such as `open`, `with`, `print`, `map`, `zip`, etc. A few rules also govern this hierarchy: - The lowest scope is local - Lower scopes can access the namespaces of higher scopes - Higher scopes cannot directly access the namespaces of lower scopesHere's a way to visualize these rules:So, let's say you reference a particular name. If this name cannot be found in the local namespace(s), Python moves the search to any enclosing namespaces. If the search in the enclosed scope is also unsuccessful, Python searches the global namespace next. If the global namespace doesn't have the name, Python will then search the built-in namespace. Finally, if the name isn't a built-in name, a `NameError` will be raised.To see this in action, consider this code, which comes straight from the official Python [documentation](https://docs.python.org/3/tutorial/classes.htmlscopes-and-namespaces-example).<jupyter_code>def scope_test():
    def do_local():
        spam = "local spam"

    def do_nonlocal():
        nonlocal spam             #`nonlocal` allows you to assign to variables in an outer, but non-global scope
        spam = "nonlocal spam"

    def do_global():
        global spam               #`global` changes or creates global variables in a local context
        spam = "global spam"

    spam = "test spam"
    do_local()
    print("After local assignment:", spam)
    do_nonlocal()
    print("After nonlocal assignment:", spam)
    do_global()
    print("After global assignment:", spam)

scope_test()
print("In global scope:", spam)<jupyter_output>After local assignment: test spam
After nonlocal assignment: nonlocal spam
After global assignment: nonlocal spam
In global scope: global spam<jupyter_text>For the first `print` statement, note how the local assignment didn’t change `scope_test`‘s binding of "test spam" to `spam`. But then for the second `print` statement, the nonlocal assignment changed `scope_test`‘s binding of spam.Then, for the third `print` statement, the global assignment changed the module-level binding but not the local-level binding, which is checked before the global level. Finally, for the fourth `print` statement, you can see that there was no previous binding for `spam` before the global assignment.  Module NamespacesNamespaces can be further nested whenever you import modules. That's why it's good practice to use prefixes to access the names within a module. Check out all these objects named `pi`:<jupyter_code>import numpy
import math
import scipy

pi = 3.14
print(math.pi, 'from the math module')
print(numpy.pi, 'from the numpy package')
print(scipy.pi, 'from the scipy package')
print(pi, 'from the global namespace')<jupyter_output>3.141592653589793 from the math module
3.141592653589793 from the numpy package
3.141592653589793 from the scipy package
3.14 from the global namespace<jupyter_text>Explicit is better than implicitIt's also not recommended to use `from  import *`. Why? Well, the first reason is that the zen of Python tells us explicit is better than implicit. The second reason is that it puts a lot of names into your namespace that might either shadow some other object from a previous import or even a built-in Python keyword. Consider that if you were to do `from numpy import *`, then `numpy.any` would shadow Python's built-in `any`.  Global Variables and Side EffectsWhen you define a variable in the main body of a `py` file, you've created a global variable. As such, the variable will be accessible throughout the file and also any other file which imports that file. Whenever you change a global variable, you're causing what's termed a [**side effect**](https://en.wikipedia.org/wiki/Side_effect_(computer_science).Side effects aren't bad, but when your programs get large, defining everything as a global variable means that you can have *unintended* side effects. Because of this, it's usually good practice to have only those objects that are intended to be used globally, like functions and classes, defined in the global namespace.  Creating A ClassClasses allow you to define your own object types. As you know, everything in Python is an object. And every object is an instance of some particular `type`. We can see that using the built-in `type` function:<jupyter_code>type(1)
type('word')
def dummy_function():
    pass

type(dummy_function)<jupyter_output><empty_output><jupyter_text>Even `type` is an instance of the type `type`:<jupyter_code>type(type)<jupyter_output><empty_output><jupyter_text>In other words, everything in Python is an instance of some `class`. So think of a `class` as just another kind of data type, like `str` or `list`. The difference is that *you* get to define it!  Class Definition The simplest class definitions use the `class` keyword and then look something like this:> `class ClassName:`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;``&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`.`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`.`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`.`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;``    Just like `function` definitions, `class` definitions must be executed before they can have any effect.> *A note on class definitions and namepsaces:*>> *Once a `class` definition is entered, a new namespace is created and then used as the local scope. This means that all assignments to local variables go into this new namespace. Then, once the class has been defined, the original local scope (the one in effect just before the class definition was entered) is reinstated. Then the class object is bound to the given class name (e.g. `ClassName` in the example above).*  Class ObjectsClass objects support two kinds of operations:  - Attribute references - Instantiation  Attribute ReferencesAttribute references follow the syntax used for all attribute references in Python: `obj.name`. Valid attribute names are all the names that were in the class’s namespace when the class object was created. Say we had a class definition that looked like this:<jupyter_code>class MyClass:
    """A simple example class"""
    i = 12345

    def f(self):               #what's this weird "self" parameter?? You'll learn about that shortly!
        return 'hello world'<jupyter_output><empty_output><jupyter_text>Then `MyClass.i` and `MyClass.f` are both valid attribute references, returning an integer and a function object, respectively.<jupyter_code>MyClass.i
MyClass.f<jupyter_output><empty_output><jupyter_text>`__doc__` is also a valid attribute, returning the docstring belonging to the class:<jupyter_code>MyClass.__doc__<jupyter_output><empty_output><jupyter_text>Class InstantiationClass instantiation uses the parentheses of function notation. For example:<jupyter_code>x = MyClass() #creates a new instance of the class and assigns this 
              #object to the local variable x.<jupyter_output><empty_output><jupyter_text>The instantiation operation creates an object that will have the attribte references from the class definition:<jupyter_code>x.i
x.__doc__<jupyter_output><empty_output><jupyter_text>**`__init__`**> Sometimes you might want to have class instances customized to a specific initial state. Therefore a class may define a special method named `__init__()`:<jupyter_code>class MyClass:
    """A simple example class"""
    def __init__(self):
        self.data = ["this", "is", "a", "list","of","data"]
    
    i = 12345

    def f(self):
        return 'hello world'<jupyter_output><empty_output><jupyter_text>> You may be wondering why we used the argument `self` up there. And beforehand in the function definition for `f()`. That's because the first argument of every class method, including `__init__`, must be a reference to the current instance of the class. This argument is named `self` by convention. In the `__init__` method, `self` will refer to the instance of the class object you're creating.>  The `__init__()` method also accepts arguments for greater flexibility:<jupyter_code>class Person:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name<jupyter_output><empty_output><jupyter_text>Then, when you want to create an instance of the class object, the arguments you give to the class instantiation operator are passed on to `__init__()`. For example,<jupyter_code>x = Person("Bob", "Dylan")
x.first_name, x.last_name<jupyter_output><empty_output><jupyter_text>Instance ObjectsThose `x`'s we created above are **instance objects**. They're simply instances of our class objects. When working with them, the only operations they understand are attribute references. And there are two types of attribute references:  **data attributes** and **methods**.  Data Attributes Think of **data attributes** as the data values associated with any given instance of a `class` object. They're kind of like the attribute references of the `class` itself except they're specific to each particular `class` instantiation. We've actually already seen them above, namely when we got the `first_name` and the `last_name` of our `Person` `class` once we assigned it with the arguments `Bob` and `Dylan` to `x`. Below, both `first_name` and `last_name` are attributes of `x`.<jupyter_code>x.first_name, x.last_name<jupyter_output><empty_output><jupyter_text>MethodsA **method** is a `function` defined within a `class`. Note that methods aren't actually unique to `class` instances; `list` objects, for example, have methods called `append`, `insert`, `remove`, `sort`, and so on.In our examples, `x.f` is a valid method reference, since `MyClass.f` is a `function` within `MyClass`. However, `x.i` is not a valid method reference, since `MyClass.i` is not. That's actually a data attribute. But note also that `x.f` is not the same thing as `MyClass.f` — the former is a method object while the latter is a `function` object:<jupyter_code>MyClass.f
x = MyClass()
x.f<jupyter_output><empty_output><jupyter_text>And here's how we call a method:<jupyter_code>x.f()<jupyter_output><empty_output><jupyter_text>As you can see, `x.f()` returned the string `'hello world'`. However, it is not necessary to call a method right away. Since `x.f` is a method object, it can be stored and then called later. For example:<jupyter_code>xf = x.f
for i in range(10):
    print(xf())<jupyter_output>hello world
hello world
hello world
hello world
hello world
hello world
hello world
hello world
hello world
hello world<jupyter_text>So what exactly happens when a method is called? You may have noticed that `x.f()` was called without an argument above, even though the function definition for `f()` within `MyClass` specified an argument:  `self`. How is that possible? Well, remember when we said that the first argument of every class method, including `__init__`, must be a reference to the current instance of the class? That's what happened there! Generally speaking, this means that if you define a class method with $n$ arguments (including `self`), then you can call that method with $n-1$ arguments:<jupyter_code>class MyClass:
    """A simple example class"""
    def __init__(self):
        self.data = []
    
    i = 12345

    def f(self,punctuation):               #defined with 2 arguments
        return 'hello world'+punctuation
x = MyClass()
x.f("!")                                   #called with 1 arugment<jupyter_output><empty_output><jupyter_text>Class and Instance VariablesGenerally speaking, **instance variables** are for data unique to each instance while **class variables** are for attributes and methods shared by all instances of the class:<jupyter_code>class Dog:

    genus = 'canine'            # class variable that will be shared by all instances

    def __init__(self, name):
        self.name = name        # instance variable unique to each instance
        
d = Dog("Fido")
e = Dog("Buddy")
d.genus  #shared by all dogs
e.genus  #shared by all dogs
d.name  #unique to d
e.name  #unique to e<jupyter_output><empty_output><jupyter_text>Be Careful with Mutable Class VariablesWhen dealing with mutable types like lists or dictionaries, class variables can have some unintended effects. For example, the tricks list in the following code should not be used as a class variable because the single list would be shared by all Dog instances:<jupyter_code>class Dog:

    tricks = []                       # mistaken use of a class variable

    def __init__(self, name):
        self.name = name

    def add_trick(self, trick):
        self.tricks.append(trick)
d = Dog("Fido")
e = Dog("Buddy")
d.add_trick('roll over')
e.add_trick('play dead')
d.tricks                              # unexpectedly shared by all dogs<jupyter_output><empty_output><jupyter_text>As you can see, the tricks that we meant to assign to the individual dogs, `Fido` and `Buddy`, ended up being shared by both dogs. Correct design of the class should have used an instance variable instead:<jupyter_code>class Dog:

    def __init__(self, name):
        self.name = name
        self.tricks = []    # creates a new empty list for each dog

    def add_trick(self, trick):
        self.tricks.append(trick)
        
d = Dog("Fido")
e = Dog("Buddy")
d.add_trick('roll over')
e.add_trick('play dead')
d.tricks
e.tricks<jupyter_output><empty_output><jupyter_text>A Note on Naming Conventions Data attributes override method attributes with the same name. To avoid accidental name conflicts, which may cause hard-to-find bugs in large programs, it is wise to use some kind of convention that minimizes the chance of conflicts. Possible conventions include capitalizing method names, prefixing data attribute names with a small unique string, or using verbs for methods and nouns for data attributes.  Methods Calling Other MethodsMethods can call other Methods by using method attributes of the self argument:<jupyter_code>class Bag:
    def __init__(self):
        self.data = []

    def add(self, x):
        self.data.append(x)

    def add_twice(self, x):
        self.add(x)
        self.add(x)
a = Bag()
b = Bag()

a.add("orange")
b.add_twice("appple")

a.data
b.data<jupyter_output><empty_output><jupyter_text>The Four Pillars of Object-Oriented ProgrammingNow that we understand classes, let's dive into some programming theory to understand why they're useful. There are four key concepts that define object-oriented programming:   - Encapsulation - Abstraction - Inheritance - Polymorphism Each of these is at play when we create and use classes.   EncapsulationEncapsulation is the grouping of variables (data values) and their methods (the things you can do to the data) into a single object. If you recall the concept of a program's [state](https://en.wikipedia.org/wiki/State_(computer_science), a more general way of defining encapsulation is a program's ability to group its state and the methods we'd use to alter its state within a single unit, i.e. an object.Let's see how a Python `class` enacts encapsulation:<jupyter_code>class Circle():
    from scipy import pi
    
    def __init__(self, r):
        self.r = r
        
    def get_area(self):
        return (self.r**2) * pi
c = Circle(2)
c.get_area()<jupyter_output><empty_output><jupyter_text>Above, our `Circle` class contains within it an instance variable, `r`, that will hold an object's state, i.e. a data value for a circle object's radius. But we also defined a method, `get_area`, that will use this instance variable to return the area of the circle. By defining a method alongside the data it uses within the same class object, we've enacted encapsulation.   AbstractionHow to define abstraction? Let's start with the dictionary definition of abstract as a verb:> `to consider as a general quality or characteristic apart from specific objects or instances`How does this apply to programming? Well, whenever you create a class to model something, you have to leave out some of the extraneous details. Take the `Dog` class from above. We defined the `genus` of dog as canine. But why did we stop there? Why didn't we fully flesh out our object's biological taxonomy as it should be:> `Domain- Eukarya`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`Kingdom- Animalia`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`Phylum- Chordata`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`Subphylum- Vertebrata`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`Class- Mammalia`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`Order- Carnivora`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`Family- Canidae`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`Genus- Canis`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`Species- Canis lupus`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`Subspecies- Canis lupus familiaris`Obviously we didn't model all of that because it was unecessary for our purpose. Abstraction in object-oriented programming allows you - or perhaps requires you - to strip out the unnecessary details and focus on the task at hand.    InheritanceIn Python, as well as other object-oriented languages, a class can inherit attributes and methods from another class. The class from which something is inherited is called the *superclass* or *parent class* while the class that's doing the inheriting is called the *subclass* or *child class*.The syntax for a *subclass */ *child class* definition looks like this:> `class SubClassName(SuperClassName):`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;``&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`.`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`.`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`.`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`` So why would we want to use inheritance in our programs? Put simply, because it makes classes better able to model relationships that we know from real life. Consider a program that helps a Human Resources specialist keep track of both their business's employees and customers. Their program might look something like this, using a superclass to model a `Person` in the abstract and then more specific subclasses to model an `Employee` versus a `Customer`.<jupyter_code>class Person:

    def __init__(self, firstName, lastName):
        self.firstName = firstName
        self.lastName = lastName

    def name(self):
        return self.firstName + " " + self.lastName
    

class Employee(Person):

    def __init__(self, firstName, lastName, staffNumber):
        Person.__init__(self, firstName, lastName)
        self.staffNumber = staffNumber

    def get_employee(self):
        return self.name() + ", " +  self.staffNumber
    

class Customer(Person):

    def __init__(self, firstName, lastName, customerNumber, orderHistory):
        Person.__init__(self, firstName, lastName)
        self.customerNumber = customerNumber
        self.orderHistory = orderHistory
    
    def get_most_ordered(self):
        """Returns the most common item from  a list.
        """
        self.most_ordered = max(set(self.orderHistory), key=self.orderHistory.count)
        return "Hi, {}! Seems like you like {}. Would you like to order some more?".format(self.name(),self.most_ordered)



p = Person("Bugs", "Bunny")
e = Employee("Daffy", "Duck", "101")
c = Customer("<NAME>","2001",["dynamite", "bird food", "dynamite", "traps"])

print(p.name())
print(e.get_employee())
print(c.get_most_ordered())<jupyter_output>Bugs Bunny
Daffy Duck, 101
Hi, <NAME>! Seems like you like dynamite. Would you like to order some more?<jupyter_text>Above, we made the `Employee` and `Customer` classes inherit the attributes and method from `Person`. You can see this most clearly when we called `c.get_most_ordered()`. Although we never defined the `name()` method within `Customer`, we were still able to call that method on `self` because the `Customer` class inherited it from `Person`. Inheritance and Scope Your superclass must be defined in the same scope as the subclass definition. So what do you do when the superclass is defined in another module? Well, you can do this!`class DerivedClassName(modname.BaseClassName):`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;``&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`.`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`.`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`.`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;``  Built-in Functions to Work with InheritancePython has two built-in functions to help you check inherited class relationships.**`isinstance()`**> This will check an instance’s `type`. For example, `isinstance(obj, int)` will be True only if `obj.__class__` is `int` or some class derived from int.<jupyter_code>integer = 1
isinstance(integer, int)<jupyter_output><empty_output><jupyter_text>**`issubclass()`** > This will check class inheritance. For example, `issubclass(bool, int)` is True since `bool` is a subclass of `int`.<jupyter_code>issubclass(bool, int)
issubclass(Customer, Person)<jupyter_output><empty_output><jupyter_text>Multiple InheritanceA class can inherit from multiple classes. A class definition with multiple superclasses looks like this:> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`class SuperClassName(SubClass1, SubClass2, SubClass3):`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;``&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`.`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`.`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`.`&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`` As you can imagine, namespace searches with multiple inheritance can get a little complicated, especially when two classes, `B` and `C`, inherit from a superclass `A` and then there's a class `D` that inherits from both `B` and `C`. This is called the "diamond problem" because of the shape of the class inheritance diagram. In this case, class `A` is at the top, both `B` and `C` sit separately beneath it, and `D` joins the two together at the bottom to form a diamond shape:The problem arises if there is a method in `A` that `B` and `C` have overridden but that is not overrideen in `D`. In such a case, which version of the method does `D` inherit: that of `B` or that of `C`?Python deals with this by creating a list of classes using the [C3 linearization](https://en.wikipedia.org/wiki/C3_linearization) algorithm. This algorithm enforces a particular Method Resolution Order (MRO), which follows two constraints: 1. Children precede their parents2. If a class inherits from multiple classes, it's kept in the order specified in the tuple of base classes. In our diagram above, name resolution will therefore follow the order `D`, `B`, `C`, `A`. We can test that ourselves by looking at the MRO of this class structure using the `mro()` method.<jupyter_code>class A(): 
    pass

class B(A): 
    pass
class C(A): 
    pass

class D(B,C): 
    pass

print(D.mro())<jupyter_output>[<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>]<jupyter_text>As you can see, the order is `D`, `B`, `C` and then `A`. > *If you really want to read more about this algorithm in Python, see [this](https://www.python.org/download/releases/2.3/mro/).*   PolymorphismPolymorphism is the ability of functions or methods to act on different underlying data types. This means that functions and methods can use objects of different types at different times without us having to specify every nitty-gritty detail.To get an idea of why this is valuable, consider the built-in `print` function:<jupyter_code>print('one')
print(1)
print(1.0)<jupyter_output>one
1
1.0<jupyter_text>Although we passed three different data types (`str`, `int`, and `float`) to the `print` function, the function still knew what to do. In other words, we didn't have to use separate functions - like `print_str`, `print_int`, or `print_float` - for each parameter. This behaviour is useful when creating more complex programs. Take, for example, a program that prints a list of objects. Without polymorphism, you'd have to check the type of each object to determine the correct `print` function to use. This would make the program harder to write, read, and edit. To make use of polymorphism ourselves, we’re going to create two classes that we'll then use with two separate instance objects. Polymorphism will allow these separate classes to have a common interface, i.e. they'll have methods that are distinct but that share the same name.First, we'll create a `Dog` class and a `Cat` class. In each of these classes, we'll define methods for `run()`, `play()`, and `speak()`. Although these method names will be the same, the things they do will be slightly different.<jupyter_code>class Dog:
    def run(self):
        print("The dog is running.")

    def play(self):
        print("This dog loves to play, so he's playing.")

    def speak(self):
        print("wuff, ruff, arf, au au, borf, bow-wow, yip-yip, etc....")
        
        
class Cat:
    def run(self):
        print("The cat is running.")

    def play(self):
        print("This cat does nothing, eyes you contemptuously...")

    def speak(self):
        print("This cat does nothing, until you turn your back to him, and then he bites you.")<jupyter_output><empty_output><jupyter_text>Above, both `Dog` and `Cat` have three methods of the same name. However, each method does something somewhat different. To see this, let's instantiate each class.<jupyter_code>odie = Dog()
garfield = Cat()<jupyter_output><empty_output><jupyter_text>Now that we have an instance of each class, let's call the `run()` method to see that each object behaves as intended:<jupyter_code>odie.run()
garfield.run()<jupyter_output>The cat is running.<jupyter_text>Since both of these objects have a `run()` method - as well as `play()` and `speak()` methods -  we can say that they're making use of a common interface. In Python, this means we can use the two objects in the same way regardless of the class differences. To prove this, we'll iterate through a tuple of these objects, calling each of the three methods with each iteration.<jupyter_code>for animal in (odie, garfield):
    animal.run()
    animal.play()
    animal.speak()
    print("\n")<jupyter_output>The dog is running.
This dog loves to play, so he's playing.
wuff, ruff, arf, au au, borf, bow-wow, yip-yip, etc....


The cat is running.
This cat does nothing, eyes you contemptuously...
This cat does nothing, until you turn your back to him, and then he bites you.<jupyter_text>The `for` loop iterated through the tuple, using first the `odie` instance of the `Dog` class and then the `garfield` instance of the `Cat` class. Polymorhpism is being exhibited here because we used the same methods on each object without specifying the class type of each object.  Polymorphism with a FunctionIt's also possible to create functions that can accept any object.To see this, we'll create a function called `on_a_leash()` that accepts as an argument a class instance.<jupyter_code>def on_a_leash(pet):     #we're calling this pet, but it could be anything really
    pet.run()            #we'll call the run() method since this is defined in both the Dog 
                        #and Cat classes<jupyter_output><empty_output><jupyter_text>Now we’ll recreate instances of `Dog` and `Cat`. We'll then pass those objects into our `on_a_leash()` function.<jupyter_code>odie = Dog()
garfield = Cat()

on_a_leash(odie)
on_a_leash(garfield)<jupyter_output>The dog is running.
The cat is running.<jupyter_text>Above, we passed intances of our two class objects into the function `on_a_leash()`. Since both objecst had `run()` methods defined in their class definitions, the function was able to access those without any type checking.  Polymorphism and InheritanceThese two concepts are closely related in object-oriented programming. When you have superclasses and subclasses, polymorphism allows a subclass to override a method of the superclass. This means that a method of a class can do different things within different subclasses. As an example, let's make `Dog` and `Cat` subclasses of an `Animal` class that also has a `speak()` method. We'll also give `Animal` a unique method called `sleep()`.<jupyter_code>class Animal:
    
    def speak(self):
        print("ROAR!!")
        
    def sleep(self):
        print("zzzzzz")

        
class Dog(Animal):
    
    def run(self):
        print("The dog is running.")

    def play(self):
        print("This dog loves to play, so he's playing.")

    def speak(self):
        print("wuff, ruff, arf, au au, borf, bow-wow, yip-yip, etc....")
        
        
class Cat(Animal):
    
    def run(self):
        print("The cat is running.")

    def play(self):
        print("This cat does nothing, eyes you contemptuously...")

    def speak(self):
        print("This cat does nothing, until you turn your back to him, and then he bites you.")<jupyter_output><empty_output><jupyter_text>Now let's see how the `speak()` method of the subclass `Dog()` can override the `speak()` method of the superclass `Animal`:<jupyter_code>snuffaluffagus = Animal()
lassie = Dog()
snuffaluffagus.speak()
lassie.speak()<jupyter_output>ROAR!!
wuff, ruff, arf, au au, borf, bow-wow, yip-yip, etc....<jupyter_text>And there you have it! We'll see later how you can supress this behavior, too, by the way.But for now, let's also show that `lassie` inheirted the `sleep()` method from its `Animal` superclass:<jupyter_code>lassie.sleep()<jupyter_output>zzzzzz<jupyter_text>Phew!   Dynamically Typed vs. Statically TypedPython is a **dynamically typed** language. This is the opposite of a **statically typed** language. In a **statically typed** language, every variable name is generally bound to both its object and its object type. This is done at compile time. So once a variable name is bound to a type through a declaration, like `String s = "This is a string"`, only string values can be rebound to the variable name `s`. Any attempt to bind the name to an object of another type, like `int`, would raise a type error.In a **dynamically typed** language like Python, every variable is just a *name*. It is only bound to its object and not its type. Consider this code, which would be illegal in a statically typed language:<jupyter_code>var = 123
var = "abc"<jupyter_output><empty_output><jupyter_text>> In the first statement, we created an `int` object with the value `123` and then bound the name `var` to it. In the second statement, we created a `str` object with the value `"abc"` and then rebound the name `var` to it. Python can bind a name to objects of different types throughout the execution of the program because variable names in **dynamically typed** languages are bound to their objects at execution (as opposed to compile) time.   Strongly Typed vs. Weakly TypedPython is a **strongly typed** langauge. This is the opposite of a **weakly typed** language.In a **weakly typed** language, variables of unrelated types can be implicitly coerced to the same type. Most languages allow for a small degree of this type coercion, like when you add an integer and a float. But weakly typed langauges take this one step further, like when you try to add a number to a string. Thus a statement like this is perfectly legal:>`a  = 1``b = "1"``c = concatenate(a, b)   this will return "11"``d = add(a, b)           this will return 2`In a **strongly typed** language, the last two statements above would have raised type errors:<jupyter_code>a = 1
b = "1"
c = a + b<jupyter_output><empty_output><jupyter_text>We avoid these errors in a strongly typed language like Python through explicit type conversions:<jupyter_code>a = 1
b = "1"
c = str(a) + b
c
a = 1
b = "1"
c = a + int(b)
c<jupyter_output><empty_output><jupyter_text>Duck TypingA concept closely related to Polymorphism, but that shouldn't be conlfated with it, is **duck typing**. You've probably heard the expression:In Python, think of duck typing as a style or feature of dynamic typing that is closely related to polymorphism thanks to concepts known as [late binding](https://en.wikipedia.org/wiki/Late_binding) and [dynamic dispatch](https://en.wikipedia.org/wiki/Dynamic_dispatch). With duck typing, the set of methods and attributes defined within a class determine what we can do with that class. This means that it doesn't really matter what type of data you have so long as the method you're trying to use on it exists. To see it action, let's look at how we access items in lists and dictionaries. > A lot of Python is [syntactic sugar](https://en.wikipedia.org/wiki/Syntactic_sugar) for underlying methods. For example, the `__getitem__()` method is called whenever you access members of a `list` or `dict` type object using square brackets:<jupyter_code>l = [0, 1, 2, 3, 4, 5]
print(l[0])
d = {'a': 0, 'b': 1, 'c': 2}
print(d['a'])
print(list.__getitem__(l, 0))
print(dict.__getitem__(d, 'a'))<jupyter_output>0
0<jupyter_text>We just saw an example of duck typing because when we do `d['a']` Python doesn't care whether `d` is an instance of `dict` or `list`. All Python cares about is whether or not `d` has the `__getitem__()` method.When creating your own classes, duck typing means that any object can be used in any context, up until it is used in a way that it does not support. See this modified example from the Wikipeida page on [duck typing](https://en.wikipedia.org/wiki/Duck_typing):<jupyter_code>class Parrot:
    def fly(self):
        print("Parrot flying")

class Airplane:
    def fly(self):
        print("Airplane flying")

class Whale:
    def swim(self):
        print("Whale swimming")

def lift_off(entity):
    entity.fly()

parrot = Parrot()
airplane = Airplane()
whale = Whale()


for o in (parrot, airplane, whale):
    try:
        lift_off(o)                   
    except AttributeError:
        print("Whales can't fly!")<jupyter_output>Parrot flying
Airplane flying
Whales can't fly!<jupyter_text>Private vs. Public VariablesIn some object-oriented languages, it's possible to create “private” instance variables that cannot be accessed except from inside the object. This is sometimes desireable because you might have class variables that are incidental to the program's functioning and therefore subject to change without notice. In other words, you don't want people to rely on the continued existence of these variables. Consider this example, where we define a simple class with a single attribute that is then changed outside of the class.<jupyter_code>class Simple:
    
    def __init__(self, s):
        self.s = s                     #define our single attribute

    def show_s(self):                  #create a function that just returns our string
        return self.s 
        
    def show_message(self):            #create another function that uses the function (and attributes) above
        print("Message:", self.show_s())<jupyter_output><empty_output><jupyter_text>Now that we've defined the class, let's pretend that someone else - let's call him Bob - is using this class as a part of a much larger program. They've come to know and rely upon that `s` attribute of `Simple` and occasionally like to change its value. Here's how bob uses the class to do that:<jupyter_code>example = Simple("initial_string")     #instantiate class objet with an initial value for s
example.s = "new_string"               #then changes the value of s
example.show_message()                 #then do something that will use that new value of s<jupyter_output>Message: new_string<jupyter_text>But then one day we come back to our program and make a little change. We change the name of `s` to the onomatopoeic `ess`.<jupyter_code>class Simple:
    
    def __init__(self, ess):
        self.ess = ess                   #define our single attribute

    def show_ess(self):                  #create a function that just returns our string
        return self.ess 
        
    def show_message(self):              #create another function that uses the function (and attribute ess) above
        print("Message:", self.show_ess())<jupyter_output><empty_output><jupyter_text>Now, when Bob returns to his program, he all of a sudden can't get the `show_message()` method of `example` to print `"new_string"`. His attempt to assign `"new_string"` to `example.s` isn't working! It's actually just creating a new attribute for that instance of the class object.<jupyter_code>example = Simple("initial_string")     
example.s = "new_string"               
example.show_message()            #this will print "initial_string" :(<jupyter_output>Message: initial_string<jupyter_text>In another programming language, like Java, we could have prevented this with the use of public/private/protected variables. But in Python, it's generally known that you don't write to other classes' instance or class variables. In other words, Bob shouldn't have ever been doing `example.s = "new_string"`.But if you did want to emulate private variables, Python allows you to use the double-underscore (`__`) prefix. When you use that prefix, Python will [mangle](https://en.wikipedia.org/wiki/Name_mangling) the name of the variable so that they're not easily visible to code outside the class that contains them.By the same convention, the single-underscore (`_`) prefix tells people to stay away from using that variable. In short, you don't mess around with another class's variables that look like `__foo` or `_bar`. So we could have helped Bob out by defining our `Simple` class like this from the get-go:<jupyter_code>class Simple:
    
    def __init__(self, _s):
        self._s = _s                      #define our single attribute

    def show_s(self):                     #create a function that just returns our string (this would be more compled IRL)
        return self._s 
        
    def show_message(self):               #create another function that uses the function (and attribute s) above
        print("Message:", self.show_s())<jupyter_output><empty_output><jupyter_text>And then Bob would know to just initialzie this class with `"new_string"` instead of `"initial_string"`.<jupyter_code>example = Simple("new_string")
example.show_message()<jupyter_output>Message: new_string<jupyter_text>Name ManglingA valid use-case for name mangling is to ensure that a subclass doesn't override the private methods and attributes of their superclass. Consider this first codeblock, which doesn't use name mangling:<jupyter_code>class Foo():
    
    def __init__(self):
        self.baz = "baz"
    
    def foo(self):
        print(self.baz)
        
        
class Bar(Foo):
    
    def __init__(self):
        Foo.__init__(self)
        self.baz = "bar"
    
    def bar(self):
        print(self.baz)

x = Bar()
x.foo() #will print bar, although we might have expected baz
x.bar() #will also print bar<jupyter_output>bar
bar<jupyter_text>When we name mangle with two leading underscores, we can prevent the subclass `Bar` from overriding `Foo`.<jupyter_code>class Foo():
    
    def __init__(self):
        self.__baz = "baz"
    
    def foo(self):
        print(self.__baz)
        
        
class Bar(Foo):
    
    def __init__(self):
        Foo.__init__(self)
        self.__baz = "bar"
    
    def bar(self):
        print(self.__baz)

x = Bar()
x.foo() #this will print baz
x.bar() #this will print bar<jupyter_output>baz
bar<jupyter_text>Note, however, it's still possible to access and/or modify these "private" variables.<jupyter_code>print(x.__dict__) #this will let us access the variables
x._Foo__baz    #there it is!
x._Foo__baz = 'foobaz'  #and let's modify it (a big No-No!)
x.foo()
x._Bar__baz = "barbaz"
x.bar()<jupyter_output>barbaz
{'fraction_non_alphanumeric': 0.10196982215071532, 'fraction_numerical': 0.0033482628168891595, 'mean_word_length': 4.678395061728395, 'pattern_counts': {'":': 0, '<': 189, '<?xml version=': 0, '>': 210, 'https://': 10, 'lorem ipsum': 0, 'www.': 1, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 2, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '50', 'dir': 'jupyter-structured-clean-dedup', 'id': '199080', 'license': 'Unlicense', 'n_tokens_mistral': 13214, 'n_tokens_neox': 12662, 'n_words': 5495, 'path': 'Learn Python/3. Data Structures/5. Classes/Classes.ipynb', 'repo_name': 'mcallistercs/learning-data-science', 'size': '46012'}