As humans, we naturally view the world in from an object-oriented perspective. We identify individual instances of objects by noting their type, then draw conclusions about associated data and behavior. This gives us the power of abstraction and encapsulation. By encapsulating data and behavior into an object, we can abstract away the details of how the object is represented and how its behaviors are implemented. This lets us zoom out to not be distracted by specifics as the user of an object and lets us zoom in when we are the ones defining it. We gain code reusability as we can define the blueprint of an object once, then create many instances of it.
For example, when we walk up to a building, we will probably start looking for a door. We have certain assumptions about properties of a door, such as its material (wood, glass, metal, etc.) and how we can interact with it through expected behaviors (it should be able to open or close). Somewhere, then, we have a model for the “door” class of objects, regardless of whether we are thinking of a particular instance. This is the paradigm that object-oriented programming provides us – the ability to describe or define a type or class of object. When we encounter an instance of that type, we can then ask it to perform the associated behaviors or give us information about its particular properties. You’ve actually been using OOP in Python any time you interact with a string or list. When you want to interact with a particular object, you can “ask” it by using the dot . notation.
While OOP can be intuitive from this more abstract perspective, you may find that it takes some getting used to when you define your own classes. Keep in mind the relationship between defining a function (providing the lines of code that capture the implementation to perform a behavior) and invoking a function (resulting in those lines of code being executed). Defining a class will be analogous to defining the body of a function – we’ll provide the properties and methods that capture the implementation of an object of that type. Constructing (creating) and interacting with an instance of the class will be analogous to invoking a function. Just as functions gave us abstraction and encapsulation, OOP lets us abstract away the implementation details of an object and encapsulate its data and behavior into a single class or type.
To get us started, we’ll talk through a “real-world” example that will help highlight the distinction between a class and an instance.
Suppose we are opening a Smoothie shop and designing the menu board. We need to have an idea about how we “define” the smoothies that our customers could order. Let’s consider these 2 questions:
What information do we attach to each smoothie? (instance properties)
For this example, we’ll want a size, price and ingredient list.
What behaviors do we expect a smoothie support, or, how will we interact with it? (instance methods)
As a starting point, we’ll want add an ingredient.
It will turn out to be important to think about the creation of a smoothie – what happens when an order is placed? This is called constructing an instance of the Smoothie class. We’ll take a moment to think about what information (if any) is required to construct a new instance. For our example, let’s decide that the size of the smoothie must be specified when it is ordered and then constructed.
We’ll start moving towards python syntax. Remember, we want to be able to associate these to an object:
instance properties: These are data or “memory” that each instance of the class holds. Instance properties are sometimes also called “instance attributes.” You may want to think of these as variables associated with objects.
instance methods: These are procedures or “behaviors” that each instance of the class supports. You may want to think of them as functions associated to objects.
There is a special constructor method that is executed when a new instance of the class is created.
It’s important to note that a class definition provides the blueprint or template for an object of that type. Instances of the class only come into existence upon construction or instantiation. This is similar to how you can define a function, but code in its body will only be executed when the function is invoked.
Class definitions are placed within a text file, conventionally named <ClassName>.py.
We denote the definition a class using the keyword class, followed by the class name, (object) and a colon : as in
class Door(object):
The definition of the class will be a block of code indented below this line of code (similar to functions). We will not spend time in this course on why we must include (object). In a subsequent course, you can learn about “inheritance” and what it would mean to put something other than object in the parentheses.
Within the class definition, we’ll look at three particular components:
the constructor
instance properties (accessed through a special self parameter)
instance methods (which must include the self paramter)
(Note that, by convention, we always name this special parameter self, though we actually call it something different. What really matters is that the first parameter will refer to the object itself.)
The constructor uses the special identifier __init__ and has a similar syntax to a function definition, as in:
class <classname>(object):
def __init__(self, ...):
""" The constructor is called when a new instance is created. """
<block of code, typically initializes instance properties>
The first parameter should be self. We can then include 0 or more additional parameters as makes sense for that class.
Using self, we can access instance properties using the dot . notation. For example, self.material would access an instance property named material. You’ll actually see us move to a better style where we use two underscores, as in self.__material; we’ll discuss this later in the topic.
By convention, we will always use the constructor to initialize instance properties, as in:
class Door(object):
def __init__(self, constructionMaterial:str ):
""" The constructor is called when a new instance is created. """
# a door has a material, which is given to us as a parameter
# we'll track it through an instance property named material
self.material:str = constructionMaterial
# a door will always start out closed
# we'll track it through an instance property named isClosed
self.isClosed:bool = True
We can define as many instance methods as we want within the class definition; these should always have self as the first parameter. As with functions, we can then include a list of 0 or more parameters required by the method. The syntax is similar to a function, starting with the keyword def, as in:
class Door(object):
def __init__(self, constructionMaterial:str ):
""" The constructor is called when a new instance is created. """
# a door has a material, which is given to us as a parameter
# we'll track it through an instance property named material
self.material:str = constructionMaterial
# a door will always start out closed
# we'll track it through an instance property named isClosed
self.isClosed:bool = True
# instance properties
def close( self ):
""" Close this door. """
self.isClosed = True
def open( self ):
""" Open this door. """
self.isClosed = False
Using the class
Once a class is defined, we can construct or instantiate instances and use the dot notation to ask an instance to perform instance methods. To construct an instance, we use the name of the class, followed by parentheses and any parameters (other than self) specified by the constructor. Typically, we’ll want to keep track of the newly created object in memory by assigning it to a variable, as in:
# instantiate a Door object, using the name of the class,
# parentheses and passing the single argument required
woodenDoor:Door = Door( "wood" )
# ask the door to perform an instance method by using the dot notation,
# followed by the name of the instance method, and parentheses with any
# arguments other than self required by the method signature
woodenDoor.open()
Now let’s see how this comes together by running a complete program!
class Smoothie( object ):
"""
A class to represent a smoothie,
which has a size (small/medium/large)
a list of ingredients and a price.
"""
# all smoothies share the same price list
priceList:dict = {"small":3, "medium":4, "large":5}
# constructor
def __init__( self, size:str ):
""" Creates a new smoothie of the given size. """
self.size:str = size
self.price:int = Smoothie.priceList[size]
self.ingredients:list = []
def displayInfo( self ) -> None:
""" Prints information about the smoothie. """
print( f"This {self.size} smoothie made of {self.ingredients} costs ${self.price}." )
def addIngredient( self, ingredient ) -> None:
""" Add an ingredient to the smoothie and update the price. """
self.ingredients.append( ingredient )
self.price = self.price + 1
def main():
strawberrySmoothie:Smoothie = Smoothie( "medium" )
strawberrySmoothie.addIngredient( "banana" )
strawberrySmoothie.addIngredient( "strawberries" )
strawberrySmoothie.addIngredient( "mint leaves" )
berrySmoothie:Smoothie = Smoothie( "large" )
berrySmoothie.addIngredient( "blackberries" )
berrySmoothie.addIngredient( "strawberries" )
berrySmoothie.addIngredient( "raspberries" )
berrySmoothie.addIngredient( "ice" )
strawberrySmoothie.displayInfo()
berrySmoothie.displayInfo()
if __name__ == "__main__":
main()
class Door(object):
def __init__(self, constructionMaterial:str ):
""" The constructor is called when a new instance is created. """
# a door has a material, which is given to us as a parameter
# we'll track it through an instance property named material
self.material:str = constructionMaterial
# a door will always start out closed
# we'll track it through an instance property named isClosed
self.isClosed:bool = True
def close( self ) -> None:
""" Close this door. """
self.isClosed = True
def open( self ) -> None:
""" Open this door. """
self.isClosed = False
def instructions( self ) -> None:
""" Notify the user of how to interact with this door. """
# if the door is closed
if self.isClosed:
print( f"This {self.material} door is closed. Please knock!" )
# otherwise, the door must be open
else:
print( f"This {self.material} door is open. Walk right in!" )
# some testing code
woodenDoor:Door = Door( "wood" )
# ask the door to notify us of how to interact with it
woodenDoor.instructions()
# ask the door to open
woodenDoor.open()
# ask the door to notify us of how to interact with it
woodenDoor.instructions()
# make another door
glassDoor:Door = Door( "glass" )
# ask the door to notify us of how to interact with it
glassDoor.instructions()
You’ll notice a convention that we’ll use: the name or identifier of a class will be capitalized (Smoothie, Door), while variables pointing to an instance will not (strawberrySmoothie, woodenDoor). We’ll also use lowercase to start the identifiers for instance properties and instance methods.
Remember that one of the benefits of OOP is that we can abstract away the details of an implementation. This allows us to zoom in and out as programmers to focus on particular tasks, leading to more robust code. In the context of an object, then, we’d like to be able to interact with instances of a class without relying or revealing the implementation details. This is helpful for two reasons: (1) when we use the class, we can work with what it offers us and not be distracted by its inner workings, and (2) if we decide to change the implementation, we can focus only on the class definition and not on code that uses the class. For example, think about using a microwave. Different microwaves will have been engineered with different electronic components. However, when we go to use a microwave, even one we’ve never used before, we are only interested in the functionality it provides (will it make us popcorn??) and not in how the heating elements are put together. If we could see all the various components, we might get distracted from the task at hand (waiting until the pops are only 2 seconds apart!).
This idea is sometimes called “information hiding” and is so important that most object-oriented programming languages offer a way to restrict access to implementation details. While a bit outside the scope of this course, we pause to highlight a convention we will follow. From a style perspective, hiding as much information is considered a better approach. We’ll only focus on hiding instance properties; this is sometimes referred to as making them “private.”
In Python, the syntax for making an instance property private is to start its identifier with a double underscore __. This tells Python that only the instance itself is allowed to access the property using self.
Again, the full context for this is outside the scope of this course, but the takeway message for us is to always make our instance properties private.
class Smoothie( object ):
"""
A class to represent a smoothie, which has a size (small/medium/large)
a list of ingredients and a price.
"""
# all smoothies share the same price list (class variable)
priceList:dict = {"small":3, "medium":4, "large":5}
# constructor
def __init__( self, size:str ):
""" Creates a new smoothie of the given size. """
self.__size:str = size
self.__price:int = Smoothie.priceList[size]
self.__ingredients:list = []
def addIngredient( self, ingredient:str ) -> None:
""" Add an ingredient to the smoothie and update the price. """
self.__ingredients.append( ingredient )
self.__price = self.__price + 1
def displayInfo( self ) -> None:
""" Prints information about the smoothie. """
print( f"This {self.__size} smoothie made of {self.__ingredients} costs ${self.__price}." )
# getters
def getPrice( self ) -> int:
""" Get the price of this smoothie. """
return self.__price
def getSize( self ) -> str:
""" Get the size of this smoothie. """
return self.__size
def getIngredients( self ) -> list:
""" Get (a copy of) the ingredients of this smoothie. """
# since we don't want the ingredients to be modified,
# make a copy to give back!
return self.__ingredients.copy()
# setters
def setSize( self, newsize:str ):
""" Change the size of this smoothie and update the price. """
self.__size = newsize
self.__price = Smoothie.priceList[newsize] + len(self.__ingredients)
def main():
strawberrySmoothie:Smoothie = Smoothie( "medium" )
strawberrySmoothie.addIngredient( "banana" )
strawberrySmoothie.addIngredient( "strawberries" )
strawberrySmoothie.addIngredient( "mint leaves" )
strawberrySmoothie.displayInfo()
print( f"The price of this smoothie is {strawberrySmoothie.getPrice()}")
strawberrySmoothie.setSize( "large" )
print( "Now I have set the size to be large!" )
strawberrySmoothie.displayInfo()
if __name__ == "__main__":
main()
With instance properties being private as a best practice, we often want to provide instance methods that allow access to the data. By wrapping this access in a method, we have more control over how the data is being manipulated.
Getter methods will typically have no parameters and return the value of the instance property.
Note that we can be a little careful in what we return since it is in a method. The getIngredients method above returns a copy, since otherwise the instance property could be modified!
Setter methods will typically require one parameter and not return anything; if the parameter value is valid, the associated instance property will be updated.