3.4 More on Designing Classes#

In the previous section, we introduced the Class Design Recipe, which is a formal process for designing and implementing classes. In this section, we’ll cover some important principles and subtle points when it comes to class design that will inform how we use classes throughout the rest of this course.

Information hiding#

The fundamental themes of the Class Design Recipe are design before coding and information hiding. Just as a great deal of thought goes into precisely specifying the purpose and expected behaviour of a function before you implement it, so too do you have to think about the design of a class before implementing even a single method.

The relationship between the author and client of a class plays a powerful and subtle role in class design. When we design a class, we must think about how another person would use this class. In other words, we design a class to be used by others, whether it’s other team members, colleagues on a different project, or even ourselves when we are writing new code months or years into the future (and only vaguely recall writing the class in the first place). And one of the biggest desires of “other users” is to be able to use our class without having to know at all how it works.

Designing classes by separating the public interface of the class from the private implementation details is known as information hiding, and is one of the fundamental elements of object-oriented programming. One of the biggest advantages of designing our programs in this way is that after our initial implementation, we can feel free to modify it (e.g., add new features or make it more efficient) without disturbing the public interface, and rest assured that this doesn’t affect other code that might be using this class.

Unfortunately, this course is too small in scope to give you the opportunity to write code for other people, although do keep in mind that you’re always writing your code for your future self. We’ll encourage you to follow the Class Design Recipe and think carefully about a clear separation between what one needs to know to implement a class and what information one needs to know to use that class.

Private-ness in Python#

As we have already discussed, the Class Design Recipe places a great emphasis on the distinction between the public interface of a class and its private implementation. So far, the focus has been on using documentation to define a clear interface: explicitly writing a good class docstring with all public attributes of the class clearly documented, and method docstrings that describe the operations the class supports. In this section, we’ll discuss another important way to document the attributes and methods that we want to keep private, and then go over pitfalls concerning the very concept of “private-ness” in Python.

Leading underscores#

An extremely common Python naming convention is to name anything that is considered private with a leading underscore. An underscore on an instance attribute indicates to a programmer writing client code that they should not access the instance variable: They should not use its value, and they certainly shouldn’t change it.

We can not not only mark attributes as private, but methods as well. What would be the point of a method that client code shouldn’t call? It could be a private helper for one of the methods that client code is welcome to call.

Python’s “we’re all adults” philosophy#

In other programming languages, when we declare restrictions on which attributes can be accessed outside the class and which cannot, they are enforced as part of the language itself. In Java, for example, attempting to access or modify an attribute that has been marked as private leads to an error that prevents the program from running at all.

The Python language takes a different approach to private attributes and methods, which is informed by one of its core philosophies: “We’re all adults here.” The idea is that the language gives its programmers a great deal of freedom when writing code—including allowing programmers to access private attributes and methods of classes from outside the class. While there are some Python language mechanisms for performing further restriction on access, they are beyond the scope of the course, and they are weak mechanisms that can be circumvented.

As a result of the Python philosophy, if someone else wants to use your class, they are ultimately responsible for using it “properly.” And if they do not, well, they’re an adult; if they access a private attribute or method, they should be aware that this might lead to unexpected or disappointing results.

This permissiveness doesn’t mean that we give up on private attributes or methods altogether. Our previous discussion about the philosophy of public vs. private is still valid, and indeed respected by Python programmers. It just means that it is absolutely vital in Python to write good documentation and follow coding conventions. In particular, this is why it is not enough to implement methods so that they enforce our desired representation invariants. Because a programmer may wish to access and mutate instance attributes directly, our representation invariants must be carefully documented so that the programmer knows (and is responsible for maintaining) these invariants.

This way, we give the users of our class enough information to use it the way we intended, and alert them to the things they should not do. And if the user ignores our documentation? That’s up to them, risks and all.

Combining classes: composition#

A class is almost never defined and used in isolation. It is much more often the case that it belongs to a large collection of classes, which are all related to each other in various ways. One fundamental type of relationship between two classes occurs when the instances of one class have an attribute which refers to one or more instances of the other class.

A User object might have a list of Tweets as an attribute. Colloquially, we say that a User “has some” Tweets.

class User:
    """A Twitter user.

    Attributes:
        userid: the userid of this Twitter user.
        bio: the bio of this Twitter user.
        tweets: the tweets that this user has made.
    """
    # Attribute types
    userid: str
    bio: str
    tweets: list[Tweet]

This type of relationship between classes is called composition, and appears all the time in object-oriented programming, because it arises so naturally. Whenever we have two classes, and one refers to instances of the other, that’s composition!

It is also the case that two classes might be related by composition in more than one way. For example, we might change Tweet so that it has an instance attribute user of type User, rather than just a string for the user’s id. We could even add an extra attribute original_creator of type User as well, representing the distinction between the user who originally wrote the tweet, and another user who retweets it.

Exercises: modelling with classes#

A common programming task is to take an English description of a problem and design classes that model the problem. The main idea is that the class(es) should correspond to the most important noun(s) in the problem, the attributes should be the information (other nouns or adjectives) associated with these noun(s), and the methods correspond to the verbs.

Here are a few examples for you to try out.

People#

We’d like to create a simple model of a person. A person normally can be identified by their name, but might commonly be asked about her age (in years). We want to be able to keep track of a person’s mood throughout the day: happy, sad, tired, etc. Every person also has a favourite food: when she eats that food, her mood becomes 'ecstatic'. And though people are capable of almost anything, we’ll only model a few other actions that people can take: changing their name, and greeting another person with the phrase 'Hi ____, it's nice to meet you! I'm ____.'

Rational numbers#

It’s slightly annoying for math people to use Python, because fractions are always converted to decimals and rounded, rather than kept in exact form. Let’s fix that! A rational number consists of a numerator and denominator; the denominator cannot be 0. Rational numbers are written like 7/8. Typical operations include determining whether the rational is positive, adding two rationals, multiplying two rationals, comparing two rationals, and converting a rational to a string.

Restaurant recommendation#

We want to build an app which makes restaurant recommendations for a group of friends going out for a meal. Each person has a name, current location, dietary restrictions, and some ratings and comments for existing restaurants. Each restaurant has a name, a menu from which one can determine what dishes accommodate what dietary restrictions, and a location. The recommendation system, in addition to actually making recommendations, should be able to report statistics like the number of times a certain person has used the system, the number of times it has recommended each restaurant, and the last recommendation made for a given group of people.