CS 152: Lab 8

Title image Project 8
Spring 2020

Lab Exercise 8: Designing Classes

This project will be your first experience with writing your own classes.

This is the second part of a multi-part project where we will look at increasingly complex physical simulations. This week we will continue using just a ball and a block, but think about how to make the ball bounce.


Tasks

If you have not already done so, create a new project8 folder and bring up your text editor. Make sure you have the graphics package file graphicsPlus.py. Here is a link to the Zelle documentation.

  1. Designing a class

    Video: Creating a Class

    A reasonable analogy for a class and an object is a recipe and a cake. The recipe tells us how to make a cake, what it should look like, and what its properties are. The cake made from the recipe is a unique thing that can be modified (eaten), and is a different thing from any other cake. A recipe can be used to make many cakes, but a recipe is not cake.

    Likewise, a class is a recipe for how to make an object, but a class is not an object. An object is an instance of a class.

    To practice working with classes, we're going to make a cake class.

    1. Define a Cake class

      To define a class, start with the class keyword, followed by the name of the class and a colon to indicate we are started a block. The deifnite of the class needs to be inside the block.

      def Cake:
    2. Define the __init__ method

      Every class has a constructor function that executes when you create an instance of the class (an object). You can think of the constructor function as the process of baking a cake. It takes the concept of a cake and any input parameters and generates the actual object, filling in its properties.

      The constructor function for every class in Python has the name __init__.

      The first argument to every function in a class, including __init__, should be the parameter self. The self parameter is a reference to the object itself (the specific cake you are creating).

      In addition to self the __init__ method can take other arguments necessary to create the object. In the case of a cake, let's have parameters for the flavor, number of layers, whether to have icing, the color of the icing, and the number of candles. In the definition below, only the flavor is a required argument. All the rest have default values.

          def __init__(self, flavor, layers=3, icing=True, icing_colors="pink", candles=0):

      The purpose of the __init__ constructor is to create all the fields in the object to store information. If information is provided in parameters, then we want to copy the information into the object's fields.

      In Python, to create a place to store information, you assign something to it. In this case, we want to assign values to fields that are part of the object. The special variable self is how we access the object's symbol table. Any time you want to read or write to an object's field first use self. and then the name of the field.

      The first thing we want to do is create a field called flavor and assign to the object's field the value in the flavor parameter. To do this, put the following as the first line of the __init__ method.

              self.flavor = flavor

      Go ahead and create fields for all of the information we need to store: layers, icing, the icing color, and the number of candles. Assign to the field the corresponding parameter. The names of the fields do not need to match the parameter names, but they can. There is no name clash because the parameters exist in the function's symbol table while the field names exist in the self symbol table.

    3. Create a get method for each field

      An accessor is a method that provides the value of a field of an object. The purpose of having accessors is to control how information flows out of an object and to hide how the information is actually being stored.

      For each field (flavor, layers, icing, icing color, and number of candles), create a get method. For example, for flavor, you would do the following. As always, self is the first (sometimes only) paramater of every class method.

          def getFlavor(self):
              return self.flavor  # whatever you called the field name

      Use the following names for your get methods: getFlavor, getLayers, getIcing, getIcingColor, getNumCandles.

    4. Test the get methods

      Define a main function at the bottom of your cake.py file, outside of the class. In that function, create a Cake object with the flavor 'vanilla', 3 layers, icing, 'pink' icing color, and 4 candles. Assign the Cake object to a local variable (e.g. mycake).

      To create a Cake object, use the name of the class (e.g. Cake) as a function, and pass in the arguments according to the parameters of the __init__ method.

          mycake = Cake( 'vanilla', 3, True, 'pink, 4 )

      Use the get methods to print out the value of each field. To call the method of an object, use the variable holding the object to call the method as below. You do not need to put in anything for the parameter self. The self parameter is automatically provided by Python when you use an object variable to call one of its methods.

          print( mycake.getFlavor() )
      Then run your code and make sure it works properly. Don't forget to execute the main function at the bottom of the file.

    5. Create a set method for each field

      A mutator is a method that modifies the value of a field of an object. The purpose of having mutators is to control how information flows into an object and hide how it is being stored. In some cases, accessors will permit only values within certain ranges to be stored in an object's field.

      For each field, create a set method. The set method for the number of layers should not accept a value less than 1. The set method for the number of candles should accept only values between 0 and 30, inclusive. The following is an example set method for flavor.

          def setFlavor(self, f):
              self.flavor = f

      Use the following names for your set methods: setFlavor, setLayers, setIcing, setIcingColor, setNumCandles.

      Make sure the field names (e.g. self.flavor) you use the set methods exactly match the field names you used in the __init__ method.

    6. Test the set methods

      In your main function, add code to test the set methods. Make sure they are working correctly. To test a set method, use it to change the value of a field, then use the corresponding get method to access and print the updated value, making sure it changed if it was supposed to change.

    7. Make two more utility methods

      Define a method isBirthdayCake that returns True if the cake has at least 1 candle and otherwise returns False.

      Define a method favoriteCake that returns True if the cake is your favorite flavor (your choice of what that is) and otherwise returns False.

      Add code to your main function to test both of these methods.

    8. Define a __str__ method

      The __str__ method is a specially named method that gets called when you try to print an object or otherwise try to cast the object to a string type. By default, the method just prints the object's address in memory. If you define your own __str__ method, which should return a string, you can make it do whatever you want.

      Define a __str__ method that returns the flavor of the cake (which is a string). Remember to include self in the parameter list of the method.

      Add code to your main program that prints the cake object (e.g. print( mycake ) and see what it does.

    9. A little fun with ascii art

      Make the following code your definition for the __str__ method. Note that this function uses your get methods instead of accessing the fields directly, which means it doesn't matter if you named your fields differently than I did.

          # this function generates a string representation of the cake
          # it is called automatically when you try to print a Cake object
          def __str__(self):
              s = "\n" # put some space above the cake
              if self.getNumCandles() > 0:
                  # flame for the candles
                  spaces = 30 - self.getNumCandles()
                  halfspace = spaces//2
                  s += " "*halfspace
                  s += ","*self.getNumCandles()
                  s += " "*(30 - (self.getNumCandles() + halfspace)) + "\n"
      
                  # candles
                  s += "_"*halfspace
                  s += "|" *self.getNumCandles()
                  s += '_'*(30-(self.getNumCandles() + halfspace))
                  s +=  "\n"
              else:
                  # no candles, so just a flat top
                  s += 30*"_" + "\n"
      
              # fill with icing or blank space
              if self.getIcing():
                  filler = "~"
              else:
                  filler = " "
      
              # loop over the number of layers
              for i in range( self.getLayers() ):
                  s += '|' + filler * 28 + '|' + "\n"
      
              # bottom of the cake
              s += "-" * 30 + "\n"
      
              # finish with a descriptor
              if self.getIcing():
                  s += "A " + self.getFlavor() + " cake with " + self.getIcingColor() + " icing"
              else:
                  s += "A " + self.getFlavor() + " cake with no icing"
                  
              return s

      Try printing cakes with different numbers of layers, candles, or whether it has icing or not.


  2. Define a Ball class

    Create a file named physics_objects.py. Your first task in the lab is to create a class Ball that will represent a ball in your simulation. The ball will have the same set of fields that we stored in a list for project 7: position, velocity, acceleration, a shapes list, a window, whether the shapes are current drawn, a radius, and a color. We will also recreate the same methods for the ball class.

    1. Define the Ball class and its __init__ method

      The __init__ method should have self and a GraphWin object (win) and a radius as its only non-optional arguments. Add optional arguments for position, velocity, acceleration, and color. Inside the __init__ method, create fields to hold each of the following pieces of information. You can use your own names for the fields.

      1. The ball's radius - assign it the value from the parameter.
      2. The ball's position - this will be a two-element list, representing the x and y position in simulation space. Copy the parameter position list.
      3. The ball's velocity - a two-element list. Copy the parameter velocity list.
      4. The ball's acceleration - a two-element list. Copy the parameter acceleration list.
      5. A list of Zelle graphics shapes - the graphics shapes that make up the object, initially the empty list.
      6. The GraphWin window - a GraphWin object (the win parameter).
      7. Whether the ball is drawn into the window - either True or False, initially False.
      8. The ball's color - a string like 'blue' or a Zelle color object generated by color_rgb.

      At the end of the Ball's __init__ method, call the render method, which we will define next.

      To call a method of an object when you are in an object method, use self. before the name of the method. For example, to call the Ball's render method use self.render(). Because the object (self) is automatically passed to the render method, you don't need to give it any arguments.

    2. Write a render method

      The remaining methods will all be virtually identical to the set of functions you wrote last week. The main difference will be in how you access the ball's information (through fields instead of lists) and how you call the other ball functions (using self).

      The render method should follow the same steps.

          def render(self):
              # Assign the drawn field to a local variable
              # if the ball is drawn
                  # call the ball's undraw method (e.g. self.undraw()
              # create a new Zelle Circle object using the appropriate position and radius 
              # set the Circle object's color using the color field
              # assign to the shapes field the Circle object in a list
              # if the ball is drawn
                  # call the ball's draw method
    3. Write draw and undraw methods
          def draw(self):
              # if the objects are not drawn, draw the Zelle graphics shapes into the window
              # set the drawn field to True
      
          def undraw(self):
              # if the objects are drawn, undraw the Zell graphics objects
              # set the drawn field to False

      The methods should loop over the field with the list of shapes and have each item call its draw/undraw method. Remember the graphics object draw method needs the window as its argument.

      test8-1.py tests draw and undraw.

    4. Write get methods for the Ball class

      Create get methods for position, velocity, acceleration, radius, and color. Use the following names for the get methods: getPosition, getVelocity, getAcceleration, getRadius, getColor. Remember to put self in the parameter list. You must follow these specifications or the test files will not work without modification. The position, velocity, and acceleration methods should return a 2-element list with the x and y values.

      When writing get methods, you want to avoid returning the internal list. For example, if the following getPos method returns a reference to the list being used by the object to store its position.

      # a bad example
      def getPos(self):
          return self.position

      By returning a reference to the list, another program can now change the values stored in self.pos. Instead, return a copy of the list.

      # a good example
      def getPos(self):
          return self.position[:]

      By returning a copy, another program can't unexpectedly edit the Ball's internal list.

    5. Write set methods for the ball class

      Write the following set methods: setPosition, setVelocity, setAcceleration, setRadius, setColor. When updating a list (e.g. position, velocity, acceleration) make sure you are copying the information into the field. The setPosition, setVelocity, and setAcceleration method parameters should be self and a 2-element list with the new x and y values.

      def setPosition(self, pos): # pos is a 2-element list with x and y
          # code here
      
      def setVelocity(self, vel): # vel is a 2-element list with vx and vy
          # code here
      
      def setAcceleration(self, acc): # acc is a 2-element list with ax ans ay
          # code here
      
      def setRadius(self, r): # r is a single integer
          # code here
      
      def setColor(self, color): # color is a Zelle color or a string
          # code here

      The tricky methods are setPosition, setRadius, and setColor, which require not only updating the appropriate field, but also moving or changing the visualization. Follow the same steps as last week for setPosition and setRadius. Have setPosition move the objects using the Zelle move method, and have setRadius call the render method after updating the shape.

      The method setColor is new. One way to update the color is to assign the new value to the color field and then call the render method. An alternative (more efficient) method is to manipulate the color of the elements in the shapes list directly.

      + (more detail)

      If a Circle is in screen space location A and you want it to be in screen space location B, then moving the object by the amount (B - A) does what you want to do. To calculate B-A, calculate the difference in simulation space and then move the objects by dx and -dy.

          def setPosition(self, pos):
              # assign to x_old the current x position
              # assign to y_old the current y position
      
              # assign to the x coordinate in self.pos the new x coordinate
              # assign to the y coordinate in self.pos the new y coordinate
      
              # assign to dx the new x position - the old x position 
              # assign to dy the new y position - the old y position
      
              # for each item in the shapes list field of self
                  # call the move method of the item, passing in dx and -dy
      										

      test8-2.py tests setPosition.

      test8-3.py tests setRadius.

      testBall_1.py has code to test some of the get/set functions, but it is not complete. As you write get/set functions, add test code to the function to make sure your functions work properly.

    6. Write an update method that implements Newtonian physics

      Write a method, update that adjusts the internal position and velocity values based on current accelerations and forces. It will use the same steps as the prior project. The method should use the equations of motion under uniform acceleration to update the velocity and position.

      The method also needs to move the visualization. The function will take in a time step, dt that indicates how much time to model.

      + (more detail)

      The following is a step-by-step algorithm for updating the ball.

          def update(self, dt):
              # assign to dx the x motion = x_vel*dt + 0.5*x_acc*dt*dt
              # assign to dy the y motion = y_vel*dt + 0.5*y_acc*dt*dt 
      
              # increment the x position by dx
              # increment the y position by dy
      
              # increment the x velocity by adding the acceleration times dt
              # increment the y velocity by adding the acceleration times dt
      
              # for each item in the shapes list
                  # call the move method of the graphics object with dx and -dy as arguments

      To test the update method, uncomment the last section of the testBall main function. It should move the ball down and to the right.

      test8-4.py tests update.

    7. Write a test file for the Ball class

      Write a test file that will create a Ball in the center of the screen, give it a random initial velocity and an acceleration of (0, -20), and then loop until the ball leaves the screen, calling the ball's update function inside the loop. If the ball goes out of bounds, the program should reposition the ball to the center of the screen and give it a new random velocity.

      If you wish, you can use fall.py as a template.

      Test your code and make sure you have a ball falling down, then re-spawning.


    When you are done with the lab exercises, you may begin the project.