<jupyter_start><jupyter_text>Functions and the Python Object ModelWe may now consider the nature of functions as they operate within the unique model of object-oriented programming offered by Python. This section addresses three considerations: * Python functions are themselves first-class objects.* Code contained within Python functions can access objects outside of their local frame.* Functions direct the flow of program execution by spawning a cascade of sub-procedures called "the stack".Earlier, we stated that while it is legal to refer to variables in a more global scope than the one currently executing, this practice is largely inadvisable. Below, we explore why this is the case. It is nearly always preferable to exchange data between a function and its calling context using the formal mechanisms of arguments and return values.  Functions as First-Class ObjectsA Python function is considered first-class because it enjoys all theprivileges of any other object in memory: * Functions may be assigned to variables. * Functions may be programmatically created, stored, and destroyed* Functions may be offered as the return value of other functions. * Functions may be ***passed as an argument to other functions***.The `map()` function is built into the Python language, and takes a function as one of its arguments. This function iteratively applies, or “maps”, a given function to a sequence of values that are regarded as arguments, and returns a collection of the resulting values. In its simplest form, it reproduces the process of looping over the given values and collecting the results.The statement `map(func,vals)` is equivalent to the List comprehension         [func(v) for v in vals]Multiple value Lists may be used.The statement `map(func,vals_a,vals_b)` is exactly equivalent to the statement    [func(a,b) for a,b in zip(vals_a,vals_b)]The first piece of code below uses this function to limit a given collection of Points to a given x- and y-coordinate bounds. The second piece of code below defines a function that takes two arguments, and passes two sequences of values to the `map()` function. Both of these examples offer a new means to familiar ends.<jupyter_code>"""
Mapping a Function to A Sequence
Given a collection of Points pts, limits the coordinates of each Point
to a bounds defined by two Intervals by applying the built-in map 
function.
"""
ival_x = Interval(1,2)
ival_y = Interval(-1,1)

def limit_to_bnds(pt):
    pt.x = ival_x.limit_val(pt.x)
    pt.y = ival_y.limit_val(pt.y)
    return pt
    
pts = map(limit_to_bnds, pts)
"""
Mapping a Function to Two Sequences
Given a collection of Segments segs, iteratively produces a new 
Segment that spans between a Point evaluated along each given 
Segment by applying the built-in map function.
"""
def func(seg_a, seg_b):
    return Segment(seg_a.eval(t),seg_b.eval(t))
    
for n in range(gens):
    # shift the List of Segments to make matching pairs
    shifted = [segs[-1]]+segs[:-1]
    segs = map(func, segs, shifted)<jupyter_output><empty_output><jupyter_text>Variable Scope and LifetimeVariables initialized within a function are not available to code outside of that function, and are limited to the lifetime of the function. This is not true for variables defined within or as iterators for a loop, which do remain available outside the codeblock in which they are instantiated, and persist beyond the lifetime of the loop.***What can account for this difference in the behavior?***Two concepts in OOP offer a rationale:* ***scope***, the parts of a program to which a variable is accessible* ***lifetime***, the duration of the existence of a variableAny variable defined in the main body of a script, including those within codeblocks related to control-flow structures, are called ***global variables***. These are available for access throughout the file (they have *global scope*) and persist for the duration of the shell (they have an *unlimited lifetime*) unless they are explicitly deleted. In contrast, any variable defined within the body of a function, including thecodeblocks contained within the function, are called ***local variables***. These are accessible only by subsequent statements within the function (they are *locally scoped*) and exist only as long as the function is executing (they have a *limited lifetime*). When the function is complete, these variables are removed from memory.While they provide a connection to its calling context, ***the arguments of a function are treated as local variables***.A series of small examples illustrates the distinction between local and global variables when working with functions.  Scenario IHere we demonstrate the typical situation of linking the interior and exterior of a function only by arguments. The variable `pt_a` is defined in the global scope before the initialization of the function `nudged()`, and remains available for the lifetime of the script. The variable `vec` is a local variable defined within the function, and persists only as the function is executing and is therefore unavailable outside of this scope.<jupyter_code>"""
Scenario I
The interior and exterior of a function are linked only through 
argument references
"""
pt_a = Point()
def nudged(pt,amt):
    vec = UZ * amt
    return pt + vec #{breakpoint a}
    
pt_b = nudged(pt_a,10)
pt_c = nudged(pt_b,20)  #{breakpoint b}<jupyter_output><empty_output><jupyter_text>Scenario IIHere we find a reference to a variable defined in a more global scope. Again, `pt_a` is a global variable and `vec` is a local variable, but in contrast to the code above, the global variable is referenced from within the body of the function. This is a perfectly legal reference to a more global scope, as `pt_a` is available at the point from which it is called at breakpoint C.<jupyter_code>"""
Scenario II
A variable defined in a more global scope is referenced from within 
a function
"""
pt_a = Point()
def nudged(amt):
    vec = UZ * amt
    return pt_a + vec  #{breakpoint c}
    
pt_b = nudged(10)
pt_c = nudged(20)

print(pt_b)
print(pt_c)<jupyter_output>pt[0,0,10]
pt[0,0,20]<jupyter_text>Scenario IIIWhile access is permitted in these situations, assignment is not. Once again, `pt_a` is defined in the global scope and is available for access from within the function. However, when we attempt to assign to this variable within the function, creating a reference to a freshly initialized Point on the first line, something unexpected happens. Rather than re-assigning the existing global variable to a new object, an entirely new local one is created with the same name. Since this new local variable is available for access and modification within the function, the print statement at breakpoint D represents the values of this new Point at coordinates (0,0,0). It appears from within the function that we have modified `pt_a`, but in truth we have done so only with local effect. The global variable `pt_a` and its related object remain unaffected. At breakpoint E, after the function exits and the local variables are discarded, we see that the original value remains.<jupyter_code>"""
Scenario III
Does not work as intended. Assignment of a global variable from 
within a function results in the creation of a new local variable 
with the same name.
"""
pt_a = Point(0,0,10)
def reset():
    pt_a = Point()  #{breakpoint d}
    print(pt_a, "at breakpoint D")

reset()
print(pt_a, "at breakpoint E")  #{breakpoint e}<jupyter_output>pt[0,0,0] at breakpoint D
pt[0,0,10] at breakpoint E<jupyter_text>Scenario IVAttempts to re-assign a variable that has been passed into a function as an argument. Since the arguments of a function are treated as local variables, this code misbehaves in much the same manner as the previous. While the global variable `pt_a` and the local argument `pt` both refer to the same object, at breakpoint F the object reference associated with `pt` is re-assigned to the result of the `pt + vec` expression (which produces an entirely new object), leaving the more global object reference intact. Again, any subsequent calls to `pt` within the scope of the function will reflect the values of the new object, but once the function exits, we can see that this is only a local effect as the global variable `pt_a` remains unaffected at breakpoint G.<jupyter_code>"""
Scenario IV
Does not work as intended. Re-assignment of an argument produces only 
local effect, and does not alter the variable reference in the more 
global scope.
"""
pt_a = Point()
def nudge(pt, amt):
    vec = UZ * amt
    pt = pt + vec  #{breakpoint f}
    print (pt, "at breakpoint F")

nudge(pt_a, 10)
print (pt_a, "at breakpoint G")  #{breakpoint g}<jupyter_output>pt[0,0,10] at breakpoint F
pt[0,0,0] at breakpoint G<jupyter_text>Scenario VTo properly achieve the desired effect of these two scripts, and to re-assign a global variable from within a function, we must explicitly tell the Python shell that this is our intention. To do so requires the use of the keyword ***`global`***, which is used exclusively for this purpose.<jupyter_code>"""
Scenario V
The use of the global keyword allows for the assignment of a global 
variable
"""
pt_a = Point(0,0,10)
def reset():
    global pt_a
    pt_a = Point()  #{breakpoint h}
    print pt_a, "at breakpoint H"

reset()
print pt_a, "at breakpoint I"  #{breakpoint i}<jupyter_output><empty_output><jupyter_text>Scenario VIAll of the above examples operate on a global variable that refers to a structured object, but would manifest different behavior if operating on a primitive object. Given that we are operating on a structured object, and that out-of-scope access is treated differently than assignment, an alternative to using the `global` keyword is to avoid re-assigning the reference, and instead re-assigning each member. Consider the code below, which achieves the goal of “resetting” a Point to the world origin by individually setting its x,y, and z coordinates. Any similar modification of the members of an object referenced by a global variable is permitted without the `global` keyword.<jupyter_code>"""
Scenario VI
The global variable will register the effects of manipulating the 
members of structured objects within a function
"""
pt_a = Point(0,0,10)
def reset():
    pt_a.x, pt_a.y, pt_a.z = 0,0,0
    print (pt_a, "at breakpoint J") #{breakpoint j}

reset()
print (pt_a, "at breakpoint K") #{breakpoint k}<jupyter_output>pt[0,0,0] at breakpoint J
pt[0,0,0] at breakpoint K<jupyter_text>The StackFunctions involve a manipulation of the way in which the Python shell moves across our code from statement to statement, evaluating expressions and storing objects in memory. How are these movements organized across the multiplicity of frames engendered by function calls?From our discussion of the evaluation of statements, We can imagine that the process of evaluating one set of statements might be “paused” when a function call is encountered, and must wait at that location until the results of another process that evaluates the contents of that function become available. This presumption is basically correct, and that the Python shell proceeds in this way through a mechanism called a ***call stack***.The “stack”, is a collection-like data structure common to nearly all programming languages. Its purpose is to keep track of the ongoing processes and spaces of computer memory related to a program.The main body of our script and its related frame as the first item on the stack. Each time a function is called, a new routine is spawned, and a new frame is added to the stack. This process continues, resulting in a cascade of frames.Each time a function finishes executing, the related item on the stack is discarded and control returns to the context from which the function was called. To illustrate, consider the movements of the interpreter and the nature of the stack as they navigate between the frames constructed by one of the subdivision routines presented earlier.<jupyter_code>for n in range(gens): 
    faces = subdivide()
    
"""
A function
"""
def subdivide():
    sfs = []
    while faces:
        fac = faces.pop()

        cen = Point.centroid([seg.ept for seg in fac])

        csubs = [Segment(seg.ept,cen) for seg in fac]
        sfs.append((fac[0],csubs[0],csubs[2].inverted()))
        sfs.append((fac[1],csubs[1],csubs[0].inverted()))
        sfs.append((fac[2],csubs[2],csubs[1].inverted()))
        
    return sfs

"""
The static method of the Point class (will not run in this context)

@staticmethod
def centroid(pts):
    return Point(Vec.average(pts))
    
"""<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.062440720834650645, 'fraction_numerical': 0.005611760986405311, 'mean_word_length': 4.699549549549549, 'pattern_counts': {'":': 0, '<': 29, '<?xml version=': 0, '>': 29, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '8', 'dir': 'jupyter-structured-clean-dedup', 'id': '614695', 'license': 'MIT', 'n_tokens_mistral': 3320, 'n_tokens_neox': 3219, 'n_words': 1821, 'path': '105 - Functions/198 - Functions and the Python Object Model.ipynb', 'repo_name': 'ksteinfe/decodes_ipynb', 'size': '12652'}