⚙️OOP-1

OBJECT ORIENTED PROGRAMMING-I

Object Oriented Programming-I

Object-oriented programming (OOP) is a fundamental programming paradigm used by nearly every developer at some point in their career.

OOP is the "most popular programming paradigm" and is taught as the standard way to code for most of a programmers educational career.

OOP is a programming paradigm that relies on the concept of "classes" and "objects".

It is used to structure a software program into simple, reusable pieces of code blueprints (usually called classes), which are used to create individual instances of objects.

There are many object-oriented programming languages including JavaScript, C++, Java, and Python.

Benefits of OOP:

  • OOP models complex things as reproducible, simple structures

  • Reusable, OOP objects can be used across programs

  • Allows for class-specific behavior through polymorphism

  • Easier to debug, classes often contain all applicable information to them

  • Secure, protects information through encapsulation

Python is a multi-paradigm programming language. It supports different programming approaches.

One of the popular approaches to solve a programming problem is by creating objects. This is known as Object-Oriented Programming (OOP).

An object has two characteristics:

  • attributes

  • behavior

Let's take an example:

A parrot is can be an object,as it has the following properties:

  • name, age, color as attributes

  • singing, dancing as behavior

The concept of OOP in Python focuses on creating reusable code. This concept is also known as DRY (Don't Repeat Yourself).

Primitive data structures—like numbers, strings, and lists—are designed to represent simple pieces of information, such as the cost of an apple, the name of a poem, or your favorite colors, respectively. What if you want to represent something more complex?

For example, let’s say you want to track employees in an organization. You need to store some basic information about each employee, such as their name, age, position, and the year they started working.

One way to do this is to represent each employee as a list:

1 kirk = ["James Kirk", 34, "Captain", 2265]
2 spock = ["Spock", 35, "Science Officer", 2254]
3 mccoy = ["Leonard McCoy", "Chief Medical Officer", 2266]

There are a number of issues with this approach.

First, it can make larger code files more difficult to manage. If you reference kirk[0] several lines away from where the kirk list is declared, will you remember that the element with index 0 is the employee’s name?

Second, it can introduce errors if not every employee has the same number of elements in the list. In the mccoy list above, the age is missing, so mccoy[1] will return "Chief Medical Officer" instead of Dr. McCoy’s age.

A great way to make this type of code more manageable and more maintainable is to use classes .

Classes vs Instances

Classes are used to create user-defined data structures. Classes define functions called methods , which identify the behaviors and actions that an object created from the class can perform with its data.

A class is a blueprint for how something should be defined. It doesn’t actually contain any data. The Dog class specifies that a name and an age are necessary for defining a dog, but it doesn’t contain the name or age of any specific dog.

While the class is the blueprint, an instance is an object that is built from a class and contains real data. An instance of the Dog class is not a blueprint anymore. It’s an actual dog with a name, like Miles, who’s four years old.

Put another way, a class is like a form or questionnaire. An instance is like a form that has been filled out with information. Just like many people can fill out the same form with their own unique information, many instances can be created from a single class.

How to Define a Class

1 class Dog:
2     pass

All class definitions start with the class keyword, which is followed by the name of the class and a colon. Any code that is indented below the class definition is considered part of the class’s body.

Note: Python class names are written in CapitalizedWords notation by convention. For example, a class for a specific breed of dog like the Jack Russell Terrier would be written as JackRussellTerrier .

The Dog class isn’t very interesting right now, so let’s spruce it up a bit by defining some properties that all Dog objects should have. There are a number of properties that we can choose from, including name, age, coat color, and breed. To keep things simple, we’ll just use name and age.

The properties that all Dog objects must have are defined in a method called .init() . Every time a new Dog object is created, .init() sets the initial state of the object by assigning the values of the object’s properties. That is, .init() initializes each new instance of the class.

This type of function is also called constructor in Object Oriented Programming (OOP). We normally use it to initialize all the variables.

You can give .init() any number of parameters, but the first parameter will always be a variable called self . When a new class instance is created, the instance is automatically passed to the self parameter in .init() so that new attributes can be defined on the object.

Let’s update the Dog class with an .init() method that creates .name and .age attributes:

1 class Dog:
2     def __init__ (self, name, age):
3         self.name = name
4         self.age = age

Notice that the __init__() method’s signature is indented four spaces. The body of the method is indented by eight spaces. This indentation is vitally important. It tells Python that the .__init__() method belongs to the Dog class.

In the body of __init__() , there are two statements using the self variable:

  1. self.name = name creates an attribute called name and assigns to it the value of the name parameter.

  2. self.age = age creates an attribute called age and assigns to it the value of the age parameter.

Attributes created in __init__() are called instance attributes . An instance attribute’s value is specific to a particular instance of the class. All Dog objects have a name and an age, but the values for the name and age attributes will vary depending on the Dog instance.

On the other hand, class attributes are attributes that have the same value for all class instances. You can define a class attribute by assigning a value to a variable name outside of __init__() .

For example, the following Dog class has a class attribute called species with the value

"Canis familiaris" :

1 class Dog:
2     # Class attribute
3     species = "Canis familiaris"
4
5     def init (self, name, age):
6         self.name = name
7         self.age = age

Class attributes are defined directly beneath the first line of the class name and are indented by four spaces. They must always be assigned an initial value. When an instance of the class is created, class attributes are automatically created and assigned to their initial values.

Use class attributes to define properties that should have the same value for every class instance. Use instance attributes for properties that vary from one instance to another.

Instantiate an Object in Python

1 class Dog:
2     pass

This creates a new Dog class with no attributes or methods.

>>> Dog()
 <  main .Dog object at 0x106702d30>

You now have a new Dog object at 0x106702d30 . This funny-looking string of letters and numbers is a memory address that indicates where the Dog object is stored in your computer’s memory. Note that the address you see on your screen will be different.

Now instantiate a second Dog object:

>>> Dog()
 <  main .Dog object at 0x0004ccc90>

The new Dog instance is located at a different memory address. That’s because it’s an entirely new instance and is completely unique from the first Dog object that you instantiated.

To see this another way, type the following:

>>> a = Dog()
>>> b = Dog()
>>> a == b
False

Even though a and b are both instances of the Dog class, they represent two distinct objects in memory.

Class and Instance Attributes

Now create a new Dog class with a class attribute called .species and two instance attributes called .name and .age :

1 class Dog:
2     species = "Canis familiaris"
3     def init (self, name, age):
4         self.name = name
5         self.age = age

To instantiate objects of this Dog class, you need to provide values for the name and age . If you don’t, then Python raises a TypeError :

>>> Dog()
Traceback (most recent call last):
    File "<pyshell#6>", line 1, in <module>
       Dog()
TypeError: init () missing 2 required positional arguments: 'name' and 'age

To pass arguments to the name and age parameters, put values into the parentheses after the class name:

buddy = Dog("Buddy", 9)
miles = Dog("Miles", 4)

This creates two new Dog instances—one for a nine-year-old dog named Buddy and one for a four-year-old dog named Miles.

The Dog class’s .init() method has three parameters, so why are only two arguments passed to it in the example?

When you instantiate a Dog object, Python creates a new instance and passes it to the first parameter of .init() . This essentially removes the self parameter, so you only need to worry about the name and age parameters.

After you create the Dog instances, you can access their instance attributes using dot notation :

>>> buddy.name
 'Buddy'
>>> buddy.age
 9

>>> miles.name
 'Miles'
>>> miles.age
 4

You can access class attributes the same way:

>>> buddy.species
 'Canis familiaris'

One of the biggest advantages of using classes to organize data is that instances are guaranteed to have the attributes you expect. All Dog instances have .species , .name , and .age attributes, so you can use those attributes with confidence knowing that they will always return a value.

Although the attributes are guaranteed to exist, their values can be changed dynamically:

>>> buddy.age = 10
>>> buddy.age
 10

>>> miles.species = "Felis silvestris"
>>> miles.species
 'Felis silvestris'

In this example, you change the .age attribute of the buddy object to 10 . Then you change the .species attribute of the miles object to "Felis silvestris" , which is a species of cat. That makes Miles a pretty strange dog, but it is valid Python!

The key takeaway here is that custom objects are mutable by default. An object is mutable if it can be altered dynamically. For example, lists and dictionaries are mutable, but strings and tuples are immutable.

Deleting Attributes and Objects

Any attribute of an object can be deleted anytime, using the del statement. Try the following on the Python shell to see the output.

>>> num1 = ComplexNumber(2,3)
>>> del num1.imag
>>> num1.get_data()
Traceback (most recent call last):
...
AttributeError: 'ComplexNumber' object has no attribute 'imag'
>>> del ComplexNumber.get_data
>>> num1.get_data()
Traceback (most recent call last):
...
AttributeError: 'ComplexNumber' object has no attribute 'get_data'

We can even delete the object itself, using the del statement.

>>> c1 = ComplexNumber(1,3)
>>> del c1
>>> c1
Traceback (most recent call last):
...
NameError: name 'c1' is not defined

Actually, it is more complicated than that. When we do c1 = ComplexNumber(1,3), a new instance object is created in memory and the name c1 binds with it.

On the command del c1, this binding is removed and the name c1 is deleted from the corresponding namespace. The object however continues to exist in memory and if no other name is bound to it, it is later automatically destroyed.

This automatic destruction of unreferenced objects in Python is also called garbage collection.

Deleting objects in Python removes the name binding

Instance Methods

Instance methods are functions that are defined inside a class and can only be called from an instance of that class. Just like .init() , an instance method’s first parameter is always self .

 class Dog:
     species = "Canis familiaris"

     def init (self, name, age):
         self.name = name
         self.age = age

     # Instance method
     def description(self):
         return f"{self.name} is {self.age} years old"

     # Another instance method
     def speak(self, sound):
         return f"{self.name} says {sound}"

This Dog class has two instance methods:

  1. .description() returns a string displaying the name and age of the dog.

  2. .speak() has one parameter called sound and returns a string containing the dog’s name and the sound the dog makes.

>>> miles = Dog("Miles", 4)

>>> miles.description()
 'Miles is 4 years old'

>>> miles.speak("Woof Woof")
 'Miles says Woof Woof'

>>> miles.speak("Bow Wow")
 'Miles says Bow Wow'

When you create a list object, you can use print() to display a string that looks like the list:

>>> names = ["Fletcher", "David", "Dan"]
>>> print(names)
 ['Fletcher', 'David', 'Dan']

Let’s see what happens when you print() the miles object:

>>> print(miles)
 < main .Dog object at 0x00aeff70>

When you print(miles) , you get a cryptic looking message telling you that miles is a Dog object at the memory address 0x00aeff70 . This message isn’t very helpful. You can change what gets printed by defining a special instance method called .__str__() .

In the editor window, change the name of the Dog class’s .description() method to __str__() :

class Dog:
    # Leave other parts of Dog class as-is

    # Replace .description() with str  ()
     def str (self):
         return f"{self.name} is {self.age} years old"
>>> miles = Dog("Miles", 4)
>>> print(miles)
 'Miles is 4 years old'

Methods like __init__() and __str__() are called dunder methods because they begin and end with double underscores. There are many dunder methods that you can use to customize classes in Python. Although too advanced a topic for a beginning Python book, understanding dunder methods is an important part of mastering object-oriented programming in Python.

Check Your Understanding

Create a Car class with two instance attributes:

1. .color , which stores the name of the car’s color as a string
2. .mileage , which stores the number of miles on the car as an integer

Then instantiate two Car objects—a blue car with 20,000 miles and a red car
 with 30,000 miles—and print out their colors and mileage.
  Your output should look like this:
  
  
1 The blue car has 20,000 miles.
2 The red car has 30,000 miles.

Class Variables

Inherit From Other Classes in Python

Inheritance is the process by which one class takes on the attributes and methods of another. Newly formed classes are called child classes , and the classes that child classes are derived from are called parent classes .

Child classes can override or extend the attributes and methods of parent classes. In other words, child classes inherit all of the parent’s attributes and methods but can also specify attributes and methods that are unique to themselves.

Although the analogy isn’t perfect, you can think of object inheritance sort of like genetic inheritance.

You may have inherited your hair color from your mother. It’s an attribute you were born with. Let’s say you decide to color your hair purple. Assuming your mother doesn’t have purple hair, you’ve just overridden the hair color attribute that you inherited from your mom.

You also inherit, in a sense, your language from your parents. If your parents speak English, then you’ll also speak English. Now imagine you decide to learn a second language, like German. In this case you’ve extended your attributes because you’ve added an attribute that your parents don’t have.

Dog Park Example

Pretend for a moment that you’re at a dog park. There are many dogs of different breeds at the park, all engaging in various dog behaviors.

Suppose now that you want to model the dog park with Python classes. The Dog class that you wrote in the previous section can distinguish dogs by name and age but not by breed.

You could modify the Dog class in the editor window by adding a .breed attribute:

class Dog:
    species = "Canis familiaris"

    def init (self, name, age, breed):
        self.name = name
        self.age = age
        self.breed = breed

miles = Dog("Miles", 4, "Jack Russell Terrier")
buddy = Dog("Buddy", 9, "Dachshund")
jack = Dog("Jack", 3, "Bulldog")
jim = Dog("Jim", 5, "Bulldog")

Each breed of dog has slightly different behaviors. For example, bulldogs have a low bark that sounds like woof , but dachshunds have a higher-pitched bark that sounds more like yap .

Using just the Dog class, you must supply a string for the sound argument of .speak() every time you call it on a Dog instance:

>>> buddy.speak("Yap")
 'Buddy says Yap'

>>> jim.speak("Woof")
 'Jim says Woof'

>>> jack.speak("Woof")
 'Jack says Woof'

Passing a string to every call to .speak() is repetitive and inconvenient. Moreover, the string representing the sound that each Dog instance makes should be determined by its .breed attribute, but here you have to manually pass the correct string to .speak() every time it’s called.

You can simplify the experience of working with the Dog class by creating a child class for each breed of dog. This allows you to extend the functionality that each child class inherits, including specifying a default argument for .speak() .

Parent Classes vs Child Classes

Let’s create a child class for each of the three breeds mentioned above: Jack Russell Terrier, Dachshund, and Bulldog.

class Dog:
    species = "Canis familiaris"

    def init (self, name, age):
        self.name = name
        self.age = age

    def str (self):
        return f"{self.name} is {self.age} years old"

    def speak(self, sound):
        return f"{self.name} says {sound}"

Remember, to create a child class, you create new class with its own name and then put the name of the parent class in parentheses.

class JackRussellTerrier(Dog):
    pass

class Dachshund(Dog):
    pass

class Bulldog(Dog):
    pass

miles = JackRussellTerrier("Miles", 4)
buddy = Dachshund("Buddy", 9)
jack = Bulldog("Jack", 3)
jim = Bulldog("Jim", 5)

Instances of child classes inherit all of the attributes and methods of the parent class:

>>> miles.species
 'Canis familiaris'

>>> buddy.name
 'Buddy'

>>> print(jack)
 Jack is 3 years old

>>> jim.speak("Woof")
 'Jim says Woof'

To determine which class a given object belongs to, you can use the built-in type() :

>>> type(miles)
 <class '  main .JackRussellTerrier'>

What if you want to determine if miles is also an instance of the Dog class? You can do this with the built-in isinstance() :

>>> isinstance(miles, Dog)
True

Notice that isinstance() takes two arguments, an object and a class. In the example above, isinstance() checks if miles is an instance of the Dog class and returns True .

The miles , buddy , jack , and jim objects are all Dog instances, but miles is not a Bulldog instance, and jack is not a Dachshund instance:

>>> isinstance(miles, Bulldog)
False

>>> isinstance(jack, Dachshund)
False

More generally, all objects created from a child class are instances of the parent class, although they may not be instances of other child classes.

Now that you’ve created child classes for some different breeds of dogs, let’s give each breed its own sound.

Extend the Functionality of a Parent Class

Since different breeds of dogs have slightly different barks, you want to provide a default value for the sound argument of their respective .speak() methods. To do this, you need to override .speak() in the class definition for each breed.

To override a method defined on the parent class, you define a method with the same name on the child class. Here’s what that looks like for the JackRussellTerrier class:

class JackRussellTerrier(Dog):
    def speak(self, sound="Arf"):
        return f"{self.name} says {sound}"

Now .speak() is defined on the JackRussellTerrier class with the default argument for sound set to "Arf" .

Update dog.py with the new JackRussellTerrier class. You can now call .speak() on a JackRussellTerrier instance without passing an argument to sound :

>>> miles = JackRussellTerrier("Miles", 4)
>>> miles.speak()
 'Miles says Arf'

Sometimes dogs make different barks, so if Miles gets angry and growls, you can still call .speak() with a different sound:

>>> miles.speak("Grrr")
 'Miles says Grrr'

One thing to keep in mind about class inheritance is that changes to the parent class automatically propagate to child classes. This occurs as long as the attribute or method being changed isn’t overridden in the child class.

For example, in the editor window, change the string returned by .speak() in the Dog class:

class Dog:
    # Leave other attributes and methods as they are

    # Change the string returned by .speak()
    def speak(self, sound):
        return f"{self.name} barks: {sound}"
>>> jim = Bulldog("Jim", 5)
>>> jim.speak("Woof")
 'Jim barks: Woof'

However, calling .speak() on a JackRussellTerrier instance won’t show the new style of output:

>>> miles = JackRussellTerrier("Miles", 4)
>>> miles.speak()
 'Miles says Arf'

Sometimes it makes sense to completely override a method from a parent class. But in this instance, we don’t want the JackRussellTerrier class to lose any changes that might be made to the formatting of the output string of Dog.speak() .

To do this, you still need to define a .speak() method on the child JackRussellTerrier class. But instead of explicitly defining the output string, you need to call the Dog class’s .speak() inside of the child class’s .speak() using the same arguments that you passed to JackRussellTerrier.speak() .

You can access the parent class from inside a method of a child class by using super() :

class JackRussellTerrier(Dog):
    def speak(self, sound="Arf"):
        return super().speak(sound)

When you call super().speak(sound) inside JackRussellTerrier , Python searches the parent class, Dog , for a .speak() method and calls it with the variable sound .

Update dog.py with the new JackRussellTerrier class.

>>> miles = JackRussellTerrier("Miles", 4)
>>> miles.speak()
 'Miles barks: Arf'

Now when you call miles.speak() , you’ll see output reflecting the new formatting in the Dog class.

Note: In the above examples, the class hierarchy is very straightforward. The JackRussellTerrier class has a single parent class, Dog . In real-world examples, the class hierarchy can get quite complicated. super() does much more than just search the parent class for a method or an attribute. It traverses the entire class hierarchy for a matching method or attribute. If you aren’t careful, super() can have surprising results.

Create a GoldenRetriever class that inherits from the Dog class. Give the sound argument of GoldenRetriever.speak() a default value of "Bark" . Use the following code for your parent Dog class:

class Dog:
    species = "Canis familiaris"

    def init (self, name, age):
        self.name = name
        self.age = age

    def str (self):
        return f"{self.name} is {self.age} years old"

    def speak(self, sound):
        return f"{self.name} says {sound}"

Python Multiple Inheritance

A class can be derived from more than one base class in Python. This is called multiple inheritance.

In multiple inheritance, the features of all the base classes are inherited into the derived class. The syntax for multiple inheritance is similar to single inheritance.

class Base1:
    pass

class Base2:
    pass

class MultiDerived(Base1, Base2):
    pass

Here, the MultiDerived class is derived from Base1 and Base2 classes.

The MultiDerived class inherits from both Base1 and Base2 classes.

Every class in Python is derived from the object class. It is the most base type in Python.

So technically, all other classes, either built-in or user-defined, are derived classes and all objects are instances of the object class.

# Output: True
print(issubclass(list,object))

# Output: True
print(isinstance(5.5,object))

# Output: True
print(isinstance("Hello",object))

In the multiple inheritance scenario, any specified attribute is searched first in the current class. If not found, the search continues into parent classes in depth-first, left-right fashion without searching the same class twice.

So, in the above example of MultiDerived class the search order is [MultiDerived, Base1, Base2, object].

Python super()

The super() builtin returns a proxy object (temporary object of the superclass) that allows us to access methods of the base class

In Python, super() has two major use cases:

  • Allows us to avoid using the base class name explicitly

  • Working with Multiple Inheritance

Example 1: super() with Single Inheritance

In the case of single inheritance, it allows us to refer base class by super() .

class Mammal(object):
    def init (self, mammalName):
        print(mammalName, 'is a warm-blooded animal.')

class Dog(Mammal):
    def init (self):
        print('Dog has four legs.')
        super(). init ('Dog')

d1 = Dog()

Here, we called the __init__() method of the Mammal class (from the Dog class) using code

super().__init__('Dog')

instead of

Mammal.__init__(self, 'Dog')

Since we do not need to specify the name of the base class when we call its members, we can easily change the base class name (if we need to).

# changing base class to CanidaeFamily
class Dog(CanidaeFamily):
    def init (self):
        print('Dog has four legs.')

    # no need to change this
    super(). init ('Dog')

The super() builtin returns a proxy object, a substitute object that can call methods of the base class via delegation. This is called indirection (ability to reference base object with super() )

Since the indirection is computed at the runtime, we can use different base classes at different times (if we need to).

Example 2: super() with Multiple Inheritance

class Animal:
    def init (self, Animal):
        print(Animal, 'is an animal.');

class Mammal(Animal):
    def init (self, mammalName):
        print(mammalName, 'is a warm-blooded animal.')
        super(). init (mammalName)

class NonWingedMammal(Mammal):
    def init (self, NonWingedMammal):
        print(NonWingedMammal, "can't fly.")
        super(). init (NonWingedMammal)

class NonMarineMammal(Mammal):
    def init (self, NonMarineMammal):
        print(NonMarineMammal, "can't swim.")
        super(). init (NonMarineMammal)

class Dog(NonMarineMammal, NonWingedMammal):
    def init (self):
        print('Dog has 4 legs.');
        super(). init ('Dog')

d = Dog()
print('')
bat = NonMarineMammal('Bat')

END OF THE LECTURE

Last updated

Change request #338: