CS5001 / CS5003:
Intensive Foundations of Computer Science

 

Lecture 8: Introduction to Classes and OOP

Lecture 8: Midterm Review

  • Let's first talk about the midterm exam: great job overall!
  • The questions were meant to be challenging but not tricky.
  • If you still have questions about the midterm, please email me to chat.
  • I want to look at a couple of problems that seemed to be most difficult.

Lecture 8: Midterm Review

  • Question 1c
  def mystery_c(s1, s2):
    """
    TODO: Explain what the function does
    :param s1: a string
    :param s2: a string
    :return: None
    Note: For the doctest, assume file.txt contains the following three lines:
    the cat in the hat
    green eggs and ham
    fox in socks
    >>> mystery_c('file.txt', 'ae')
    >>> with open('file.txt') as f:
    ...     for line in f:
    ...         print(line[:-1])
    TODO: Doctest output (note, the doctest output is just going to be the
          contents of the file after you run the test)
    """
    with open(s1, "r") as f:
        lines = f.readlines()

    with open(s1, "w") as f:
        for line in lines:
            f.write(''.join([c.upper() for c in line if c not in s2]))
  • Lots of people asked about the doctest: a doctest is just a REPL listing. Lines 11-13 plus your answer make up the doctest in this case.
  • Some people missed the fact that all characters that made it through the filter were changed to uppercase.

Lecture 8: Midterm Review

  • Question 2: Checksum -- great job!
def checksum(s):
    """
    Returns the sum of all the ASCII values of the characters in the string.
    :param s: A string
    :return: The sum of the ASCII values of the string
    >>> checksum("hello")
    532
    """
    sum = 0
    for c in s:
        sum += ord(c)
    return sum
  • Most students figured this one out, including figuring out a string that would produce the same checksum as 'hello'.

Lecture 8: Midterm Review

  • Question 3: Hamming distance -- some solutions were too verbose!
def hamming_distance(s1, s2):
    """
    Returns the Hamming distance for two strings, or None if the two strings
    have different lengths.
    :param s1: the first string
    :param s2: the second string
    :return: An integer representing the Hamming distance between s1 and s2,
             or None if the strings have different lengths
    >>> hamming_distance('GGACG', 'GGTCA')
    2
    """
    if len(s1) != len(s2):
        return None
    hd = 0
    for c1, c2 in zip(s1, s2):
        if c1 != c2:
            hd += 1
    return hd
  • This was a great time to use the zip function.
    • ​​There were other perfectly fine ways to do this problem.

Lecture 8: Midterm Review

  • Question 4: Count and Wrap: I saw some tortured solutions
def count_and_wrap(total, wrap_after):
    """
    Prints total number of lines, starting from 0 and wrapping after
    wrap_after.
    :param total: an integer
    :param wrap_at: an integer
    :return: None
    >>> count_and_wrap(9, 4)
    0
    1
    2
    3
    4
    0
    1
    2
    3
    """
    for i in range(total):
        print(i % (wrap_after + 1))
  • This took a bit of thinking to get right, but the solution is straightforward.
  • I saw some correct solutions that I had to code up and try before I was convinced they were correct.

Lecture 8: Midterm Review

  • Question 5b: multiply recursively
def multiply(a, b):
    """
    Multiplies a and b using recursion and only + and - operators
    :param a: a positive integer
    :param b: a positive integer
    :return: a * b
    """
    if b == 0:
        return 0
    return a + multiply(a, b - 1)
  • Remember:
    • Base case
    • Work towards a solution by making the problem a bit smaller
    • Recurse
  • Some students counted down a, and others counted down b. Either was fine.
    • How could we ensure we are doing the least amount of work?

Lecture 8: Midterm Review

  • Least amount of work (a more efficient solution):
def multiply_efficient(a, b):
    if a < b:
        return multiply(b, a)
    if b == 0:
        return 0
    return a + multiply_efficient(a, b - 1)
    import timeit
    print("Timing multiply(10, 900):")
    print(timeit.timeit(lambda: multiply(10, 900), number=10000))
    print()

    print("Timing multiply(900, 10):")
    print(timeit.timeit(lambda: multiply(900, 10), number=10000))
    print()

    print("Timing multiply_efficient(900, 10):")
    print(timeit.timeit(lambda: multiply_efficient(900, 10), number = 10000))
    print()

    print("Timing multiply_efficient(10, 900):")
    print(timeit.timeit(lambda: multiply_efficient(10, 900), number = 10000))
    print()
  • We now count down the value that is smallest -- why does this save time?
  • We can use Python to test a function (we will learn about lambdas soon):
  • This tests the functions by running them 10,000 times in a row

Lecture 8: Midterm Review

    import timeit
    print("Timing multiply(10, 900):")
    print(timeit.timeit(lambda: multiply(10, 900), number=10000))
    print()

    print("Timing multiply(900, 10):")
    print(timeit.timeit(lambda: multiply(900, 10), number=10000))
    print()

    print("Timing multiply_efficient(900, 10):")
    print(timeit.timeit(lambda: multiply_efficient(900, 10), number = 10000))
    print()

    print("Timing multiply_efficient(10, 900):")
    print(timeit.timeit(lambda: multiply_efficient(10, 900), number = 10000))
    print()
Timing multiply(10, 900):
2.596630092

Timing multiply(900, 10):
0.017811094999999888

Timing multiply_efficient(900, 10):
0.020884906000000036

Timing multiply_efficient(10, 900):
0.019478217000000075
  • The original function was super-slow, because it had to count down from 900, which takes time.
  • Also: we couldn't go to 1000, because we would have a stack overflow
  • The efficient solution is fast no matter what

Lecture 8: Introduction to Classes and OOP

  • This week, we are going to start talking about classes and object oriented programming.
  • Object Oriented Programming uses classes to create objects that have the following properties:
    • An object holds its own code and variables
    • You can instantiate as many objects of a class as you'd like, and each one can run independently.
    • You can have objects communicate with each other, but this is actually somewhat rare.
  • You saw an example of a class in last week's lab
  • The Ball class is an object
    • You can create as many balls as you want
    • Each can have its own attributes
      • color
      • direction
      • size
      • etc.

Lecture 8: Creating a class creates a type

  • When we create a new class, we actually create a new type. We have only used types that are built in to python so far: strings, ints, floats, dicts, lists, tuples, etc.
  • Now, we are going to create our own type, which we can use in a way that is similar to the built-in types.
  • Let's start with the Ball example, but let's make it a bit simpler than we saw it in the lab. In fact, let's make it really simple (in that it doesn't do anything):
class Ball:
    """
    The Ball class defines a "ball" that can bounce around the screen
    """
>>> class Ball:
...     """
...     The Ball class defines a "ball" that can bounce around the screen
...     """
...
>>> print(Ball)
<class '__main__.Ball'>
>>>
  • In the REPL:

Notice that the full name of the type is '__main__.Ball'

Lecture 8: Creating a class creates a type

  • Once we have a class, we can create an instantiation of the class to create an object of the type of the class we created:
>>> class Ball:
...     """
...     The Ball class defines a "ball" that can bounce around the screen
...     """
...
>>> print(Ball)
<class '__main__.Ball'>
>>>
>>> my_ball = Ball()
>>> print(my_ball)
<__main__.Ball object at 0x109b799e8>
>>>
  • Now we have a Ball instance called my_ball that we can use. We can create as many more instances as we'd like:
>>> lots_of_balls = [Ball() for x in range(1000)]
>>> len(lots_of_balls)
1000
>>> print(lots_of_balls[100])
<__main__.Ball object at 0x109dc6e10>
>>>
  • We now have 1000 instances of the Ball type in a list.

Lecture 8: The __init__ method of a class

  • Let's make our Ball a bit more interesting. Let's add a location for the Ball, and let's also make a method that draws the ball on a canvas, which is a drawing surface available to Python through the Tkinter GUI (Graphical User Interface)
  • We can add functions to a class, too -- they are called methods, and are run with the dot notation we are used to. There is a special method called "__init__" that runs when we create a new class object:
class Ball:
    """
    The Ball class defines a "ball" that can 
    bounce around the screen
    """
    def __init__(self, canvas, x, y):
        self.canvas = canvas
        self.x = x
        self.y = y
        self.draw()

    def draw(self):
        width = 30
        height = 30
        outline = 'black'
        fill = 'black'
        self.canvas.create_oval(self.x, self.y, 
                                self.x + width,
                                self.y + height,
                                outline=outline,
                                fill=fill)
  • What is this "self" business?
    • "self" refers to the instance, and each instance has its own attributes that can be shared among the methods.
    • All methods in a class have a default "self" parameter.
    • In __init__, we set the parameters to be attributes  for use in all the methods.

Lecture 8: The __init__ method of a class

class Ball:
    """
    The Ball class defines a "ball" that can 
    bounce around the screen
    """
    def __init__(self, canvas, x, y):
        self.canvas = canvas
        self.x = x
        self.y = y
        self.draw()

    def draw(self):
        width = 30
        height = 30
        outline = 'blue'
        fill = 'blue'
        self.canvas.create_oval(self.x, self.y, 
                                self.x + width,
                                self.y + height,
                                outline=outline,
                                fill=fill)
  • The __init__ method is called immediately when we create an instance of the class. You can think of it as the setup, or initialization routine.
  • Notice in "draw" that we create regular variables. Those can only be used in the method itself.
  • If we want, we can promote those variables to become attributes so different instances can have different values.

Lecture 8: The __init__ method of a class

class Ball:
    """
    The Ball class defines a "ball" that can 
    bounce around the screen
    """
    def __init__(self, canvas, x, y):
        self.canvas = canvas
        self.x = x
        self.y = y
        self.draw()

    def draw(self):
        width = 30
        height = 30
        outline = 'blue'
        fill = 'blue'
        self.canvas.create_oval(self.x, self.y, 
                                self.x + width,
                                self.y + height,
                                outline=outline,
                                fill=fill)
  
def animate(playground):
    canvas = playground.get_canvas()
    ball = Ball(canvas, 10, 10)
    canvas.update() // redraw canvas
  • Because Tkinter needs some setup, I haven't included it here. But, assume you have an animate function that has a playground parameter that gives you a canvas (see Lab 8 if you want details).
  • When we instantiate ball, the __init__ method is called, which sets up the attributes, and then draws the ball on the screen.

Lecture 8: The __init__ method of a class

class Ball:
    """
    The Ball class defines a "ball" that can 
    bounce around the screen
    """
    def __init__(self, canvas, x, y):
        self.canvas = canvas
        self.x = x
        self.y = y
        self.draw()

    def draw(self):
        width = 30
        height = 30
        outline = 'blue'
        fill = 'blue'
        self.canvas.create_oval(self.x, self.y, 
                                self.x + width,
                                self.y + height,
                                outline=outline,
                                fill=fill)
  
def animate(playground):
    canvas = playground.get_canvas()
    balls = []
    for i in range(10)
        balls.append(Ball(canvas, 30 * i, 30 * i))
    canvas.update() // redraw canvas
  • We can, of course, create as many balls as we want.

Lecture 8: The __init__ method of a class

class Ball:
    """
    The Ball class defines a "ball" that can 
    bounce around the screen
    """

    def __init__(self, canvas, x, y, width, height, fill):
        self.canvas = canvas
        self.x = x
        self.y = y
        self.width = width
        self.height = height
        self.fill = fill
        self.draw()

    def draw(self):
        self.canvas.create_oval(self.x, self.y,
                                self.x + self.width,
                                self.y + self.height,
                                outline=self.fill,
                                fill=self.fill)
        
def animate(playground):
    canvas = playground.get_canvas()

    ball1 = Ball(canvas, 100, 100, 50, 30, "magenta")
    ball2 = Ball(canvas, 40, 240, 10, 100, "aquamarine")
    ball3 = Ball(canvas, 200, 200, 150, 10, "goldenrod1")
    ball4 = Ball(canvas, 300, 300, 1000, 1000, "yellow")

    canvas.update()
  • Now, we can modify each of the ball's position, size, and color independently.
  • What could we do if we wanted to give each attribute a default value?
    • Just like with regular functions, the __init__ method can accept defaults (see next slide)

Lecture 8: The __init__ method of a class

class Ball:
    """
    The Ball class defines a "ball" that can 
    bounce around the screen
    """

    def __init__(self, canvas, x, y, 
                 width=30, height=30, fill="blue"):
        self.canvas = canvas
        self.x = x
        self.y = y
        self.width = width
        self.height = height
        self.fill = fill
        self.draw()

    def draw(self):
        self.canvas.create_oval(self.x, self.y,
                                self.x + self.width,
                                self.y + self.height,
                                outline=self.fill,
                                fill=self.fill)
        
def animate(playground):
    canvas = playground.get_canvas()

    ball1 = Ball(canvas, 100, 100) # default size and color
    ball2 = Ball(canvas, 40, 240, fill="aquamarine")
    ball3 = Ball(canvas, 200, 200, 150, 10)
    ball4 = Ball(canvas, 300, 300, 1000, 1000, "yellow")

    canvas.update()
  • Q: Why do we have to say fill="aquamarine" ?
    • A: If we leave out default arguments, we have to name any other default arguments

Lecture 8: The __str__ and __eq__ methods of a class

  • Besides __init__, there are a couple of other special methods that classes know about, and that you can write:
    • __str__
      • Returns a string that you can print out that tells you about the instance
    • __eq__
      • If you pass in two instances, __eq__ will return True if they are the same, and False if they are different
  • ​We can define these functions to do whatever we want, but we generally want them to make sense for creating a string representation of the object, and for determining if two objects are equal.

Lecture 8: The __str__ and __eq__ methods of a class

  • Before we write the functions, let's see what happens when we try to print a ball, and to determine if two balls are equal:
    ball1 = Ball(canvas, 100, 100)  # default size and color
    ball2 = Ball(canvas, 40, 240, fill="aquamarine")
    ball3 = Ball(canvas, 200, 200, 150, 10)
    ball4 = Ball(canvas, 300, 300, 1000, 1000, "yellow")
    ball5 = Ball(canvas, 300, 300, 1000, 1000, "yellow") // same as ball4
    
    canvas.update()

    print(ball1)
    print(ball2)
    print(ball3)
    print(ball4)
    
    print(f"ball4 == ball5 ? {ball4 == ball5}")
    print(f"ball1 == ball5 ? {ball1 == ball5}")
ball4 == ball5 ? False
ball1 == ball5 ? False
<__main__.Ball object at 0x10484f1d0>
<__main__.Ball object at 0x10484f208>
<__main__.Ball object at 0x10484f240>
<__main__.Ball object at 0x10484f278>
  • This is probably not what we want. ball4 and ball5 should be equal, and when we print out a ball, it isn't very useful.

Lecture 8: The __str__ and __eq__ methods of a class

  • Here is an example of the __str__ method for our Ball class:
    def __str__(self):
        """
        Creates a string that defines a Ball
        :return: a string
        """
        ret_str = ""
        ret_str += (f"x=={self.x}, y=={self.y}, "
                    f"width=={self.width}, height=={self.height}, "
                    f"fill=={self.fill}")
        return ret_str
  • We create a string with the attributes we care to print, and then we return the string.

Lecture 8: The __str__ and __eq__ methods of a class

  • Here is an example of the __eq__ method for our Ball class:
    def __eq__(self, other):
        return (
                self.canvas == other.canvas and
                self.x == other.x and
                self.y == other.y and
                self.width == other.width and
                self.height == other.height and
                self.fill == other.fill
        )
  • We perform the comparison on the two different balls, and return True if they are the same, and False otherwise.

Lecture 8: The __str__ and __eq__ methods of a class

  • There are other, related methods you can also create:
    • __ne__   (not equal). In Python 3, we don't usually bother creating this, because the language just treats != as the opposite of ==.
    • __lt__ (less than)
    • __le__ (less than or equal to)
    • __gt__ (greater than)
    • __ge__ (greater than or equal to)
  • There isn't necessarily a good way to determine if a ball is "less than" another ball, but for some objects it makes more sense.

Lecture 8 - Introduction to Classes and OOP

By Chris Gregg

Lecture 8 - Introduction to Classes and OOP

  • 1,530