5.2.1. Objects and classes

5.2.1.1. Initial setup for the Lab

  1. In the VSCode terminal enter:

    cd /workspaces/`ls /workspaces` && mkdir -p lab-g
    cd /workspaces/`ls /workspaces`/lab-g
    uv init .
    

    Remember to enter these one at a time, not both together.

    This will make a new Python project in the current folder. It will be named automatically after the folder name.

    To analyze these lines:

    • cd /workspaces/`ls /workspaces` && mkdir -p lab-g makes the lab-g folder. There are no pre-downloaded files needed for Lab G. Sometimes this means that the Lab G folder isn’t downloaded automatically (because it’s empty).

    • cd /workspaces/`ls /workspaces`/lab-g makes sure we are working in the lab-g folder.

    • uv init . is the interesting command. This actually sets up our virtual environment.

    The above will make the a pyproject.toml file, a main.py file, and a number of others.

  2. Add some structure to your folder by entering the commands:

    mkdir tests docs src
    mv main.py src
    uv run src/main.py
    touch tests/__init__.py
    
    • Here we’ve moved main.py into the src folder.

    • We’ve made a tests folder for any tests that we might want to write later, and a docs folder for any documentation. We won’t ask you to put anything into these as part of the lab instructions, but you might want to write some tests for your code to check that it’s working!

  3. Install the required dependencies for this lab by entering the command:

    uv add numpy
    
  4. Run

      uv run src/main.py
    

    to make sure the virtual environment is built.

  5. When you open a Python file, make sure that the correct Python virtual environment is activated. See the instructions in Lab D if you’re unsure.

5.2.1.2. Objects

  1. Make a new Python file, with any suitable name, and copy the code below into it. Then run the code.

class ExampleClass:
    """
    Docstring for ExampleClass
    """

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


def main():
    # Create instances of ExampleClass
    a = ExampleClass(2)
    b = ExampleClass(3)
    c = ExampleClass(4)

    # Print values
    print(a.value)
    print(b.value)
    print(c.value)


if __name__ == "__main__":
    main()
  1. This has defined a new class called ExampleClass. To analyze the code:

    • The class is defined using the class keyword, followed by the name of the class.

    • The body of the class is indented below this line.

    • The class has one method, __init__() which defines what happens when a new instance of the class is created.

      • A method is defined using the def keyword, just like a function. We just call it a method because the definition is inside a class.

      • The __init__ method name starts and ends with a double underscore __. This is known as a dunder method. All classes will have a range of built in dunder methods which provide default functionality. We don’t have to use them all, but they are there if we want to.

      • The def __init__(self, value) method takes two inputs, self and value. A method always takes self as its first input. self means take the name of the current object. That is, if we have a = ExampleClass(2), self represents a. When we have b = ExampleClass(2), self represents b. The class definition doesn’t know what we’re going to call any particular object that we might make, and so it uses self instead.

      • Any inputs after self are just whatever we want to provide to get the behaviour that we want. Here we’re just making a class that holds a single value.

  2. Fundamentally that’s it! We now just need to make the class as simple or as complicated as we need it to be for whatever problem we’re trying to solve. We’ll make this example a bit more detailed before moving on to something possibly more realistic.

  3. Add

    print(a)
    

    to your main() function, and run the code again. You’ll see something like the below displayed:

    <__main__.ExampleClass object at 0x7fc9ca73cc20>
    
    View of a simple class in VSCode

    Screenshot of VSCode, software from Microsoft. See course copyright statement.

  4. A method __str__() in a class defines what happens when we try and print an object of that class. Add the method below to your class definition and run the code again. You should see it displays something more useful now.

    def __str__(self):
        return "Hello from ExampleClass"
    
    View of a simple class in VSCode with a __str__ method

    Screenshot of VSCode, software from Microsoft. See course copyright statement.

  5. Modify the __str__() method to display the value the object is storing.

  6. So far we’ve only used some of the built-in dunder methods that Python provides. We can of course add our own methods, in order to add whatever functionality we want.

    Add to the class definition the following code:

    def multiply(self, factor):
        return self.value * factor
    

    Then in the main() function, add the code:

    print(a.multiply(1))
    print(b.multiply(2))
    print(c.multiply(3))
    

    Your overall code should look like that in the box below. Run the code to see the results.

    The multiply() method has its own input, called factor. This is just a normal input to a function, except that the first input is always self, which represents the object that the method is being called on. This method multiples the stored value in the object by the input factor.

  7. Add a new method called pow() which returns the square of the value stored in the object, if that value is even, and the cube of the value if the value is odd. Test your method by calling it on each of the three objects a, b and c in the main() function, and printing the results.

5.2.1.3. Another example

  1. Let’s make an object that stores information about a student. Firstly, think about what information we want it to store.

    This is a really important question that gets to the heart of why we want to make our own objects. The object is tailored to the problem that we’re trying to solve. We’ll store:

    • The student’s name

    • The student’s ID number

    • The assignment marks (for Labs A to T)

    • The exam mark

    • The overall course mark

    We could imagine storing attendance information, which degree program the student is on, and other things. We won’t here to keep it compact.

  2. In a new Python file, copy the code below to define a StudentMarksEEEN11202 class. Then run the code.

    class StudentMarksEEEN11202:
        """
        Docstring for StudentMarksEEEN11202
        """
    
        def __init__(self, name, id):
            self.name = name
            self.id = id
            self.assignment_marks = None
            self.exam_mark = None
            self.overall_mark = None
    
    
    def main():
        # Create instance of StudentMarksEEEN11202
        student1 = StudentMarksEEEN11202("Alex", "12345")
        student2 = StudentMarksEEEN11202("Casson", "67890")
    
        # Print contents
        for student in (student1, student2):
            print(
                f"Student Name: {student.name}\n"
                f"ID: {student.id}\n"
                f"Assignment marks: {student.assignment_marks}\n"
                f"Exam mark: {student.exam_mark}\n"
                f"Overall mark: {student.overall_mark}\n"
            )
    
    
    if __name__ == "__main__":
        main()
    

    This uses a slightly different approach for the __init__() method. We’ve told it explicitly that when we create a new instance of the class, we need to provide the student’s name and ID number. The other data (assignment marks, exam mark and overall mark) can’t be set when the object is made, they will require methods to set the value. When an object is first made, these values are set to a default value of None. (See None in our discussion on datatypes. We’re actually going to change this in the next step.)

    Note

    Note that we’ve used None rather than 0 as the default mark. Using None lets us differentiate between a mark that hasn’t been set yet, and a student who has submitted work but got 0 for it. In turn, this lets us follow up with students who haven’t submitted yet to make sure they do so before the deadline, and to follow up with students who tried the assignment but got 0, to offer them more support. We can’t do these if we just initialize all of the marks to 0. Of course, at the end of the course any remaining None marks need to be set to 0. This use of different data types is part of our data model for this problem.

  3. The above isn’t quite a good enough starting point. There are 20 assignments in this course (Assignments A to T) and so we need an array of some form to put these in rather than a single value. We could use a list. We’ll actually use a numpy array, because we’ll want to do some sums on these to get the overall mark.

    For a numpy array, rather than None we use NaN (see NaN in our discussion on datatypes) via np.nan() to represent missing numerical data. This is just the choice we’re making here. You could chose a different data model if you wanted to.

    Modify the class definition to be:

    import numpy as np
    
    
    class StudentMarksEEEN11202:
        """
        Docstring for StudentMarksEEEN11202
        """
    
        def __init__(self, name, id):
            no_assignments = 20
            self.name = name
            self.id = id
            self.assignment_marks = np.full((no_assignments,), np.nan)
            self.exam_mark = np.nan
            self.overall_mark = np.nan
    

    np.full((no_assignments,), np.nan) makes an array with 20 elements (no_assignments), each set to np.nan. The output of the overall code will now look like:

    Student Name: Alex
    ID: 12345
    Assignment marks: [nan nan nan nan nan nan nan nan nan nan nan nan nan nan nan nan nan nan nan nan]
    Overall mark: nan
    Exam mark: nan
    
  4. Let’s make one more improvement before continuing. Using np.nan to represent missing data is fine, but it relies on us remembering that np.nan means “no submission has been made”.

    We can make the code more readable by making another class, NoSubmission to represent no submission. This will just a be wrapper for np.nan, but it makes the code more readable.

    Add the following code above the StudentMarksEEEN11202 class definition:

    class NoSubmission:
        """
        Class to represent no submission made.
        """
    
        def __init__(self):
            self.value = np.nan
    
        def __str__(self):
            return "No Submission"
    
        def __repr__(self):
            return "No Submission"
    

    The __repr__() method is similar to the __str__() method, but is used when the object is displayed in a list or array or similar.

    StudentMarksEEEN11202 can then become:

    class StudentMarksEEEN11202:
        """
        Docstring for StudentMarksEEEN11202
        """
    
        def __init__(self, name, id):
            no_assignments = 20
            self.name = name
            self.id = id
            self.assignment_marks = np.full((no_assignments,), NoSubmission())
            self.exam_mark = NoSubmission()
            self.overall_mark = NoSubmission()
    

    Here, it’s a bit clearer that we’re setting the default to represent no submission being made. If you run the code now, the output will look like:

    Student Name: Alex
    ID: 12345
    Assignment marks: [No Submission No Submission No Submission No Submission No Submission No Submission
     No Submission No Submission No Submission No Submission No Submission No Submission
     No Submission No Submission No Submission No Submission No Submission No Submission
     No Submission No Submission]
    Overall mark: No Submission
    Exam mark: No Submission
    

    which again is a bit clearer.

  5. To complete the class we want to add three methods.

    • To set the exam mark. Say, set_exam_mark()

    • To set an assignment mark. Say, set_assignment_mark()

    • To compute the overall mark. Say, _set_overall_mark()

    We could add more, but this will be sufficient for now. We’ll do the first one in full, and then leave a few steps for you to complete in the other two.

  6. Add another def block to the StudentMarksEEEN11202 class definition:

    def set_exam_mark(self, mark):
        self.exam_mark = mark
    

    This is hopefully fairly clear now. In your main() function, add

    student1.set_exam_mark(65)
    student2.set_exam_mark(75)
    

    Check this does what you expect. The current complete code is given below.

  7. Setting an assignment mark is a little more complicated because we need to provide both a mark, and which assignment to set the mark for.

    We want to call the method like:

    student1.set_assignment_mark("a", 2)
    student1.set_assignment_mark("b", 5)
    

    Make a new method in the StudentMarksEEEN11202 class called set_assignment_mark() which takes two inputs, the assignment letter (a string) and the mark (a number), and sets the appropriate element in the assignment_marks array to the provided mark.

    You will need to convert the letter to an index in the self.assignment_marks array. That is, so that "a" corresponds to index 0, "b" to index 1 and so on. You can use the ord() function to get the position of a letter in the alphabet.

  8. Make your method case insensitive, so it can be called as either

    student1.set_assignment_mark("a", 2)
    student1.set_assignment_mark("A", 2)
    
  9. The final method, _set_overall_mark() is slightly complicated for two reasons.

    • We don’t want the main code to call this as student1._set_overall_mark(65) or similar. That’s not how the overall mark works. The overall mark is a result of the combined assessment and exam marks, not something that can be set directly. Instead, ._set_overall_mark() should be called automatically whenever an assignment or exam mark is updated. Thus, it’s the class itself that calls ._set_overall_mark(). This is known as a private method. By convention, private methods start with an underscore _ to indicate that they shouldn’t be called directly from outside the class. Also as a result, implementing ._set_overall_mark() requires updating set_assignment_mark() and set_exam_mark() to call it whenever a mark is updated.

    • We’ve stored not-submitted results as a class NotSubmitted. We need to decide what to do with these when calculating the overall mark. We will assume that any not-submitted assignments are worth 0 when calculating the overall mark. This lets a running total mark be calculated as assignments are submitted.

    Implement the _set_overall_mark() method, and update the code to call it and check it works. The overall unit mark is made up 50% from the summed assignment marks, and 50% from the exam mark.