Functions and Classes#

Challenge: DNA to RNA#


If you’ve taken a Biology class, you know that DNA is essentially a long string comprised of 4 nucleotides:

  • Cytosine (C)

  • Thymine (T)

  • Adenine (A)

  • Guanine (G)

Example:

dna = 'ACGTAAAACGTGGTGGATTTGACGTGTTTG'

RNA is similar to DNA with one exception: all instances of Thymine (T) are replaced with Uracil (U). Our DNA from above would look like this:

rna = 'ACGUAAAACGUGGUGGAUUUGACGUGUUUG'

In the cell below, create a function called dna_to_rna that accepts a string of DNA and converts it to RNA.

dna = 'ACGTAAAACGTGGTGGATTTGACGTGTTTG'
def dna_to_rna():
    pass

Challenge: Hamming Distance#


The DNA strand 'AAAA' is similar to the strand 'AAAT' with one exception: the 4th nucleotide is different. In other words, the two strands have a hamming distance of 1, where hamming distance is the number of nucleotides that differ between two strands.

In the cell below, create a function called hamming_distance that accepts two parameters (dna1 and dna2) and calculates the hamming distance between the two strands.

NOTE: You can assume the two strands will have the same length.

def hamming_distance():
    pass
dna1 = 'ACGTAAAACGTGGTGGATTTGACGTGTTTG'
dna2 = 'ATGTAAACCTGGTGGATTTCACGTGTTTG'
hamming_distance(dna1, dna2)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[5], line 1
----> 1 hamming_distance(dna1, dna2)

TypeError: hamming_distance() takes 0 positional arguments but 2 were given

args#

*args is a parameter that allows any number of parameters to be passed to a function. The * at the beginning specifies the variable number of arguments.

def multiply(x, y):
    return x*y
multiply(2, 3)
multiply(2, 3, 4)
#use *args to pass variable length arguments
def multiply(*args):
    product = 1
    for num in args:
        product = product * num
    return product
multiply(2, 3)
multiply(2, 3, 4)

Unpacking Argument Lists#

You may find a situation where you have a collection that you want to unpack into a list. This is another situation where you can encounter the * operation.

list(range(3, 6, 2))            # normal call with separate arguments

args = [3, 6, 2]
list(range(*args))

**kwargs

Using kwargs allows unpacking of variable length dictionary like arguments.

def cheeseshop(kind, **kwargs):
    print("-- Do you have any", kind, "?")
    print("-- I'm sorry, we're all out of", kind)

    print("-" * 40)
    for kw in kwargs:
        print(kw, ":", kwargs[kw])
cheeseshop("Limburger",
           shopkeeper="Michael Palin",
           client="John Cleese",
           sketch="Cheese Shop Sketch")
cheeseshop("Limburger",
           shopkeeper="Michael Palin",
           client="John Cleese",
           sketch="Cheese Shop Sketch",
           year = 1979)

Similar to *args you can unpack dictionary items as variable keyword arguments.

data = {'shopkeeper':"Michael Palin",
           'client':"John Cleese",
           'sketch':"Cheese Shop Sketch",
           'year': 1979}
cheeseshop("Limburger",
           **data)
#putting *args and **kwargs together
#note that args and kwargs are not mandatory names
def cheeseshop(kind, *arguments, **keywords):
    print("-- Do you have any", kind, "?")
    print("-- I'm sorry, we're all out of", kind)
    for arg in arguments:
        print(arg)
    print("-" * 40)
    for kw in keywords:
        print(kw, ":", keywords[kw])
cheeseshop("Limburger", "It's very runny, sir.",
           "It's really very, VERY runny, sir.",
           shopkeeper="Michael Palin",
           client="John Cleese",
           sketch="Cheese Shop Sketch")

Exercise: Write a function, sum_everything that takes any numbers of arguments and adds them together.

Decorators#

A decorator wraps a function and adds functionality to a function based on the decorator function.

def a_decorator(f):
    def wrapper():
        print("Before function call")
        f()
        print("After function call")
    return wrapper
def howdy():
    print("Howdy!")
howdy()
@a_decorator
def howdy():
    print("Howdy!")
howdy()

Recursion#

A concept \(x\) is recursive if it is used in its own definition.

Example:

Suppose you are to write a function that takes in a list of playing card values and returns the sum of these cards. A loop may be an obvious solution, but you might also use recursion to solve this problem.

Here, you can think of it as taking the first card from the deck and giving the rest of the deck to someone else to add them. This person continues this pattern, taking the first card and passing the remaining deck elsewhere.

def add_cards(deck):
    smaller_deck = deck[1:]
    partial_total = add_cards(smaller_deck)
    extra_card = deck[0]
    return extra_card + partial_total

The function above would continue on forever, thus we need a way to stop the handing over of the summing. Here, we can understand the stopping place as when the list is empty – deck == 0. This is our base case.

def add_cards(deck):
    #base case
    if deck == []:
        return 0
    #recursion
    else:
        smaller_deck = deck[1:]
        partial_total = add_cards(smaller_deck)
        extra_card = deck[0]
        return extra_card + partial_total

print(add_cards([5, 2, 7, 3]))

Exercise: Define a function sum_from_m_to_n which returns the sum of all values from m to n. Write this function recursively.

To help you get started:

  1. What is a smaller version of the problem? Summing from m+1 to n

  2. What do we do with the solved version of the smaller problem? Add m to it

  3. What is the smallest version of the problem (base case)? When m equals n

def sum_from_m_to_n(m, n):
    pass
sum_from_m_to_n(1, 5)

Use Case: Memoization#

In mathematics, the Fibonacci numbers, commonly denoted Fn, form a sequence, called the Fibonacci sequence, such that each number is the sum of the two preceding ones, starting from 0 and 1. That is

\[\displaystyle F_{0}=0,\quad F_{1}=1\]

and

\[F_{n}=F_{n-1}+F_{n-2}\]

for n > 1.

The beginning of the sequence is thus:

\[ 0,\;1,\;1,\;2,\;3,\;5,\;8,\;13,\;21,\;34,\;55,\;89,\;144,\;\ldots \]
def fib(n):
    if n <= 1:
        return n
    else:
        return fib(n-1) + fib(n - 2)
fib(10)
fib(100)

Visualizing the function calls for fib(5):

from functools import lru_cache
@lru_cache
def fib(n):
    if n <= 1:
        return n
    else:
        return fib(n-1) + fib(n - 2)
fib(100)

Object Oriented Programming with Python#

https://docs.python.org/3/tutorial/classes.html

type('Lenny')
'Lenny'.__len__()

Example of Basic Class#

One finds similar hierarchical organization in biology with trees of life.

Making an Account Class#

  • Define class

  • Create instance

  • Assign attributes

class Account:
    pass
lennys_account = Account()
type(lennys_account)
jacobs_account = Account()
lennys_account.balance = 100
lennys_account.balance
jacobs_account.balance

Defining Methods on the Account#

  • Define class methods

  • Use __init__ as constructor in class

class Account:
    def __init__(self, name, balance):
        self.name = name
        self.balance = balance

    def withdraw(self, amount):
        self.balance -= amount
        
lennys_account = Account('Lenny', 100)
lennys_account.balance
lennys_account.withdraw(20)
lennys_account.balance
hardys_account = Account('Hardy', 1000)
hardys_account.
Constructor Method

__init__(): a special type of method that gives access to the attributes of the class.

Exercise:

We would like to avoid the

self.amount = self.amount - howmuch

sytax and instead refactor this method to increment the attribute inside the withdraw method. Add two more methods to the class, deposit and statement. The deposit method should allow to make deposits similar to the withdrawl, and the statement method should print who the owner is and what amount of money is in the account.

class Account:
    def __init__(self, name, balance):
        pass
        

    def withdraw(self, amount):
        pass

    def deposit(self, amount):
        pass

    def statement(self):
        pass
lennys_account = Account('Lenny', 100)
lennys_account.statement()
lennys_account.deposit(1_000_000)
lennys_account.statement()

Class variables#

It is important to understand the consequence of using general variables rather than attaching the variable to each instance of a class.

class Dog:

    tricks = []             # mistaken use of a class variable

    def __init__(self, name):
        self.name = name

    def add_trick(self, trick):
        self.tricks.append(trick)
d = Dog('Fido')
e = Dog('Buddy')
d.add_trick('roll over')
e.add_trick('play dead')
d.tricks 
class Dog:

    def __init__(self, name):
        self.name = name
        self.tricks = []    # creates a new empty list for each dog

    def add_trick(self, trick):
        self.tricks.append(trick)
d = Dog('Fido')
e = Dog('Buddy')
d.add_trick('roll over')
e.add_trick('play dead')
d.tricks 

Inheritence#

  • Create class that inherits from parent class

  • Override methods of parent class

class SavingsAccount(Account):
    def __init__(self, name, balance, rate = 0.05):
        super().__init__(name = name, balance = balance)
        self.rate = rate

    def add_interest(self):
        self.balance *= (1 + self.rate)
lennys_account = SavingsAccount('Lenny', 100, .07)
lennys_account.balance
lennys_account.add_interest()
lennys_account.balance
print(lennys_account)
lennys_account

The __str__ and __repr__ methods#

  • Define string representations of class

class SavingsAccount(Account):
    def __init__(self, name, balance, rate = 0.05):
        super().__init__(name = name, balance = balance)
        self.rate = rate

    def add_interest(self):
        self.balance *= (1 + self.rate)
lennys_account = SavingsAccount('Lenny', 100)
lennys_account
print(lennys_account)
class SavingsAccount(Account):
    def __init__(self, name, balance, rate = 0.05):
        super().__init__(name = name, balance = balance)
        self.rate = rate

    def add_interest(self):
        self.balance *= (1 + self.rate)

    def __str__(self):
        return f'This is a savings account belonging to {self.name}'

    def __repr__(self):
        return f'{self.name} owns this account'
lennys_account = SavingsAccount('Lenny', 100)
lennys_account
print(lennys_account)

Exercise: Create a Band class. A Band should have the following properties and methods:

  • name: String

  • members: a list of Strings, defaults to an empty list

  • introduce_lineup(): a method that prints all of the strings in members

  • add_member(new_member): a method that adds a new member to the members then invokes introduce_lineup

  • kick_out(old_member): a method that removes the given member from the members list. If the members list is empty, add a disbanded property equal to True. Otherwise, invoke introduce_lineup.

Exercise:

Create the following subclasses that extend the Band class functionality:

  • Punk bands have a street_cred property set to True and earnings property set to 0

  • sell_out(amount): a method that changes street_cred to False and earnings increase by amount

  • destroy_hotel_room(): a method that changes street_cred to True and earnings decrease by 5000

  • Jazz bands have a songbook property set to an empty list

  • add_song(song_title): a method that adds the given song_title to the songbook

  • solo(): a method that prints a message saying “____ is cooking!” fill in the blank with the first string in the members list, then move that member to the end of the list.

class Punk:
    pass

class Jazz:
    pass