Classes

python
Author
Published

April 9, 2024

Modified

April 8, 2024

So far, the kinds of objects of our own that we’ve created in python are variables and functions.

Variables:

python
width = 8
height = 5

Functions:

python
def area(width, height):
    return width * height

def perimeter(width, height):
    return (2*width) + (2*height)
python
area(8, 5)
40
python
perimeter(8, 5)
26

Today we’re going to introduce a new type called classes. You’ve already been interacting with classes from other python packages, so this might demystify some of how python works.

python
import numpy as np

Classes

Classes are python’s implementation of what’s known as “object oriented programming”. They let us bundle together lots of different values, and functions for operating and updating those values in one single “object”.

For example, I’m currently working on a project that will need to bundle together lots of different speaker demographics, so I’ve been working on a Speaker class.

python
class Speaker():
    pass

The class keyword says that what follows will be a new kind of class. By convention, class names are in TitleCase.

Initializing a class

To tell a class what kind of values it should be “initialized” with, we need to define a special function inside the class called __init__().

python
class Speaker():
    def __init__(
            self, 
            name = None,
            pronoun = None,
            age = None,
            year = None
    ):
        self.name = name
        self.pronoun = pronoun
        self.age = age
        self.year = year

__init__() should always take a special variable called self as the first argument. The other arguments I defined all get assigned to an attribute of self. Here’s how that looks in use.

python
# creating an *instance*
joe = Speaker(
    name = "Joe F", 
    pronoun = "he",
    age = 39, 
    year = 2024
)

We can then get the attributes of the joe object with the . syntax.

python
joe.name
'Joe F'
python
joe.age
39
python
joe.year
2024

We can create another instance of Speaker.

python
noam = Speaker(
    name = "Noam C", 
    pronoun = "he",
    age = 95, 
    year = 2024
)
noam.name
'Noam C'

More stuff in __init__().

Above, the __init__() function just assigns each input value to the same name attribute of the instance. But we can do more.

python
class Speaker():
    def __init__(
            self, 
            name = None,
            pronoun = None,
            age = None,
            year = None
    ):
        self.name = name
        self.pronoun = pronoun
        self.age = age
        self.year = year
        self.dob = year - age
python
joe = Speaker(
    name = "Joe F", 
    pronoun = "he",
    age = 39, 
    year = 2024
)

noam = Speaker(
    name = "Noam C", 
    pronoun = "he",
    age = 95, 
    year = 2024
)
python
joe.dob
1985
python
noam.dob
1929

Making a class print nice

Right now, if we say print(joe), we’re going to get a pretty ugly output.

python
print(joe)
<__main__.Speaker object at 0x110022dd0>

We can tell a class what it should print by defining a __repr__() method inside of it.

python
class Speaker():
    def __init__(
            self, 
            name = None,
            pronoun = None,
            age = None,
            year = None
    ):
        self.name = name
        self.pronoun = pronoun
        self.age = age
        self.year = year
        self.dob = year - age

    def __repr__(self):
        return f"{self.name} was interviewed in "\
               f"{self.year} when {self.pronoun} "\
               f"was {self.age} years old."
python
joe = Speaker(
    name = "Joe F", 
    pronoun = "he",
    age = 39, 
    year = 2024
)

noam = Speaker(
    name = "Noam C", 
    pronoun = "he",
    age = 95, 
    year = 2024
)
python
print(joe)
Joe F was interviewed in 2024 when he was 39 years old.
python
print(noam)
Noam C was interviewed in 2024 when he was 95 years old.

Adding methods

There might be things you want to calculate or otherwise derive from the values within class instance, or values you want to update. You can do this by defining additional methods.

python
class Speaker():
    def __init__(
            self, 
            name = None,
            pronoun = None,
            age = None,
            year = None
    ):
        self.name = name
        self.pronoun = pronoun
        self.age = age
        self.year = year
        self.dob = year - age

    def __repr__(self):
        return f"{self.name} was interviewed in "\
               f"{self.year} when {self.pronoun} "\
               f"was {self.age} years old."
    
    def age_in(self, year):
        age = year - self.dob
        if age < 0:
            return np.nan
        return age
    
    def year_when(self, age):
        return self.dob + age
python
joe = Speaker(
    name = "Joe F", 
    pronoun = "he",
    age = 39, 
    year = 2024
)

noam = Speaker(
    name = "Noam C", 
    pronoun = "he",
    age = 95, 
    year = 2024
)

If we wanted to see how old noam was when joe was born, we can now do this

python
noam.age_in(joe.dob)
56

We’ll get nan if we do the reverse, because we wrote the age_in() method not to return negative numbers.

python
joe.age_in(noam.dob)
nan

There was a notable event in 1971 when Noam C met Michel F over a large carafe of orange juice. Let’s see how old he was.

python
noam.age_in(1971)
42

If Joe F wants to reach a similar landmark meeting by that age, what year will he have to do it in?

python
joe.year_when(42)
2027

What’s the point of classes?

Classes won’t always be useful for a one-off usage, but they can be really useful when used in batches. Here’s some sample demographics from the Philadelphia Neighborhood Corpus.

python
names = [
    "Adam M", 
    "Adan F", 
    "Adrienne M", 
    "Agnes", 
    "Agnes P"
]

pronouns = [
    "he",
    "he",
    "she",
    "she",
    "she"
]

ages = np.array([
    83, 35, 35, 59, 50
])

years = np.array([
    2004, 1981, 1991, 1979, 1974
])

We can create a list of speaker objects from this data.

python
speakers = [
    Speaker(name, pronoun, age, year)
    for name, pronoun, age, year in zip(
        names, pronouns, ages, years
    )
]
python
speakers
[Adam M was interviewed in 2004 when he was 83 years old.,
 Adan F was interviewed in 1981 when he was 35 years old.,
 Adrienne M was interviewed in 1991 when she was 35 years old.,
 Agnes was interviewed in 1979 when she was 59 years old.,
 Agnes P was interviewed in 1974 when she was 50 years old.]

We can get the dates of birth for everyone now with relatively little code.

python
dobs = np.array([
    speaker.dob
    for speaker in speakers
])
dobs
array([1921, 1946, 1956, 1920, 1924])

Two large events in Philadelphia were papal visits in 1979 and 2015. We can figure out how old everyone was on those two dates now.

python
first_pope_age = np.array([
    speaker.age_in(1979)
    for speaker in speakers
])
first_pope_age
array([58, 33, 23, 59, 55])
python
second_pope_age = np.array([
    speaker.age_in(2015)
    for speaker in speakers
])
second_pope_age
array([94, 69, 59, 95, 91])

More advanced class features

There are some more advanced features we can build into classes in python. A lot of them are accessed by these methods that begin and end with double underscores __, so-called “dunder methods”. Here’s nice blog post about them.

Comparison methods

For example, with our Speaker class, theres no way to compare two instances with < or ==.

python
joe > noam
TypeError: '>' not supported between instances of 'Speaker' and 'Speaker'

But, we can tell a class how to behave with respect to these operators. Let’s say that the dimension we wanted to compare people on was their date of birth.

python
class Speaker():
    def __init__(
            self, 
            name = None,
            pronoun = None,
            age = None,
            year = None
    ):
        self.name = name
        self.pronoun = pronoun
        self.age = age
        self.year = year
        self.dob = year - age

    def __repr__(self):
        return f"{self.name} was interviewed in "\
               f"{self.year} when {self.pronoun} "\
               f"was {self.age} years old."
    
    # defines < between self and another obj
    def __lt__(self, obj):
        return self.dob < obj.dob
    
    # defines <= self and another obj
    def __le__(self, obj):
        return self.dob <= obj.dob

    def age_in(self, year):
        age = year - self.dob
        if age < 0:
            return np.nan
        return age
    
    def year_when(self, age):
        return self.dob + age

As it turns out, defining just __lt__() and __le__() is enough to get all of the comparison operators to work.

python
joe = Speaker(
    name = "Joe F", 
    pronoun = "he",
    age = 39, 
    year = 2024
)

noam = Speaker(
    name = "Noam C", 
    pronoun = "he",
    age = 95, 
    year = 2024
)
python
joe < noam
False
python
joe >=  noam
True
python
joe == noam
False

Having these less than and greater than methods defines also allows us to sort a list of objects.

python
speakers = [
    Speaker(name, pronoun, age, year)
    for name, pronoun, age, year in zip(
        names, pronouns, ages, years
    )
]

The order of objects in this list is just the order they appeared in in the original lists/arrays.

python
speakers
[Adam M was interviewed in 2004 when he was 83 years old.,
 Adan F was interviewed in 1981 when he was 35 years old.,
 Adrienne M was interviewed in 1991 when she was 35 years old.,
 Agnes was interviewed in 1979 when she was 59 years old.,
 Agnes P was interviewed in 1974 when she was 50 years old.]

But, with all of the comparison methods defined, we can get the max() and min() of this list.

python
youngest = max(speakers)
youngest
Adrienne M was interviewed in 1991 when she was 35 years old.
python
oldest = min(speakers)
oldest
Agnes was interviewed in 1979 when she was 59 years old.

And we can sort the list.

python
sorted(speakers)
[Agnes was interviewed in 1979 when she was 59 years old.,
 Adam M was interviewed in 2004 when he was 83 years old.,
 Agnes P was interviewed in 1974 when she was 50 years old.,
 Adan F was interviewed in 1981 when he was 35 years old.,
 Adrienne M was interviewed in 1991 when she was 35 years old.]

Dynamic properties

Sometimes, you might want to define dynamic properties of classes. For example, let’s look at noam.

python
(
    noam.age,
    noam.year,
    noam.dob
)
(95, 2024, 1929)

Let’s say we actually typoed the year, and Noam C was actually interviewed in 2023. We can update the year, but the date of birth won’t be correctly updated.

python
noam.year = 2023

(
    noam.age,
    noam.year,
    noam.dob
)
(95, 2023, 1929)

We can fix this by moving the definition of dob outside of the __init__() method, and into its own method decorated with @property.

python
class Speaker():
    def __init__(
            self, 
            name = None,
            pronoun = None,
            age = None,
            year = None
    ):
        self.name = name
        self.pronoun = pronoun
        self.age = age
        self.year = year

    def __repr__(self):
        return f"{self.name} was interviewed in "\
               f"{self.year} when {self.pronoun} "\
               f"was {self.age} years old."
    
    # defines < between self and another obj
    def __lt__(self, obj):
        return self.dob < obj.dob
    
    # defines <= self and another obj
    def __le__(self, obj):
        return self.dob <= obj.dob
    
    # dynamically define dob
    @property
    def dob(self):
        return self.year - self.age

    def age_in(self, year):
        age = year - self.dob
        if age < 0:
            return np.nan
        return age
    
    def year_when(self, age):
        return self.dob + age
python
joe = Speaker(
    name = "Joe F", 
    pronoun = "he",
    age = 39, 
    year = 2024
)

noam = Speaker(
    name = "Noam C", 
    pronoun = "he",
    age = 95, 
    year = 2024
)

The basic values have remained the same,

python
(
    noam.age,
    noam.year,
    noam.dob
)
(95, 2024, 1929)

But now, if we change the value of noam.year, the date of birth will be dynamically updated.

python
noam.year = 2023

(
    noam.age,
    noam.year,
    noam.dob
)
(95, 2023, 1928)

Chaining together classes

Let’s say we were going to follow some new practices in asking speakers about their gender, and gave them a rating scale between 0 and 10 for masculine and feminine. We could define a new Gender class like so:

python
class Gender():
    def __init__(self, masc=None, fem=None):
        self.masc = masc
        self.fem = fem

    def __repr__(self):
        return f"masc: {self.masc}; fem: {self.fem}"
python
Gender(5, 5)
masc: 5; fem: 5

We can then include a gender parameter within the Speaker class.

python
class Speaker():
    def __init__(
            self, 
            name = None,
            pronoun = None,
            gender = Gender(),
            age = None,
            year = None
    ):
        self.name = name
        self.pronoun = pronoun
        self.gender = gender
        self.age = age
        self.year = year

    def __repr__(self):
        return f"{self.name} was interviewed in "\
               f"{self.year} when {self.pronoun} "\
               f"was {self.age} years old."
    
    # defines < between self and another obj
    def __lt__(self, obj):
        return self.dob < obj.dob
    
    # defines <= self and another obj
    def __le__(self, obj):
        return self.dob <= obj.dob
    
    # dynamically define dob
    @property
    def dob(self):
        return self.year - self.age

    def age_in(self, year):
        age = year - self.dob
        if age < 0:
            return np.nan
        return age
    
    def year_when(self, age):
        return self.dob + age
python
joe = Speaker(
    name = "Joe F",
    pronoun = "he",
    gender = Gender(8, 2),
    age = 39,
    year = 2024
)

We can access the gender attribute with the . syntax.

python
joe.gender
masc: 8; fem: 2

And we can access the attributes of that object as well.

python
joe.gender.masc
8

Subclases

Now, let’s say we wanted to generalize our Gender class to have any given set of labels we wanted. We can create the more generic class like so:

python
class Gender():
    def __init__(self, dims: dict):
        self.dim_names = list(dims.keys())
        self.dict = dims
        for key in self.dim_names:
            setattr(self, key, self.dict[key])

    def __repr__(self):
        labs = [
            f"{dim}: {self.dict[dim]}; "
            for dim in self.dim_names
        ]
        return "".join(labs)
python
general = Gender({"big":9, "small": 2})
python
general.big
9

Now, we can make a subclass called BinaryGender from the more general class.

python
class BinaryGender(Gender):
    def __init__(self, masc = None, fem = None):
        super().__init__(
            {"masc": masc, "fem": fem}
        )

Passing Gender as an argument in defining the class tells python that BinaryGender is going to be a “subclass”. That means any methods or properties we defined for Gender will be automatically available to BinaryGender. So, for example, we never defined the __repr__() function for BinaryGender, so it’ll just use the one from Gender

python
bgender = BinaryGender(masc = 8, fem = 2)
python
bgender
masc: 8; fem: 2; 

Inside of the __init__() method, we called this special super().__init__() function. That tells python to run all of the __init__() code from the superclass (Gender) with the specified arguments.

python
bgender.masc
8
Back to top

Reuse

CC-BY-SA 4.0

Citation

BibTeX citation:
@online{fruehwald2024,
  author = {Fruehwald, Josef},
  title = {Classes},
  date = {2024-04-09},
  url = {https://lin511-2024.github.io/notes/programming/08_classes.html},
  langid = {en}
}
For attribution, please cite this work as:
Fruehwald, Josef. 2024. “Classes.” April 9, 2024. https://lin511-2024.github.io/notes/programming/08_classes.html.