diff --git a/.gitignore b/.gitignore
index 5a29c4d9..02d30563 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,8 @@ __pycache__/
dask-worker-space
.vscode/
-.ipynb_checkpoints/
\ No newline at end of file
+.ipynb_checkpoints/
+
+lectures/mathfoo.py
+lectures/mod.py
+lectures/test.py
\ No newline at end of file
diff --git a/lectures/_toc.yml b/lectures/_toc.yml
index 046662a9..82d2af3b 100644
--- a/lectures/_toc.yml
+++ b/lectures/_toc.yml
@@ -10,6 +10,7 @@ parts:
- file: functions
- file: python_essentials
- file: oop_intro
+ - file: names
- file: python_oop
- file: workspace
- caption: The Scientific Libraries
diff --git a/lectures/names.md b/lectures/names.md
new file mode 100644
index 00000000..3ce66b14
--- /dev/null
+++ b/lectures/names.md
@@ -0,0 +1,572 @@
+---
+jupytext:
+ text_representation:
+ extension: .md
+ format_name: myst
+kernelspec:
+ display_name: Python 3
+ language: python
+ name: python3
+---
+
+(oop_names)=
+```{raw} jupyter
+
+```
+
+# Names and Namespaces
+
+## Overview
+
+This lecture is all about variable names, how they can be used and how they are
+understood by the Python interpreter.
+
+This might sound a little dull but the model that Python has adopted for
+handling names is elegant and interesting.
+
+In addition, you will save yourself many hours of debugging if you have a good
+understanding of how names work in Python.
+
+(var_names)=
+## Variable Names in Python
+
+```{index} single: Python; Variable Names
+```
+
+Consider the Python statement
+
+```{code-cell} python3
+x = 42
+```
+
+We now know that when this statement is executed, Python creates an object of
+type `int` in your computer's memory, containing
+
+* the value `42`
+* some associated attributes
+
+But what is `x` itself?
+
+In Python, `x` is called a **name**, and the statement `x = 42` **binds** the name `x` to the integer object we have just discussed.
+
+Under the hood, this process of binding names to objects is implemented as a dictionary---more about this in a moment.
+
+There is no problem binding two or more names to the one object, regardless of what that object is
+
+```{code-cell} python3
+def f(string): # Create a function called f
+ print(string) # that prints any string it's passed
+
+g = f
+id(g) == id(f)
+```
+
+```{code-cell} python3
+g('test')
+```
+
+In the first step, a function object is created, and the name `f` is bound to it.
+
+After binding the name `g` to the same object, we can use it anywhere we would use `f`.
+
+What happens when the number of names bound to an object goes to zero?
+
+Here's an example of this situation, where the name `x` is first bound to one object and then **rebound** to another
+
+```{code-cell} python3
+x = 'foo'
+id(x)
+x = 'bar'
+id(x)
+```
+
+In this case, after we rebind `x` to `'bar'`, no names bound are to the first object `'foo'`.
+
+This is a trigger for `'foo'` to be garbage collected.
+
+In other words, the memory slot that stores that object is deallocated and returned to the operating system.
+
+Garbage collection is actually an active research area in computer science.
+
+You can [read more on garbage collection](https://rushter.com/blog/python-garbage-collector/) if you are interested.
+
+## Namespaces
+
+```{index} single: Python; Namespaces
+```
+
+Recall from the preceding discussion that the statement
+
+```{code-cell} python3
+x = 42
+```
+
+binds the name `x` to the integer object on the right-hand side.
+
+We also mentioned that this process of binding `x` to the correct object is implemented as a dictionary.
+
+This dictionary is called a namespace.
+
+```{admonition} Definition
+A **namespace** is a symbol table that maps names to objects in memory.
+```
+
+
+Python uses multiple namespaces, creating them on the fly as necessary.
+
+For example, every time we import a module, Python creates a namespace for that module.
+
+To see this in action, suppose we write a script `mathfoo.py` with a single line
+
+```{code-cell} python3
+%%file mathfoo.py
+pi = 'foobar'
+```
+
+Now we start the Python interpreter and import it
+
+```{code-cell} python3
+import mathfoo
+```
+
+Next let's import the `math` module from the standard library
+
+```{code-cell} python3
+import math
+```
+
+Both of these modules have an attribute called `pi`
+
+```{code-cell} python3
+math.pi
+```
+
+```{code-cell} python3
+mathfoo.pi
+```
+
+These two different bindings of `pi` exist in different namespaces, each one implemented as a dictionary.
+
+If you wish, you can look at the dictionary directly, using `module_name.__dict__`.
+
+```{code-cell} python3
+import math
+
+math.__dict__.items()
+```
+
+```{code-cell} python3
+import mathfoo
+
+mathfoo.__dict__
+```
+
+As you know, we access elements of the namespace using the dotted attribute notation
+
+```{code-cell} python3
+math.pi
+```
+
+This is entirely equivalent to `math.__dict__['pi']`
+
+```{code-cell} python3
+math.__dict__['pi']
+```
+
+## Viewing Namespaces
+
+As we saw above, the `math` namespace can be printed by typing `math.__dict__`.
+
+Another way to see its contents is to type `vars(math)`
+
+```{code-cell} python3
+vars(math).items()
+```
+
+If you just want to see the names, you can type
+
+```{code-cell} python3
+# Show the first 10 names
+dir(math)[0:10]
+```
+
+Notice the special names `__doc__` and `__name__`.
+
+These are initialized in the namespace when any module is imported
+
+* `__doc__` is the doc string of the module
+* `__name__` is the name of the module
+
+```{code-cell} python3
+print(math.__doc__)
+```
+
+```{code-cell} python3
+math.__name__
+```
+
+## Interactive Sessions
+
+```{index} single: Python; Interpreter
+```
+
+In Python, **all** code executed by the interpreter runs in some module.
+
+What about commands typed at the prompt?
+
+These are also regarded as being executed within a module --- in this case, a module called `__main__`.
+
+To check this, we can look at the current module name via the value of `__name__` given at the prompt
+
+```{code-cell} python3
+print(__name__)
+```
+
+When we run a script using IPython's `run` command, the contents of the file are executed as part of `__main__` too.
+
+To see this, let's create a file `mod.py` that prints its own `__name__` attribute
+
+```{code-cell} ipython
+%%file mod.py
+print(__name__)
+```
+
+Now let's look at two different ways of running it in IPython
+
+```{code-cell} python3
+import mod # Standard import
+```
+
+```{code-cell} ipython
+%run mod.py # Run interactively
+```
+
+In the second case, the code is executed as part of `__main__`, so `__name__` is equal to `__main__`.
+
+To see the contents of the namespace of `__main__` we use `vars()` rather than `vars(__main__)`.
+
+If you do this in IPython, you will see a whole lot of variables that IPython
+needs, and has initialized when you started up your session.
+
+If you prefer to see only the variables you have initialized, use `%whos`
+
+```{code-cell} ipython
+x = 2
+y = 3
+
+import numpy as np
+
+%whos
+```
+
+## The Global Namespace
+
+```{index} single: Python; Namespace (Global)
+```
+
+Python documentation often makes reference to the "global namespace".
+
+The global namespace is *the namespace of the module currently being executed*.
+
+For example, suppose that we start the interpreter and begin making assignments.
+
+We are now working in the module `__main__`, and hence the namespace for `__main__` is the global namespace.
+
+Next, we import a module called `amodule`
+
+```{code-block} python3
+:class: no-execute
+
+import amodule
+```
+
+At this point, the interpreter creates a namespace for the module `amodule` and starts executing commands in the module.
+
+While this occurs, the namespace `amodule.__dict__` is the global namespace.
+
+Once execution of the module finishes, the interpreter returns to the module from where the import statement was made.
+
+In this case it's `__main__`, so the namespace of `__main__` again becomes the global namespace.
+
+## Local Namespaces
+
+```{index} single: Python; Namespace (Local)
+```
+
+Important fact: When we call a function, the interpreter creates a *local namespace* for that function, and registers the variables in that namespace.
+
+The reason for this will be explained in just a moment.
+
+Variables in the local namespace are called *local variables*.
+
+After the function returns, the namespace is deallocated and lost.
+
+While the function is executing, we can view the contents of the local namespace with `locals()`.
+
+For example, consider
+
+```{code-cell} python3
+def f(x):
+ a = 2
+ print(locals())
+ return a * x
+```
+
+Now let's call the function
+
+```{code-cell} python3
+f(1)
+```
+
+You can see the local namespace of `f` before it is destroyed.
+
+## The `__builtins__` Namespace
+
+```{index} single: Python; Namespace (__builtins__)
+```
+
+We have been using various built-in functions, such as `max(), dir(), str(), list(), len(), range(), type()`, etc.
+
+How does access to these names work?
+
+* These definitions are stored in a module called `__builtin__`.
+* They have their own namespace called `__builtins__`.
+
+```{code-cell} python3
+# Show the first 10 names in `__main__`
+dir()[0:10]
+```
+
+```{code-cell} python3
+# Show the first 10 names in `__builtins__`
+dir(__builtins__)[0:10]
+```
+
+We can access elements of the namespace as follows
+
+```{code-cell} python3
+__builtins__.max
+```
+
+But `__builtins__` is special, because we can always access them directly as well
+
+```{code-cell} python3
+max
+```
+
+```{code-cell} python3
+__builtins__.max == max
+```
+
+The next section explains how this works ...
+
+## Name Resolution
+
+```{index} single: Python; Namespace (Resolution)
+```
+
+Namespaces are great because they help us organize variable names.
+
+(Type `import this` at the prompt and look at the last item that's printed)
+
+However, we do need to understand how the Python interpreter works with multiple namespaces.
+
+Understanding the flow of execution will help us to check which variables are in scope and how to operate on them when writing and debugging programs.
+
+
+At any point of execution, there are in fact at least two namespaces that can be accessed directly.
+
+("Accessed directly" means without using a dot, as in `pi` rather than `math.pi`)
+
+These namespaces are
+
+* The global namespace (of the module being executed)
+* The builtin namespace
+
+If the interpreter is executing a function, then the directly accessible namespaces are
+
+* The local namespace of the function
+* The global namespace (of the module being executed)
+* The builtin namespace
+
+Sometimes functions are defined within other functions, like so
+
+```{code-cell} python3
+def f():
+ a = 2
+ def g():
+ b = 4
+ print(a * b)
+ g()
+```
+
+Here `f` is the *enclosing function* for `g`, and each function gets its
+own namespaces.
+
+Now we can give the rule for how namespace resolution works:
+
+The order in which the interpreter searches for names is
+
+1. the local namespace (if it exists)
+1. the hierarchy of enclosing namespaces (if they exist)
+1. the global namespace
+1. the builtin namespace
+
+If the name is not in any of these namespaces, the interpreter raises a `NameError`.
+
+This is called the **LEGB rule** (local, enclosing, global, builtin).
+
+Here's an example that helps to illustrate.
+
+Visualizations here are created by [nbtutor](https://github.com/lgpage/nbtutor) in a Jupyter notebook.
+
+They can help you better understand your program when you are learning a new language.
+
+Consider a script `test.py` that looks as follows
+
+```{code-cell} python3
+%%file test.py
+def g(x):
+ a = 1
+ x = x + a
+ return x
+
+a = 0
+y = g(10)
+print("a = ", a, "y = ", y)
+```
+
+What happens when we run this script?
+
+```{code-cell} ipython
+%run test.py
+```
+
+First,
+
+* The global namespace `{}` is created.
+
+```{figure} /_static/lecture_specific/oop_intro/global.png
+```
+
+* The function object is created, and `g` is bound to it within the global namespace.
+* The name `a` is bound to `0`, again in the global namespace.
+
+```{figure} /_static/lecture_specific/oop_intro/global2.png
+```
+
+Next `g` is called via `y = g(10)`, leading to the following sequence of actions
+
+* The local namespace for the function is created.
+* Local names `x` and `a` are bound, so that the local namespace becomes `{'x': 10, 'a': 1}`.
+
+Note that the global `a` was not affected by the local `a`.
+
+```{figure} /_static/lecture_specific/oop_intro/local1.png
+```
+
+
+* Statement `x = x + a` uses the local `a` and local `x` to compute `x + a`, and binds local name `x` to the result.
+* This value is returned, and `y` is bound to it in the global namespace.
+* Local `x` and `a` are discarded (and the local namespace is deallocated).
+
+```{figure} /_static/lecture_specific/oop_intro/local_return.png
+```
+
+
+(mutable_vs_immutable)=
+### {index}`Mutable ` Versus {index}`Immutable ` Parameters
+
+This is a good time to say a little more about mutable vs immutable objects.
+
+Consider the code segment
+
+```{code-cell} python3
+def f(x):
+ x = x + 1
+ return x
+
+x = 1
+print(f(x), x)
+```
+
+We now understand what will happen here: The code prints `2` as the value of `f(x)` and `1` as the value of `x`.
+
+First `f` and `x` are registered in the global namespace.
+
+The call `f(x)` creates a local namespace and adds `x` to it, bound to `1`.
+
+Next, this local `x` is rebound to the new integer object `2`, and this value is returned.
+
+None of this affects the global `x`.
+
+However, it's a different story when we use a **mutable** data type such as a list
+
+```{code-cell} python3
+def f(x):
+ x[0] = x[0] + 1
+ return x
+
+x = [1]
+print(f(x), x)
+```
+
+This prints `[2]` as the value of `f(x)` and *same* for `x`.
+
+Here's what happens
+
+* `f` is registered as a function in the global namespace
+
+```{figure} /_static/lecture_specific/oop_intro/mutable1.png
+```
+
+* `x` is bound to `[1]` in the global namespace
+
+```{figure} /_static/lecture_specific/oop_intro/mutable2.png
+```
+
+* The call `f(x)`
+ * Creates a local namespace
+ * Adds `x` to the local namespace, bound to `[1]`
+
+```{figure} /_static/lecture_specific/oop_intro/mutable3.png
+```
+
+```{note}
+The global `x` and the local `x` refer to the same `[1]`
+```
+
+We can see the identity of local `x` and the identity of global `x` are the same
+
+```{code-cell} python3
+def f(x):
+ x[0] = x[0] + 1
+ print(f'the identity of local x is {id(x)}')
+ return x
+
+x = [1]
+print(f'the identity of global x is {id(x)}')
+print(f(x), x)
+```
+
+* Within `f(x)`
+ * The list `[1]` is modified to `[2]`
+ * Returns the list `[2]`
+
+```{figure} /_static/lecture_specific/oop_intro/mutable4.png
+```
+* The local namespace is deallocated, and the local `x` is lost
+
+```{figure} /_static/lecture_specific/oop_intro/mutable5.png
+```
+
+If you want to modify the local `x` and the global `x` separately, you can create a [*copy*](https://docs.python.org/3/library/copy.html) of the list and assign the copy to the local `x`.
+
+We will leave this for you to explore.
+
+
+
diff --git a/lectures/oop_intro.md b/lectures/oop_intro.md
index d6043eca..708f1ee9 100644
--- a/lectures/oop_intro.md
+++ b/lectures/oop_intro.md
@@ -18,47 +18,53 @@ kernelspec:
```
-
-
-# OOP I: Objects and Names
-
-```{contents} Contents
-:depth: 2
-```
+# OOP I: Objects and Methods
## Overview
-[Object-oriented programming](https://en.wikipedia.org/wiki/Object-oriented_programming) (OOP) is one of the major paradigms in programming.
-
-The traditional programming paradigm (think Fortran, C, MATLAB, etc.) is called *procedural*.
+The traditional programming paradigm (think Fortran, C, MATLAB, etc.) is called [procedural](https://en.wikipedia.org/wiki/Procedural_programming).
It works as follows
* The program has a state corresponding to the values of its variables.
-* Functions are called to act on these data.
-* Data are passed back and forth via function calls.
+* Functions are called to act on and transform the state.
+* Final outputs are produced via a sequence of function calls.
+
+Two other important paradigms are [object-oriented programming](https://en.wikipedia.org/wiki/Object-oriented_programming) (OOP) and [functional programming](https://en.wikipedia.org/wiki/Functional_programming).
+
-In contrast, in the OOP paradigm
+In the OOP paradigm, data and functions are bundled together into "objects" --- and functions in this context are referred to as **methods**.
-* data and functions are "bundled together" into "objects"
+Methods are called on to transform the data contained in the object.
-(Functions in this context are referred to as **methods**)
+* Think of a Python list that contains data and has methods such as `append()` and `pop()` that transform the data.
-### Python and OOP
+Functional programming languages are built on the idea of composing functions.
-Python is a pragmatic language that blends object-oriented and procedural styles, rather than taking a purist approach.
+* Influential examples include [Lisp](https://en.wikipedia.org/wiki/Common_Lisp), [Haskell](https://en.wikipedia.org/wiki/Haskell) and [Elixir](https://en.wikipedia.org/wiki/Elixir_(programming_language)).
-However, at a foundational level, Python *is* object-oriented.
+So which of these categories does Python fit into?
-In particular, in Python, *everything is an object*.
+Actually Python is a pragmatic language that blends object-oriented, functional and procedural styles, rather than taking a purist approach.
+
+On one hand, this allows Python and its users to cherry pick nice aspects of different paradigms.
+
+On the other hand, the lack of purity might at times lead to some confusion.
+
+Fortunately this confusion is minimized if you understand that, at a foundational level, Python *is* object-oriented.
+
+By this we mean that, in Python, *everything is an object*.
In this lecture, we explain what that statement means and why it matters.
+We'll make use of the following third party library
+
+
+```{code-cell} python3
+!pip install rich
+```
+
+
## Objects
```{index} single: Python; Objects
@@ -201,7 +207,7 @@ These attributes are important, so let's discuss them in-depth.
Methods are *functions that are bundled with objects*.
-Formally, methods are attributes of objects that are callable (i.e., can be called as functions)
+Formally, methods are attributes of objects that are **callable** -- i.e., attributes that can be called as functions
```{code-cell} python3
x = ['foo', 'bar']
@@ -251,562 +257,83 @@ x
(If you wanted to you could modify the `__setitem__` method, so that square bracket assignment does something totally different)
+## Inspection Using Rich
-(name_res)=
-## Names and Name Resolution
-
-### Variable Names in Python
-
-```{index} single: Python; Variable Names
-```
-
-Consider the Python statement
-
-```{code-cell} python3
-x = 42
-```
-
-We now know that when this statement is executed, Python creates an object of
-type `int` in your computer's memory, containing
-
-* the value `42`
-* some associated attributes
-
-But what is `x` itself?
-
-In Python, `x` is called a *name*, and the statement `x = 42` *binds* the name `x` to the integer object we have just discussed.
-
-Under the hood, this process of binding names to objects is implemented as a dictionary---more about this in a moment.
-
-There is no problem binding two or more names to the one object, regardless of what that object is
-
-```{code-cell} python3
-def f(string): # Create a function called f
- print(string) # that prints any string it's passed
-
-g = f
-id(g) == id(f)
-```
-
-```{code-cell} python3
-g('test')
-```
-
-In the first step, a function object is created, and the name `f` is bound to it.
-
-After binding the name `g` to the same object, we can use it anywhere we would use `f`.
-
-What happens when the number of names bound to an object goes to zero?
-
-Here's an example of this situation, where the name `x` is first bound to one object and then rebound to another
-
-```{code-cell} python3
-x = 'foo'
-id(x)
-```
-
-```{code-cell} python3
-x = 'bar' # No names bound to the first object
-```
-
-What happens here is that the first object is garbage collected.
-
-In other words, the memory slot that stores that object is deallocated, and returned to the operating system.
-
-Garbage collection is actually an active research area in computer science.
-
-You can [read more on garbage collection](https://rushter.com/blog/python-garbage-collector/) if you are interested.
-
-### Namespaces
-
-```{index} single: Python; Namespaces
-```
-
-Recall from the preceding discussion that the statement
-
-```{code-cell} python3
-x = 42
-```
-
-binds the name `x` to the integer object on the right-hand side.
-
-We also mentioned that this process of binding `x` to the correct object is implemented as a dictionary.
-
-This dictionary is called a *namespace*.
-
-**Definition:** A namespace is a symbol table that maps names to objects in memory.
-
-Python uses multiple namespaces, creating them on the fly as necessary.
-
-For example, every time we import a module, Python creates a namespace for that module.
-
-To see this in action, suppose we write a script `mathfoo.py` with a single line
-
-```{code-cell} python3
-%%file mathfoo.py
-pi = 'foobar'
-```
-
-Now we start the Python interpreter and import it
-
-```{code-cell} python3
-import mathfoo
-```
-
-Next let's import the `math` module from the standard library
-
-```{code-cell} python3
-import math
-```
-
-Both of these modules have an attribute called `pi`
-
-```{code-cell} python3
-math.pi
-```
-
-```{code-cell} python3
-mathfoo.pi
-```
-
-These two different bindings of `pi` exist in different namespaces, each one implemented as a dictionary.
-
-We can look at the dictionary directly, using `module_name.__dict__`
-
-```{code-cell} python3
-import math
-
-math.__dict__.items()
-```
-
-```{code-cell} python3
-import mathfoo
-
-mathfoo.__dict__.items()
-```
-
-As you know, we access elements of the namespace using the dotted attribute notation
-
-```{code-cell} python3
-math.pi
-```
-
-In fact this is entirely equivalent to `math.__dict__['pi']`
-
-```{code-cell} python3
-math.__dict__['pi'] == math.pi
-```
-
-### Viewing Namespaces
-
-As we saw above, the `math` namespace can be printed by typing `math.__dict__`.
-
-Another way to see its contents is to type `vars(math)`
-
-```{code-cell} python3
-vars(math).items()
-```
-
-If you just want to see the names, you can type
-
-```{code-cell} python3
-# Show the first 10 names
-dir(math)[0:10]
-```
-
-Notice the special names `__doc__` and `__name__`.
-
-These are initialized in the namespace when any module is imported
-
-* `__doc__` is the doc string of the module
-* `__name__` is the name of the module
-
-```{code-cell} python3
-print(math.__doc__)
-```
-
-```{code-cell} python3
-math.__name__
-```
-
-### Interactive Sessions
-
-```{index} single: Python; Interpreter
-```
-
-In Python, **all** code executed by the interpreter runs in some module.
-
-What about commands typed at the prompt?
-
-These are also regarded as being executed within a module --- in this case, a module called `__main__`.
-
-To check this, we can look at the current module name via the value of `__name__` given at the prompt
-
-```{code-cell} python3
-print(__name__)
-```
-
-When we run a script using IPython's `run` command, the contents of the file are executed as part of `__main__` too.
-
-To see this, let's create a file `mod.py` that prints its own `__name__` attribute
-
-```{code-cell} ipython
-%%file mod.py
-print(__name__)
-```
-
-Now let's look at two different ways of running it in IPython
-
-```{code-cell} python3
-import mod # Standard import
-```
-
-```{code-cell} ipython
-%run mod.py # Run interactively
-```
-
-In the second case, the code is executed as part of `__main__`, so `__name__` is equal to `__main__`.
-
-To see the contents of the namespace of `__main__` we use `vars()` rather than `vars(__main__)`.
-
-If you do this in IPython, you will see a whole lot of variables that IPython
-needs, and has initialized when you started up your session.
-
-If you prefer to see only the variables you have initialized, use `%whos`
-
-```{code-cell} ipython
-x = 2
-y = 3
-
-import numpy as np
-
-%whos
-```
-
-### The Global Namespace
-
-```{index} single: Python; Namespace (Global)
-```
-
-Python documentation often makes reference to the "global namespace".
-
-The global namespace is *the namespace of the module currently being executed*.
-
-For example, suppose that we start the interpreter and begin making assignments.
-
-We are now working in the module `__main__`, and hence the namespace for `__main__` is the global namespace.
-
-Next, we import a module called `amodule`
-
-```{code-block} python3
-:class: no-execute
-
-import amodule
-```
-
-At this point, the interpreter creates a namespace for the module `amodule` and starts executing commands in the module.
-
-While this occurs, the namespace `amodule.__dict__` is the global namespace.
-
-Once execution of the module finishes, the interpreter returns to the module from where the import statement was made.
-
-In this case it's `__main__`, so the namespace of `__main__` again becomes the global namespace.
+There's a nice package called [rich](https://github.com/Textualize/rich) that
+helps us view the contents of an object.
-### Local Namespaces
-
-```{index} single: Python; Namespace (Local)
-```
-
-Important fact: When we call a function, the interpreter creates a *local namespace* for that function, and registers the variables in that namespace.
-
-The reason for this will be explained in just a moment.
-
-Variables in the local namespace are called *local variables*.
-
-After the function returns, the namespace is deallocated and lost.
-
-While the function is executing, we can view the contents of the local namespace with `locals()`.
-
-For example, consider
-
-```{code-cell} python3
-def f(x):
- a = 2
- print(locals())
- return a * x
-```
-
-Now let's call the function
-
-```{code-cell} python3
-f(1)
-```
-
-You can see the local namespace of `f` before it is destroyed.
-
-### The `__builtins__` Namespace
-
-```{index} single: Python; Namespace (__builtins__)
-```
-
-We have been using various built-in functions, such as `max(), dir(), str(), list(), len(), range(), type()`, etc.
-
-How does access to these names work?
-
-* These definitions are stored in a module called `__builtin__`.
-* They have their own namespace called `__builtins__`.
-
-```{code-cell} python3
-# Show the first 10 names in `__main__`
-dir()[0:10]
-```
-
-```{code-cell} python3
-# Show the first 10 names in `__builtins__`
-dir(__builtins__)[0:10]
-```
-
-We can access elements of the namespace as follows
-
-```{code-cell} python3
-__builtins__.max
-```
-
-But `__builtins__` is special, because we can always access them directly as well
-
-```{code-cell} python3
-max
-```
+For example,
```{code-cell} python3
-__builtins__.max == max
-```
-
-The next section explains how this works ...
-
-### Name Resolution
-
-```{index} single: Python; Namespace (Resolution)
+from rich import inspect
+x = 10
+inspect(10)
```
-
-Namespaces are great because they help us organize variable names.
-
-(Type `import this` at the prompt and look at the last item that's printed)
-
-However, we do need to understand how the Python interpreter works with multiple namespaces.
-
-Understanding the flow of execution will help us to check which variables are in scope and how to operate on them when writing and debugging programs.
-
-
-At any point of execution, there are in fact at least two namespaces that can be accessed directly.
-
-("Accessed directly" means without using a dot, as in `pi` rather than `math.pi`)
-
-These namespaces are
-
-* The global namespace (of the module being executed)
-* The builtin namespace
-
-If the interpreter is executing a function, then the directly accessible namespaces are
-
-* The local namespace of the function
-* The global namespace (of the module being executed)
-* The builtin namespace
-
-Sometimes functions are defined within other functions, like so
+If we want to see the methods as well, we can use
```{code-cell} python3
-def f():
- a = 2
- def g():
- b = 4
- print(a * b)
- g()
+inspect(10, methods=True)
```
-Here `f` is the *enclosing function* for `g`, and each function gets its
-own namespaces.
-
-Now we can give the rule for how namespace resolution works:
-
-The order in which the interpreter searches for names is
-
-1. the local namespace (if it exists)
-1. the hierarchy of enclosing namespaces (if they exist)
-1. the global namespace
-1. the builtin namespace
-
-If the name is not in any of these namespaces, the interpreter raises a `NameError`.
+In fact there are still more methods, as you can see if you execute `inspect(10, all=True)`.
-This is called the **LEGB rule** (local, enclosing, global, builtin).
-Here's an example that helps to illustrate.
-Visualizations here are created by [nbtutor](https://github.com/lgpage/nbtutor) in a Jupyter notebook.
+## A Little Mystery
-They can help you better understand your program when you are learning a new language.
+In this lecture we claimed that Python is, at heart, an object oriented language.
-Consider a script `test.py` that looks as follows
+But here's an example that looks more procedural.
```{code-cell} python3
-%%file test.py
-def g(x):
- a = 1
- x = x + a
- return x
-
-a = 0
-y = g(10)
-print("a = ", a, "y = ", y)
-```
-
-What happens when we run this script?
-
-```{code-cell} ipython
-%run test.py
-```
-
-First,
-
-* The global namespace `{}` is created.
-
-```{figure} /_static/lecture_specific/oop_intro/global.png
-:figclass: auto
-```
-
-* The function object is created, and `g` is bound to it within the global namespace.
-* The name `a` is bound to `0`, again in the global namespace.
-
-```{figure} /_static/lecture_specific/oop_intro/global2.png
-:figclass: auto
+x = ['a', 'b']
+m = len(x)
+m
```
-Next `g` is called via `y = g(10)`, leading to the following sequence of actions
-
-* The local namespace for the function is created.
-* Local names `x` and `a` are bound, so that the local namespace becomes `{'x': 10, 'a': 1}`.
-
- * Note that the global `a` was not affected by the local `a`.
-```{figure} /_static/lecture_specific/oop_intro/local1.png
-:figclass: auto
-```
+If Python is object oriented, why don't we use `x.len()`?
+The answer is related to the fact that Python aims for readability and consistent style.
+In Python, it is common for users to build custom objects --- we discuss how to
+do this {doc}`later `.
-* Statement `x = x + a` uses the local `a` and local `x` to compute `x + a`, and binds local name `x` to the result.
+It's quite common for users to add methods to their that measure the length of
+the object, suitably defined.
+When naming such a method, natural choices are `len()` and `length()`.
-* This value is returned, and `y` is bound to it in the global namespace.
-* Local `x` and `a` are discarded (and the local namespace is deallocated).
+If some users choose `len()` and others choose `length()`, then the style will
+be inconsistent and harder to remember.
-```{figure} /_static/lecture_specific/oop_intro/local_return.png
-:figclass: auto
-```
+To avoid this, the creator of Python chose to add
+`len()` as a built-in function, to help emphasize that `len()` is the convention.
+Now, having said all of this, Python *is* still object oriented under the hood.
-(mutable_vs_immutable)=
-### {index}`Mutable ` Versus {index}`Immutable ` Parameters
+In fact, the list `x` discussed above has a method called `__len__()`.
-This is a good time to say a little more about mutable vs immutable objects.
+All that the function `len()` does is call this method.
-Consider the code segment
+In other words, the following code is equivalent:
```{code-cell} python3
-def f(x):
- x = x + 1
- return x
-
-x = 1
-print(f(x), x)
-```
-
-We now understand what will happen here: The code prints `2` as the value of `f(x)` and `1` as the value of `x`.
-
-First `f` and `x` are registered in the global namespace.
-
-The call `f(x)` creates a local namespace and adds `x` to it, bound to `1`.
-
-Next, this local `x` is rebound to the new integer object `2`, and this value is returned.
-
-None of this affects the global `x`.
-
-However, it's a different story when we use a **mutable** data type such as a list
-
-```{code-cell} python3
-def f(x):
- x[0] = x[0] + 1
- return x
-
-x = [1]
-print(f(x), x)
-```
-
-This prints `[2]` as the value of `f(x)` and *same* for `x`.
-
-Here's what happens
-
-* `f` is registered as a function in the global namespace
-
-```{figure} /_static/lecture_specific/oop_intro/mutable1.png
-:figclass: auto
-```
-
-* `x` bound to `[1]` in the global namespace
-
-```{figure} /_static/lecture_specific/oop_intro/mutable2.png
-:figclass: auto
-```
-
-* The call `f(x)`
- * Creates a local namespace
- * Adds `x` to the local namespace, bound to `[1]`
-
-```{figure} /_static/lecture_specific/oop_intro/mutable3.png
-:figclass: auto
-```
-
-```{note}
-The global `x` and the local `x` refer to the same `[1]`
+x = ['a', 'b']
+len(x)
```
-
-We can see the identity of local `x` and the identity of global `x` are the same
+and
```{code-cell} python3
-def f(x):
- x[0] = x[0] + 1
- print(f'the identity of local x is {id(x)}')
- return x
-
-x = [1]
-print(f'the identity of global x is {id(x)}')
-print(f(x), x)
-```
-
-* Within `f(x)`
- * The list `[1]` is modified to `[2]`
- * Returns the list `[2]`
-
-```{figure} /_static/lecture_specific/oop_intro/mutable4.png
-:figclass: auto
-```
-* The local namespace is deallocated, and the local `x` is lost
-
-```{figure} /_static/lecture_specific/oop_intro/mutable5.png
-:figclass: auto
+x = ['a', 'b']
+x.__len__()
```
-If you want to modify the local `x` and the global `x` separately, you can create a [*copy*](https://docs.python.org/3/library/copy.html) of the list and assign the copy to the local `x`.
-
-We will leave this for you to explore.
-
## Summary
-Messages in this lecture are clear:
+The message in this lecture is clear:
- * In Python, *everything in memory is treated as an object*.
- * Zero, one or many names can be bound to a given object.
- * Every name resides within a scope defined by its namespace.
+* In Python, *everything in memory is treated as an object*.
This includes not just lists, strings, etc., but also less obvious things, such as
@@ -815,83 +342,8 @@ This includes not just lists, strings, etc., but also less obvious things, such
* files opened for reading or writing
* integers, etc.
-Consider, for example, functions.
-
-When Python reads a function definition, it creates a **function object** and stores it in memory.
-
-The following code illustrates further this idea
-
-```{code-cell} python3
-#reset the current namespace
-%reset
-```
-
-```{code-cell} python3
-def f(x): return x**2
-f
-```
-
-```{code-cell} python3
-type(f)
-```
-
-```{code-cell} python3
-id(f)
-```
-
-```{code-cell} python3
-f.__name__
-```
-
-We can see that `f` has type, identity, attributes and so on---just like any other object.
-
-It also has methods.
-
-One example is the `__call__` method, which just evaluates the function
-
-```{code-cell} python3
-f.__call__(3)
-```
-
-Another is the `__dir__` method, which returns a list of attributes.
-
-We can also find `f` our current namespace
-
-```{code-cell} python3
-'f' in dir()
-```
-
-Modules loaded into memory are also treated as objects
-
-```{code-cell} python3
-import math
-
-id(math)
-```
-
-We can find `math` in our global namespace after the import
-
-```{code-cell} python3
-print(dir()[-1::-1])
-```
-
-We can also find all objects associated with the `math` module in the private namespace of `math`
-
-```{code-cell} python3
-print(dir(math))
-```
-
-We can also directly import objects to our current namespace using `from ... import ...`
-
-```{code-cell} python3
-from math import log, pi, sqrt
-
-print(dir()[-1::-1])
-```
-
-We can find these names appear in the current namespace now.
-
-*This uniform treatment of data in Python (everything is an object) helps keep the language simple and consistent.*
+Remember that everything is an object will help you interact with your programs
+and write clear Pythonic code.
## Exercises
@@ -900,12 +352,14 @@ We can find these names appear in the current namespace now.
```
We have met the {any}`boolean data type ` previously.
-Using what we have learnt in this lecture, print a list of methods of boolean objects.
+
+Using what we have learnt in this lecture, print a list of methods of the
+boolean object `True`.
```{hint}
:class: dropdown
- You can use `callable()` to test whether an attribute of an object can be called as a function
+You can use `callable()` to test whether an attribute of an object can be called as a function
```
```{exercise-end}
@@ -915,48 +369,37 @@ Using what we have learnt in this lecture, print a list of methods of boolean ob
:class: dropdown
```
-Firstly, we need to find all attributes of a boolean object.
-
-You can use one of the following ways:
-
-*1.* You can call the `.__dir__()` method
+Firstly, we need to find all attributes of `True`, which can be done via
```{code-cell} python3
print(sorted(True.__dir__()))
```
-*2.* You can use the built-in function `dir()`
+or
```{code-cell} python3
print(sorted(dir(True)))
```
-*3.* Since the boolean data type is a primitive type, you can also find it in the built-in namespace
+Since the boolean data type is a primitive type, you can also find it in the built-in namespace
```{code-cell} python3
print(dir(__builtins__.bool))
```
-Next, we can use a `for` loop to filter out attributes that are callable
+Here we use a `for` loop to filter out attributes that are callable
```{code-cell} python3
-attrls = dir(__builtins__.bool)
-callablels = list()
+attributes = dir(__builtins__.bool)
+callablels = []
-for i in attrls:
+for attribute in attributes:
# Use eval() to evaluate a string as an expression
- if callable(eval(f'True.{i}')):
- callablels.append(i)
+ if callable(eval(f'True.{attribute}')):
+ callablels.append(attribute)
print(callablels)
```
-Here is a one-line solution
-
-```{code-cell} python3
-print([i for i in attrls if callable(eval(f'True.{i}'))])
-```
-
-You can explore these methods and see what they are used for.
```{solution-end}
```