diff --git a/docs/chapter-3/lesson-3.2.md b/docs/chapter-3/lesson-3.2.md index 24d3740..80e0072 100644 --- a/docs/chapter-3/lesson-3.2.md +++ b/docs/chapter-3/lesson-3.2.md @@ -14,7 +14,7 @@ for i in range(5): The output is: -``` linenums="1" +``` 0 1 2 @@ -53,7 +53,7 @@ for i in range(10, 100, 2): print(i) ``` -`range(10, 100, 2)` represents the sequence `10, 12, ..., 98`. In general, `range(start, stop, step)` represents the sequence `start, start + step, start + 2 * step, ..., last`, where `last` is the largest element in this sequence that is less than `stop`. This is true when the `step` parameter is positive. +`#!py range(10, 100, 2)` represents the sequence $10, 12, ..., 98$. In general, `#!py range(start, stop, step)` represents the sequence `start, start + step, start + 2 * step, ..., last` where `last` is the largest element in this sequence that is less than `stop`. This is true when the `step` parameter is positive. The following are equivalent: @@ -86,7 +86,7 @@ for i in range(10, 5): The point to note is that neither of these code snippets produces any error. Finally, try executing the following snippet and observe the output. -```python +```python linenums="1" ##### Alarm! Wrong code snippet! ##### for i in range(0.0, 10.0): print(i) @@ -97,7 +97,7 @@ for i in range(0.0, 10.0): ### Iterating through Strings -Since a string is a sequence of characters, we can use the `#!py for` loop to iterate through strings. The following code will print each character of the string `x` in one line: +Since a string is a sequence of characters, we can use the `#!py for` loop to iterate through strings. The following code will print each character of the string `x` in a separate line: ```python linenums="1" word = 'good' @@ -107,7 +107,7 @@ for char in word: The output is: -``` linenums="1" +``` g o o @@ -126,7 +126,7 @@ for char in word: The output is: -``` linenums="1" +``` g occurs at position 1 in the string good o occurs at position 2 in the string good o occurs at position 3 in the string good diff --git a/docs/chapter-3/lesson-3.3.md b/docs/chapter-3/lesson-3.3.md index 0c432f2..b1ce020 100644 --- a/docs/chapter-3/lesson-3.3.md +++ b/docs/chapter-3/lesson-3.3.md @@ -44,26 +44,26 @@ print(count) The basic idea behind the solution is as follows: - The outer for loop goes through each element in the sequence $2, 3, ..., n$. `i` is the loop variable for this sequence. -- We begin with the guess that `i` is prime. In code, we do this by setting `flag` to be `True`. +- We begin with the assumption that `i` is prime. In code, we do this by setting `flag` to be `True`. - Now, we go through all potential divisors of `i`. This is represented by the sequence $2, 3, ..., i - 1$. Variable `j` is the loop variable for this sequence. Notice how the sequence for the inner loop is dependent on `i`, the loop variable for the outer loop. -- If `j` divides `i`, then `i` cannot be a prime. We correct our initial assumption by updating `flag` to `False` whenever this happens. As we know that `i` is not prime, there is no use of continuing with the inner-loop, so we break out of it. +- If `j` divides `i` for some value of `j`, then `i` cannot be a prime. We correct our initial assumption by updating `flag` to `False` whenever this happens. As we know that `i` is not prime, there is no use of continuing with the inner-loop, so we break out of it. - If `j` doesn't divide `i` for any `j` in this sequence, then `i` is a prime. In such a situation, our initial assumption is right, and `flag` stays `True`. -- Once we are outside the inner-loop, we check if `flag` is `True`. if that is the case, then we increment count as we have hit upon a prime number. +- Once we are outside the inner-loop, we check if `flag` is `True`. If this is the case, then we increment count as we have hit upon a prime number. Some important points regarding nested loops: - Nesting is not restricted to `#!py for` loops. Any one of the following combinations is possible: - - `#!py for` inside `#!py for` - - `#!py for` inside `#!py while` - - `#!py while` inside `#!py while` - - `#!py while` inside `#!py for` + - `#!py for` inside `#!py for` + - `#!py for` inside `#!py while` + - `#!py while` inside `#!py while` + - `#!py while` inside `#!py for` - Multiple levels of nesting is possible. ## `#!py while` versus `#!py for` -`#!py for` loops are typically used in situations where the number of iterations can be quantified, whereas `#!py while` loops are used in situations where the number of iterations cannot be quantified exactly. This doesn't mean that the number of iterations in a `#!py for` loop is always constant. For example: +`#!py for` loops are typically used in situations where the number of iterations can be quantified and is known in advance, whereas `#!py while` loops are used in situations where the number of iterations cannot be quantified exactly. This doesn't mean that the number of iterations in a `#!py for` loop is always constant. For example: ```python linenums="1" n = int(input()) @@ -83,7 +83,7 @@ The number of iterations in the above code can be determined only after it termi -## print: `end`, `sep` +## `#!py print:` `end`,`sep` ### `end` @@ -98,7 +98,7 @@ For a given value of `n`, say `n` = 9, we want the output to be: 1,2,3,4,5,6,7,8,9 ``` -The following solution won't work: +Here's an attempt at solving this using the concepts learnt so far ```python linenums="1" n = int(input()) @@ -106,9 +106,9 @@ for i in range(1, n + 1): print(i, ',') ``` -For `n` = 9, this will give the following output: +For `n` = 9, this will give the following output, certainly not what we need: -``` linenums="1" +``` 1 , 2 , 3 , @@ -135,7 +135,9 @@ For `n` = 9, this will give the required output: 1,2,3,4,5,6,7,8,9 ``` -Whenever we use the `#!py print()` function, it prints the expression passed to it and immediately follows it up by printing a newline. This is the default behaviour of `#!py print()`. It can be altered by using a special argument called `end`. The default value of `end` is set to the newline character. So, whenever the end argument is not explicitly specified in the print function, a newline is appended to the input expression by default. In the code given above, by setting `end` to be a comma, we are forcing the `#!py print()` function to insert a comma instead of a newline at the end of the expression passed to it. It is called `end` because it is added at the end. To get a better picture, consider the following code: +Whenever we use the `#!py print()` function, it prints the expression passed to it and immediately follows it up by printing a newline. This is the default behaviour of `#!py print()`. It can be altered by using a special argument called `end`. The default value of `end` is set to the newline character `#!py \n`[^1]. So, whenever the end argument is not explicitly specified in the print function, a newline is appended to the input expression by default. In the code given above, by setting `end` to be a comma, we are forcing the `#!py print()` function to insert a comma instead of a newline at the end of the expression passed to it. It is called `end` because it is added at the end. To get a better picture, consider the following code: + +[^1]: Remember [escape characters](../chapter-1/lesson-1.5.md/#escape-characters) from chapter 1? ```python linenums="1" print() @@ -148,13 +150,13 @@ print(3, end = ',') This output is: -``` linenums="1" - +``` +⠀ ,1 1,2,3, ``` -Even though nothing is being passed to the print function in the first line of code, the first line in the output is a newline because the default value of `end` is a newline character (`'\n'`). No expression is passed as input to print in the second line of code as well, but `end` is set to `,`. So, only a comma is printed. Notice that line 3 of the code is printed in line 2 of the output. This is because `end` was set to `,` instead of the newline character in line 2 of the code. +Even though nothing is being passed to the print function in the first line of code, the first line in the output is a newline because the default value of `end` is the newline character `\n`. No expression is passed as input to print in the second line of code either, but `end` is set to `,`. So, only a comma is printed. Notice that line 3 of the code is printed in line 2 of the output. This is because `end` was set to `,` instead of the newline character in line 2 of the code. @@ -172,7 +174,7 @@ The output is: this is cool ``` -What if we do not want the space or if want some other separator? This can be done using `sep`: +What if we don't want the space or if want some other separator? This can be done using the `sep` arguement: ```python print('this', 'is', 'cool', sep = ',') diff --git a/docs/chapter-3/lesson-3.4.md b/docs/chapter-3/lesson-3.4.md index 2720dc0..606af1d 100644 --- a/docs/chapter-3/lesson-3.4.md +++ b/docs/chapter-3/lesson-3.4.md @@ -88,7 +88,7 @@ for i in range(1, 11): For an input of 3, this will give the following result: -``` linenums="1" +``` Multiplication table for 3 3 X 1 = 3 3 X 2 = 6 @@ -106,7 +106,7 @@ The `\t` is a tab character. It has been added before and after the `=`. Remove Till now we have passed f-strings to the `#!py print()` function. Nothing stops us from using it to define other string variables: -```python +```python linenums="1" name = input() qual = input() gender = input() @@ -125,14 +125,14 @@ Try to guess what this code is doing. Another way to format strings is using a string method called `#!py format()`. -```python +```python linenums="1" name = input() print('Hi, {}!'.format(name)) ``` In the above string, the curly braces will be replaced by the value of the variable `name`. Another example: -```python +```python linenums="1" l, b = int(input()), int(input()) print('The length of the rectangle is {} units'.format(l)) print('The breadth of the rectangle is {} units'.format(b)) @@ -157,7 +157,7 @@ fruit2 = 'banana' print('{} and {} are fruits'.format(fruit1, fruit2)) ``` -In this code, the mapping is implicit. The first pair of curly braces is mapped to the first argument and so on. This can be made explicit by specifying which argument a particular curly braces will be mapped to: +In this code, the mapping is implicit. The first pair of curly braces is mapped to the first argument and so on. This can be made explicit by specifying which argument a particular pair of curly braces will be mapped to: ```python linenums="1" fruit1 = 'apple' @@ -208,12 +208,12 @@ The value of pi is approximately 3.14 Let us look at the content inside the curly braces: `{pi_approx:.2f}`. The first part before the `:` is the variable. Nothing new here. The part after `:` is called a format specifier. `.2f` means the following: - `.` - this signifies the decimal point. -- `2` - since this comes after the decimal point, it stipulates that there should be exactly two numbers after the decimal point. In other words, the value (`pi_approx`) should be rounded off to two decimal places. +- `2` - since this comes after the decimal point, it stipulates that there should be exactly two numbers after the decimal point. In other words, the value `pi_approx` should be rounded off to two decimal places. - `f` - this signifies that we are dealing with a `float` value. Let us consider a variant of this code: -```python +```python linenums="1" pi_approx = 22 / 7 print(f'The value of pi is approximately {pi_approx:.3f}') ``` @@ -243,7 +243,7 @@ BSC1002: 100 BSC1003: 90.15 ``` -While this is not bad, we would like the marks to be right aligned and have a uniform representation for the marks. The following code helps us achieve this:This is what we wish to see: +While this is not bad, it would look a tad bit cleaner if we could get the marks to be right aligned. The following code helps us achieve this: ```python linenums="1" roll_1, marks_1 = 'BSC1001', 90.5 @@ -256,18 +256,16 @@ print(f'{roll_3}: {marks_3:10.2f}') The output of the above code will be: -``` linenums="1" +``` BSC1001: 90.50 BSC1002: 100.00 BSC1003: 90.15 ``` -This is much more neater. +As seen above we get a number of spaces before the floating point values and the values themselves are now right aligned. Let's take a closer look at the contents of the second pair of curly braces: `{marks_1:10.2f}`. The part before the `:` as before is the variable. The part after the `:` is `10.2f`. Here again, `.2f` signifies that the float value should be rounded off to two decimal places. The `10` before the decimal point is the minimum width of the column used for printing this value. If the number has fewer than 10 characters (including the decimal point), this will be compensated by adding spaces before the number. -The part that might be confusing is the second curly braces in each of the print statements. Let us take a closer look: `{marks_1:10.2f}`. The part before the `:` is the variable. The part after the `:` is `10.2f`. Here again, `.2f` signifies that the float value should be rounded off to two decimal places. The `10` before the decimal point is the minimum width of the column used for printing this value. If the number has fewer than 10 characters (including the decimal point), this will be compensated by adding spaces before the number. - -For a better understanding of this concept, let us turn to printing integers with a specific formatting. This time, we will use the `#!py format()` function: +For a better understanding of this concept, let us turn to printing integers with a specific formatting. This time, we will use the `#!py format()` method: ```python linenums="1" print('{0:5d}'.format(1)) @@ -280,7 +278,7 @@ print('{:5d}'.format(111111)) This gives the following output: -``` linenums="1" +``` 1 11 111 @@ -295,5 +293,6 @@ Points to note in the code: - First three print statements have the index of the argument — `0` in this case — before the `:`. Last three statements do not have the index of the argument. In fact there is nothing before the `:`. Both representations are valid. - The `5d` after the `:` means that the width of the column used for printing must be at least 5. - Lines 1 to 4 have spaces before them as the integer being printed has fewer than five characters. - +- Line 5 has exactly five characters, leaving no empty room for spaces. +- And line 6 obviously has more characters than the specified minimum width and thus is printed as such. diff --git a/docs/chapter-3/lesson-3.5.md b/docs/chapter-3/lesson-3.5.md index 2891685..d092c76 100644 --- a/docs/chapter-3/lesson-3.5.md +++ b/docs/chapter-3/lesson-3.5.md @@ -1,6 +1,6 @@ # Lesson-3.5 -## Library +## Library (Continued) We will look at two more libraries — `math` and `random` — and use them to solve some fascinating problems in mathematics. @@ -24,7 +24,7 @@ for n in range(1, 6): If we execute the above code, we get the following output: -``` linenums="1" +``` n = 1, x_n = 1.414 n = 2, x_n = 1.848 n = 3, x_n = 1.962 @@ -54,7 +54,7 @@ for n in range(1, 20): print(x) ``` -After just 20 iterations, the value is so close to two: `#!py 1.9999999999910236`. But we have used trial and error to decide when to terminate the iteration. A better way to do this is to define a tolerance: if the difference between the previous value and the current value in the sequence is less than some predefined value (tolerance), then we terminate the iteration. +After just 20 iterations, the value is so close to two: `#!py 1.9999999999910236`. But we have used trial and error to decide when to terminate the iteration. A better way to do this is to define a _tolerance_: if the difference between the previous value and the current value in the sequence is less than some predefined value (tolerance), then we terminate the iteration. ```python linenums="1" import math @@ -64,11 +64,16 @@ while abs(x_curr - x_prev) >= tol: x_prev = x_curr x_curr = math.sqrt(2 + x_prev) count += 1 -print(f'Value of x at {tol} tolerance is {x_curr}') +print(f'Value of x at {tol:.5f} tolerance is {x_curr}') print(f'It took {count} iterations') ``` +The output of the above code would be: +``` +Value of x at 0.00001 tolerance is 1.9999976469034038 +It took 9 iterations +``` ### `random` @@ -79,11 +84,13 @@ import random print(random.choice('HT')) ``` -That is all there is to it! `random` is a library and `#!py choice()` is a function defined in it. It accepts any sequence as input and returns an element chosen at random from this sequence. In this case, the input is a string, which is nothing but a sequence of characters. +That is all there is to it! `random` is a library and `#!py choice()` is a function defined in it. It accepts any sequence as input and returns an element chosen at random from this sequence. In this case, the input is a string, which is nothing but a sequence of characters and each time the code is run we get either of these characters, `H` or `T`. -We know that the probability of obtaining a head on a coin toss is 0.5. This is the theory. Is there a way to see this rule in action? Can we computationally verify if this is indeed the case? For that, we have to set up the following experiment. Toss a coin $n$ times and count the number of heads. Dividing the total number of heads by $n$ will give the empirical probability. As $n$ becomes large, this probability must approach 0.5. +We know that the probability of obtaining a head on a coin toss is 0.5. Atleast that's what theory says. Is there a way to see this rule in action? Can we computationally verify if this is indeed the case? For this, we need to set up an experiment: toss a coin $n$ times and count the number of heads. Dividing the total number of heads by $n$ will give the _empirical probability[^1]_. As $n$ becomes larger and larger, this probability must approach 0.5. -```python +[^1]: Simply put [empirical probability](https://en.wikipedia.org/wiki/Empirical_probability) gives the likelihood of an event to occur based on past or historical data. + +```python linenums="1" import random n = int(input()) heads = 0 @@ -107,12 +114,12 @@ Let us run the above code for different values of $n$ and tabulate our results: | 1,000,000 | 0.499983 | -The value is approaching `#!py 0.5` as expected! `random` is quite versatile. +The value is indeed approaching `#!py 0.5` as expected! -!!! question "Exercise" - Let us now roll a dice! `randint(a, b)` returns a random integer $N$ such that $a \leq N \leq b$. +!!! question "Practice Problem" + Let us now try to simulate a die roll! `random.randint(a, b)` returns a random integer $N$ such that $a \leq N \leq b$. ```python import random diff --git a/docs/chapter-3/lesson-3.6.md b/docs/chapter-3/lesson-3.6.md index 81cbf8e..b84665f 100644 --- a/docs/chapter-3/lesson-3.6.md +++ b/docs/chapter-3/lesson-3.6.md @@ -18,7 +18,7 @@ $$ a_n = \left( \sqrt{2} - 1 \right)^n $$ -As $n$ becomes very large, the values in this sequence will become smaller and smaller. This is because, if you keep multiplying a fraction with itself, it becomes smaller and smaller. In mathematical terms, the limit of this sequence as $n$ tends to infinity is zero. Let us verify this programmatically: +As $n$ becomes very large, the values in this sequence will become smaller and smaller. This is because, if you keep multiplying a fraction with itself, it keeps getting smaller. In mathematical terms, the limit of this sequence as $n$ tends to infinity is zero. Let us verify this programmatically: @@ -40,30 +40,34 @@ Now, here is another fact. For every number $n$, there are unique integers $x$ a $$ (\sqrt{2} - 1)^n = x + y \cdot \sqrt{2} $$ -For $n = 1$, this is obvious: $x = -1, y = 1$. What about higher values of $n$? . We can prove this using mathematical induction. The following is a sketch of the inductive proof. If $(\sqrt{2} - 1)^n = x_n + y_n \cdot \sqrt{2}$, then: +For $n = 1$, this is obvious: $x = -1, y = 1$. What about higher values of $n$? . We can prove this using mathematical induction. The following is a sketch of the inductive proof. If we have that $(\sqrt{2} - 1)^n = x_n + y_n \cdot \sqrt{2}$, then: + $$ -(\sqrt{2} - 1)^{n + 1} = (x_n + y_n \cdot \sqrt{2}) \cdot (\sqrt{2} - 1)\\ -= (2y_n - x_n) + (x_n - y_n) \cdot \sqrt{2}\\ -= x_{n + 1} + y_{n + 1} \cdot \sqrt{2} +\begin{align} +(\sqrt{2} - 1)^{n + 1} &= (x_n + y_n \cdot \sqrt{2}) \cdot (\sqrt{2} - 1) \\ +&= (2y_n - x_n) + (x_n - y_n) \cdot \sqrt{2} \\ +&= x_{n + 1} + y_{n + 1} \cdot \sqrt{2} +\end{align} $$ -The equation given above defines what is called a recurrence relation: each new term in the sequence is a function of the preceding terms. In this sequence we have $x_1 = -1, y_1 = 1$. For $n > 0$, the pair of equations given below forms the recurrence relation: + +The equation given above defines what is called a recurrence relation: each new term in the sequence is a function of it's preceding term (or terms). In this sequence we have $x_1 = -1, y_1 = 1$. For $n > 0$, the pair of equations given below forms the recurrence relation: + $$ \begin{align} -x_{n + 1} &= 2 y_n - x_n\\ +x_{n + 1} &= 2 y_n - x_n \\ y_{n + 1} &= x_n - y_n \end{align} $$ + Loops are useful tools when it comes to computing terms in such sequences: -```python +```python linenums="1" n = int(input()) # sequence length x_n, y_n = -1, 1 # x_1 and y_1 for i in range(n - 1): x_n, y_n = 2 * y_n - x_n, x_n - y_n ``` - - ### Rational Approximation This in turn provides a way to approximate $\sqrt{2}$ using rational numbers: @@ -74,4 +78,4 @@ As $n$ becomes large, this approximation will become increasingly accurate. For $$ \frac{228725309250740208744750893347264645481}{161733217200188571081311986634082331709} $$ -Is any of this useful? I don't know. But honestly, who cares? We don't do things because they are useful. We do them because they are interesting. And all interesting things will find their use at some point of time in the future. \ No newline at end of file +Is any of this useful? Well, not all things we learn need to be useful. Sometimes we learn things just because they seem interesting and because we are curious. _And sometimes curiosity might just be the spark that ignites innovation._ \ No newline at end of file diff --git a/docs/chapter-4/lesson-4.1.md b/docs/chapter-4/lesson-4.1.md index 7d7130d..1217c42 100644 --- a/docs/chapter-4/lesson-4.1.md +++ b/docs/chapter-4/lesson-4.1.md @@ -4,7 +4,7 @@ ### Introduction -In mathematics, a function is an object that accepts one or more inputs and produces one or more outputs. For example, $f(x) = x^2$, is a function that accepts a number and returns the square of that number. Functions in Python play a similar role, but are much more richer than their mathematical counterparts. Let us quickly convert the mathematical function, $f(x) = x^2$, into a Python function: +In mathematics, a function is any object that accepts one or more inputs and produces one or more outputs. For example, $f(x) = x^2$, is a function that accepts a number and returns the square of that number. Functions in Python play a similar role, but are much more richer than their mathematical counterparts. Let us quickly convert the mathematical function, $f(x) = x^2$, into a Python function: ```python linenums="1" def f(x): @@ -12,7 +12,12 @@ def f(x): return y ``` -The code given above is called the **definition** of function `f`. `#!py def` is the keyword used to define functions. `f` is the name of the function. `x` is a parameter of the function. Lines 2 and 3 make up the body of the function and are indented. The body of a function is a collection of statements that describe what the function does. At line 3, the value stored in variable `y` is returned. `#!py return` is the keyword used for this purpose. +The code given above is called the **definition** of the function `f`. Lines 2 and 3 make up the body of the function and are indented. The body of a function is a collection of statements that describe what the function does. Here, + +- `#!py def` - is the keyword used to define functions +- `f` - is the name we have given to the function +- `x` - is a parameter (or input) we give to the function +- `#!py return` - is a keyword used to return (or output) the value `y` from the function If we run the above code, we will not get any output. Functions are not executed unless they are called. The following code demonstrates what a **function call** looks like: @@ -30,25 +35,23 @@ The output is: 4 ``` -`#!py square(2)` is a function call. We use the name of the function, `square`, and pass the number 2 as an argument to it. The `x` in the function definition is called the **parameter**. The value that is passed to the function in the call is called the **argument**. This is a convention that we will follow throughout this lesson. +`#!py square(2)` is a function call. We use the name of the function, `square` and pass the number 2 as an argument to it. Keep in mind that though the words parameter and argument are often used interchangeably, technically they are two different terms. A _parameter_ is a variable in a function definition. It could be thought of as a placeholder. An _argument_ on the other hand is the value passed to a function when it is called. So in the above code we have, the `x` in line 1 is a parameter and the `#!py 2` in line 5 is an argument. A visual representation of the terms we have defined so far is given below: ![functions](../assets/images/img-010.png) -A mental model to understand functions: +A mental model to better understand functions: -- Parameters can be thought of as the function's inputs. -- The body of the function can be pictured as the sequence of steps that transform the input into the output. +- Parameters as we've seen so far are the inputs to the function. +- The body of the function can be pictured as the sequence of steps that transform the input to the output. - The return statement can be thought of as a means of communicating the output to the rest of the code. - - ### Examples -We will look at a wide variety of function definitions. The focus will be on the syntactical aspects of function definitions. +Now let's look at some more examples of function definitions keeping the focus on the various syntactical aspects of functions in python. -- Functions could have multiple parameters: +- We can have functions with multiple parameters: ```python linenums="1" # This function computes the area of a rectangle. @@ -57,14 +60,14 @@ def area(l, b): return l * b ``` -- Functions could have no parameters: +- We can also have functions with no parameters at all: ```python linenums="1" def foo(): return "I don't like arguments visiting me!" ``` -- Functions could have no return value: +- Functions could also be defined with no return value: ```python linenums="1" def foo(): @@ -79,27 +82,27 @@ When the code given above is executed, we get the following output: I don't like talking to the outside world! ``` -Note that we didn't have to type `#!py print(foo())`. We just had to call the function — `foo()` — since it already has the print statement inside it. But what happens if we type `#!py print(foo())`? We get the following output: +Note that we didn't have to type `#!py print(foo())`. We just had to call the function — `foo()` — since it already has the print statement inside it's body. But what happens if we type `#!py print(foo())`? We get the following output: -``` linenums="1" +``` I don't like talking to the outside world! None ``` -If no explicit return statement is present in a function, `None` is the default value returned by it. When the interpreter comes across the `#!py print(foo())` statement, first the function `foo()` is evaluated. This results in the first line of the output. Since `foo()` has no explicit return statement, it returns `None` by default. That is why the second line in the output is `None`. +If no explicit return statement is present in a function, `None` is the default value returned by it. When the interpreter comes across the `#!py print(foo())` statement, first the function `foo()` is evaluated. This results in the first line of the output. And since `foo()` has no explicit return statement, it returns `None` by default which gets printed to the second line of the output. - A minimal Python function looks like the one given below: ```python linenums="1" def foo(): - pass + pass # pass can be used as a placeholder ``` -`#!py pass` is a keyword in Python. When the interpreter comes across a `#!py pass` statement, it doesn't perform any computation and moves on to the next line. The reason this is minimal is because it has only those features that are absolutely essential for a function definition to be syntactically valid: function name and at least one statement in the body. +`#!py pass` is a keyword in Python. When the interpreter comes across a `#!py pass` statement, it doesn't perform any computation and _passes_ control to the next line. The reason this is minimal is because it has only those features that are absolutely essential for a function definition to be syntactically valid: a function name and at least one statement in the body. -Such functions might seem useless at first sight, but they do have their place in programming. While writing a complex piece of code, a coder may realize that they need to define a function to perform a specific task. But they might not know the exact details of the implementation or it may not be an urgent requirement. In such a scenario, they will add a minimal function like the one given above in their code and name it appropriately. Implementing this function will become a task on their to-do list and will be taken up as and when the need arises. +Such functions might seem useless at first sight, but they do have their place in programming. While writing a complex piece of code, a coder may realize that they need to define a function to perform a specific task. But they might not know the exact details of the implementation or it may not be an urgent requirement. In such scenarios, they can make use of a minimal function like the one given above, using `#!py pass` as a placeholder that can be replaced with the actual function body as and when the need arises. -- Functions could have multiple return statements, but the moment the first return is executed, control exits from the function: +- Functions could have multiple return statements, but the moment the first return is encountered, control exits from the function: ```python linenums="1" def foo(): @@ -107,7 +110,7 @@ def foo(): return 2 ``` -`foo()` will always return 1. Line 3 is redundant. An example of a function having multiple returns that are not redundant: +`foo()` will always return 1. Line 3 is redundant. Thanks to `#!py if-else` statements we can always control which return gets executed, when there are multiple return statements: ```python linenums="1" def evenOrOdd(n): @@ -122,14 +125,14 @@ print(evenOrOdd(11)) The output is: -``` linenums="1" +``` even odd ``` -When `evenOrOdd` is called with an even number as argument, the return statement in line 3 is executed. When the same function is called with an odd number as argument, the return statement in line 5 is executed. +Here, when `evenOrOdd` is called with an even number as argument, the return statement in line 3 is executed. When the same function is called with an odd number as argument, the return statement in line 5 is executed. -- Functions could return multiple values: +- Functions could also have multiple return values: ```python linenums="1" # Accept only positive floating point numbers @@ -143,9 +146,15 @@ l, u = bound(y) print(f'{l} < {y} < {u}') ``` +The output is: + +``` +7 < 7.3 < 8 +``` + The exact mechanism of what happens here will become clear when we come to the lesson on tuples. In line 8, the first value returned by `bound` is stored in `l` and the second value returned by `bound` is stored in `u`. -- Functions have to be defined before they can be called. The function call cannot come before the definition. For example: +- Functions have to be defined before they can be called. A function call cannot precede the function definition. For example: ```python linenums="1" ##### Alarm! Wrong code snippet! ##### @@ -156,9 +165,9 @@ def f(x): ##### Alarm! Wrong code snippet! ##### ``` -When the above code is executed, it throws a `NameError`. Why does this happen? The Python interpreter executes the code from top to bottom. At line 2, `f` is a name that the interpreter has never seen before and therefore it throws a `NameError`. Recall that `NameError` occurs when we try to reference a name that the interpreter has not seen before. +When the above code is executed, it throws a `NameError`. Why does this happen? The Python interpreter executes the code from top to bottom. At line 2, `f` is a name that the interpreter has never seen before and therefore it throws a `NameError`. Recall that `NameError` occurs when we try to reference a name that the interpreter has not encountered before. -- Function calls could be used in expressions: +- Function calls can be used as part of expressions: ```python linenums="1" def square(a): @@ -198,7 +207,7 @@ bar() print('I am outside both foo and bar') ``` -- Functions can be defined inside other functions: +- Functions can also be defined inside other functions: ```python linenums="1" def foo(): @@ -220,15 +229,15 @@ Consider the following function: ```python linenums="1" def square(x): - """Return the square of x.""" + """Returns the square of x.""" return x ** 2 ``` The string immediately below the function definition is called a docstring. From the Python [docs](https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring): -> A docstring is a string literal that occurs as the first statement in a module, function, class, or method definition. Such a docstring becomes the `__doc__` special attribute of that object. +> _A docstring is a string literal that occurs as the first statement in a module, function, class, or method definition. Such a docstring becomes the `__doc__` special attribute of that object._ -Ignore unfamiliar terms such as "module" and "class". For now, it is sufficient to focus on functions. Adding the docstring to functions is a good practice. It may not be needed for simple and obvious functions like the one defined above. As the complexity of the functions you write increases, docstrings can be a life safer for other programmers reading your code. +Ignore unfamiliar terms such as "module" and "class". For now, it is sufficient to focus on functions. Adding the docstring to functions is a good practice. It may not be needed for simple and obvious functions like the one defined above. But as the complexity of the functions you write increases, docstrings can be a life saver for other programmers reading your code. The docstring associated with a given function can be accessed using the `__doc__` attribute: @@ -236,7 +245,7 @@ The docstring associated with a given function can be accessed using the `__doc_ print(square.__doc__) ``` -This gives `Return the square of x.` as output. +This gives `Returns the square of x.` as output. diff --git a/docs/chapter-4/lesson-4.2.md b/docs/chapter-4/lesson-4.2.md index ff8fcee..ea6d970 100644 --- a/docs/chapter-4/lesson-4.2.md +++ b/docs/chapter-4/lesson-4.2.md @@ -25,7 +25,7 @@ print(isRight(5, 4, 3)) # 5 is passed to x, 4 is passed to y, 3 is passed to z The output is: -``` linenums="1" +``` True False ``` @@ -43,7 +43,7 @@ isRight(3, 4, 5, 6) ### Keyword arguments -Keyword arguments introduce more flexibility while passing arguments. Let us take up the same problem that we saw in the previous section and just modify the function calls: +Keyword arguments introduce more flexibility while passing arguments. Let's take the same example from the previous section and tweak the function call a bit: ```python linenums="1" # The following is just a function call. @@ -60,7 +60,7 @@ isRight(x = 3, y = 4, z = 5) # same as intended call isRight(z = 5, y = 4, x = 3) # same as intended call ``` -Keyword arguments and positional arguments can be combined in a single call: +Keyword arguments and positional arguments can also be combined in a single call: ```python isRight(3, y = 4, z = 5) @@ -74,7 +74,7 @@ isRight(x = 3, 4, 5) #### Alarm! Wrong code snippet! #### ``` -The interpreter throws a `TypeError` with the following message: `positional argument follows keyword arguments`. That is, in our function call, the positional arguments — `#!py 4` and `#!py 5` — come after the keyword argument `#!py x = 3`. Why does the interpreter objects to this? Whenever both positional and keyword arguments are present in a function call, the keyword arguments must always come at the end. This is quite reasonable: positional arguments are extremely sensitive to position, so it is best to have them at the beginning. +The interpreter throws a `TypeError` with the following message: `positional argument follows keyword arguments`. That is, in our function call, the positional arguments — `#!py 4` and `#!py 5` — come after the keyword argument `#!py x = 3`. Why does the interpreter object to this? Whenever both positional and keyword arguments are present in a function call, the keyword arguments must always come at the end. This is quite reasonable: positional arguments are extremely sensitive to position, so it is best to have them at the beginning. How about the following call? @@ -84,7 +84,7 @@ isRight(3, x = 3, y = 4, z = 5) #### Alarm! Wrong code snippet! #### ``` -The interpreter objects by throwing a `TypeError` with the following message: `isRight() got multiple values for argument x`. Objection granted! Another reasonable requirement from the Python interpreter: there must be exactly one argument in the function call for each parameter in the function definition, nothing more, nothing less. This could be a positional argument or a default argument, but not both. +The interpreter objects by throwing a `TypeError` with the following message: `isRight() got multiple values for argument x`. Objection granted! Another reasonable requirement from the Python interpreter: there must be exactly one argument in the function call for each parameter in the function definition, nothing more, nothing less. This could be a positional argument or a keyword argument, but not both. @@ -100,7 +100,7 @@ Consider the following scenario. The image that you see here is a map of your ne Let us say that a self-driving car startup operating in your neighborhood uses both these metrics while computing distances. Assume that its code base invokes the Euclidean distance 10 times and the Manhattan distance 1000 times. Since these metrics are used repeatedly, it is a good idea to represent them as functions in the code base: -```python +```python linenums="1" # Assume that O is the origin # All distances are computed from the origin def euclidean(x, y): @@ -112,7 +112,7 @@ def manhattan(x, y): While the above code is fine, it ignores the fact that the Manhattan distance is being used hundred times more frequently compared to the Euclidean distance. Default arguments can come in handy in such situations: -```python +```python linenums="1" def distance(x, y, metric = 'manhattan'): if metric == 'manhattan': return abs(x) + abs(y) @@ -126,7 +126,7 @@ The parameter `metric` has `#!py 'manhattan'` as the default value. Let us try c print(distance(3, 4)) ``` -This gives `7` as the output. Since no value was provided in the function call, the default value of `#!py 'manhattan'` was assigned to the `metric` parameter. In the code base, wherever the Manhattan distance is invoked, we can just replace it with the function call `distance(x, y)`. +This gives `7` as the output. Since no value was passed to the `metric` parameter in the function call, the default value of `#!py 'manhattan'` was assigned to it. Now, in the code base, wherever the Manhattan distance is invoked, we can just replace it with the function call `distance(x, y)`. The following points are important to keep in mind: @@ -147,15 +147,15 @@ def distance(metric = 'manhattan', x, y): #### Alarm! Wrong code snippet! #### ``` -The above code throws a `SyntaxError` with the following message: `non-default argument follows default argument`. In the function definition, the default parameter must always come at the end of the list of parameters. Now, for different ways of passing arguments in the presence of default parameters: +The above code throws a `SyntaxError` with the following message: `non-default argument follows default argument`. In a function definition, the default parameters must always come at the end of the list of parameters. Now, for different ways of passing arguments in the presence of default parameters: -```python +```python linenums="1" distance(3, 4) distance(3, 4, 'manhattan') distance(3, 4, metric = 'manhattan') ``` -All three function calls are equivalent. The first one uses default value of `metric`. The second call explicitly passes `'manhattan'` as the metric using a positional argument. The last call explicitly passes `'manhattan'` as a keyword argument. +All three function calls above are equivalent. The first one uses default value of `metric`. The second call explicitly passes `#!py 'manhattan'` as the metric using a positional argument. The last call explicitly passes `#!py 'manhattan'` as a keyword argument. @@ -193,7 +193,7 @@ x = 10 x_squared = square(x) ``` -We are using the same name for both the parameter of the function `square` and the argument passed to it. This is a bad practice. It is always preferable to differentiate the names of the parameters from the names of the arguments that are passed in the function call. This avoids confusion and makes code more readable. At this stage, you might be wondering how the variable `x` inside the function is related to the variable `x` outside it. This issue will be taken up in the next lesson on scopes. The above code could be rewritten as follows: +We are using the same name for both the parameter of the function `square` and the argument passed to it. This is a bad practice. It is always preferable to differentiate the names of the parameters from the names of the arguments that are passed in the function call. This avoids ambiguity and makes code more readable. At this stage, you might be wondering how the variable `x` inside the function is related to the variable `x` outside it. This issue will be taken up in the next lesson on scopes. The above code could be rewritten as follows: ```python linenums="1" def square(num): diff --git a/docs/chapter-4/lesson-4.3.md b/docs/chapter-4/lesson-4.3.md index 93e5726..98796a1 100644 --- a/docs/chapter-4/lesson-4.3.md +++ b/docs/chapter-4/lesson-4.3.md @@ -4,7 +4,7 @@ Consider the following code: -```python +```python linenums="1" def foo(): x = 1 print('This is a veritable fortress. None can enter here.') @@ -16,7 +16,7 @@ print(x) This will give the following output: -``` +```pycon This is a veritable fortress. None can enter here. 😏 Traceback (most recent call last): @@ -25,15 +25,15 @@ Traceback (most recent call last): NameError: name 'x' is not defined ``` -Why did the interpreter throw an an error in line-7? It tried to look for the name `x` and was unable to find it. But isn't `x` present in the function `foo`? Is the interpreter careless or are we missing something? The interpreter is never wrong! The region in the code where a name can be referenced is called its scope. If we try to reference a variable outside its scope, the interpreter will throw a `NameError`. +Why did the interpreter throw an an error in line-7? It tried to look for the name `x` and was unable to find it. But isn't `x` present in the function `foo`? Is the interpreter careless or are we missing something? The interpreter is never wrong! The region in the code where a name can be referenced is called its _scope_ - a _scope_ could be thought of as a box or a container that encloses a set of names or variables. Variables defined in one scope can only be used within that particular scope, and not in any other scope. If we try to reference a variable outside its scope, the interpreter will throw a `NameError`. ### Local vs Global -In the above example, the scope of the name `x` is **local** to the function; `x` has a meaningful existence only inside the function and any attempt to access it from outside the function is going to result in an error. Think about functions as black holes: they don't let variables (light) escape the function's definition (event-horizon)! Let us take another example: +In the above example, the scope of the name `x` is **local** to the function; `x` has a meaningful existence only inside the function and any attempt to access it from outside the function is going to result in an error. Let's take a look at another example: -```python +```python linenums="1" y = 10 def foo(): x = 1 @@ -43,17 +43,19 @@ def foo(): foo() ``` -The name `y` is accessible from within the function as well. We say that the scope of `y` is **global**. That is, it can be referenced from anywhere within the program — even inside a function — after it has been defined for the first time. There is a slight catch here: if another variable with the same name is defined within the function, then things change. We will take up this case later. +Here, the name `y` is accessible from within the function as well. We say that the scope of `y` is **global**. That is, it can be referenced from anywhere within the program — even inside a function — after it has been defined for the first time. There is a slight catch though, if we have another variable with the same name inside the function definition, then things change. Let's keep that case aside for now. -At this stage, we are ready to formulate the rules for local and global variables [[refer](https://docs.python.org/3/faq/programming.html#what-are-the-rules-for-local-and-global-variables-in-python)]: +At this stage, we are ready to formulate the rules for local and global scopes[^1]: -> **Local**: Whenever a variable is assigned a value anywhere within a function, its scope becomes local to that function. In other words, whenever a variable appears on the left side of an assignment statement anywhere within a function, it becomes a local variable. -> -> **Global**: If a variable is only referenced inside a function and is never assigned a value inside it, it is implicitly treated as a global variable. +[^1]: [More on rules for local and global variables](https://docs.python.org/3/faq/programming.html#what-are-the-rules-for-local-and-global-variables-in-python) -The scope of the parameters in the function definition are local. The following code will throw a `NameError` when executed: +- **Local Scope**: Whenever a variable is assigned a value anywhere within a function, its scope becomes local to that function. It can only be accessed from within the function body. -```python +- **Global Scope**: Variables defined outside of any function are implicitly treated to be part of the global scope, making them accessible from any part of the program. + +The scope of the parameters in a function definition are local. The following code will throw a `NameError` when executed: + +```python linenums="1" def double(x): x = x * 2 return x @@ -66,9 +68,9 @@ print(x) ### Examples -Let us now look at few more examples that bring out some fine points regarding local and global scope: +Let's now look at a few more examples that bring out some of the finer points regarding local and global scope: -```python +```python linenums="1" ### Variant-1 def foo(): x = 1 @@ -79,9 +81,9 @@ y = 10 foo() ``` -Notice the difference between this code and the one at the beginning of the earlier section. Here, the variable `y` is defined after the function definition, while in the earlier version `y` was defined before the function definition. But both versions give the same output. All that matters is for `y` to be defined before the function call. What happens if `y` is defined after `foo` is called? +Notice the difference between this code and the one at the beginning of the earlier section. Here, the variable `y` is defined after the function definition, while in the earlier version `y` was defined before the function definition. But both versions would give the same output. Why? Because all that matters is for `y` to be defined before the **function call**. What happens if `y` is defined after `foo` is called? -```python +```python linenums="1" ### Variant-2 def foo(): x = 1 @@ -92,11 +94,11 @@ foo() y = 10 ``` -This throws a `NameError` at line-5, which is reasonable as `y` is not defined in the main program before `foo` is called. The scope of `y` is still global; it can be referenced anywhere in the program once it has been defined. +This throws a `NameError` at line-5, which is reasonable as `y` is not defined in the main program before `foo` is called. The scope of `y` is still global; it can be referenced anywhere in the program, but only after the point where it has been defined. Now, let us crank up the difficulty level: -```python +```python linenums="1" def foo(): x = 10 print(f'x inside foo = {x}') @@ -106,7 +108,7 @@ foo() print(f'x outside foo = {x}') ``` -We have the same name — `x` — appearing inside the function and outside the function. Are they the same or different? Let us check the output: +We have the same name `x` appearing both inside the function and outside the function. Are they the same or different? Let us check the output: ``` x inside foo = 10 @@ -116,7 +118,7 @@ x outside foo = 100 They are different! The `x` inside `foo` is different from the `x` outside `foo`. - The scope of the name `x` inside `foo` is local; it is a local variable. This is because of the first rule: a variable that is assigned a value inside the function becomes a local variable. Since `x` is assigned a value in line-2, it becomes a local variable. -- The scope of the `x` outside `foo` is global. Though there is another `x` inside the function `foo`, that cannot be accessed outside the function. +- The scope of the `x` outside `foo` is global. Though there is another `x` inside the function `foo`, it cannot be accessed outside the function. This may start to get a little confusing. How does Python internally manage local and global variables? For this, we will briefly turn to the concept of namespaces. This will give a different perspective to the problem of name resolution. @@ -126,22 +128,24 @@ This may start to get a little confusing. How does Python internally manage loca Consider the following snippet of code: -```python +```python linenums="1" x = 1.0 avar = 'cool' def foo(): pass ``` -We have used three different names here: `x`, `avar` and `foo`. The first two names represent variables that store literals. The last name represents a function. How does the Python interpreter internally process these names? It uses a concept called namespaces. A namespace can be thought of as a lookup table — dictionary to be precise — that maps names to objects. +We are using three different names here: `x`, `avar` and `foo`. The first two names represent variables that store literals. The last name represents a function. How does the Python interpreter internally process these names? It uses a system of _namespaces_. We can imagine a namespace as a mapping of every name we have defined to corresponding objects. It is used to store the values of variables and other objects in the program, and to associate them with a specific name. ![Namespace](../assets/images/img-021.png) -### globals() +A typical python program uses different types of namespaces and these different namespaces are isolated from each other thus allowing us to use the same name for different variables or objects in different parts of our code, without causing any conflicts or confusion. -There are different types of namespaces. The variables that we define in the main program are represented in the `globals` namespace. For example: +### `#!py globals()` -```python +As we said there are different types of namespaces. The variables that we define in the main program body ie., outside any function body are stored in the `#!py globals` namespace. For example: + +```python linenums="1" x = 1.0 avar = 'cool' def foo(): @@ -153,29 +157,37 @@ print(globals()) This returns the following output: -![](../assets/images/img-022.png) +``` hl_lines="5" +{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': +<_frozen_importlib_external.SourceFileLoader object at 0x0000022009CA89A0>, +'__spec__': None, '__annotations__': {}, '__builtins__': +, '__file__': 'main.py', '__cached__': None, +'x': 1.0, 'avar': 'cool', 'foo': } +``` -Ignore all the other details and just focus on the region highlighted in yellow. Notice that the names `x`, `avar` and `foo` are present in the namespace. `x` and `avar` are mapped to the objects `1` and `cool` respectively, while `foo` is mapped to some complex looking object: ``. The number `0x7f8ecd2aa1f0` is the location in the memory where the function's definition is stored [[refer](https://stackoverflow.com/questions/19333598/in-python-what-does-function-at-mean)]. There is another way to check whether a given name is in a namespace: +Ignore all the other details, for now just focus on the line highlighted in blue. Notice that the names `x`, `avar` and `foo` are present in the output which means that they are part of the `#!py global` namespace. `x` and `avar` are mapped to the objects `#!py 1` and `#!py 'cool'` respectively, while `foo` is mapped to some complex looking object: ``. The number `0x0000022009BE3E20` represents the location in the memory where the function's definition is stored [^2]. There is another way to check whether a given name is in the global namespace: -```python +[^2]: Have a look at [this stackoverflow answer](https://stackoverflow.com/questions/19333598/in-python-what-does-function-at-mean) to know what `#!pycon ` means + +```python linenums="1" print('x' in globals()) print('avar' in globals()) print('foo' in globals()) ``` -All three lines result in `True`. +All three lines should result in `True`. -### locals() +### `#!py locals()` -Notice something interesting in the previous code, the name `y` is not found in the `globals` namespace! We can verify this as follows: +Notice something interesting in the previous code, the name `y` does not seem to part of the `#!py globals` namespace! We can verify this as follows: ```python print('y' in globals()) ``` -This results in `False`. Variables that are assigned a value inside a function are `local` to the function and cannot be accessed outside it. How does the Python interpreter handle names inside functions? It creates a separate namespace every time a function is called. This is called a local namespace. Now, consider the following code: +This results in `False`. Variables that are assigned a value inside a function are `local` to the function and cannot be accessed outside it. How does the Python interpreter handle names inside functions? - Simply by creating a new namespace separate from the global namespace every time a function is called. This new namespace is called a local namespace. Now, consider the following code: -```python +```python linenums="1" def foo(): y = 2.0 print('Is y in locals?', 'y' in locals()) @@ -195,9 +207,9 @@ Is y in globals? False ## Scope and Namespaces -For every function call, the interpreter creates a local namespace that contains all names and their corresponding objects that are defined in the function. Let us take an example: +Now that we have a good mental picture of what namespaces are, let's try to look at how the concept of scope ties in with namespaces. Consider the following example: -```python +```python linenums="1" def foo(): print(y) print(locals()) @@ -216,24 +228,23 @@ This gives the output: {'x': 1} ``` -Since `y` is only being referenced inside `foo`, it doesn't become a part of the local namespace. It remains a global variable. Since `x` is being assigned a value inside `foo`, it is a local variable and therefore enters the local namespace. The moment control exits the function, the namespace corresponding to it is deleted. +Here, the interpreter creates a local namespace for `foo` when it is called in line-8. Since `y` is only being referenced inside `foo`, it doesn't become a part of the local namespace. It remains a global variable. `x` on the other hand is being assigned a value inside `foo`, therefore is enters the local namespace and is a local variable. The moment control exits the function `foo`, the namespace corresponding to it is deleted. -Whenever the interpreter comes across a name in a function it sticks to the following protocol: +Whenever the interpreter comes across a name inside the body of a function, it sticks to the following protocol: -- First peep into the local namespace created for that function call to see if the name is present in it. If it is present, then go ahead and use the value that this variable points to in the local namespace. -- If it is not present, then look at the global namespace. If it is present in the global namespace, then use the value corresponding to this name. -- If it is not present in the global namespace, then look into the `built-in` namespace. We will come back to the `built-in` namespace right at the end. -- If it is not present in any of these namespaces, then raise a `NameError`. - -The following image captures this idea. The `built-in` namespace has been ignored for now. Refer to the last section to get the complete image. +- First it peeps into the local namespace created for that function call to see if the name is present there. If it is present, then it goes ahead and uses the value that the name points to in the local namespace. +- If it is not present in the local namespace, then the interpreter moves to the global namespace. If it is present in the global namespace, the corresponding value found there is used for the name. +- If it is not found in the global namespace either, then it looks into the `built-in` namespace (We'll come back to the `built-in` namespace right at the end). +- Finally if the interpreter could not find the name in any of these namespaces, then it raises a `NameError`. +The following image captures this idea. We'll ignore the `built-in` namespace for now. ![](../assets/images/img-024.png) With this context, let us revisit the problem that we looked at the end of the first section: -```python +```python linenums="1" def foo(): x = 10 print(f'x inside foo = {x}') @@ -243,21 +254,28 @@ foo() print(f'x outside foo = {x}') ``` -When the function is called at line-6, the interpreter creates a local namespace for `foo`. At line-2, `x` becomes a part of this namespace. When `x` is referenced at line-3, the interpreter first looks at the local namespace for `foo`. Since `x` is present there, it is going to use the value corresponding to it - in this case `10`. Once control exits the function, the local namespace corresponding to it is deleted. At line-7, the interpreter will replace the name `x` with the value `100` which is present in the global namespace. +We were getting the following output: + +``` +x inside foo = 10 +x outside foo = 100 +``` + +When the function is called at line-6, the interpreter creates a local namespace for `foo`. At line-2, `x` becomes a part of this namespace. When `x` is referenced at line-3, the interpreter first looks at the local namespace for `foo`. Since `x` is present there, it is going to use the value corresponding to it - in this case `10`. Once control exits the function, the local namespace corresponding to `foo` is deleted. At line-7, the interpreter will replace the name `x` with the value `100` which is present in the global namespace. -## `global` keyword +## `#!py global` keyword Let us revisit the scope rules: -> **Local**: Whenever a variable is assigned a value anywhere within a function, its scope becomes local to that function. In other words, whenever a variable appears on the left side of an assignment statement anywhere within a function, it becomes a local variable. -> -> **Global**: If a variable is only referenced inside a function and is never assigned a value inside it, it is implicitly treated as a global variable. +- **Local Scope**: Whenever a variable is assigned a value anywhere within a function, its scope becomes local to that function. It can only be accessed from within the function body. -Consider the following code: +- **Global Scope**: Variables defined outside of any function are implicitly treated to be part of the global scope, making them accessible from any part of the program. -```python +Now, consider the following code: + +```python linenums="1" def foo(): print(x) x = x + 1 @@ -266,11 +284,34 @@ x = 10 foo() ``` -When the above code is executed, we get the following error: `UnboundLocalError: local variable 'x' referenced before assignment` [[refer](https://docs.python.org/3/faq/programming.html#why-am-i-getting-an-unboundlocalerror-when-the-variable-has-a-value)]. This code violates the first rule. `x` is being assigned a value in line-3 of the function; hence it becomes a local variable. At line-2 we are trying to reference a value that is yet to be defined. Note that the assignment statement in line-5 doesn't count as the `x` there is not local to `foo`, but is a global variable. +When the above code is executed, we get the following error[^3]: -But what if we want to reuse the global variable `x` inside the function `foo`? Python provides a keyword called `global` for this purpose: +[^3]: [Example taken from python docs FAQ](https://docs.python.org/3/faq/programming.html#why-am-i-getting-an-unboundlocalerror-when-the-variable-has-a-value) -```python +```pycon +Traceback (most recent call last): + File "/home/main.py", line 6, in + foo() + File "/home/main.py", line 2, in foo + print(x) +UnboundLocalError: local variable 'x' referenced before assignment +``` + +Let's try to decode this error message: an `UnboundLocalError` is being traced back to line-2 that is part of the function `foo` - what does this mean? Basically the interpreter is trying to warn us that we are referencing a local variable at line-2 that doesn't have any value bound to it yet. The value of `x` in the global scope is not utilized because of our first rule for scopes, that is if we make an assignment to a variable inside a function, the variable becomes part of the local scope of the function thus shadowing any similarly named variable in the global scope. + +To reaffirm this point let's try running the above code without the assignment to `x` in the function body: + +```python linenums="1" +def foo(): + print(x) + +x = 10 +foo() +``` + +This will work fine and output `10` as expected. But what if we intentionally want to reuse the global variable `x` and be able to assign it new values from within the function `foo`? Python provides a keyword called `global` for this purpose: + +```python linenums="1" def foo(): global x print(f'x inside foo = {x}') @@ -290,13 +331,13 @@ x inside foo = 10 x inside foo = 11 ``` -By declaring `x` to be global inside `foo`, a new local variable `x` is not created even though it appears to the left of an assignment statement in line-4. +By declaring `x` to be global inside `foo`, we're telling the interpreter to use the value of `x` available in the global namespace instead of associating it with a new value in the local namespace of `foo`. ## Built-ins -So far we have been freely using built-in functions like `print`, `int`, `input` and so on. At some level, these are also names in Python and these also get resolved during run-time. There is a separate namespace called `builtins` where these functions are defined. +So far we have been blindly using built-in functions like `#!py print`, `#!py int`, `#!py input` and so on. At some level, these too are names in Python and these also get resolved during run-time. There is a separate namespace called `builtins` where all the built-in functions, data types and exceptions in python are defined and stored. Consider the following code: @@ -306,7 +347,7 @@ print = 1 ##### Never do something like this! ##### ``` -If the above code is executed, we don't get an error! This is somewhat surprising. But syntactically, there is nothing wrong here. But we will get into serious problems when we try to do the following: +If the above code is executed, we don't get an error! Surprising right? This is because syntactically, there is nothing wrong here. But we will get into serious problems when we try to do the following: ```python ##### Alarm! Wrong code snippet! ##### @@ -315,9 +356,9 @@ print(1) ##### Alarm! Wrong code snippet! ##### ``` -This will throw a `TypeError`. The name `print` has been hijacked and is being used as an `int` variable. How does Python allow this to happen? +This will throw a `TypeError`. The name `#!py print` has been hijacked and is being used as a variable of type `#!py int`. How does Python allow this to happen? ![](../assets/images/img-025.png) -When resolving names, the built-in namespace is the last stage in the interpreter's journey. Syntactically, nothing prevents us from using the name of a built-in function, such as `print`, as the name of a variable. But this is a very bad practice that should be avoided at any cost! +When resolving names, the built-in namespace is the last stage in the interpreter's journey. Syntactically, nothing prevents us from using the name of a built-in function, such as `#!py print`, as the name of a variable. But this is a very bad practice that should be avoided at any cost! diff --git a/docs/chapter-4/lesson-4.4.md b/docs/chapter-4/lesson-4.4.md index 3f194fa..ef4ede1 100644 --- a/docs/chapter-4/lesson-4.4.md +++ b/docs/chapter-4/lesson-4.4.md @@ -4,7 +4,7 @@ Consider the following program: -```python +```python linenums="1" def first(): second() print('first') @@ -27,28 +27,28 @@ second first ``` -We have already seen that a function can be called from inside another function. In the code snippet given above, we have a slightly complex version. Let us try to understand this visually. This method of visualization is novel and is called the **traffic-signal** method. You will see why it has been christened this way. +We have already seen that a function can be called from inside another function. The code snippet given above is a slightly complex example demonstrating this idea. Let us try to understand this visually. The method of visualization we'll employ here is novel and is called the **traffic-signal** method. You'll soon understand the motive behind naming it this way. -Consider a simple function which doesn't call any other function within its body. Most of the functions we have seen so far are like this. The call corresponding to this function could be in one of these two states: ongoing or completed. +Consider a simple function which doesn't call any other function within its body. Most of the functions we have seen so far are of this type. The call corresponding to this function could be in one of these two states: ongoing or completed. -- Ongoing if the control is inside the body of the function, executing one of its lines. -- Completed if all the lines in the body of the function have been executed and control has exited out of the function, either because a `return` statement was encountered or because the control reached the last line in the function, in which case `None` is returned by default. +- **Ongoing** if the control is inside the body of the function, executing one of its lines. +- **Completed** if all the lines in the body of the function have been executed and control has exited out of the function, either because a `#!py return` statement was encountered or because the control reached the last line in the function, in which case `#!py None` is returned by default. -A function which calls another function inside it could find itself in one of the three states: ongoing, suspended or completed. They are color coded as follows. Now you see why it is called the traffic-signal theory: +While, a function which calls another function inside it could find itself in one of three states: ongoing, suspended or completed. Let's use the below color code to represent these three states: ![](../assets/images/img-033.png) -Ongoing and completed have the same meaning. To understand the suspended state, consider the following diagrams that correspond to the code given above: +Now you see why it's called the traffic-signal method. Here, ongoing and completed have the same meanings as we've defined above. To understand the suspended state, consider the following diagrams that correspond to the code given above: ![](../assets/images/img-037.png) -Each column here is called a stack. They all represent the same stack at different instants of time, i.e., the columns here show the state of the stack at three different time instants. The horizontal arrow shows the passage of time. The vertical arrow indicates that each new function call gets added onto the top of the stack. +Each column here represents what we call a _stack_ in programming jargon. They all represent the same stack at different instants of time, i.e., the columns here show the state of the stack at three different time instants. The horizontal arrow shows the passage of time. The vertical arrow indicates that each new function call gets added onto the top of the stack — and whenever a new function is called, the previous function in the stack enters the suspended state. ![](../assets/images/img-038.png) Re-introducing the code for reference: -```python +```python linenums="1" def first(): second() print('first') @@ -63,7 +63,7 @@ def third(): first() ``` -As `third()` doesn't call any other function, it never enters the suspended state. Line-10 is the first print statement to be executed; this is why we see `third` as the first entry in the output. The job of the function `third` is done and it turns red. Now, the call transfers to the most recent suspended function - `second`. The execution of `second` resumes from the point where it got suspended; the print statement at line-7 is executed following which `second` turns red. Finally, control transfers to `first`, the print statement at line-3 is executed and `first` turns red. +As `third()` doesn't call any other function, it never enters the suspended state. The print statement in line-10 is the first to be executed; this is why we see `third` as the first entry in the output. The job of the function `third` is done and it turns red indicating it's completion. Now, the control transfers to the most recently suspended function - `second`. The execution of `second` resumes from the point where it got suspended; the print statement at line-7 is executed following which `second` turns red. Finally, control transfers to `first`, the print statement at line-3 gets executed and `first` also turns red. @@ -71,24 +71,24 @@ As `third()` doesn't call any other function, it never enters the suspended stat A recursive function is one which calls itself inside the body of the function. A typical example of recursion is the factorial function: -```python +```python linenums="1" def fact(n): if n == 0: return 1 return n * fact(n - 1) ``` -In the `fact` function given above, when the interpreter comes to line-4, it sees a recursive call to `fact`. In such a case, it suspends or temporarily halts the execution of `fact(n)` and starts executing `fact(n - 1)`. Let us take a concrete example. This is what happens when `fact(4)` is called: +In the `fact` function given above, when the interpreter comes to line-4, it sees a recursive call to `fact`. In such a case, it suspends or temporarily halts the execution of `fact(n)` and starts executing `fact(n - 1)`. Let's take a look into a concrete example. This is what happens when `fact(4)` is called: ![](../assets/images/img-034.png) -When `fact(0)` is called, there are no more recursive calls. This is because, the condition in line-2 evaluates to `True` and the value `1` is returned. This condition is called the base-case of the recursion. In the absence of a base-case, the recursion continues indefinitely and never terminates. +When `fact(0)` is called, there are no more recursive calls. This is because, the condition in line-2 evaluates to `True` and the value `1` is returned. Such a condition is called the _base-case_ of a recursive function. In the absence of a base-case, the recursion continues indefinitely and never terminates. ![](../assets/images/img-035.png) -Once the base-case kicks in, `fact(0)` is done with its duty. So, the call transfers to the most recent suspended function. On the stack, we see that this is `fact(1)`. `fact(1)` now becomes active. When it returns the value `1`, its life comes to an end, so the control transfers to the most recent suspended function, which is `fact(2)`. This goes on until we reach `fact(4)`. When `fact(4)` returns the value `24`, all calls have been completed and we are done! +Once the base-case kicks in, `fact(0)` is done with its duty. So, the control transfers to the most recent suspended function. On the stack, we see that this is `fact(1)`. `fact(1)` now becomes active. When it returns the value `1`, its life comes to an end, so the control transfers to the most recent suspended function, which is `fact(2)`. This goes on until we reach `fact(4)`. When control exits `fact(4)`, the function finally returns the value `24`; all calls have been completed and we are done! @@ -108,7 +108,7 @@ x_n = x_{n - 1} + x_{n - 2} $$ We can now compute the $n^{th}$ term of the Fibonacci series using a recursive function: -```python +```python linenums="1" def fibo(n): if n == 1 or n == 2: return 1 @@ -121,9 +121,9 @@ Now, try calling `fibo(40)`. You will notice that it takes a very long time to c This is a different representation of the recursive computation and is called a recursion tree. Notice how some function calls appear multiple times. `fibo(3)` and `fibo(1)` are being computed twice, `fibo(2)` is being computed thrice. For a larger value of `n` such as `50`, there would be even more wasteful computation. -Practically, how can we estimate the time that it takes for this program to run? One way would be to sit in front of the computer with a stopwatch in hand. But that is so un-Pythonic. Thankfully, the `time` library provides a good solution to this problem: +How can we estimate the time that it takes for this program to run? One way would be to sit in front of the computer with a stopwatch in hand. But that is so _un-Pythonic_. Thankfully, the `time` library provides a more practical solution to this problem: -```python +```python linenums="1" import time def fibo(n): @@ -137,9 +137,15 @@ end = time.time() print(f'It took approximately {round(end - start)} seconds.') ``` -In a standard Python repl, it takes almost a minute! Coming back to the problem of Fibonacci series, we see that naive recursion doesn't give us an efficient solution. We can instead look at the following iterative solution: +The output on a standard Python repl as recorded on the day of writing this lesson is: -```python +``` +It took approximately 118 seconds. +``` + +It takes almost two minutes! That's definitely a lot of wasted time. So for the problem of Fibonacci series, we see that naive recursion doesn't give us an efficient solution. A better approach here would instead be an iterative solution as follows: + +```python linenums="1" import time def fibo(n): @@ -157,7 +163,7 @@ end = time.time() print(f'It took approximately {round(end - start)} seconds.') ``` -Line-8 in the above code may be a little confusing. This is nothing but multiple assignment in the same line done simultaneously. The RHS of the assignment statement will be evaluated first, these two values will then be simultaneously assigned to their respective containers on the LHS. A better and more accurate explanation will be given in the next chapter when we discuss tuples. +Line-8 in the above code might be a bit confusing. This is nothing but a multiple assignment of two variables in the same line done simultaneously. The RHS of the assignment statement will be evaluated first, these two values will then be simultaneously assigned to their respective variable names on the LHS. A better and more accurate explanation for multiple assignment will be given in the next chapter when we discuss tuples. @@ -165,7 +171,7 @@ Line-8 in the above code may be a little confusing. This is nothing but multiple How do we compute the number of times a function is called? We can do this using a global variable: -```python +```python linenums="1" def fact(n): global count count = count + 1 @@ -178,7 +184,7 @@ fact(4) print(count) ``` -This is one of the potential uses of global variables. +This is one of the potential uses of the `#!py global` keyword. @@ -186,14 +192,33 @@ This is one of the potential uses of global variables. What happens if we have a recursive function without a base case? The simplest example of such a pathological function is: -```python +```python linenums="1" ##### Alarm! Bad code snippet! ##### def foo(): foo() + +foo() ##### Alarm! Bad code snippet! ##### ``` +When this code is run we get an error message saying: + +```pycon +Traceback (most recent call last): + File "/home/main.py", line 4, in + foo() + File "/home/main.py", line 2, in foo + foo() + File "/home/main.py", line 2, in foo + foo() + File "/home/main.py", line 2, in foo + foo() + [Previous line repeated 996 more times] +RecursionError: maximum recursion depth exceeded +``` + +This is a safeguard built into programming languages to prevent a stack overflow[^1] error - where a program tries to use more memory for a call stack than it was supposed to. The number of nested recursive calls in a stack is called the recursion depth of the stack and the limit for this depth is usually set to 1000 in most systems. If a program tries to exceed this limit, it triggers the fail safe mechanism as seen above and the program gets terminated. If you'd like to verify what the maximum recursion depth is for your system, you can run the following code: -When the above function is called with `foo()`, we get a `RecursionError` with the following message: `maximum recursion depth exceeded`. The limit is usually set to 1000 in most systems, i.e., If there are more than 1000 recursive calls, then that is going to result in this error. To verify what the limit is, you can run the following code: +[^1]: Did you know that the name [Stack Overflow](https://stackoverflow.com/) was selected from among a list of other names after a [a poll](https://blog.codinghorror.com/help-name-our-website/) held by one of the founders of the website? ```python import sys diff --git a/mkdocs.yml b/mkdocs.yml index 3d03ffb..80412c6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,4 +48,4 @@ extra_css: extra_javascript: - https://polyfill.io/v3/polyfill.min.js?features=es6 - - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js \ No newline at end of file + - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js