---
title: "Control flow,<br/>list comprehensions,<br/>and functions"
subtitle: "Lecture 03"
author: "Dr. Colin Rundel"
footer: "Sta 663 - Spring 2023"
format:
  revealjs:
    theme: slides.scss
    transition: fade
    slide-number: true
    self-contained: true
execute:
  echo: true
---

```{r setup, message=FALSE, warning=FALSE, include=FALSE}
options(
  width=80
)

local({
  hook_old <- knitr::knit_hooks$get("error")  # save the old hook
  knitr::knit_hooks$set(error = function(x, options) {
    x = sub("## \n## Detailed traceback:\n.*$", "", x)
    x = sub("Error in py_call_impl\\(.*?\\)\\: ", "", x)
    hook_old(x, options)
  })
})
```


# Control Flow

## Conditionals

Python supports typical if / else style conditional expressions,

:::: {.columns}
::: {.column width='50%'}
```{python}
x = 42

if x < 0:
    print("X is negative")
elif x > 0:
    print("X is positive")
else:
    print("X is zero")
```
:::

::: {.column width='50%'}
```{python}
x = 0

if x < 0:
    print("X is negative")
elif x > 0:
    print("X is positive")
else:
    print("X is zero")
```
:::
::::

## Significant whitespace

This is a fairly unique feature of Python - expressions are grouped together via *indenting*. This is relevant for control flow (`if`, `for`, `while`, etc.) as well as function and class definitions and many other aspects of the language.

Indents should be 2 or more spaces (4 is generally preferred based on [PEP 8](https://www.python.org/dev/peps/pep-0008/)) or tab character(s) - generally your IDE will handle this for you.

. . .

<br/>

If there are not multiple expressions then indenting is optional, e.g.

```{python}
if x == 0: print("X is zero")
```


## Conditional scope

Conditional expressions do not have their own scope, so variables defined within will be accessible / modified outside of the conditional. 

This is also true for other control flow constructs (e.g. `for`, `while`, etc.)

```{python}
s = 0
s

if True:
    s = 3

s
```

## `while` loops

will repeat until the provided condition evaluates to `False`,

```{python}
i = 17
seq = [i]

while i != 1:
    if i % 2 == 0:
        i /= 2
    else:
        i = 3*i + 1
        
    seq.append(i)

seq
```

## `for` loops

iterate over the elements of a *sequence*,

:::: {.columns}
::: {.column width='50%'}
```{python}
for w in ["Hello", "world!"]:
    print(w, ":", len(w))
```

<br/>

```{python}
sum = 0
for v in (1,2,3,4):
    sum += v
sum
```
:::

::: {.column width='50%' .fragment}
```{python}
res = []
for c in "abc123def567":
    if (c.isnumeric()):
        res.append(int(c))
res
```

<br/>

```{python}
res = []
for i in range(0,10):
    res += [i]
res
```
:::
::::

## `break` and `continue`

allow for either an early loop exit or a step to the next iteration respectively,


:::: {.columns}
::: {.column width='50%'}
```{python}
for i in range(1,10):
    if i % 3 == 0:
        continue
    
    print(i, end=" ")
```
:::

::: {.column width='50%'}
```{python}
for i in range(1,10):
    if i % 3 == 0:
        break
    
    print(i, end=" ")
```
:::
::::

## loops and `else`

Both `for` and `while` loops can also include an `else` clauses which execute when the loop is completes by either fully iterating (`for`) or meetings the `while` condition, i.e. when `break` is not used.

::: {.small}
```{python}
for n in range(2, 10):
    for x in range(2, n):
        if n % x == 0:
            print(n, 'equals', x, '*', n//x)
            break
    else:
        print(n, 'is a prime number')
```
:::

::: {.aside}
Example based on Python Tutorial - Section 4.4
:::


## `pass`

is a placeholder expression that does nothing, it can be used when an expression is needed syntactically.

```{python}
x = -3

if x < 0:
    pass
elif x % 2 == 0:
    print("x is even")
elif x % 2 == 1:
    print("x is odd")
```


# List comprehensions

## Basics

List comprehensions provides a concise syntax for generating lists (or other sequences) via iteration over another list (or sequences).

:::: {.columns}
::: {.column width='50%'}
```{python}
res = []
for x in range(10):
    res.append(x**2)
res
```
:::

::: {.column width='50%'}
```{python}
[x**2 for x in range(10)]
```
:::
::::

. . .

Since it uses the for loop syntax, any sequence / iterable object is fair game:

```{python}
[x**2 for x in [1,2,3]]

[x**2 for x in (1,2,3)]

[c.lower() for c in "Hello World!"]
```


## Using `if`

List comprehensions can include a conditional clause(s) to filter the input list / object,

```{python}
[x**2 for x in range(10) if x % 2 == 0]

[x**2 for x in range(10) if x % 2 == 1]
```

. . .

The comprehension can include multiple `if` statements (which are combined via `and`)

```{python}
[x**2 for x in range(10) if x % 2 == 0 if x % 3 ==0]

[x**2 for x in range(10) if x % 2 == 0 and x % 3 ==0]
```

## Multiple `for` keywords

Similarly, the comprehension can also contain multiple `for` statements which is the equivalent of nested `for` loops,

```{python}
res = []
for x in range(3):
    for y in range(3):
        res.append((x,y))
res
```

. . .

<br/>

```{python}
[(x, y) for x in range(3) for y in range(3)]
```


## `zip`

Is a useful function for "joining" the elements of multiple sequences (so they can be jointly iterated over),

::: {.medium}
```{python}
x = [1,2,3]
y = [3,2,1]
z = zip(x, y)
z
list(z)
```
:::

. . .

::: {.medium}
```{python}
[a**b for a,b in zip(x,y)]
[b**a for a,b in zip(x,y)]
```
:::

::: {.aside}
The zip object is an example of a [generator](https://wiki.python.org/moin/Generators) which we will talk more about later on.
:::


## `zip` and length mismatches

The length of the shortest sequence will be used, additional elements will be ignored (silently)

```{python}
x = [1,2,3,4]
y = range(3)
z = "ABCDE"

list(zip(x,y))
list(zip(x,z))

list(zip(x,y,z))
```



## Exercise 1

Using list comprehensions, complete the following tasks:

* Create a list containing tuples of x and y coordinates of all points of a regular grid for $x \in [0, 10]$ and $y \in [0, 10]$.

* Count the number of points where $y > x$.

* Count the number of points $x$ or $y$ is prime.


# Functions

## Basic functions

Functions are defined using `def`, arguments can be defined with out without default values.

```{python}
def f(x, y=2, z=3):
    print(f"x={x}, y={y}, z={z}")
```

:::: {.columns}
::: {.column width='50%'}
```{python}
f(1)
f(1,z=-1)
f("abc", y=True)
```
:::

::: {.column width='50%'}
```{python error=TRUE}
f(z=-1, x=0)
f()
```
:::
::::

::: {.aside}
Arguments with defaults *must* must follow the argument(s) without defaults
:::


## `return` statements

Functions must explicitly include a `return` statement to return a value.

:::: {.columns .small}
::: {.column width='50%'}
```{python}
def f(x):
    x**2

f(2)
type(f(2))
```
:::

::: {.column width='50%'}
```{python}
def g(x):
    return x**2
  
g(2)
type(g(2))
```
:::
::::

. . .

Functions can contain multiple `return` statements

::: {.small}
```{python}
def is_odd(x):
    if x % 2 == 0: return False
    else:          return True
    
is_odd(2)
is_odd(3)
```
:::


## Multiple return values

Functions can return multiple values using a tuple or list,

::: {.small}
```{python}
def f():
    return (1,2,3)
f()
```

```{python}
def g():
    return [1,2,3]
g()
```
:::

. . .

If multiple values are present and not in a sequence, then it will default to a tuple,

:::: {.columns .small}
::: {.column width='50%'}
```{python}
def h():
    return 1,2,3

h()
```
:::

::: {.column width='50%'}
```{python}
def i():
    return 1, [2, 3]

i()
```
:::
::::


## Docstrings

A common practice in Python is to document functions (and other objects) using a doc string - this is a short concise summary of the objects purpose. Docstrings are specified by supplying a string as the very line in the function definition.

:::: {.columns .small}
::: {.column width='50%'}
```{python}
def f():
    "Hello, I am the function f() \
and I don't do anything"
    
    pass

f.__doc__
```
:::

::: {.column width='50%' .fragment}
```{python}
def g():
    """This function also 
does absolutely nothing.
"""
    
    pass

g.__doc__
```
:::
::::


## Using docstrings

::: {.small}
```{python}
print(max.__doc__)
print(str.__doc__)
print("".lower.__doc__)
```
:::






## Argument order

In Python the argument order matters - positional arguments must always come before keyword arguments.

::: {.small}
```{python}
def f(x, y, z):
    print(f"x={x}, y={y}, z={z}")
```
:::

. . .

:::: {.columns .small}
::: {.column width='50%'}
```{python error=TRUE}
f(1,2,3)
f(x=1,y=2,z=3)
f(1,y=2,z=3)
f(y=2,x=1,z=3)
```
:::

::: {.column width='50%' .fragment}
```{python error=TRUE}
f(x=1,y=2,3)
```
```{python error=TRUE}
f(x=1,2,z=3)
```
```{python error=TRUE}
f(1,2,z=3)
```
:::
::::



## Positional vs keyword arguments

::: {.small}
```
def f(pos1, pos2, /, pos_or_kwd, *, kwd1, kwd2):
      -----------    ----------     ----------
        |             |                  |
        |        Positional or keyword   |
        |                                - Keyword only
         -- Positional only
```
:::

::: {.aside}
Based on Python tutorial [Sec 4.8.3](https://docs.python.org/3/tutorial/controlflow.html#special-parameters)
:::

. . .


For the following function `x` can only be passed by position and `z` only by name

::: {.small}
```{python}
def f(x, /, y, *, z):
    print(f"x={x}, y={y}, z={z}")
```
:::

. . .

:::: {.columns .small}
::: {.column width='50%'}
```{python}
f(1,1,z=1)
```
:::

::: {.column width='50%'}
```{python}
f(1,y=1,z=1)
```
:::
::::

. . .

::: {.small}
```{python error=TRUE}
f(1,1,1)
f(x=1,y=1,z=1)
```
:::


## Variadic arguments

If the number of arguments is unknown / variable it is possible to define variadic functions using `*` or `**`. The former is for unnamed arguments which will be treated as a `tuple`, the latter is for named arguments which will be treated as a `dict`.

```{python}
def paste(*x, sep=" "):
    return sep.join(x)
```

. . .

```{python}
paste("A")
paste("A","B","C")
paste("1","2","3",sep=",")
```

## Anonymous functions

are defined using the `lambda` keyword, they are intended to be used for very short functions (syntactically limited to a single expression, and do not need a return statement)

:::: {.columns}
::: {.column width='50%'}
```{python}
def f(x,y):
    return x**2 + y**2

f(2,3)
type(f)
```
:::

::: {.column width='50%'}
```{python}
g = lambda x, y: x**2 + y**2


g(2,3)
type(g)
```
:::
::::


## Function annotations (type hinting)

Python nows supports syntax for providing metadata around the expected type of arguments and the return value of a function. 


```{python}
def f(x: str, y: str, z: str) -> str:
    return x + y + z
```

. . .

These annotations are stored in the `__annotations__` attribute

```{python}
f.__annotations__
```

. . .

But doesn't actually do anything at runtime: 

```{python}
f("A","B","C")
```

```{python}
f(1,2,3)
```

## Exercise 2

1. Write a function, `kg_to_lb`, that converts a list of weights in kilograms to a list of weights in pounds (there a 1 kg = 2.20462 lbs). Include a doc string and function annotations.

<br/>

2. Write a second function, `total_lb`, that calculates the total weight in pounds of an order, the input arguments should be a list of item weights in kilograms and a list of the number of each item ordered.