# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: light
#       format_version: '1.5'
#       jupytext_version: 1.14.4
#   kernelspec:
#     display_name: Python 3
#     language: python
#     name: python3
# ---

# # OOP in Python
#
# OOP stands for Object-Oriented Programming.  As it relates to this course, we will discuss OOP in the context of Python including the obvious keyword 'class' but also the overall organization of Python.

# ## Class
#
# The fundamental component of object-oriented programming is the class.  In Python a class is created using the keyword 'class'.  Below is an example of the simplest possible class in Python.

# +
class MyClass:
    pass

# To instantiate the class use the class name and open/closed parenthesis

MyClass()

# To assign (or create a reference to an instance) use a variable and the 
# equal sign

mc = MyClass()

print(mc)
# -

# What is the type of or mc?
print(type(mc))

# What is the type of MyClass?
print(type(MyClass))

# What does MyClass contain
dir(MyClass)


# ## Construction
#
# An important part of creating classes in OOP languages is commonly referred to as construction.  In Python, construction has two distinct parts implemented by two special methods:
#
# * __new__
# * __init__
#
# The first special method, __new__, is where the object is actual created.  All Python classes inherit implicitly from a based class called object.  The __new__ function takes care of creating the underlying implementation.
#
# **Note: Prior to Python 3, it was necessary to explicitly include object in a classes inheritance.**
#
# It is possible to write your own __new__ function for a class, but it is extremely rare.  99.99% of the time, you will only define an __init__ funcion for a class.  However, Python does give you access and control over the lower level of instantiation through __new__.
#
# Also, note how there is no 'new' keyword.
#
# The second special method, __init__, is where you can initialize the attributes of a Python class.  Let's implement an __init__ method, add some attributes and initialize them.

# +
class MyClass:
    def __init__(self):
        self.a = 10
        self.b = 'hello'
        self.c = ['a', 'b', 'c']
        
my = MyClass()

print(my.a, my.b, my.c)

my.a += 1
my.b += ' world'
my.c += 'd'

print(my.a, my.b, my.c)


# -

# **Note: For those wondering about the analog to construction, destruction, all object lifetimes in Python, including those created by instantiating a class, are handled by the garbage collector.  So, we don't have to worry about.  In the words of Forest Gump, "That's good, one less thing."**

# ## Self
#
# An important thing to notice above is the use of 'self' as a parameter to __init__ and as the reference for accessing the attributes a, b, c.  'self' is a reference to the current instance.  It is equivalent to 'this' in C++, C# and Java.  Smalltalk also uses 'self'.  Think of 'self' as a reference to the instance data that needs to be passed to the class functions so they operate on the correct data.

# ## Initialization
#
# Notice above, the values of the attributes, a, b, c, are initialized in __init__.  We'd like to be able to initialize the instance when we declare the variable.  Since the __init__ method is a function, it allows arguments to be passed and that is how we initialize the instance attributes.

# +
class MyClass:
    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c
        
mc1 = MyClass(10, 'hello', ['a', 'b', 'c'])
mc2 = MyClass(20, 'world', ['d', 'e', 'f'])

print(mc1.a, mc1.b, mc1.c)
print(mc2.a, mc2.b, mc2.c)

print(mc1)
print(mc2)
# -

# **Note: __init__ is a function so it can accept all of the various type of arguments that any function can, e.g. positional, named, and keyword.**

# ## Representation
#
# You'll notice above that when we printed the class instances, mc1 and mc2, we didn't get a particularly readable output.  Python provides two ways to display a class in a more readable manner:
#
# * __repr__
# * __str__
#
# According to the official Python documentation, __repr__ is a built-in function used to compute the "official" string reputation of an object, while __str__ is a built-in function that computes the "informal" string representations of an object.
#
# I agree that is a subtle distinction.  The intent of __repr__ is that is can be used to re-create the object by passing its results to the function eval().  Below is an example of the datetime object.

# +
import datetime

now = datetime.datetime.now()

# Prints the actual value
print(str(now))
# Prints a string that can be used to recreate the object
print(repr(now))

# Passing the output of repr into eval and assigning the result to now2 
# creates a new object with the same value as now

now2 = eval(repr(now))
print(str(now2))


# -

# So, what's the 'take away':
#
# Implement __str__ to make ojects readable and generate output for end user
# Implement __repr__ to generate code to reproduce the object and to generate output for developers
#
# One other thing to note is that is you provide a definition for __repr__ but do not provide __str__ Python will call __repr__ when str is invoked on the instance; however, this opposite is not true.  If no __repr__ is defined then the default representation is used.
#
# Let's add __str__ and __repr__ functions to our class

# +
class MyClass:
    def __repr__(self):
        return 'MyClass({}, "{}", {})'.format(self.a, self.b, self.c)
    
    def __str__(self):
        return 'MyClass(a={}, b={}, c={})'.format(self.a, 
                                                  self.b, 
                                                  ';'.join(map(str, 
                                                               self.c)))
    
    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c
        
mc1 = MyClass(10, 'hello', [1, 2, 3, 4])

print(repr(mc1))
print(str(mc1))

mc2 = eval(repr(mc1))
print(mc2)

# -

# ## Methods
#
# While a class can be used to hold only data, most classes contains both data and functionality.  There are three types of methods that can be declared as part of a class:
#
# * Instance
# * Class
# * Static

# ### Instance
#
# We have already seen some instance methods, __str__, __repr__, and __init__.  These were 'special' methods provided by Python to extend/enhance the class.  But, we can also add our own methods.
#
# **Note: Because Python uses the convention \_\_xxx\_\_ for special methods, you should avoid using this convention for your own methods to avoid any language conflicts**
#
# Instance methods are associated with and can only be called by an instance of the class.

# +
class MyClass:
    def __repr__(self):
        return 'MyClass({}, "{}", {})'.format(self.a, self.b, self.c)
    
    def __str__(self):
        return 'MyClass(a={}, b={}, c={})'.format(self.a, 
                                                  self.b, 
                                                  ';'.join(map(str, 
                                                               self.c)))
    
    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c
        
    def format(self, format_str='{}, {}, {}'):
        return format_str.format(self.a, 
                                 self.b, 
                                 ','.join(map(str, iter(self.c))))
    
mc1 = MyClass(10, 'hello', [1, 2, 3, 4])

print(mc1.format())

print(mc1.format('{:05d}<->{}<->{}'))


# -

# ### Class
#
# Class methods are methods associated with the class.  They are typically meant to implement functionality that applies to all instances of the class.  Class methods can be called using the class name and can be called from class instances.  To define a class method a feature of Python called a decorator is used -- the @classmethod decorator.  Any method 'decorated' with the @classmethod decorator will become a class method.

# +
class MyClass:
    # Note: This is a class attribute that is accessible using the class name.
    count = 0
    
    @classmethod
    def instance_increment(cls):
        cls.count += 1
    
    def __repr__(self):
        return 'MyClass({}, "{}", {})'.format(self.a, self.b, self.c)
    
    def __str__(self):
        return 'MyClass(a={}, b={}, c={})'.format(self.a, 
                                                  self.b, 
                                                  ';'.join(map(str, 
                                                               self.c)))
    
    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c
        
        MyClass.instance_increment()
        
    def format(self, format_str='{}, {}, {}'):
        return format_str.format(self.a, 
                                 self.b, 
                                 ','.join(map(str, 
                                              iter(self.c))))
    
    
for i in range(10):
    mc = MyClass(1, 'a', [1,])
    
print(MyClass.count)


# -

# ### Static
#
# Static methods are methods that are bound to a class but that do not use/access a class instance.  Static methods are essentially functions that are scoped by the class name.  They can be accessed using the class name or instance variable.

# +
class MyClass:
    @staticmethod
    def do_anything(x, y, z):
        print(x*y*z)
    
    # Note: This is a class attribute that is accessible using the class name.
    count = 0
    
    @classmethod
    def instance_increment(cls):
        cls.count += 1
    
    def __repr__(self):
        return 'MyClass({}, "{}", {})'.format(self.a, self.b, self.c)
    
    def __str__(self):
        return 'MyClass(a={}, b={}, c={})'.format(self.a, self.b, ';'.join(map(str, self.c)))
    
    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c
        
        MyClass.instance_increment()
        
    def format(self, format_str='{}, {}, {}'):
        return format_str.format(self.a, self.b, ','.join(map(str, iter(self.c))))
    
    
mc = MyClass(10, 'hello', [1, 2, 3, 4])

mc.do_anything(1, 2, 3)

MyClass.do_anything(4, 5, 6)


# -

# ### Overloading
#
# Method overloading is the ability to use the same method name more than once but with different arguments.  Python does not support method overloading.  If the same method is defined more than once, the last definition will be used.

# +
class MyClass:
    def overload(self, a):
        print(a)
    def overload(self, a, b):
        print(a, b)
        
mc = MyClass()

mc.overload(1)


# -

# It is possible using the positional \* operator and/or keyword \*\* arguments to acheive similar results as overloading.

# +
class MyClass:
    def overload1(self, *args):
        print(*args)
        
    def overload2(self, **kwargs):
        print(kwargs.items())
        
mc = MyClass()

mc.overload1(1)
mc.overload1(1, 2)

mc.overload2(a=1)
mc.overload2(a=1, b=2)


# -

# ## Accessibility
#
# Python does not support access modifiers/specifiers for data or methods.  This means that all data and methods accessible to all.  While there is no strict enforcement of access, there are conventions for access.  The general conventions for class are:
#
# * Public data and methods are lowercase and '_' separated
# * Private data and methods have an '_' prepended
# * Protected data and methods have a double '_' prepended
#
# **Note: data and methods with double '_' under go name mangling where an '_' and class name is prepended to the name.
#
# **Note: the above conventions apply to both class-level and instance-level variables and data

# +
class MyClass:
    A = 10
    _A = 20
    __A = 30
    
    @classmethod
    def blah(cls):
        print(cls.blah.__name__)
    @classmethod
    def _blah(cls):
        print(cls._blah.__name__)
    @classmethod
    def __blah(cls):
        print(cls._MyClass__blah.__name__)
        
    def __init__(self):
        self.a = 100
        self._a = 200
        self.__a = 300
        
        
    def grok(self):
        print(self.grok.__name__)
    def _grok(self):
        print(self._grok.__name__)
    def __grok(self):
        print(self.__grok.__name__)
        
mc = MyClass()

mc.A = 20
print(mc.A, MyClass.A)
del mc.A
print(mc.A)

print(MyClass.A)
print(MyClass._A)
#print(MyClass.__A)
print(MyClass._MyClass__A)
MyClass.blah()
MyClass._blah()
MyClass._MyClass__blah()

mc.grok()
mc._grok()
mc._MyClass__grok()    


# -

# ### Shadowing
#
# Talk about changing the value of a class attribute from a instance and how it then creates a shadow of the class attribute and will forever diverge (except if it is deleted).

# +
class MyClass:
    A = 10
    
print("Class variable: ", MyClass.A)

mc = MyClass()

mc.A
mc.A = 100

print("Shadow variable: ", mc.A, " Class variable: ", MyClass.A)

MyClass.A = 1000

print("Shadow variable: ", mc.A, " Class variable: ", MyClass.A)

del mc.A

print("Restored Class variable", mc.A, " Class variable: ", MyClass.A)


# -

# ## Dynamic
#
# Python is a dynamic language so it shouldn't be surprising that classes are also dynamic.  So, what does that mean?  Well, in typed languages it is necessary to define all of the detail of the type before it can be used -- including classes because they are a type.  Dynamic languages like Python allow types to change over time.
#
# Like nearly all types in Python, classes are backed by a dictionary.  The attribute __dict__ holds the attributes of a class.

# +
class MyClass:
    def __init__(self):
        self.a = 10
        self.b = 20
    def add(self):
        return self.a + self.b
    
mc = MyClass()
print(mc.add())

print(mc.__dict__)

mc.c = 30

print(mc.__dict__)


# myclass.py
#  define MyClass

# otherfile.py
#  import myclass
#  mc = myclass.MyClass()

#  import myclass.MyClass
#  mc = MyClass()

#  from myclass import MyClass
#  mc = MyClass()
# -

# **Note: Many ORM modules use the ability to dynamically create classes and attributes that match the data being received from a database or from a web page.**
#
#

# ## Inheritance
#
# Python supports class inheritance including multiple inheritance.  In the class definition, after the class name, a parameter list of base classes can be passed:

# +
class BaseClass:
    A = 10
    
class MyClass(BaseClass):
    pass

mc = MyClass()
print(mc.A)


# -

# ### Overriding
#
# Overriding is the ability to define the same method is a child class as has been defined in a base class.  There are two reasons why overriding is used:
#
# * To replace the default behavior of the base class without changing the class semantics
# * To extend the functionality in the base class either by calling the base class first and modifying the results or changing the inputs to the base class method to change the results.
#
#

# #### Replacing

# +
class BaseClass:
    def method(self):
        print("doing base method")
        
class MyClass(BaseClass):
    def method(self):
        print("doing myclass method")
        
mc = MyClass()

mc.method()


# -

# #### Extending

# +
class BaseClass:
    def method1(self):
        print("doing base method1")
        
    def method2(self):
        print("doing base method2")
        
        
class MyClass(BaseClass):
    def method1(self):
        super().method1()
        print("doing myclass method1")
        
    def method2(self):
        print("doing myclass method2")
        super().method2()
        
mc = MyClass()

mc.method1()
mc.method2()


# -

# ## Accessors
#
# In Python, one can access the instance attributes directly using 'dot' notation.  However, sometimes attributes are not simple variables and they must be derived, e.g., a calulcation or query on a database.  Python provides a several methods for creating attribute accessors.
#
# The most ovbious accessor is a method.  It is as simple as defining a method (or two) on the class, known as getter and setter methods.  Typically, such methods include the words 'get' and 'set'.

# +
class MyClass:
    def __init__(self, a, b):
        self._a = a
        self._b = b
        
    def set_a(self, value):
        self._a = value
    def get_a(self):
        return self._a
    def set_b(self, value):
        self._b = value
    def get_b(self):
        return self._b
    
mc = MyClass(1, 2)

print(mc.get_a())
print(mc.get_b())

mc.set_a(2)
mc.set_b(4)

print(mc.get_a())
print(mc.get_b())


# -

# While there is nothing particularly wrong with setter/getter methods, they expose a particular implementation choice.  OOP purists would insist that the client should not have to know that an attribute is implemented as data vs a function.  That's not an invalid point, by the way.  To that point, Python has two main primary ways to define attributes or properties on a class:
#
# * property()
# * @property

# ### Property()
#
# The property() function is a builtin function that accepts four arguments:
#
# * fget - a function for getting an attribute value
# * fset - a function for setting an attribute value
# * fdel - a function for deleting an attribute value
# * doc - creates a docstring for the attribute
#
# The result provides a way to hide the implementation of the property x.  Access is as if it were an data attribute, but there is a transparent intervening function.

# +
class MyClass:
    def __init__(self):
        self._x = None

    def getx(self):
        print("this is getx")
        return self._x

    def setx(self, value):
        print("this is setx")
        self._x = value

    def delx(self):
        print("this is delx")
        del self._x

    x = property(getx, setx, delx, "I'm the 'x' property.")
    
mc = MyClass()

mc.x = 10
print(mc.x)
del mc.x
print(mc.x)


# -

# ### @property
#
# The other way to create properties on a class is to use the property decorator and the corresponding setter/deleter decorators.

# +
class MyClass:
    def __init__(self):
        self._x = None

    @property
    def x(self):
        """I'm the 'x' property."""
        print('this is @property x')
        return self._x

    @x.setter
    def x(self, value):
        print('this is @x.setter')
        self._x = value

    @x.deleter
    def x(self):
        print('this is @x.deleter')
        del self._x
        
mc = MyClass()

mc.x = 10
print(mc.x)
del mc.x
print(mc.x)


# -

# ## Inheritance
#
# As an object-oriented language, Python supports inheritance.  Python classes can inherit data (attributes) and behavior (methods) from the parent or base class.  The syntax for inheriting is:

# +
class BaseClass:
    pass

class DerivedClass(BaseClass):
    pass


# -

# Below is a basic class from which we will build an class hierarchy using inheritance.

# +
class Person:
    def __str__(self):
        return self.Name()
    
    def __init__(self, first_name, last_name):
        self._first_name = first_name
        self._last_name = last_name
        
    def Name(self):
        return self._first_name + ' ' + self._last_name
    
    def Income(self):
        return 0.0
    
p = Person('Tim', 'Slator')

print(p)
print(p.Name())


# -

# ### Is-A Relationship
#
# It is important to understand what inheritance means.  Inheritance implies a relationship between the derived (child) class and the base (parent) class.  That relationship is described as 'is-a', as in, <derived> is a <base>.
#
# For example, above we defined a Person class.  Now, any class that derives from Person must be describable using the 'is-a' relationship.  How about an employee? customer? a boss (its debatable whether your boss is a person)? Friend? Student? etc.
#
# **Note: No object-oriented language enforces these abstractions so it is up to you to do so when you program**

# Let's create an Employee class that inherits from Person and adds an attribute of 'job'.

# +
class Employee(Person):
    def __str__(self):
        '''
        Explanation:
        __str__ is implemented in Person.  It prints the attributes of Person: first_name and last_name
        We want to print all attributes: first_name, last_name, and job
        There are a couple of ways to get first_name and last_name:
        1. invoke the __str__ method in the super class
        2. invoke the Name method in the super class
        3. reimplement __str__ (copy-paste coding is not recommended)
        Note: Here we are extending (or building on) the existing functionality which is the whole point of
        Object-Oriented programming.
        '''
        return super().__str__() + ', ' + str(self._job)
        #Alternate approach:
        #return super().Name() + ', ' + str(self._job)
    
    def __init__(self, first_name, last_name, job):
        '''
        Explanation:
        __init__ is implemented in Person.  It initializes the attributes of Person: first_name and last_name
        We want to initialize the Person class with first_name and last_name then initialize the Employee
        attribute job.
        Again, there are a couple of ways to do this:
        1. invoke __init__ on the super class
        2. set first_name and last_name directory
        '''
        super().__init__(first_name, last_name)
        #Alternate:
        #self._first_name = first_name
        #self._last_name = last_name
        #Note: Not recommended to reimplement what is already in the base class.  One advantage
        #of object-oriented programming is to leverage existing code.  By extension, that also
        #allows leveraging existing testing.  Reimplementing code that has already been tested
        #potentially impacts schedule and product quality.
        self._job = job
        
    def Job(self):
        '''
        Explanation:
        Nothing to do wrt to inheritance, just return the job.
        '''
        return self._job
    
    

e = Employee('Tim', 'Slator', 'chief bottle washer (CBW)')

print(e)

print('{last_name}, {first_name} : {job}'.format(last_name=e._last_name, first_name=e._first_name, job=e._job))

print(e.Job())  # We get this from Employee class
print(e.Name()) # We get this from Person class

# Just to demonstrate that Person does not have a job attribute
p = Person('Tim', 'Slator')
print(p._job)   # AttributeError generated


# -

# ## Overriding
#
# Above we saw an example of extending existing functionality by calling the base class method and then doing some additional work.  Sometimes base classes have no implementation or a default implementation that you don't want.  In that case, you can override the method, that is, hide the base class implementation, and provide you own implementation.  We do that with the Income method.

# +
class Employee(Person):
    def __str__(self):
        return super().Name() + ', ' + str(self._job) + ': ' + str(self._salary)
    
    def __init__(self, first_name, last_name, job, salary):
        super().__init__(first_name, last_name)
        self._job = job
        self._salary = salary
        
    def Job(self):
        return self._job
    
    def Income(self):
        return self._salary

    
e = Employee('Tim', 'Slator', 'chief bottle washer (CBW)', 'peanuts')

print(e.Name())
print(e.Job())
print(e.Income())
print(e)
        
# -

# ## Multiple Inheritance
#
#

# +
class Vehicle:
    def __str__(self):
        return "Vehicle"
    
class LandVehicle(Vehicle):
    def __str__(self):
        return "LandVehicle::" + super().__str__()
    
class WaterVehicle(Vehicle):
    def __str__(self):
        return "WaterVehicle::" + super().__str__()
    
class Car(LandVehicle):
    def __str__(self):
        return 'Car::' + super().__str__()
    
class Boat(WaterVehicle):
    def __str__(self):
        return "Boat::" + super().__str__()
    
class Hovercraft(LandVehicle, WaterVehicle):
    def __str__(self):
        return "Hovercraft::" + super().__str__()
    
    
c = Car()
b = Boat()
hc = Hovercraft()

print(c)
print(b)
print(hc)
# -

# ## Abstract Class
#
#

StarcoderdataJupyterScriptsDedupFiltered