Wednesday, July 18, 2018

Classes


Overview of Object-Oriented Programming (OOP)
---------------------------------------------

What is OOP?
  - programming paradigm based on the following:
      a. objects - may contain data in the form of fields (often known as
                   attributes)
      b. code - procedures (also known as methods)
  - makes code reusable
  - allows easy debugging, maintenance, and testing
  - what's inside a class?
      a. data
      b. functions

What are objects?
  - a car is compose of parts: engine, wheel, electronics, etc..
  - we use the car by putting the key, stepping at the pedal
  - object is like a car, a package composes of components that we can use to
    without knowing the internal components
  - instance of a class
  - anything you put `()` on their names (eg: x(), foo(), ..)

What are classes?
  - similar to car's blueprint that explains how the object was built
  - parent category/template of a car: has 4 wheels, engine, brakes, etc..
  - certain criteria can be passed on classes to make unique objects like a car
    with v8 engine, a 2-door car, etc..

What is class inheritance?
  - deriving classes from parent class
  - e.g: breaking a car into subclasses (car -> sports car -> V8 sports car)

What is abstract interaction?
  - We can use TV remote control by pressing certain keys and without
    actually knowing what and how the components inside (microchips) function.
  - OOP is similar to that, we use the object (remote control) w/o knowing
    the code inside (microchips)


Popular OOP Languages:

Java
Python
Ruby
C++
Smalltalk
Visual Basic .NET
Objective-C*
Curl
Delphi
Eiffel

* OOP is a core tenet of iOS mobile app programming,
and Objective-C is essentially the C language with
an object-oriented layer.

Namespaces
----------

- mapping of names to objects
- a name is anything you define
    a. variable
    b. function
    c. class
- mostly implemented as python dictionary for improved performance
- created at different moments
- have different lifetimes
- examples:
    a. built-in names
         - created when interpreter starts up
         - never deleted
    b. global names in a module
         - created when the module is read
         - lasts until interpreter quits
    c. local names in a function
         - created when the function is called
         - deleted when function returns / returns an exception
- allows same names in different locations
    * same file name in multiple directories
    * same first name but with different surnames

























Different namespaces
Important thing to note is that there is no relation between names in different
namespaces. In the example below, the variable `spam` has different values on
each 3 namespaces -  module, function and subfunction levels.

spam = 'module spam'  # local to module

def parent():
  spam = 'parent spam'  # local to parent()
  print(spam)

  def child():
    spam = 'child1 spam'  # local to child()
    print(spam)

  child()

parent()
print(spam)

The output would be:
 
parent spam
child1 spam
module spam
Useful namespace functions
This allows you to access the contents of a namespace by converting it into a dictionary.
>>> import argparse
>>> parser = argparse.ArgumentParser(description='testing')
>>> parser.add_argument('-H', help='test option')
_StoreAction(option_strings=['-H'], dest='H', nargs=None, const=None, default=None, type=None, choices=None, help='test option', metavar=None)
>>>
>>>
>>> args = parser.parse_args()
>>> type(args)
in
>>> args
Namespace(H=None)
>>> vars(args)
{'H': None}
>>>

Scopes
------

- textual region in python where the namespace is accessible
- defines part of the program where you can use that name w/o using a prefix
  (function.var_name)
- order of search:
    1. innermost scope (current function)
    2. scope of enclosing functions moving outward (if there is any)
    3. middle scope (global names in module level)
    4. outermost scope (built-in names)
- when no name was found, `NameError` exception is raised

Demo on scope and namespaces
Having this sample code,
 
def scope_test():
    def do_local():
        spam = "local spam"

    def do_nonlocal():
        nonlocal spam
        spam = "nonlocal spam"

    def do_global():
        global spam
        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)

will produce this result.
 
After local assignment: test spam
After nonlocal assignment: nonlocal spam
After global assignment: nonlocal spam
In global scope: global spam
Using dir() to explore current
scope
dir() can be used to see the names defined in the current scope

>>> dir()
['__builtins__', '__doc__', '__name__', '__package__']
>>> a = 'apple'  # let's define another name
>>> a
'apple'
>>>
>>> # the newly defined name now appears
>>> dir()
['__builtins__', '__doc__', '__name__', '__package__', 'a']
>>>
>>> # let's define a function
>>> def test():
...   pass
...
>>>
>>> t = test()
>>>
>>> # the function is now also included in the current namespace
>>> dir()
['__builtins__', '__doc__', '__name__', '__package__', 'a', 't', 'test']
>>>
>>> # same is true for a class
>>> class testClass:
...   pass
...
>>>
>>> dir()
['__builtins__', '__doc__', '__name__', '__package__', 'a', 't', 'test', 'testClass']
>>>

Python Classes
--------------

- a set of code bundled together providing functionality
- must be executed before taking effect (similar to function definitions)
- statements inside are usually function definitions (others are allowed)
- when a class is created, a new namespace is created and used as a local scope
- ways of using class:
    a. by assigning attributes -> class.num = 23
    b. by instantiating it     -> x = class()
- meant to be reausable

General syntax
class ClassName:
  .. put something here ..

Class Objects
-------------

- any object that is created from a class
- supports 2 operations:
    a. attribute references
    b. instantiation
- FYI: in Python, all are objects (functions, class, strings, etc ..)

Attribute references
Consider the following class:
>>> class MyClass:
...   """A simple example class"""
...   i = 12345
...   def f(self):
...     return 'hello world'
...
>>>

Both `i`, `f(self)`, and `__doc__` are valid attribute references.
>>> type(MyClass.i)
>>> type(MyClass.f)
>>> type(MyClass.__doc__)
>>>

Since class objects supports attribute references, you can define another name on
its local scope:
>>> MyClass.fruit = 'apple'
>>> MyClass.fruit
'apple'
>>>

You may also change a name's value on its local scope.
>>> MyClass.__doc__
'A simple example class'
>>> MyClass.__doc__ = 'I have overwritten the original __doc__'
>>> MyClass.__doc__
'I have overwritten the original __doc__'
>>>
Instantiation
To instantiate a class, assign it to a local variable:
x = MyClass()

That creates an empty object.

To create an initial state everytime you instantiate a class, use `__init__`.
def __init__(self):
    self.data = []

Another example of an initial state with some statements inside:
>>> class Complex:
...     def __init__(self, realpart, imagpart):
...         self.r = realpart
...         self.i = imagpart
...
>>> x = Complex(3.0, -4.5)
>>> x.r, x.i
(3.0, -4.5)

Instance Objects
----------------

- when you instantiate a class (x = MyClass()), the variable where it is stored
  (x) is called the instance of the class
- instance object and class instance are the same thing
- instance objects only understood 1 operation which is attribute references
- valid attribute names:
    a. data attribute
         - instance variables
         - just like a normal variable but this time you are defining it in a
           class instance
    b. methods
         - aka: instance methods
         - functions that belong to an object
         - basically just a function inside a class (but it is more than that
           ..)
         - calling it is similar to a regular function but this time you call it
           via a class instance
         - important: a method doesn't always pertain to class instances, there
                      are also methods on other
                      object type like a list which have methods append(),
                      insert(), sort(), and so on ..

Data attributes
Consider the same `MyClass` on the previous section and its instance `x`. We can define
a data attribute or a new variable on its local scope. After defining, you do whatever you
want with it or even destroy it if you no longer need it.
x.counter = 1
while x.counter < 10:
    x.counter = x.counter * 2
print(x.counter)
del x.counter
Methods
Details on method objects will be discuss on the next section but for now, let's try to
see what is an instance method.
>>> x = MyClass()
>>> type(x.f)
>>>

Note that in the previous `MyClass` definition, `f()` is a function which makes it a valid
method of the class.

Method Objects
--------------

- function definition inside a class instance
- first argument passed is always the class instance (self)
- can also be stored on a variable
- a method can call other method within same class instance

What is "self"?
"self" is a convention which is used to refer to the class' own instance.
class SampleClass:
  def hello(self):
    pass

x = SampleClass()

Since it is a convention, you may use whatever you want like "x", "elephant", etc.
But "self" is a convention used by all and you should use same thing. using another convention
might decrease the redability of your code.

When a method in a class instance is called, it always passes the instance in to itself as the
first argument.

So this one:
x.hello()

Is equivalent to:
SampleClass.hello(x)
An instance without "self"
Let's define a function definition without using "self"
>>> class test():
...   def hello():   
...     print("hello world")   
...
>>> test.hello()
hello world
>>> test.hello  # this is not a method, just an ordinary function
    
>>>

If we try to create an instance of it and call its method, this will look for an extra argument
(which self) and will raise an exception when it dosn't find it.
>>> a = test()
>>> a.hello()
Traceback (most recent call last):
  File "", line 1, in
TypeError: hello() takes 0 positional arguments but 1 was given
>>>      
Removing "self"
If we remove self like this:
class SampleClass:
  def hello(input1):
    print(input1)

And call the instance:
x = SampleClass()
x.hello('hi')

We will receive the following error:
Traceback (most recent call last):
  File "practice.py", line 6, in
    x.hello('hi')
TypeError: hello() takes 1 positional argument but 2 were given

That happened since a method always passes the instance to itself as the first argument. So we need
to add "self" to catch that 1st argument.
Function define outside
a class
A function, which turns into a method when class is instantiated, can also be defined outside
the class definition.
# Function defined outside the class
def f1(self, x, y):
    return min(x, x+y)

class C:
    f = f1

    def g(self):
        return 'hello world'

    h = g
Methods calling other
methods
class Bag:
    def __init__(self):
        self.data = []

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

    def addtwice(self, x):
        self.add(x)
        self.add(x)

Class and Instance Variables
----------------------------

- class variables    -> shared among all instances
- instance variables -> unique for every instance

Simple demo
Looking at this sample class:
class Dog:

    kind = 'canine'         # class variable shared by all instances

    def __init__(self, name):
        self.name = name    # instance variable unique to each instance

We can see that `kind` is same on all instances of the class while `name` is unique.
>>> d = Dog('Fido')
>>> e = Dog('Buddy')
>>> d.kind                  # shared by all dogs
'canine'
>>> e.kind                  # shared by all dogs
'canine'
>>> d.name                  # unique to d
'Fido'
>>> e.name                  # unique to e
'Buddy'
Another example
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
['roll over']
>>> e.tricks
['play dead']

Class Inheritance
-----------------

- construction of a "derived" class from "base" class(es)
- derived class aka child class
- base class aka parent class
- search for method/attribute starts from the derived class the on to the base
  class(es)
- derived classes may override methods of their base classes
- useful built-in functions:
    * isintance()  -- checks instance type
    * issubclass() -- checks class inheritance
- multiple inheritance is also supported
    * search order:
        a. inside derive class
        b. inside 1st base class (and all its base classes if any)
        c. inside next base class (and all its base classes if any)

General syntax
form 1
class DerivedClassName(BaseClassName):
   
    .
    .
    .
   

form 2 -- base class coming from a specific module
class DerivedClassName(modname.BaseClassName):
   
    .
    .
    .
   
Multiple inheritance
class DerivedClassName(Base1, Base2, Base3):
   
    .
    .
    .
   
Class inheritance in action
base class:
class Fish:
    def __init__(self, first_name, last_name="Fish",
                 skeleton="bone", eyelids=False):
        self.first_name = first_name
        self.last_name = last_name
        self.skeleton = skeleton
        self.eyelids = eyelids

    def swim(self):
        print("The fish is swimming.")

    def swim_backwards(self):
        print("The fish can swim backwards.")

derived class:
class Trout(Fish):
    pass  # since `pass` is the only statement, all methods of the base
          # class will be inherited

terry = Trout("Terry")
print(terry.first_name + " " + terry.last_name)
print(terry.skeleton)
print(terry.eyelids)
terry.swim()
terry.swim_backwards()

output:
Terry Fish
bone
False
The fish is swimming.
The fish can swim backwards.
Derived class with different
method
Using same base class above, we will add a new derived class with its own unique method.
class Clownfish(Fish):

    # this method belongs only to Clownfish unless Clownfish is used also
    # as a base class of other derived class(es)
    def live_with_anemone(self):
        print("The clownfish is coexisting with sea anemone.")

casey = Clownfish("Casey")
print(casey.first_name + " " + casey.last_name)
casey.swim()
casey.live_with_anemone()

Class `Clownfish` will still inherit all methods of its base class together with its
own unique method which is `live_with_anemone`

output:
Casey Fish
The fish is swimming.
The clownfish is coexisting with sea anemone.
derived class overriding its
base class' method
derived class:
class Shark(Fish):
    # we want to override the parent method because Shark's skeleton is cartilage
    # and the last name should be Shark
    def __init__(self, first_name, last_name="Shark",
                 skeleton="cartilage", eyelids=True):
        self.first_name = first_name
        self.last_name = last_name
        self.skeleton = skeleton
        self.eyelids = eyelids

    def swim_backwards(self):
        print("The shark cannot swim backwards, but can sink backwards.")

sammy = Shark("Sammy")
print(sammy.first_name + " " + sammy.last_name)
sammy.swim()
sammy.swim_backwards()
print(sammy.eyelids)
print(sammy.skeleton)

output:
Sammy Shark
The fish is swimming.
The shark cannot swim backwards, but can sink backwards.
True
cartilage
accessing the overwritten
method using super()
Using same parent class as above
class Trout(Fish):
    def __init__(self, water = "freshwater"):
        self.water = water
        super().__init__(self)

terry = Trout()

Initialize first name
terry.first_name = "Terry"

Use parent __init__() through super()
print(terry.first_name + " " + terry.last_name)
print(terry.eyelids)

Use child __init__() override
print(terry.water)

Use parent swim() method
terry.swim()

Output:
Terry Fish
False
freshwater
The fish is swimming.
Multiple inheritance
1st base class:
class school(): 
  def knowledge(self):
    print('I will shape a child\'s future')

  def building(self):
    print('I am made of concrete')

2nd base class:
class hospital(): 
  def heal(self):
    print('I save lives!')

  def building(self):
    print('I am made of metal')

derived class:
class community(school, hospital):
  pass

c = community()
c.knowledge()
c.heal()
c.building()  # this will inherit the method from the first base
              # class called w/c is school

Output:
  1. I will shape a child's future
I save lives!
I am made of concrete

Private Variables and Methods
-----------------------------

- used to preserver contents of variables/methods with same name
- not used to ro prevent accidental access
- syntax (atleast 2 leading underscores): __VarName or __FunctionName
- once the private variable is defined, python will convert its name to:
   _ClassName__VarName or _ClassName__FunctionName

Simple demo
Sample statement:
class vehicle():

  def __init__(self):
    self.__door = 4

  def show_door_4(self):
    print("door:", self.__door)

class sports_car(vehicle):

  def __init__(self):
    super(sports_car, self).__init__()  # access __init__ of parent class
    self.__door = 2

  def show_door_2(self):
    print("door:", self.__door)

v = sports_car()
v.show_door_2()
v.show_door_4()

Output:
door: 4
door: 2

If we name self._door as self.door, the value that will return is
the value on the child class; thus, overwriting the parent's.

If we use `dir(v)`, we can see that both classes' `__door` was converted to
`_ClassName__door`.
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__le__', '__lt__', '__module__', '__ne__', '__new__'
, '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_sports_car__door', '_vehicle__door', 'show_door_2', 'show_door_4']
Calling a private method
Let's create our class containing a private method and instantiate it afterwards.
>>> class warriors():
...   def soldier(self):
...     print("upfront and can be seen")
...   def __ninja(self):
...     print("hidden in the dark")
...
>>>
>>> h = warriors()
>>>
>>> h.soldier()  # calling soldier is easy as pie
upfront and can be seen
>>>

Trying to call it using its name as defined in the class will raise an exception.
>>> h.__ninja()
Traceback (most recent call last):
  File "", line 1, in
AttributeError: 'warriors' object has no attribute '__ninja'
>>> dir(h)  # here reveals a new way of calling that ninja
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_warriors__ninja', 'soldier']
>>>

To call it correctly, use the convention used by the interpreter after name conversion.
>>> h._warriors__ninja()
hidden in the dark
>>>

Where to use classes?
---------------------

Empty class definition to create records
class Employee:
    pass

john = Employee()  # Create an empty employee record

# Fill the fields of the record
john.name = 'John Doe'
john.dept = 'computer lab'
john.salary = 1000
Iterators
As we know, iterators can accept operations in any of the following `for`
statements.
for element in [1, 2, 3]:
    print(element)
for element in (1, 2, 3):
    print(element)
for key in {'one':1, 'two':2}:
    print(key)
for char in "123":
    print(char)
for line in open("myfile.txt"):
    print(line, end='')

Behind the scenes, `for` statements calls `iter()` on the container object
which returns an iterator object. The iterator object defines the method
`next()` which accesses the elements one at a time. If there are no more
elements, `next()` raises a `StopIteration` exception. Here is a demo on
how `iter()` and `next()` works.
>>> s = 'abc'
>>> it = iter(s)
>>> it
>>> next(it)
'a'
>>> next(it)
'b'
>>> next(it)
'c'
>>> next(it)
Traceback (most recent call last):
  File "", line 1, in
    next(it)
StopIteration

We can construct a class having an iterator behavior like this.
class Reverse:
    """Iterator for looping over a sequence backwards."""
    def __init__(self, data):
        self.data = data
        self.index = len(data)

    def __iter__(self):
        return self

    def __next__(self):
        if self.index == 0:
            raise StopIteration
        self.index = self.index - 1
        return self.data[self.index

Output:
>>> rev = Reverse('spam')
>>> iter(rev)
<__main__ .reverse="" 0x00a1db50="" at="" object="">
>>> for char in rev:
...     print(char)
...
m
a
p
s
Generators
Generators are a simple and powerful tool for creating iterators. Instead of returning a single object (like in a function), it returns a sequence of results which can be produced by iterating over it (e.g using a for loop).

In contrast to functions, generators use `yield` instead of `return`. The values that can be iterated is created by
the `yield` statement.
>>> def gen():
...   yield 123
...
>>>
>>> def func():
...   return 123
...
>>>

A generator automatically creates `__iter__()` and `next()` methods. It remembers the last state and local variables
are save between subsequent calls. Whereas in functions, execution always start at the beginning.
>>> def gen():
...   yield('a')
...   yield('b')
...   yield('c')
...   yield('d')
...
>>> g = gen()
>>>
>>> next(g)
'a'
>>> next(g)
'b'
>>> next(g)
'c'
>>> next(g)
'd'
>>> next(g)
Traceback (most recent call last):
  File "", line 1, in
StopIteration
>>>

The order operation are as follows:
  1. Generator is called like a function, return value is an iterator. Code is not yet execute at this stage
  2. Iterator can be used via `next()` method. Codes are executed until the 1st yield statement is reached.
  3. The value right after `yield` is return. Python keeps track of this value.
  4. Any succeedng calls via `next()` will execute codes after the `yield` statement (if there is any) and return the next `yield` statements
  5. All iterators are finished
>>> def gen():
...   print('first')
...   yield('a')
...   print('second')
...   yield('b')
...
>>> g = gen()
>>>
>>>
>>> next(g)
first
'a'
>>>
>>> next(g)
second
'b'
>>> next(g)
Traceback (most recent call last):
  File "", line 1, in
StopIteration
>>>

Here are some uses of generators:

Printing in reverse:
def reverse(data):
    for index in range(len(data)-1, -1, -1):
        yield data[index]

# output
>>> for char in reverse('golf'):
...     print(char)
...
f
l
o
g


Generators can be used like list comprehension which uses parenthesis instead of brackets. They are design to be used in situation
where the enclosing function uses it right away. Generators are more compact bu less versatile than full generator definitions and
tend to be more memory friendly than list comprehensions.
>>> sum(i*i for i in range(10))                 # sum of squares
285

>>> xvec = [10, 20, 30]
>>> yvec = [7, 5, 3]
>>> sum(x*y for x,y in zip(xvec, yvec))         # dot product
260

>>> from math import pi, sin
>>> sine_table = {x: sin(x*pi/180) for x in range(0, 91)}

>>> unique_words = set(word  for line in page  for word in line.split())

>>> valedictorian = max((student.gpa, student.name) for student in graduates)

>>> data = 'golf'
>>> list(data[i] for i in range(len(data)-1, -1, -1))
['f', 'l', 'o', 'g']

Tips and Tricks
---------------

Using namedtuples()
to create classes

# Why Python is Great: Namedtuples
# Using namedtuple is way shorter than
# defining a class manually:
>>> from collections import namedtuple
>>> Car = namedtuple('Car', 'color mileage')

# Our new "Car" class works as expected:
>>> my_car = Car('red', 3812.4)
>>> my_car.color
'red'
>>> my_car.mileage
3812.4

# We get a nice string repr for free:
>>> my_car
Car(color='red' , mileage=3812.4)

# Like tuples, namedtuples are immutable:
>>> my_car.color = 'blue'
AttributeError: "can't set attribute"

*** I still don't fully understand this because I am encountering the
error below.

>>> from collections import namedtuple
>>> help(namedtuple)
>>> car = namedtuple('model', 'speed')
>>> bmw = car('bmw', 1212.34)
Traceback (most recent call last):
  File "", line 1, in
TypeError: __new__() takes 2 positional arguments but 3 were given
>>>
>>> bmw = car('bmw', '1212.34')
Traceback (most recent call last):
  File "", line 1, in
TypeError: __new__() takes 2 positional arguments but 3 were given
>>>

No comments:

Post a Comment