11. Classes
Classes are the way to model your code after real-world objects. The act of coding to model real-world objects is called Object Oriented Programming
or OOP
. Classes define two things about objects: their properties and actions. Properties are simply traits, characteristics or attributes of an object. Actions are what the object can do. As such, properties are variables attached to a class and actions are functions attached to a class. When you start defining an object with a class (or start writing a class definition), always keep in mind the properties and actions that are important to that object and make a sketch or outline. Let’s do some examples.
If you are trying to model a person, what are some properties of a person and what can a person do? A person has a first and last name, as well as an age. A person can also eat, sleep or talk.
- Person
- properties
first name
last name
age
- actions
eat
sleep
talk
If you are trying to model a square, what are some properties and actions of a square? A square has a side and can compute its own area and perimeter.
- Triangle
- properties
side
- actions
compute area
compute perimeter
What about modeling a car? A car has a make, model, year, doors and gas efficiency rating. A car can start up, speed up, slow down or stop.
- Car
- properties
make
model
year
number of doors
miles per gallon
- actions
start up
speed
slow
stop
Note
It is very helpful to name properties to be like nouns
and to name functions (actions) to be like verbs
. Names of properties should be nouny
and names of functions should be verby
.
11.1. Basic
The best way to understand how to define an object is by looking at the simplest examples possible. Below, we are starting to define classes for a Person, Triangle and Car. Since we just want to view the simplest possible way to define classes without providing actual logic, we use the pass
statement as a placeholder for future code.
1class Person(object):
2 pass
3
4class Triangle(object):
5 pass
6
7class Car(object):
8 pass
Note
In Python 2, classes could be defined as follows.
class Person:
pass
This way of declaring a class is called old-style
class definition. The new-style
class definition is to make all class inherit from object
explicitly as follows.
class Person(object):
pass
In Python 3, there are only new-style
classes, and whether you explicitly inherit from object
or not, all classes in Python 3 are new-style
. We encourage coders to explicitly inherit from object
regardless of using Python 2 or 3. In Python 2, if you use old-style
class definition, then the class (by way of the .__class__
property) and type (by way of calling type()
) are not aligned. There are many benefits of using new-style
classes.
11.2. Constructor
Let’s just focus on the class definition of Car
. Below, only the properties of a car are defined (make, model and year), and no actions are being modeled and defined, yet. However, there is one action (or method) that we have defined with the def
keyword called __init__()
. The function __init__()
is called the constructor
of the class; when we create an instance of a Car
, this constructor method is called to create the instance and its state. The constructor
dictates the essential properties that we must supply when we create an instance of a class. Notice that we have self
as a part of the arguments to the constructor? What is self
? What is self
doing there in the signature of the constructor? self
refers to the instance of Car
that we are trying to create, which is passed along with the properties.
1class Car(object):
2 def __init__(self, make, model, year):
3 pass
Note
Sometimes, the constructor is referred to as the ctor
.
How do we use self
? We use self
to associate the instance with the properties. Note how we use dot .
notation to say the instance’s make self.make
is assigned to the make
passed into the constructor, and the same for model and year as well.
1class Car(object):
2 def __init__(self, make, model, year):
3 self.make = make
4 self.model = model
5 self.year = year
Note
The constructor __init__()
has two underscores _
before and after the name init
. Why? By convention, Python has specified that magic methods
have double underscores before and after the method name. These magic methods are sometimes called dunders
for double underscores
. Dunders are magic methods in the sense that if they are defined, then they may be used with functions and operators with the intended, natural result. For example, another dunder is __repr__()
which should return a string representation of an instance of a class. When issuing print()
on an instance of a class, __repr__()
will be called to get the string representation.
11.3. Instantiation
Now that we have class definition for Car
, how do we create an instance of Car
? Remember, according to the constructor, we need to always pass in the make, model and year. When create an instance, we simply refer to the name of the class with parentheses, and inside the parentheses, supply the required properties. Note that self
is not supplied, Python will do that part.
1class Car(object):
2 def __init__(self, make, model, year):
3 self.make = make
4 self.model = model
5 self.year = year
6
7# instantiation: create a Car instance
8car = Car('Honda', 'Accord', 2019)
After we have an instance of Car
, we can access the properties (and methods) using dot .
notation.
1class Car(object):
2 def __init__(self, make, model, year):
3 self.make = make
4 self.model = model
5 self.year = year
6
7# instantiation: create a Car instance
8car = Car('Honda', 'Accord', 2019)
9
10# access the properties
11print(car.make)
12print(car.model)
13print(car.year)
14print(f'{car.make} {car.model} {car.year}')
11.4. Class methods
As stated before, classes has properties and actions. Actions defined for a class is no different than the general way of defining a function. However, the self
argument is always supplied to class functions or methods.
1class Car(object):
2 def __init__(self, make, model, year):
3 self.make = make
4 self.model = model
5 self.year = year
6
7 def start_up(self):
8 print('starting up')
9
10 def speed(self):
11 print('increasing speed')
12
13 def slow_down(self):
14 print('slowing down')
15
16 def stop(self):
17 print('stopping')
18
19# instantiation: create a Car instance
20car = Car('Honda', 'Accord', 2019)
21
22# access, call or invoke the methods of an instance of Car
23car.start_up()
24car.speed()
25car.slow_down()
26car.stop()
11.4.1. Getters and setters
If you come from other languages like Java or C#, it is typical to prevent direct read and write acces to properties. You may generate getters
and setters
to read and write to properties, correspondingly.
1class Car(object):
2 def __init__(self, make, model, year):
3 self.make = make
4 self.model = model
5 self.year = year
6
7 def get_make(self):
8 return self.make
9
10 def get_model(self):
11 return self.model
12
13 def get_year(self):
14 return self.year
15
16 def set_make(self, make):
17 self.make = make
18
19 def set_model(self, model):
20 self.model = model
21
22 def set_year(self, year):
23 self.year = year
24
25
26car = Car('Honda', 'Accord', 2019)
27
28car.set_make('Toyota')
29car.set_model('Supra')
30car.set_year(2019)
31
32print(f'{car.get_make()} {car.get_model()} {car.get_year()}')
Note
In general, getters
are called accessors
and setters
are called mutators
. Typically, in Python, it is not common to see getters and setters being defined for classes. It seems to be that Pythonistas want code to be concise.
11.4.2. Access specifiers
In OOP, you may desire to specify who can reference your class properties or invoke your class functions. Typically, there are three levels of access.
public
: anyone can have accessprotected
: only sub-classes can have accessprivate
: only the class itself can have access
The convention to signal access specifiers
is with underscores _
.
public
: names of properties or functions of a class without any underscore are publicprotected
: names of properties or functions of a class with one underscore as the prefix are protectedprivate
: names of properties or functions of a class with two underscores as the prefix are private
Below, we have defined __check_year()
to be private, and users cannot not invoke this method directly. The set_year()
method calls __check_year()
internally when the year is being mutated.
1class Car(object):
2 def __init__(self, make, model, year):
3 self.make = make
4 self.model = model
5 self.year = self.__check_year(year)
6
7 def __check_year(self, year):
8 return 2019 if year < 1950 else year
9
10 def get_make(self):
11 return self.make
12
13 def get_model(self):
14 return self.model
15
16 def get_year(self):
17 return self.year
18
19 def set_make(self, make):
20 self.make = make
21
22 def set_model(self, model):
23 self.model = model
24
25 def set_year(self, year):
26 self.year = self.__check_year(year)
27
28
29car = Car('Honda', 'Accord', 2019)
30
31car.set_make('Toyota')
32car.set_model('Supra')
33car.set_year(1800)
34
35print(f'{car.get_make()} {car.get_model()} {car.get_year()}')
Here’s another example of modeling a student. Notice that the first and last names are stored internally as private properties (two double underscores). Thus, we cannot access those properties directly. We have to access through the decorated methods (those methods are public).
1class Student(object):
2 def __init__(self, fname, lname):
3 self.__fname = fname
4 self.__lname = lname
5
6 @property
7 def fname(self):
8 return self.__fname
9
10 @property
11 def lname(self):
12 return self.__lname
13
14student1 = Student('John', 'Doe')
15student2 = Student('Jane', 'Smith')
16
17print(student1.fname) # will work
18print(student1.__fname) # private, will NOT work
11.4.3. Static method
You can define functions at the class level, as opposed to the instance level, by annotating a function with @staticmethod
. Such function is said to be a static method
and is defined without using self
as an argument since the function is not associated with an instance, but, rather, with the class. You do not need an instance of a class to call static methods, you can access a static method through the class name using dot .
notation.
1class Car(object):
2 def __init__(self, make, model, year):
3 self.make = make
4 self.model = model
5 self.year = Car.check_year(year)
6
7 @staticmethod
8 def check_year(year):
9 return 2019 if year < 1950 else year
10
11 def get_make(self):
12 return self.make
13
14 def get_model(self):
15 return self.model
16
17 def get_year(self):
18 return self.year
19
20 def set_make(self, make):
21 self.make = make
22
23 def set_model(self, model):
24 self.model = model
25
26 def set_year(self, year):
27 self.year = Car.check_year(year)
28
29
30car = Car('Honda', 'Accord', 2019)
31
32car.set_make('Toyota')
33car.set_model('Supra')
34car.set_year(1800)
35
36print(f'{car.get_make()} {car.get_model()} {car.get_year()}')
11.4.4. Method overriding dunders
Overriding a method means to redefine it (assumes that a method has been previously defined). When defining a class, two dunder functions that should be overridden are __str__()
and __repr__()
. Both methods return a string representation of the instance (of a class), however,
__str__()
returns an informal representation, and__repr__()
returns a formal representation.
By convention, the string returned by __repr__()
should be able to be used to reconstruct the instance. Many times, __str__()
can just call __repr()__
(or vice-versa).
1class Car(object):
2 def __init__(self, make, model, year):
3 self.make = make
4 self.model = model
5 self.year = year
6
7 def __repr__(self):
8 return f"{{'make': {self.make}, 'model': {self.model}, 'year': {self.year}}}"
9
10 def __str__(self):
11 return f'Car(make={self.make}, model={self.model}, year={self.year})'
12
13
14car = Car('Honda', 'Accord', 2019)
15
16print(car)
17print(repr(car))
11.5. Inheritance
One of the core tenets of coding is to reuse existing code. Why? Writing code is hard and testing code to assure quality and correctness is resource intensive. When you can, reuse code. This core tenent is so highly valued, we create languages like Python with features like OOP to encourage and enable code reuse. Specifically, inheritance
is the feature of OOP that maximizes code-reuse. If we define a base class, there may be many sub-classes that share the same properties and actions, but with slight caveats. Inheritance enables the sub-classes to acquire the properties and actions of the base or parent class, and we can extend or modify the parent class’ features by adding or overriding its features.
Let’s understand inheritance a bit better with an example dealing with shapes. In particular, we want to model a rectangle as follows. A rectangle has a width and length (properties). A rectangle should be able to compute its own area and perimeter (actions).
- Rectangle
- properties
width
length
- actions
computes area
computes perimeter
A Rectangle
class definition could look like the following.
1class Rectangle(object):
2 def __init__(self, width, length):
3 self.name = type(self).__name__
4 self.width = width
5 self.length = length
6
7 def get_area(self):
8 return self.width * self.length
9
10 def get_perimeter(self):
11 return self.width * 2 + self.length * 2
12
13 def __str__(self):
14 return self.__repr__()
15
16 def __repr__(self):
17 return f'{self.name}[width={self.width}, length={self.length}]'
18
19r = Rectangle(10, 5)
20print(r)
21print(r.get_area())
22print(r.get_perimeter())
Now we want to create a class definition for a square. We know that a square is a rectangle with all sides equaled to one another. We do not want to recreate all the logic of that goes into computing the area and perimeter of a square since we have that logic defined in the Rectangle
class. We should use inheritance to achieve the goal of logical relationship and code reuse. In the Square
class definition, the constructor is different by requiring only the value of one side (all sides are equal, or width is the same as length). Also, we use super()
to call the ctor of Rectangle
; Rectangle
is called the base, parent or super class of Square
. Lastly, notice how Square
does not have to define get_area()
, get_perimeter()
, __str__()
or __repr__()
? Square
has inherited such methods from Rectangle
.
1class Square(Rectangle):
2 def __init__(self, side):
3 super().__init__(side, side)
Here is the all-in-one example of class inheritance.
1class Rectangle(object):
2 def __init__(self, width, length):
3 self.name = type(self).__name__
4 self.width = width
5 self.length = length
6
7 def get_area(self):
8 return self.width * self.length
9
10 def get_perimeter(self):
11 return self.width * 2 + self.length * 2
12
13 def __str__(self):
14 return self.__repr__()
15
16 def __repr__(self):
17 return f'{self.name}[width={self.width}, length={self.length}]'
18
19class Square(Rectangle):
20 def __init__(self, side):
21 super().__init__(side, side)
22
23r = Rectangle(10, 5)
24print(r)
25print(r.get_area())
26print(r.get_perimeter())
27
28s = Square(5)
29print(s)
30print(s.get_area())
31print(s.get_perimeter())
Note
In OOP, much discussion is around differentiating between inheritance
and composition
. Inheritance is when one class (sub-class) derives from another class (base class). Composition is when one class has another class as one of its properties. Along with our Car
class, we can also create a Wheel
class, and specify that a Car
has 4 Wheel
. Two classes in an inheritance relationship is called a is-a
relationship and two classes in a compos relationship is called a has-a
relationship.
11.6. Abstract Classes
Here, we have a base class Animal
. Noticed how the Animal class itself inherits from ABC
(Abstract Base Class)?
We annotate the make_noise
method @abstractmethod
decorator. A class that inherits from ABC
cannot be
instantiated directly. We create two subclasses, Dog
and Cat
, from Animal. These derived classes can be
instantiated, however, they must implement all methods annotated with @abstractmethod
or a TypeError
will be thrown.
The Animal class is also called a formal interface
.
1from abc import ABC, abstractmethod
2
3class Animal(ABC):
4 def __init__(self, name):
5 self.name = name
6
7 @abstractmethod
8 def make_noise(self):
9 pass
10
11class Dog(Animal):
12 def __init__(self, name):
13 super().__init__(name)
14
15 def make_noise(self):
16 print(f'{self.name} says woof')
17
18class Cat(Animal):
19 def __init__(self, name):
20 super().__init__(name)
21
22 def make_noise(self):
23 print(f'{self.name} says meow')
24
25animals = [Dog('clifford'), Cat('heathcliff')]
26for animal in animals:
27 animal.make_noise()
11.7. Informal Interface
An informal interface is defined like a class but has no internal state and method implementations are not provided. Below, we have a circle calculator which computes the area and circumference of a circle given the radius. We also show how to implement the informal interface.
1class CircleCalculator(object):
2 def get_area(self, radius):
3 raise NotImplementedError
4
5 def get_circumference(self, radius):
6 raise NotImplementedError
7
8class CircleCalculatorLogger(CircleCalculator):
9 def get_area(self, radius):
10 area = 3.1415 * radius * radius
11 print(f'radius = {radius:.5f}, area = {area:.5f}')
12 return area
13
14 def get_circumference(self, radius):
15 circumference = 2.0 * radius * 3.1415
16 print(f'radius = {radius:.5f}, circumference = {circumference:.5f}')
17 return circumference
18
19calc = CircleCalculatorLogger()
20calc.get_area(3)
21calc.get_circumference(3)
11.8. Mixin
A mixin is like an abstract base class, but does not have state. It sits somewhere between an informal interface and abstract base class. Philosophically, a mixin avoids problems with single and multiple inheritance. Single inheritance can be taken to an extreme where long chains of inheritance can obfuscate the intentions of methods and properties (fragmentation). Multiple inheritance is problematic when diamond dependencies are created.
Below, we have an example class DivisionSolver
that solves a division problem; the dividend (numerator)
and divisor (denominator) must be given. The DivFloatMixin
returns the solution as a float.
The DivQRMixin
returns the solution with the quotient and remainder.
1class DivFloatMixin(object):
2 def div_float(self):
3 return self.dividend / self.divisor
4
5class DivQRMixin(object):
6 def div_qr(self):
7 return (self.dividend // self.divisor, self.dividend % self.divisor)
8
9# in Python, class hierarchy is defined right to left
10# object is the base class since it comes last
11# mixins should be defined first
12# if mixins override each other's methods, the priority is resolved left to right
13class DivisionSolver(DivQRMixin, DivFloatMixin, object):
14 def __init__(self, dividend, divisor):
15 self.dividend = dividend
16 self.divisor = divisor
17
18solver = DivisionSolver(11, 5)
19answer = solver.div_float()
20print(f'{answer:0.5f}')
21
22quotient, remainder = solver.div_qr()
23print(f'{quotient}r{remainder}')
Python has a lot of mixins. Below, we use the Mapping
mixin to model a person’s mailing label.
Notice that the MailingLabel
class behaves like a dictionary?
1from collections.abc import Mapping
2import textwrap
3import inspect
4
5class MailingLabel(Mapping):
6 def __init__(self, **kwargs):
7 self.data = kwargs
8
9 def __getitem__(self, key):
10 return self.data[key]
11
12 def __iter__(self):
13 return iter(self.data)
14
15 def __len__(self):
16 return len(self.data)
17
18 def __repr__(self):
19 s = f"""
20 {self.data['fname']} {self.data['lname']}
21 {self.data['street']}
22 {self.data['city']}, {self.data['state']} {self.data['zip']}
23 """
24 s = inspect.cleandoc(s)
25 return s
26
27label = MailingLabel(**{
28 'fname': 'One-Off',
29 'lname': 'Coder',
30 'street': '7526 Old Linton Hall Road',
31 'city': 'Gainesville',
32 'state': 'VA',
33 'zip': '20155'
34})
35
36print(label)
37print('-'*5)
38for k, v in label.items():
39 print(f'{k}: {v}')
11.9. Exercise
Model a teacher and students in a classroom. The class definitions required are as follows.
- Person
- properties
first name
last name
gender
- actions
a check to see if the person is a male
- Teacher
- properties
a list of students
course name
- actions
a way to count the number of students
a way to get the average grade of all students
a way to get the average grade of all male students
a way to get the average grade of all female students
- Student
- properties
a list of numeric grades
- actions
a way to return the list of numeric grades as a list of letter grades
a way to get the overall grade numerically
a way to get the overall letter grade
Create instances of a Teacher
and a list of Students
with the following data.
What is the number grade of each student?
What is the letter grade of each student?
What is the average grade of all students of the teacher?
What is the average grade of all male students?
What is the average grade of all female students?
1def get_teacher_data():
2 return {
3 'first_name': 'Janet',
4 'last_name': 'Wang',
5 'gender': 'female',
6 'course': 'Intro to Python'
7 }
8
9def get_student_data():
10 import random
11 random.seed(37)
12 get_grades = lambda lower, upper: [random.randrange(lower, upper) for _ in range(10)]
13
14 return [
15 {'first_name': 'Jack', 'last_name': 'Smith', 'gender': 'Male', 'grades': get_grades(75, 85)},
16 {'first_name': 'Joe', 'last_name': 'Johnson', 'gender': 'Male', 'grades': get_grades(85, 95)},
17 {'first_name': 'Jeremy', 'last_name': 'Zhang', 'gender': 'Male', 'grades': get_grades(55, 100)},
18 {'first_name': 'Justin', 'last_name': 'Ali', 'gender': 'Male', 'grades': get_grades(75, 90)},
19 {'first_name': 'Jeff', 'last_name': 'McDaniel', 'gender': 'Male', 'grades': get_grades(75, 90)},
20 {'first_name': 'Nancy', 'last_name': 'Wu', 'gender': 'Female', 'grades': get_grades(75, 100)},
21 {'first_name': 'Norah', 'last_name': 'Cortez', 'gender': 'Female', 'grades': get_grades(85, 100)},
22 {'first_name': 'Natasha', 'last_name': 'Canseco', 'gender': 'Female', 'grades': get_grades(80, 100)},
23 {'first_name': 'Natalie', 'last_name': 'Ronaldo', 'gender': 'Female', 'grades': get_grades(60, 100)},
24 {'first_name': 'Noella', 'last_name': 'Kim', 'gender': 'Female', 'grades': get_grades(90, 100)}
25 ]
26
27def get_data():
28 return {
29 'teacher': get_teacher_data(),
30 'students': get_student_data()
31 }
Solution.
1def get_teacher_data():
2 return {
3 'first_name': 'Janet',
4 'last_name': 'Wang',
5 'gender': 'female',
6 'course': 'Intro to Python'
7 }
8
9def get_student_data():
10 import random
11 random.seed(37)
12 get_grades = lambda lower, upper: [random.randrange(lower, upper) for _ in range(10)]
13
14 return [
15 {'first_name': 'Jack', 'last_name': 'Smith', 'gender': 'Male', 'grades': get_grades(75, 85)},
16 {'first_name': 'Joe', 'last_name': 'Johnson', 'gender': 'Male', 'grades': get_grades(85, 95)},
17 {'first_name': 'Jeremy', 'last_name': 'Zhang', 'gender': 'Male', 'grades': get_grades(55, 100)},
18 {'first_name': 'Justin', 'last_name': 'Ali', 'gender': 'Male', 'grades': get_grades(75, 90)},
19 {'first_name': 'Jeff', 'last_name': 'McDaniel', 'gender': 'Male', 'grades': get_grades(75, 90)},
20 {'first_name': 'Nancy', 'last_name': 'Wu', 'gender': 'Female', 'grades': get_grades(75, 100)},
21 {'first_name': 'Norah', 'last_name': 'Cortez', 'gender': 'Female', 'grades': get_grades(85, 100)},
22 {'first_name': 'Natasha', 'last_name': 'Canseco', 'gender': 'Female', 'grades': get_grades(80, 100)},
23 {'first_name': 'Natalie', 'last_name': 'Ronaldo', 'gender': 'Female', 'grades': get_grades(60, 100)},
24 {'first_name': 'Noella', 'last_name': 'Kim', 'gender': 'Female', 'grades': get_grades(90, 100)}
25 ]
26
27def get_data():
28 return {
29 'teacher': get_teacher_data(),
30 'students': get_student_data()
31 }
32
33class Person(object):
34 def __init__(self, first_name, last_name, gender):
35 self.first_name = first_name
36 self.last_name = last_name
37 self.gender = gender
38
39 def is_male(self):
40 return self.gender.lower() == 'male'
41
42class Teacher(Person):
43 def __init__(self, first_name, last_name, gender, course, students=[]):
44 super().__init__(first_name, last_name, gender)
45 self.course = course
46 self.students = students
47
48 @staticmethod
49 def convert_number_to_letter(number):
50 if 88.5 <= number <= 100.0:
51 return 'A'
52 elif 78.5 <= number < 88.5:
53 return 'B'
54 elif 68.5 <= number < 78.5:
55 return 'C'
56 elif 58.5 <= number < 68.5:
57 return 'D'
58 else:
59 return 'F'
60
61 def get_num_students(self):
62 return len(self.students)
63
64 def get_average_grade(self):
65 grades = [s.grade for s in self.students]
66 total = sum(grades)
67 n = len(grades)
68 average = total / n
69 return average
70
71 def __get_average_grade_by_gender(self, gender='Male'):
72 grades = [s.grade for s in self.students if s.gender == gender]
73 total = sum(grades)
74 n = len(grades)
75 average = total / n
76 return average
77
78 def get_male_average_grade(self):
79 return self.__get_average_grade_by_gender()
80
81 def get_female_average_grade(self):
82 return self.__get_average_grade_by_gender(gender='Female')
83
84 def get_student_grades(self, letter=True):
85 return [(s.first_name, s.last_name, s.letter_grade if letter else s.grade) for s in self.students]
86
87class Student(Person):
88 def __init__(self, first_name, last_name, gender, grades=[]):
89 super().__init__(first_name, last_name, gender)
90 self.grades = grades
91 self.letter_grades = [Teacher.convert_number_to_letter(g) for g in grades]
92 self.grade = sum(self.grades) / len(self.grades)
93 self.letter_grade = Teacher.convert_number_to_letter(self.grade)
94
95data = get_data()
96
97to_student = lambda s: Student(s['first_name'], s['last_name'], s['gender'], s['grades'])
98students = [to_student(s) for s in data['students']]
99
100teacher = Teacher(
101 data['teacher']['first_name'],
102 data['teacher']['last_name'],
103 data['teacher']['gender'],
104 data['teacher']['course'], students)
105
106print(teacher.get_student_grades(letter=False))
107print(teacher.get_student_grades())
108print(teacher.get_average_grade())
109print(teacher.get_male_average_grade())
110print(teacher.get_female_average_grade())
11.10. Data classes
Data classes are available in Python 3.7. Notice the following:
the
@dataclass
annotation is placed on the classproperties defined in a data class requires type hints
the
__eq__
and__repr__
dunders are implemented
1from dataclasses import dataclass
2
3@dataclass
4class Person:
5 first_name: str
6 last_name: str
7 age: int
8
9p1 = Person('john', 'doe', 23)
10p2 = Person('jane', 'smith', 22)
11
12print(p1)
13print(p2)
14
15print(p1 == p2)