---
title: "Control flow, list<br/>comprehensions, and functions"
subtitle: "Lecture 03"
author: "Dr. Colin Rundel"
footer: "Sta 663 - Spring 2025"
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

repeats until the condition expression 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

iterates over the elements of a *sequence*,

:::: {.columns .small}
::: {.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 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` clause 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 sequence).

:::: {.columns .small}
::: {.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:


::: {.small}
```{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 / sequence,

```{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 (equivalent to using `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 equivalent to 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),

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

. . .

::: {.small}
```{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 in a bit
:::


## `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"
```

```{python}
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.


```{r}
#| echo: false
countdown::countdown(5)
```

# Functions

## Basic functions

Functions are defined using `def`, arguments can optionally have a default values. (Arguments with defaults *must* must follow the arguments without defaults)

```{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()
```
:::
::::


## `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 .small}
::: {.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. 

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

. . .

These annotations are stored in the `__annotations__` attribute

::: {.small}
```{python}
f.__annotations__
```
:::

. . .

But doesn't actually do anything at runtime

::: {.small}
```{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.

```{r}
#| echo: false
countdown::countdown(5)
```


# Classes

## Basic syntax

These are the basic component of Python's object oriented system - we've been using them regularly all over the place and will now look at how they are defined and used.

:::: {.columns .xsmall}
::: {.column width='50%'}
```{python}
class rect:
  """An object representing a rectangle"""
  
  # Attributes
  p1 = (0,0)
  p2 = (1,2)
  
  # Methods
  def area(self):
    return ((self.p1[0] - self.p2[0]) *
            (self.p1[1] - self.p2[1]))
           
  def set_p1(self, p1):
    self.p1 = p1
  
  def set_p2(self, p2):
    self.p2 = p2
```
:::

::: {.column width='50%' .fragment}
```{python}
x = rect()
```

```{python}
x.area()

x.set_p2((1,1))
x.area()

x.p1
x.p2

x.p2 = (0,0)
x.area()
```
:::
::::


## Instantiation (constructors)

When instantiating a class object (e.g. `rect()`) we invoke the `__init__()` method if it is present in the classes' definition.

:::: {.columns .xsmall}
::: {.column width='50%'}
```{python}
class rect:
  """An object representing a rectangle"""
  
  # Constructor
  def __init__(self, p1 = (0,0), p2 = (1,1)):
    self.p1 = p1
    self.p2 = p2
  
  # Methods
  def area(self):
    return ((self.p1[0] - self.p2[0]) *
            (self.p1[1] - self.p2[1]))
           
  def set_p1(self, p1):
    self.p1 = p1
  
  def set_p2(self, p2):
    self.p2 = p2
```
:::

::: {.column width='50%' .fragment}
```{python}
x = rect()
x.area()

y = rect((0,0), (3,3))
y.area()

z = rect((-1,-1))
z.p1
z.p2
```
:::
::::


## Method chaining

We've seen a number of objects (i.e. Pandas DataFrames) that allow for method chaining to construct a pipeline of operations. We can achieve the same by having our class methods return itself via `self`.

:::: {.columns .small}
::: {.column width='50%'}
```{python}
class rect:
  """An object representing a rectangle"""
  
  # Constructor
  def __init__(self, p1 = (0,0), p2 = (1,1)):
    self.p1 = p1
    self.p2 = p2
  
  # Methods
  def area(self):
    return ((self.p1[0] - self.p2[0]) *
            (self.p1[1] - self.p2[1]))
           
  def set_p1(self, p1):
    self.p1 = p1
    return self
  
  def set_p2(self, p2):
    self.p2 = p2
    return self
```
:::

::: {.column width='50%' .fragment}
```{python}
rect().area()

rect().set_p1((-1,-1)).area()

( rect()
  .set_p1((-1,-1))
  .set_p2((2,2))
  .area()
)
```
:::
::::


## Class object string formating

All class objects have a default print method / string conversion method, but the default behavior is not very useful,

:::: {.columns .small}
::: {.column width='50%'}
```{python}
print(rect())
```
:::

::: {.column width='50%'}
```{python}
str(rect())
```
:::
::::

. . .

Both of the above are handled by the `__str__()` method which is implicitly created for our class - we can override this,

::: {.small}
```{python}
def rect_str(self):
  return f"Rect[{self.p1}, {self.p2}] => area={self.area()}"

rect.__str__ = rect_str
```
:::

. . .

::: {.small}
```{python}
rect()
print(rect())
str(rect())
```
:::

## Class representation

There is another special method which is responsible for the printing the object (see `rect()` above) called `__repr__()` which is responsible for printing the classes representation. If possible this is meant to be a valid Python expression capable of recreating the object.

::: {.small}
```{python}
def rect_repr(self):
  return f"rect({self.p1}, {self.p2})"

rect.__repr__ = rect_repr
```
:::

. . .

::: {.small}
```{python}
rect()
repr(rect())
```
:::


## Inheritance

Part of the object oriented system is that classes can inherit from other classes, meaning they gain access to all of their parents attributes and methods. We will not go too in depth on this topic beyond showing the basic functionality.

::: {.small}
```{python}
class square(rect):
    pass
```
:::

. . .

::: {.small}
```{python}
square()
square().area()
square().set_p1((-1,-1)).area()
```
:::


## Overriding methods

:::: {.columns .xsmall}
::: {.column width='50%'}
```{python}
class square(rect):
    def __init__(self, p1=(0,0), l=1):
      assert isinstance(l, (float, int)), \
             "l must be a numnber"
      
      p2 = (p1[0]+l, p1[1]+l)
      
      self.l  = l
      super().__init__(p1, p2)
    
    def set_p1(self, p1):
      self.p1 = p1
      self.p2 = (self.p1[0]+self.l, self.p1[1]+self.l)
      return self
    
    def set_p2(self, p2):
      raise RuntimeError("Squares take l not p2")
    
    def set_l(self, l):
      assert isinstance(l, (float, int)), \
             "l must be a numnber"
      
      self.l  = l
      self.p2 = (self.p1[0]+l, self.p1[1]+l)
      return self
    
    def __repr__(self):
      return f"square({self.p1}, {self.l})"
```
:::

::: {.column width='50%' .fragment}
```{python error=TRUE}
square()
square().area()
square().set_p1((-1,-1)).area()
square().set_l(2).area()

square((0,0), (1,1))
square().set_l((0,0))
square().set_p2((0,0))
```
:::
::::


## Class attributes

We can examine all of a classes' methods and attributes using `dir()`,

```{python}
[
  dir(rect)
]
```

. . .

<br/>

::: {.center}
Where did `p1` and `p2` go?
:::

##

```{python}
[
  dir(rect())
]
```
