Skip to content

Latest commit

 

History

History
798 lines (598 loc) · 20.8 KB

File metadata and controls

798 lines (598 loc) · 20.8 KB

Lab 2.2: Python Object-Oriented Programming Basics

Overview

Object-Oriented Programming (OOP) allows you to model real-world concepts as objects with attributes (data) and methods (behavior). Python's OOP is clean and practical - you'll use it for everything from configuration objects to network device abstractions.

In this lab, you'll learn:

  • Classes and objects
  • The self convention
  • Abstract base classes
  • Inheritance and polymorphism
  • Access modifiers (public, protected, private)
  • Special methods (dunder methods like __str__, __eq__)
  • Type checking with isinstance()

Part 1: Classes, Objects, and self

Step 1: Understanding self

The self parameter is Python's way of referring to the current instance of the class. It's not a keyword - just a convention (you could name it anything, but don't!).

class Dog:
    """A simple Dog class"""
    
    def __init__(self, name, age):
        """
        Constructor - called when creating a new Dog object
        self = the instance being created
        name, age = parameters we pass in
        """
        self.name = name  # Instance variable
        self.age = age    # Instance variable
    
    def bark(self):
        """Instance method - self refers to THIS dog"""
        print(f"{self.name} says: Woof!")
    
    def get_info(self):
        """Access instance variables via self"""
        return f"{self.name} is {self.age} years old"

# Create objects (instances)
dog1 = Dog("Buddy", 3)
dog2 = Dog("Max", 5)

# Call methods - Python automatically passes 'self'
dog1.bark()  # Python calls: Dog.bark(dog1)
dog2.bark()

print(dog1.get_info())
print(dog2.get_info())

# Access attributes directly
print(f"\nDirect access: {dog1.name}, {dog2.name}")

Output:

Buddy says: Woof!
Max says: Woof!
Buddy is 3 years old
Max is 5 years old

Direct access: Buddy, Max

Key concept: self is automatically passed as the first parameter. When you call dog1.bark(), Python translates it to Dog.bark(dog1).


Part 2: Abstract Base Classes and Inheritance

Abstract base classes (ABCs) define a contract - methods that subclasses MUST implement.

Step 1: Create the Abstract Animal Class

from abc import ABC, abstractmethod

class Animal(ABC):
    """
    Abstract base class - cannot be instantiated directly
    Defines the contract for all animals
    """
    
    def __init__(self, name, age):
        """Constructor - called by subclasses"""
        self.name = name
        self.age = age
        self._species = "Unknown"  # Protected attribute (by convention)
    
    @abstractmethod
    def make_sound(self):
        """
        Abstract method - MUST be implemented by subclasses
        This method has no body here
        """
        pass
    
    @abstractmethod
    def move(self):
        """Another abstract method"""
        pass
    
    def sleep(self):
        """
        Concrete method - inherited by all subclasses
        Already has implementation
        """
        print(f"{self.name} is sleeping... Zzz")
    
    def get_info(self):
        """Concrete method using instance variables"""
        return f"{self.name} ({self._species}) is {self.age} years old"

# Try to create an Animal - this will FAIL!
try:
    animal = Animal("Generic", 5)
except TypeError as e:
    print(f"Cannot instantiate abstract class: {e}")

Output:

Cannot instantiate abstract class: Can't instantiate abstract class Animal with abstract methods make_sound, move

Why abstract classes? They enforce a contract. Every Animal MUST implement make_sound() and move(). Other functions or systems will know that if you truly are an "Animal" then they can expect you to have make_sound() and move()

Step 2: Create the Cat Class (Inherits from Animal)

from abc import ABC, abstractmethod

class Animal(ABC):
    """Abstract base class"""
    
    def __init__(self, name, age):
        self.name = name
        self.age = age
        self._species = "Unknown"
    
    @abstractmethod
    def make_sound(self):
        pass
    
    @abstractmethod
    def move(self):
        pass
    
    def sleep(self):
        print(f"{self.name} is sleeping... Zzz")
    
    def get_info(self):
        return f"{self.name} ({self._species}) is {self.age} years old"

class Cat(Animal):
    """
    Cat inherits from Animal
    MUST implement abstract methods
    """
    
    def __init__(self, name, age, indoor=True):
        # Call parent constructor
        super().__init__(name, age)
        self._species = "Feline"  # Set protected attribute
        self.__indoor = indoor    # Private attribute (name mangling)
    
    def make_sound(self):
        """Implement abstract method"""
        print(f"{self.name} says: Meow!")
    
    def move(self):
        """Implement abstract method"""
        print(f"{self.name} gracefully walks on silent paws")
    
    def purr(self):
        """Cat-specific method (not in Animal)"""
        print(f"{self.name} is purring... purrrr")
    
    def is_indoor(self):
        """Access private attribute via method"""
        return self.__indoor

# Create a Cat
cat = Cat("Whiskers", 3)

# Call inherited method
cat.sleep()

# Call implemented abstract methods
cat.make_sound()
cat.move()

# Call Cat-specific method
cat.purr()

# Call inherited get_info
print(cat.get_info())

# Check indoor status
print(f"Indoor cat: {cat.is_indoor()}")

Output:

Whiskers is sleeping... Zzz
Whiskers says: Meow!
Whiskers gracefully walks on silent paws
Whiskers is purring... purrrr
Whiskers (Feline) is 3 years old
Indoor cat: True

Part 3: Access Modifiers (Public, Protected, Private)

Python uses naming conventions for access control (not enforced by the language like Java/C++).

Step 1: Understanding Access Levels

class AccessDemo:
    """Demonstrates access modifier conventions"""
    
    def __init__(self):
        # Public - accessible from anywhere
        self.public_var = "I'm public!"
        
        # Protected - by convention, don't access outside class/subclasses
        # Single underscore prefix
        self._protected_var = "I'm protected (by convention)"
        
        # Private - name mangling makes it harder to access
        # Double underscore prefix
        self.__private_var = "I'm private!"
    
    def public_method(self):
        """Public method - anyone can call"""
        return "Public method called"
    
    def _protected_method(self):
        """Protected method - by convention, internal use"""
        return "Protected method called"
    
    def __private_method(self):
        """Private method - name mangled"""
        return "Private method called"
    
    def demonstrate_access(self):
        """Show we can access everything internally"""
        print(f"Public: {self.public_var}")
        print(f"Protected: {self._protected_var}")
        print(f"Private: {self.__private_var}")
        print(f"Private method: {self.__private_method()}")

obj = AccessDemo()

# Public - works fine
print(obj.public_var)
print(obj.public_method())

print()

# Protected - works, but you shouldn't (by convention)
print(obj._protected_var)
print(obj._protected_method())

print()

# Private - name mangled, hard to access
try:
    print(obj.__private_var)
except AttributeError as e:
    print(f"Can't access private: {e}")

# Python mangles the name to _ClassName__attribute
# You CAN still access it, but it's discouraged
print(f"Accessing mangled name: {obj._AccessDemo__private_var}")

print()

# Internal access works
obj.demonstrate_access()

Output:

I'm public!
Public method called

I'm protected (by convention)
Protected method called

Can't access private: 'AccessDemo' object has no attribute '__private_var'
Accessing mangled name: I'm private!

Public: I'm public!
Protected: I'm protected (by convention)
Private: I'm private!
Private method: Private method called

Key points:

  • public_var - anyone can access
  • _protected_var - convention: internal use only
  • __private_var - name mangled to _ClassName__private_var
  • Python doesn't enforce privacy - it's based on trust

Part 4: Polymorphism and Type Checking

Polymorphism means "many forms" - different classes can be treated the same way if they share a common interface.

Step 1: Complete Animal Example

from abc import ABC, abstractmethod

class Animal(ABC):
    """Abstract base class"""
    
    def __init__(self, name, age):
        self.name = name
        self.age = age
        self._species = "Unknown"
    
    @abstractmethod
    def make_sound(self):
        """Every animal must make a sound"""
        pass
    
    @abstractmethod
    def move(self):
        """Every animal must be able to move"""
        pass
    
    def sleep(self):
        """Common behavior - inherited by all"""
        print(f"{self.name} is sleeping... Zzz")
    
    def get_info(self):
        return f"{self.name} ({self._species}) is {self.age} years old"

class Cat(Animal):
    """Cat implementation"""
    
    def __init__(self, name, age, indoor=True):
        super().__init__(name, age)
        self._species = "Feline"
        self.__indoor = indoor
    
    def make_sound(self):
        print(f"{self.name} says: Meow!")
    
    def move(self):
        print(f"{self.name} gracefully walks")
    
    def purr(self):
        print(f"{self.name} is purring...")

class Dog(Animal):
    """Dog implementation"""
    
    def __init__(self, name, age, breed):
        super().__init__(name, age)
        self._species = "Canine"
        self.__breed = breed
    
    def make_sound(self):
        print(f"{self.name} says: Woof!")
    
    def move(self):
        print(f"{self.name} runs energetically")
    
    def fetch(self):
        print(f"{self.name} fetches the ball!")

# Polymorphic function - works with ANY Animal
def animal_action(animal):
    """
    Takes any Animal and calls its methods
    Polymorphism - same interface, different behavior
    """
    # Type checking
    if not isinstance(animal, Animal):
        print(f"Error: {animal} is not an Animal!")
        return
    
    print(f"\n--- {animal.name} ---")
    print(animal.get_info())
    animal.make_sound()  # Polymorphic call - behavior depends on type
    animal.move()        # Polymorphic call
    animal.sleep()       # Inherited method

# Create animals
cat = Cat("Whiskers", 3)
dog = Dog("Buddy", 5, "Golden Retriever")

# Polymorphism - same function, different behavior
animal_action(cat)
animal_action(dog)

# Type checking with isinstance
print(f"\nIs cat an Animal? {isinstance(cat, Animal)}")
print(f"Is cat a Cat? {isinstance(cat, Cat)}")
print(f"Is cat a Dog? {isinstance(cat, Dog)}")

# Try with a non-Animal
animal_action("Not an animal")

Output:

--- Whiskers ---
Whiskers (Feline) is 3 years old
Whiskers says: Meow!
Whiskers gracefully walks
Whiskers is sleeping... Zzz

--- Buddy ---
Buddy (Canine) is 5 years old
Buddy says: Woof!
Buddy runs energetically
Buddy is sleeping... Zzz

Is cat an Animal? True
Is cat a Cat? True
Is cat a Dog? False
Error: Not an animal is not an Animal!

Polymorphism in action: The animal_action() function works with ANY Animal - it doesn't care if it's a Cat or Dog. The correct make_sound() is called automatically.


Part 5: Special Methods (Dunder Methods)

Special methods (surrounded by double underscores) let you define how objects behave with operators and built-in functions.

Step 1: __str__ and __repr__

class Animal:
    """Simple Animal without dunder methods"""
    
    def __init__(self, name, age):
        self.name = name
        self.age = age

# Without __str__
animal = Animal("Buddy", 5)
print(animal)  # Ugly output!

print()

class BetterAnimal:
    """Animal with __str__ and __repr__"""
    
    def __init__(self, name, age):
        self.name = name
        self.age = age
    
    def __str__(self):
        """
        Called by str() and print()
        Should be readable for end users
        """
        return f"{self.name} (age {self.age})"
    
    def __repr__(self):
        """
        Called by repr() and in REPL
        Should be unambiguous, ideally valid Python code
        """
        return f"BetterAnimal('{self.name}', {self.age})"

# With __str__ and __repr__
better = BetterAnimal("Max", 3)
print(better)           # Calls __str__
print(repr(better))     # Calls __repr__
print(f"Animal: {better}")  # Calls __str__

# In a list, __repr__ is used
animals = [better, BetterAnimal("Bella", 7)]
print(animals)

Output:

<__main__.Animal object at 0x7f8b3c4d5e10>

Max (age 3)
BetterAnimal('Max', 3)
Animal: Max (age 3)
[BetterAnimal('Max', 3), BetterAnimal('Bella', 7)]

Step 2: __eq__ (Equality)

class Animal:
    """Animal without __eq__"""
    
    def __init__(self, name, age):
        self.name = name
        self.age = age

# Without __eq__ - compares memory addresses
animal1 = Animal("Buddy", 5)
animal2 = Animal("Buddy", 5)

print(f"animal1 == animal2: {animal1 == animal2}")  # False - different objects
print(f"animal1 is animal2: {animal1 is animal2}")  # False

print()

class BetterAnimal:
    """Animal with __eq__"""
    
    def __init__(self, name, age):
        self.name = name
        self.age = age
    
    def __eq__(self, other):
        """
        Define what equality means
        Two animals are equal if name and age match
        """
        if not isinstance(other, BetterAnimal):
            return False
        return self.name == other.name and self.age == other.age
    
    def __str__(self):
        return f"{self.name} (age {self.age})"

# With __eq__ - compares attributes
better1 = BetterAnimal("Max", 3)
better2 = BetterAnimal("Max", 3)
better3 = BetterAnimal("Max", 5)

print(f"better1 == better2: {better1 == better2}")  # True - same name/age
print(f"better1 == better3: {better1 == better3}")  # False - different age
print(f"better1 is better2: {better1 is better2}")  # False - different objects

Output:

animal1 == animal2: False
animal1 is animal2: False

better1 == better2: True
better1 == better3: False
better1 is better2: False

Step 3: More Dunder Methods

class Animal:
    """Animal with multiple dunder methods"""
    
    def __init__(self, name, age, weight):
        self.name = name
        self.age = age
        self.weight = weight
    
    def __str__(self):
        return f"{self.name} (age {self.age}, {self.weight}kg)"
    
    def __repr__(self):
        return f"Animal('{self.name}', {self.age}, {self.weight})"
    
    def __eq__(self, other):
        """Equality - same name, age, weight"""
        if not isinstance(other, Animal):
            return False
        return (self.name == other.name and 
                self.age == other.age and 
                self.weight == other.weight)
    
    def __lt__(self, other):
        """Less than - compare by weight"""
        if not isinstance(other, Animal):
            return NotImplemented
        return self.weight < other.weight
    
    def __len__(self):
        """Length - return age in months"""
        return self.age * 12
    
    def __bool__(self):
        """Truthiness - False if age is 0"""
        return self.age > 0
    
    def __add__(self, other):
        """Addition - combine weights (silly example)"""
        if not isinstance(other, Animal):
            return NotImplemented
        return self.weight + other.weight

# Create animals
cat = Animal("Whiskers", 3, 4.5)
dog = Animal("Buddy", 5, 25.0)
puppy = Animal("Newborn", 0, 2.0)

# __str__ and __repr__
print(cat)
print(repr(dog))

print()

# __eq__
cat2 = Animal("Whiskers", 3, 4.5)
print(f"cat == cat2: {cat == cat2}")
print(f"cat == dog: {cat == dog}")

print()

# __lt__ (enables sorting)
print(f"cat < dog: {cat < dog}")  # Compare by weight
animals = [dog, cat, puppy]
animals.sort()  # Uses __lt__
print(f"Sorted by weight: {animals}")

print()

# __len__
print(f"Cat age in months: {len(cat)}")
print(f"Dog age in months: {len(dog)}")

print()

# __bool__
if cat:
    print(f"{cat.name} is active (age > 0)")
if not puppy:
    print(f"{puppy.name} is newborn (age == 0)")

print()

# __add__
total_weight = cat + dog
print(f"Combined weight: {total_weight}kg")

Output:

Whiskers (age 3, 4.5kg)
Animal('Buddy', 5, 25.0)

cat == cat2: True
cat == dog: False

cat < dog: True
Sorted by weight: [Animal('Newborn', 0, 2.0), Animal('Whiskers', 3, 4.5), Animal('Buddy', 5, 25.0)]

Cat age in months: 36
Dog age in months: 60

Whiskers is active (age > 0)
Newborn is newborn (age == 0)

Combined weight: 29.5kg

Part 6: Hands-On Challenge

Your task: Create a network device management system using OOP

Note: You don't need real network data or working connections. This is just for practicing OOP concepts. Create the classes with the right structure, and you can provide simple data (like "Router1", "10.0.0.1") when you create objects. The focus is on the class design, not real network functionality.

Requirements:

  1. Create an abstract NetworkDevice class with:

    • Constructor: hostname, ip_address, uptime_hours
    • Abstract methods: connect(), backup_config()
    • Concrete method: reboot() (prints message)
    • Protected attribute: _connection_status
    • Private attribute: __last_backup_time
  2. Create two subclasses:

    • Router - implements abstract methods, has routing_protocol attribute
    • Switch - implements abstract methods, has vlan_count attribute
  3. Implement dunder methods:

    • __str__ - user-friendly representation
    • __repr__ - technical representation
    • __eq__ - devices are equal if same hostname and IP
    • __lt__ - compare by uptime for sorting
  4. Create a polymorphic function:

    • backup_all_devices(devices) - takes a list of devices
    • Checks if each is a NetworkDevice with isinstance()
    • Calls backup_config() on each (polymorphism)
    • Returns count of successful backups
  5. Test with:

    • Create 2 routers and 2 switches
    • Add to a list
    • Call backup_all_devices()
    • Sort devices by uptime
    • Print the sorted list

Bonus: Add a get_device_info() method that returns a dictionary with all device info.


Success Criteria

You've completed this lab when you can:

  • Create classes with constructors (__init__)
  • Understand and use the self keyword
  • Create abstract base classes with ABC and @abstractmethod
  • Implement inheritance with super()
  • Use access modifiers (public, _protected, __private)
  • Implement polymorphic functions using isinstance()
  • Implement dunder methods (__str__, __repr__, __eq__, __lt__)
  • Explain how polymorphism works with inheritance

Key Takeaways

What you learned:

  • Classes and objects - Blueprint (class) vs instance (object)
  • self - Reference to the current instance
  • Abstract base classes - Define contracts with @abstractmethod
  • Inheritance - Subclasses extend parent classes
  • Access modifiers - Conventions: public, _protected, __private
  • Polymorphism - Same interface, different implementations
  • Dunder methods - Customize object behavior (__str__, __eq__, etc.)
  • Type checking - Use isinstance() to check object types

Why this matters:

  • Model network devices, servers, configurations as objects
  • Create reusable, maintainable code
  • Essential for working with APIs (objects everywhere!)
  • Foundation for frameworks like Django, Flask, FastAPI
  • Common in automation tools (Ansible modules, network SDKs)

Next steps: Apply OOP to real automation scripts - network device management, configuration objects, API clients.


Common Dunder Methods Reference

# Representation
__str__(self)      # Called by str() and print()
__repr__(self)     # Called by repr(), should be unambiguous

# Comparison
__eq__(self, other)  # ==
__ne__(self, other)  # !=
__lt__(self, other)  # <
__le__(self, other)  # <=
__gt__(self, other)  # >
__ge__(self, other)  # >=

# Numeric operations
__add__(self, other)  # +
__sub__(self, other)  # -
__mul__(self, other)  # *
__truediv__(self, other)  # /

# Container behavior
__len__(self)      # len()
__getitem__(self, key)  # obj[key]
__setitem__(self, key, value)  # obj[key] = value
__contains__(self, item)  # item in obj

# Other
__bool__(self)     # bool(), truthiness
__call__(self)     # Make object callable like a function
__hash__(self)     # hash(), needed for set/dict keys

Additional Resources


Congratulations! You now understand Python OOP fundamentals - the foundation for building scalable automation tools and working with modern Python frameworks!