Pythonic Programming

CIS1902 Python Programming

Updates

  • Assignment 0 has been released! Due Tuesday 9/17 at 11:59PM.
  • Expanded late day policy: any additional days an assignment is late is 25% off, up to 2 days.
    • This means you can submit an assignment up to max 4 days late for 50% credit.
  • I will have office hours on Wendesdays 1-2pm over Zoom. Check the website for the link.

Agenda

  1. Magic Methods
  2. Functions
  3. Comprehensions
  4. Iterators/Generators
  5. Lambdas
  6. Decorators

Magic Methods

  • Previously, we saw that we can override the __init__() function to create a custom constructor
  • __init__() is what is known as a magic method, i.e. methods that add "magic" to you classes
    • The term dunder is also sometimes used, short for double underscore
  • These magic methods allow you to customize behavior of your classes/objects!
  1. class Person:
  2. def __init__(self, name):
  3. self.name = name
  4. def say_hello(self):
  5. print(f"Hi my name is {self.name}!")

Magic Methods

  • The main use case of magic methods is to enable custom objects to behave like built-in types
  • There are ways that we can directly compare custom objects and even do arithmetic with them
  • We can even make it so that our objects can behave like sequences!
  1. # you might be familiar with something like
  2. if myObject.equals(otherObject):
  3. doSomething()
  4. # but using magic methods we can do
  5. if myObject == otherObject:
  6. doSomething()
  1. # using magic methods we can
  2. # enable this notation
  3. myObject[1]

Magic Methods

python magic methods

Magic Methods Code-Along

Let's see the power of magic methods by implementing a class to represent Cartesian coordinates

Functions

Positional Arguments & Keyword Arguments

  • We can allow for a variable number of arguments to our function using positional arguments or keywork arguments
  • Positional arguments can be thought of as a tuple
  • Keyword arguments can be thought of as a dictionary with keys of type string
    • We can also use these to set defaults for parameters!

Functions

Positional Arguments

  1. def print_lines(*args):
  2. for line in args:
  3. print(line)
  4. # I can "unpack" a list as arguments
  5. my_lines = [1, 2, 3, 4]
  6. print_lines(*my_lines)
  7. # Unpacking is not constrained to functions with positional arguments
  8. def f(x, y):
  9. print(x + y)
  10. f(*[1,2])

Functions

Keyword Arguments

Consider the following divide function. The arguments are ambiguous! We can solve this with keyword arguments.

  1. def divide(numerator, denominator):
  2. return numerator/denominator
  3. # divide(10,5) and divide(5,10) could be interpreted in different ways
  4. # to someone reading our code.
  5. divide(numerator=10, denominator=5) # no longer ambiguous

Functions

Keyword Arguments

  1. def print_kwargs(**kwargs):
  2. for key in kwargs:
  3. print(key, kwargs[key])
  4. # I can similarly "unpack" a dictionary as arguments
  5. my_kwargs = {"a": 1, "b": 2}
  6. print_kwargs(*my_kwargs)

Comprehensions

Comprehensions are ways to filter and manipulate sequences elegantly, usually within a single line!

The general syntax for a comprehension is

  1. (expression) for (value) in (sequence) (if condition)

Comprehensions

  1. # list of squares, naive approach
  2. numbers = [1,2,3,4,5]
  3. squares = list()
  4. for n in numbers:
  5. squares.append(n ** 2)

Comprehensions

  1. # list of squares, naive approach
  2. numbers = [1,2,3,4,5]
  3. squares = list()
  4. for n in numbers:
  5. squares.append(n ** 2)
  1. numbers = [1,2,3,4,5]
  2. squares = [n ** 2 for n in numbers]
  3. # one-liner
  4. squares = [n ** 2 for n in range(1,6)]
  5. odd_squares = [n ** 2 for n in range(1,6) if n % 2 == 1]

Comprehensions

  1. scores = ['david:90', 'ben:80', 'justin:100']
  2. grades = dict()
  3. for studentscore in scores:
  4. split = studentscore.split(':')
  5. name = split[0]
  6. score = split[1]
  7. grades[name] = score

Comprehensions

  1. scores = ['david:90', 'ben:80', 'justin:100']
  2. grades = dict()
  3. for studentscore in scores:
  4. split = studentscore.split(':')
  5. name = split[0]
  6. score = split[1]
  7. grades[name] = score
  1. grades = {s.split(':')[0]: s.split(':')[1] for s in scores}

Iterators

  • We just saw how comprehensions can reduce the amount of code we write and make things more succinct, but how do they work under the hood? What kinds of things can we iterate over?
  • Any object that implements the magic methods __iter__() and __next__() is considered iterable
    • This is what is known as a protocol in Python, which is very similar to an interface in Java

Iterators

  1. my_list = list()
  2. for item in my_list:
  3. doSomething(item)
  4. done()
  1. my_list = list()
  2. # calls __iter__
  3. iterator = iter(my_list)
  4. while True:
  5. try:
  6. # calls __next__
  7. item = next(iterator)
  8. except StopIteration:
  9. # a `StopIteration` error is
  10. # raised when there is nothing
  11. # left to iterate over
  12. break
  13. doSomething(item)
  14. done()

Iterators

  1. import random
  2. class RandomBinaryIterable:
  3. def __iter__(self):
  4. return self
  5. def __next__(self):
  6. roll = random.choice([1, 0, "stop"])
  7. if roll == "stop":
  8. raise StopIteration
  9. return roll
  1. In [3]: list(RandomBinaryIterable())
  2. Out[3]: []
  3. In [4]: list(RandomBinaryIterable())
  4. Out[4]: [1, 0, 0]
  5. In [5]: list(RandomBinaryIterable())
  6. Out[5]: [1, 1, 0, 1, 0]
  7. In [6]: list(RandomBinaryIterable())
  8. Out[6]: [0, 1, 0, 0, 1, 0]

Generators

  • Generators are used to lazily build or process a large sequence, typically used to build an iterator
    • These are highly useful in scenarios where you need to process things that cannot fit into memory
  • Using the yield keyword allows us to easily build a lazy iterator
  • It also saves us from writing boilerplate fairly easily

Generators

  1. def first_n(n):
  2. i = 0
  3. nums = list()
  4. while i < n:
  5. nums.append(i)
  6. i += 1
  7. return nums
  8. my_huge_list = first_n(1000000000)
  1. def first_n(n):
  2. i = 0
  3. while i < n:
  4. yield i
  5. i += 1

Generators

  1. import random
  2. class RandomBinaryIterable:
  3. def __iter__(self):
  4. return self
  5. def __next__(self):
  6. roll = random.choice([1, 0, "stop"])
  7. if roll == "stop":
  8. raise StopIteration
  9. return roll
  1. import random
  2. def random_binary():
  3. while True:
  4. roll = random.choice([1, 0, "stop"])
  5. if roll == "stop":
  6. break
  7. yield roll

Generators

  • list() can be passed a generator, which can save some code
  • Be aware of when you are working with generators and need to manage memory
  1. # these are equivalent
  2. list(first_n(100))
  3. [for x in first_n(100)]
  4. # but not this!
  5. [first_n(100)]
  1. # returns a generator
  2. first_n(100)
  3. # instantiates the entire list!
  4. list(first_n(100))

Lambdas

  • Lambdas are syntatic sugar to help define simple functions
    • These are similar to anonymous functions in other languages
  • Importantly, we saw that Python allows us to use functions as arguments, which is where the power of lambdas shines
    • You might remember map and filter from CIS1200, which Python also has

Lambdas

The general syntax for a lambda is

  1. lambda <arguments>: <expression>

which by default returns the expression.

  1. # simple increment lambda
  2. lambda x: x + 1
  3. # we can actually call it directly!
  4. (lambda x: x + 1)(1)
  5. # or use it to define a variable
  6. adder = lambda x: x + 1
  7. adder(1)
  8. # lambdas can have multiple arguments
  9. add = lambda x, y: x + y
  10. add(1,2)

Lambdas

Lambdas can help us succinctly write complex logic

  1. names = [("David", "Cao"), ("Ben", "Smith"), ("Justin", "Adam")]
  2. # recall we can sort lists like so
  3. # but this only sorts by the first element
  4. names.sort()
  5. [('Ben', 'Smith'), ('David', 'Cao'), ('Justin', 'Adam')]
  6. # we can use lambdas to customize sorting, e.g. by last name
  7. names.sort(key=lambda x: x[1])
  8. [('Justin', 'Adam'), ('David', 'Cao'), ('Ben', 'Smith')]

Lambdas

  1. # This is equivalent to what we did before, but look
  2. # how much code we saved using lambdas!
  3. names = [("David", "Cao"), ("Ben", "Smith"), ("Justin", "Adam")]
  4. def last_name(x):
  5. return x[1]
  6. names.sort(key=last_name)

However, they may be scenarios where a lambda does not suffice, e.g. complex logic needed that exceeds a single expression.

Lambdas

Lambdas can also be used in functional ways!

  1. numbers = [1,2,3,4,5,6]
  2. squares = map(lambda x: x ** 2, numbers)
  3. odds = filter(lambda x: x % 2 == 1, numbers)
  4. odd_squares = map(lambda x: x ** 2, filter(lambda x: x % 2 == 1, numbers))

Note: map and filter actually return generators.

Decorators

  • Decorators are a fairly complex topic, but we will introduce the basics and revisit them when we begin discussing Django
  • We saw that functions are able to be passed as parameters
  • Decorators help add general flow control around functions

Decorators

  1. def my_function():
  2. print("doing some stuff...")
  3. def check(func):
  4. if True:
  5. print("check is good!")
  6. return func
  7. else:
  8. raise Exception()
  9. wrapped = check(my_function)
  1. def check(func):
  2. if True:
  3. print("check is good!")
  4. return func
  5. else:
  6. raise Exception()
  7. @check
  8. def my_function():
  9. print("doing some stuff...")

Decorators

  • While the decorator we showed looks quite boring, they are useful in many paradigms
  • Consider having many functions that all need to check if a user has some specific permission before being able to run