3.3 The Class Design Recipe#

We have now introduced three elements of a class:

  • instance attributes (data)

  • methods (operations)

  • representation invariants (properties)

Now that we understand the basic mechanics of classes, it’s time to think about the design of classes. In fact, there are a whole host of design questions that you’ll face when designing object-oriented programs. To help guide you in this process, we have prepared a Class Design Recipe, which serves an analogous role to the Function Design Recipe. This is a reference document that you aren’t required to follow explicitly, but will be a helpful way to guide your thinking when designing your own classes.

You can download the example code used in this section here.

Part 1: Define the API for the class#

  1. Class name and description. Pick a noun to be the name of the class, write a one-line summary of what that class represents, and (optionally) write a longer, more detailed description. These should help others quickly figure out what your class represents.

    class Course:
        """A university course.
        """
    

    In this case, there isn’t much to say, since most everyone knows what a university course is. You may wonder why we don’t explain what aspects of a course the class is going to model, but that will come at a later step.

  2. Example. Write some simple examples of client code that uses your class. This will help you figure out what the API should be. By taking the point of view of the client, your design is likely to make the class convenient to use. Focus for now on standard cases (as opposed to a tricky or corner case). Write your code as doctest examples and add it to the class docstring. Example:

    >>> his250 = Course('his250', 3, {'a1': 10, 'a2': 10, 'midterm': 30, 'final': 50))
    >>> his250.enrol('123456789')
    True
    >>> his250.enrol('111111111')
    True
    >>> his250.enrol('888888888')
    True
    >>> his250.enrol('222222222')
    False
    >>> his250.record_grade('123456789', 'a1', 80)
    True
    >>> his250.record_grade('123456789', 'a2', 90)
    True
    >>> his250.record_grade('123456789', 'midterm', 70)
    True
    >>> his250.record_grade('123456789', 'final', 80)
    True
    >>> his250.record_grade('888888888', 'a1', 76)
    True
    >>> his250.grade('888888888', 'a1')
    76
    >>> his250.course_grade('123456789')
    78.0
    >>> # Low because student 222222222 did not get a grade for a1:
    >>> his250.class_average('a1')
    52.0
    

    In order to come up with this code, many decisions had to be made. For example, we chose to specify an enrolment cap (in this example, it is 3 so that we can demonstrate attempted enrolment into a full class) and had to come up with a way to specify the course marking scheme (we chose to use a dictionary). We also chose to have some of the methods return a boolean to indicate success or failure. As we progress through the recipe, we may realize some decisions weren’t great. That’s fine; we can come back and revise.

  3. Public methods. Using your example as a starting point, decide what services your class should provide for client code, i.e., what actions could be performed on instances of this class. For each of these actions, use the first four steps of the Function Design Recipe to define the interface for a method that will provide the action:

    1. Example

    2. Type Contract

    3. Header

    4. Description

    Since you are writing methods, not functions, don’t forget to include self as the first parameter.

    You must define an initializer, __init__, and often will want to define __str__ to generate a string representation of instances of the class, and __eq__ to check whether two instances are equal.

    For brevity, only the initializer and one other method is shown below. You can see the rest in the full example code.

    class Course:
    
        def __init__(self, name: str, cap: int, scheme: dict[str, int]) -> None:
            """Initialize this course.
    
            Precondition: The sum of all values of <scheme> must equal 100.
    
            >>> c = Course('cscFun', 50, {'exam': 100})
            >>> c.name
            'cscFun'
            >>> c.cap
            50
            """
    
        def enrol(self, student_id: str) -> bool:
            """Enrol a student in this course.
    
            Enrol the student with id <student_id> in this course, if there is
            room.
    
            Return whether enrolment was successful, i.e., this student was not
            already enrolled, and there was room for to enrol them.
    
            >>> c = Course('cscFun', 50, {'exam': 100})
            >>> c.enrol('12345')
            True
            >>> c.grade('12345', 'exam') is None
            True
            """
    
  4. Public attributes. Decide what data you would like client code to be able to access without calling a method. This is not a clear-cut decision, since one could require all data to be accessed by calling a method (and in some languages, that is the convention). Python takes the opposite point of view: treat attributes as public unless you have a good reason not to.

    Here are two situations when it makes sense to treat the attribute as private. In these cases, we expect the user to access the data by calling methods.

    • An attribute with complex restrictions on its value. If client code were to assign a value to the attribute directly, it might inadvertently violate the restriction. If instead it is required to call a method to change the value, the method implementation can enforce any restriction.

    • An attribute that represents data from the domain in a complex way. (We’ll learn some fairly complex data structures this term.) By expecting client code to access the information through a method call, we spare the client code from having to be aware of the complex details, and we also avoid the problem of client code accidentally messing up important properties of the data structure.

    Once you have chosen the public attributes, add a section to your class docstring after the description, specifying the name and description of each of these attributes. Then below the docstring, specify the type of each variable as well. Use the format below.

    class Course:
        """A university course.
    
        Attributes:
            name: the name of this course.
            cap: The enrolment cap for this course, i.e., the maximum number of
                students who may enrol.
        """
        name: str
        cap: int
    

At this point you have defined everything that client code needs in order to use the class successfully.

Part 2: Implement the class#

Now turn your attention to implementing the class. Any comments you write at this point concern implementation details, and are for the developers of the class itself. As a result, they will not go in the class docstring or in method docstrings; these are for developers of client code.

  1. Internal (private) attributes. In order to write the bodies of the public methods, you will likely need additional attributes, but ones that the client code (a) need not know about and (b) should not access directly. For example, we need a way to record all the grades in the course. We chose a dictionary of dictionaries (organized first by student, then by course element), but client code shouldn’t have to traverse this structure – that’s the job of your class. A programmer who writes client code shouldn’t even have to know which structure you chose. In fact, if client code always accesses data through your public methods, you have the freedom to change internal details without breaking any client code.

    An internal attribute is not part of the public interface of the class, and its name should begin with an underscore to indicate this.

    Add a separate section to the class docstring for private attributes. For each internal attribute, use the same format as above to define the type, name, and description of each of the internal attributes.

    """
    ...
    
    Private Attributes:
        _scheme:
            The marking scheme for this course.  Each key is an element of the
            course, and its value is the weight of that element towards the
            course grade.
        _grades:
            The grades earned so far in this course.  Each key is a student
            ID and its value is a dict mapping a course element to the student's
            mark on that element.  If a student did not submit that element,
            it does not appear as a key in the student's dict.  If, however,
            they earned a grade of zero, this is recorded as any other grade
            would be.
    """
    name: str
    cap: int
    _scheme: dict[str, int]
    _grades: dict[str, dict[str, int]]
    
  2. Representation invariants. Add a section to your class docstring containing invariants that involve your attributes: properties that must always be true (one could say they must never “vary” from truth, hence the name “invariant”). These may be restrictions that cannot be captured by types alone in Python. For example, a student’s ‘age’ must be greater than 0, or every course code must consist of 3 letters and then 3 numbers. They may also express important relationships between the attributes.

        """
        ...
    
        Representation Invariants:
            - The sum of all weights in _scheme must be 100.
            - Each key in every student's dict of grades must be an element of the
            course grading scheme, i.e., must occur as a key in _scheme.
        """
    
  3. Implement Public Methods. Use the last two steps of the Function Design Recipe to implement the public methods in your class.

    1. Implement the method body

    2. Test your method implementation

    Use helper methods to simplify your code. A helper method is not part of the public interface of the class, and its name should begin with an underscore to indicate this.

    For each method, you should assume that the representation invariants are all satisfied when the method is called, but you must ensure that the invariants are satisfied when the method exits.

    Note that your initializer should initialize all of the attributes of the instance; it should not do anything else. You can find the complete implementation in the full code example.

    class Course:
        def __init__(self, name: str, cap: int, scheme: dict[str, int]) -> None:
            """Initialize this course.
    
            Precondition: The sum of all values of <scheme> must equal 100.
    
            >>> c = Course('cscFun', 50, {'exam': 100})
            >>> c.name
            'cscFun'
            >>> c.cap
            50
            """
            self.name = name
            self.cap = cap
            self._scheme = scheme
            self._grades = {}
    
        def enrol(self, student_id: str) -> bool:
            """Enrol a student in this course.
    
            Enrol the student with id <student_id> in this course, if there is
            room.
    
            Return whether enrolment was successful, i.e., this student was not
            already enrolled, and there was room for to enrol them.
    
            >>> c = Course('cscFun', 50, {'exam': 100})
            >>> c.enrol('12345')
            True
            >>> c.grade('12345', 'exam') is None
            True
            """
            if len(self._grades) < self.cap:
                if student_id in self._grades:
                    return False
                else:
                    self._grades[student_id] = {}
                    return True
            else:
                return False
    

Notice that method __init__ does not confirm that its precondition for parameter scheme is met. The same is true of other methods with preconditions. A method simply assumes that its preconditions are true, and makes no promises about what will happen if they are not.