#! /usr/bin/env python3

''' Python Script to understand the Python topics Scopes, Closures and Decorators '''

# Free Variables and Closures
#   - Functions defined inside another function can access the outer (nonlocal) variables
#
#       def outer():
#           x = 'python'
#           def inner():
#               print('{} rocks!'.format(x))    --> this x refers to the one in the outer's scope, this nonlocal variable x is called a free variable.
#           inner()
#       outer()
# This will give the output: python rocks!
# In the above case of inner functions, the inner function will not be created till the outer function is called.
#
# When we consider inner, we really are looking at:
#   - the function inner
#   - the free variable x
# So both the function and the free variable should be bound together and this is called a Closure.

del outer

# Returning the inner function (it will be a higher order function)
def outer():
    a = 100
    x = 'python'
    def inner():
        a = 10      # will have a direct reference
        print('{} rocks!'.format(x))
    return inner    # So, in this case we are not returning the inner function but the inner closure.
fn = outer()    # Then, here fn is a closure and outer function finished its running.
print('---- Closures ----')
fn()    # Will give the output --> python rocks!
# When we called fn, at that time Python determined the value of x in the extended scope, but notice that outer had finished running before we called fn,
# it's scope was gone.
# But still it works, this is because of closure.

# Python Cells and Multi-Scoped Variables
#   - In the above code the value of x is shared between two scopes:
#       - outer
#       - closure (inner)
# The label x is in two different scopes but always reference the same 'value', Python does this by creating a `cell` as an intermediary object.
# Cell is an object that has memory address like any other object, but what it does is it basically contains a reference to another object, which is in this
# case is str object, x.
# In the above case, x in the outer function is a direct reference to the str object containing 'python' under normal circumstances but here we have a closure
# when python sees a closure in the outer function, it creates a cell object, to which x of the outer and x of the inner both points which inturns point to the 
# str object. This is also the case with the nonlocal scope variables (and not just with the closures).
# In effect, both variables x (in outer and inner), point to the same cell, When requesting the value of the variable, Python will 'double-hop' to get to the final value,
# as python know that we are dealing with cells.

# Closures
#   - You can think of the closures as function plus an extended scope that contains the free variables.
# The free variable's value is the object the cell points to - so thata could change over time! Every time the function in the closure is called and the free variable is 
# referenced: Python looks up the cell object, and then whatever the cell is pointing to.
# Whenever you create the outer function it creates a cell object and references it. It look for value when you call it.

# Introspection
print('---- Closure introspection ----')
print(fn.__code__.co_freevars)      # will give the tuple containing the free varaibles associated with the closure
print(fn.__closure__)       # It gives us a tuple of cell object which tells us the address of cell object and the address to which it is pointing.

del outer

def outer():
    x = 'python'
    print(hex(id(x)))
    def inner():
        print(hex(id(x)))
        print('{} rocks!'.format(x))
    return inner

fn = outer()
fn()
# In this case also, python do some heavy work for and gives us the indirect reference back, i.e., the address of str object in the above case.
# Though we don't need of dealing with memory addresses but it is many times helpful in debugging.

# Modifying the free variables
def counter():
    count = 0
    def inc():
        nonlocal count      # count is a free variable, it is bound to the cell count
        count += 1
        return count
    return inc
print('---- Modifying Free variables ----')
fn = counter()      # fn --> inc + count --> 0
print(fn())    #  output --> 1     count's (indirect) reference changed from the object 0 to the object 1
print(fn())    #  output --> 2     count's (indirect) refernce changes again!

# Multiple instances of Closures
#   - Every time we run a function, a new scope is created. If that function generates a closure, a new closure is created every time as well.
f1 = counter()
f2 = counter()
# Since f1 and f2 have their own scopes for counter function, then the closures generated by them will have their different cell objects at different
# memory locations.
print('---- Multiple instances of Closures ----')
print(fn.__closure__)
print(f1.__closure__)
print(f2.__closure__)
# Though the indirect reference will be same for all three of the instances of closures as 0 or 1 are the singleton objects.

del outer

# Shared Extended Scopes
def outer():
    count = 0
    def inc1():
        nonlocal count      # count is a free variable - bound to count in the extended scope
        count += 1
        return count
    def inc2():
        nonlocal count      # count is a free variale, here also - bound to the same count
        count += 1
        return count
    return inc1, inc2
# So in the above case the extended scope is shared between two closures. The above two closures have the shared free variable count.
print('---- Shared Extended Scopes ----')
f1, f2 = outer()
print(f1())
print(f2())
# We may think this shared extended scope is highly unusual... but it's not!

# Example (Three different closures - no shared scopes):
def adder(n):
    def inner(x):
        return x + n
    return inner
add_1 = adder(1)
add_2 = adder(2)
add_3 = adder(3)
print(add_1(10))
print(add_2(10))
print(add_3(10))

# Caveat-Example (using loop for reducing repetition):
print('---- Caveat Example ----')
adders = []
for n in range(1, 4):
    adders.append(lambda x: x + n)      # here n is free variable, so we created a closure.
# In the above case, we will get three closures to be generated with each iteration as the free variable n will gonna point to the same
# cell object for all three closures but the value that cell object pointing to will change with each iteration.

# So, in above example we are doing the same functionality as that of the previous example but using Shared extended scope between the three
# closures generated with every iteration as they all have same cell object whose value is changing with iteration.
# But because of this there comes a caveat, that the value of n will be changed to 3 with the last iteration and when we call any of the
# closure it will have the shared indirect reference to that changed value 3 which will create a logical error.
print(adders[0](10))
print(adders[1](10))
print(adders[2](10))

# But we want to capture the value of n for each closure separately, so what we can do is:
print('---- Caveat solution ----')
adders = []
for n in range(1, 4):
    adders.append(lambda x, y=n: x + y)     # In this case we deal with caveat with another caveat of default cases, since we know the default values
                                            # are evaluated only once. So, in this case we are not even creating a closure but a function.
print(adders[0](10))
print(adders[1](10))
print(adders[2](10))


# Nested Closures
# Example: we have to give the start value and the step value
def incrememter(n):
    # inner + n is a closure
    def inner(start):
        cur = start
        # inc + cur + n is a closure
        def inc():
            nonlocal cur    # free variable (cur)
            cur += n        # free variable (n)
            return cur
        return inc
    return inner
print('---- Nested Closures ----')
fn = incrememter(2)                 # it gives the inner 
print(fn.__code__.co_freevars)      # gives 'n', n = 2
inc_2 = fn(100)                     # it gives the inc
print(inc_2.__code__.co_freevars)   # gives 'cur' and 'n'
print(inc_2())
print(inc_2())
# In the above example inner function has a free variable n even though we dont have declared it directly inside the fucntion, but it has the
# n varaible contained in it, indirectly via inc function.
