Core Python / Classes and Objects

Core Python / Classes and Objects#

  1. What is a class in Python, and what is its purpose?

    A class in Python is a blueprint or a template for creating objects. It defines a set of properties (attributes) and methods (functions) that the objects created from that class will have. The purpose of a class is to encapsulate data and behavior into a single entity, promoting code reusability, modularity, and organization. Classes are the fundamental building blocks of object-oriented programming in Python.

  2. How do you define a class in Python?

    To define a class in Python, you use the class keyword followed by the name of the class, and then a colon (:) to start the class block.

    class ClassName:
        # Class attributes and methods go here
        pass
    

    Within the class block, you can define attributes (variables) and methods (functions) that belong to the class.

    class Dog:
        # Class attribute
        species = "Canis familiaris"
    
        # Constructor method
        def __init__(self, name, breed):
            self.name = name
            self.breed = breed
    
        # Instance method
        def bark(self):
             print("{} says: Woof!".format(self.name))
    

    In this example, Dog is the name of the class. It has a class attribute species, a constructor method __init__ to initialize object attributes, and an instance method bark that prints a message.

  3. What is an object in Python, and how is it related to a class? Explain how an object has state, behavior, and an identity.

    In Python, an object is an instance of a class. It is a concrete realization of the blueprint defined by the class. The class defines the structure and behavior, while the object represents a specific instance or entity within that structure.

    An object has the following three characteristics:

    • State: The state of an object refers to its attributes or properties, which are defined in the class and hold data or values. These attributes represent the current state or condition of the object. For example, in a BankAccount class, the account_holder, account_number, and balance would be attributes representing the state of a particular bank account object.

    • Behavior: The behavior of an object is determined by its methods, which are functions defined within the class. These methods define how the object can interact with its data or perform specific operations. In the BankAccount class, deposit(), withdraw(), and check_balance() are methods that define the behavior of a bank account object.

    • Identity: Every object in Python has a unique identity, which is represented by its memory address. Even if two objects have the same state (attribute values), they are still distinct objects with different identities. The id() function in Python can be used to get the unique identity of an object. The object may posses attributes that provide a unique identifier as well.

    class BankAccount:
        def __init__(self, account_holder, account_number, balance=0):
            self.account_holder = account_holder  # State
            self.account_number = account_number  # State
            self.balance = balance  # State
    
        def deposit(self, amount):  # Behavior
            self.balance += amount
            print(f"Deposit of ${amount} successful. New balance: ${self.balance}")
    
        def withdraw(self, amount):  # Behavior
            if self.balance >= amount:
                self.balance -= amount
                print(f"Withdrawal of ${amount} successful. Remaining balance: ${self.balance}")
            else:
                print(f"Insufficient funds. Your balance is ${self.balance}")
    
     # Creating two objects
     account1 = BankAccount("John Doe", "12345", 1000)
     account2 = BankAccount("Jane Smith", "67890", 500)
    
     # Accessing state and behavior
     print(account1.account_holder)  # State
     account1.deposit(500)  # Behavior
     print(id(account1), id(account2))  # Identity
    
  4. How do you create an instance of a class (an object)?

    Call the object’s __init___ method by using the class name as demonstrated in the code example of the previous question.

  5. What is the purpose of the __init__ method in a Python class?

    In Python, constructing an object actually consists of two special methods: __new__ and __init__.

    __new__:

    • The __new__ method is a static method that is called before the object is created.

    • It is responsible for creating and returning the new instance of the class.

    • The __new__ method is typically used for custom object creation, such as creating immutable objects or implementing custom memory allocation strategies.

    • If the __new__ method is not overridden, Python’s default __new__ implementation is used, which creates and returns a new instance of the class.

    __init__:

    • The __init__ method is an instance method that is called after the object is created by __new__.

    • It is responsible for initializing the attributes of the newly created object.

    • The __init__ method is where you typically set the initial state of the object by assigning values to its attributes.

    • If the __init__ method is not defined in a class, the object will be created without any initial attribute values.

    In most cases, you don’t need to override the __new__ method unless you have specific requirements for custom object creation or memory management. However, defining the __init__ method is a common practice to initialize the object’s attributes and set up its initial state.

  6. How do you define instance attributes in a class?

    In Python, instance attributes are defined within the __init__ method of a class. These attributes are specific to each instance (object) of the class and can have different values for different instances. There are two ways to define instance attributes:

    Inside the __init__ method

    Instance attributes are typically initialized inside the __init__ method. The self parameter is used to refer to the instance being created (technically, it is the position that matters, self is the expected convention used.), and instance attributes are assigned values using the dot notation (self.attribute_name = value).

    class Circle:
        def __init__(self, radius):
            self.radius = radius  # Instance attribute
            self.area = 3.14 * self.radius ** 2  # Instance attribute
    
    circle1 = Circle(5)
    print(circle1.radius)  # Output: 5
    print(circle1.area)    # Output: 78.5
    

    Outside the __init__ method

    Instance attributes can also be defined directly inside the class body, outside of any method. These attributes will be shared among all instances of the class, and their values can be overridden within the init method or other instance methods.

    class Rectangle:
        color = "blue"  # Class attribute
    
        def __init__(self, length, width):
            self.length = length     # Instance attribute
            self.width = width       # Instance attribute
    
    rect1 = Rectangle(5, 3)
    print(rect1.length)      # Output: 5
    print(rect1.width)       # Output: 3
    print(rect1.color)       # Output: blue.  Note, accessing the class attribute via the object
    
    rect2 = Rectangle(2, 4)
    print(rect2.color)       # Output: blue, Note, accessing the class attribute via the object 
    rect2.color = "red"      # Assigning a specific instance attribute 
    print(rect2.color)       # Output: red   
    print(rect1.color)       # Output: blue
    print(Rectangle.color)   # Output: blue
    

    In this example, color became an instance attribute with rect2.color = "red". Generally, it is preferred to establish instance attributes in the initializer to avoid any confusion.

  7. What is the difference between instance attributes and class attributes?

    The main difference between instance attributes and class attributes in Python lies in how they are associated with objects and classes.

    Instance Attributes

    • Instance attributes are specific to each instance (object) of a class.

    • Defined within the __init__ method or other instance methods.

    • Since Python is dynamic, can also be defined outside of the overall class definition. However, this should be avoided due to understandability concerns.

    • Each instance of the class has its own copy of the instance attributes, and their values can be different for different instances.

    • Instance attributes are accessed using the instance itself or the self reference within instance methods.

    Example: self.name, self.age, etc.

    Class Attributes

    • Class attributes are associated with the class itself, rather than any specific instance.

    • Defined directly within the class body, outside any method.

    • All instances of the class share the same copy of the class attribute.

    • Class attributes are usually used to store data or behavior that is common to all instances of the class.

    • Class attributes are accessed using the class name or an instance of the class.

    Example: ClassName.class_attribute or instance.class_attribute

    Class attributes are useful for storing constants or default values that should be shared among all instances of the class. Instance attributes, on the other hand, are used to store data that is specific to each object.

    Instance attributes take precedence over class attributes when accessed through an instance. If an instance attribute has the same name as a class attribute, the instance attribute will be used.

  8. How do you define methods in a Python class? What is the purpose of the self parameter in Python class methods?

    To define methods in a Python class, you use the def keyword as with a normal function, but you include the self parameter as the first argument of each method. The self parameter represents the instance of the class itself, allowing the method to access and manipulate the attributes (variables) and other methods of that specific instance. (Note: technically the parameter does not need to be called self. However, this is the standard convention in Python and using any other name will confuse other developers - and, most likely you.)

    The self parameter -

    • allows methods to access and modify the instance attributes. For example, self.balance refers to the balance attribute of that particular instance of a BankAccount.

    • enables methods to call other methods of the same class by using self.other_method(). This way, methods can interact with each other within the same instance.

    • distinguishes instance attributes from local variables inside methods. Without self, Python would treat attributes as local variables within the method scope.

    Here’s an example using a BankAccount class:

    class BankAccount:
        def __init__(self, name, initial_balance):
            self.name = name
            self.balance = initial_balance
    
        def deposit(self, amount):
            self.balance += amount
            print(f"Deposited {amount} into
    
  9. How do you access and modify instance attributes from within a class method?

    You need to use the self parameter to prefix the attribute. See the answer to the previous question for more details.

  10. What are class methods in Python, and how do they differ from instance methods?

    Class methods in Python are methods that are bound to the class itself, rather than any specific instance of the class. They are defined using the @classmethod decorator and take the class (cls) as the first argument instead of the instance (self). Class methods can access and modify class attributes, but they cannot access or modify instance attributes directly.

    The main differences between class methods and instance methods are:

    • Binding: Instance methods are bound to instances of the class, while class methods are bound to the class itself.

    • First argument: Instance methods take self as the first argument, which represents the instance object. Class methods take cls as the first argument, which represents the class itself. (Note: As with the self name, cls is used by convention.)

    • Access to attributes: Instance methods can access and modify both instance attributes and class attributes. Class methods can access and modify class attributes directly, but they cannot access instance attributes directly (they can access them indirectly through an instance).

    • Use cases: Instance methods are used for operations that involve instance-specific data or behavior. Class methods are typically used for operations that involve the class itself, such as creating
      alternative constructors (factory methods) or modifying class-level data.

    Here’s an example using a BankAccount class to illustrate the use of class methods:

    class BankAccount:
        MIN_BALANCE = 1000  # Class attribute
    
        def __init__(self, name, balance):
            self.name = name
            self.balance = balance
    
        @classmethod
        def create_account(cls, name, initial_deposit):
            if initial_deposit < cls.MIN_BALANCE:
                print("Minimum deposit of {} is required to open an account.".format(cls.MIN_BALANCE))
                return None
            else:
                return cls(name, initial_deposit)
    
        def deposit(self, amount):
            self.balance += amount
            print("Deposited {} into {}'s account. New balance: {}".format(amount, self.name, self.balance))
    
        def withdraw(self, amount):
            if self.balance >= amount:
                self.balance -= amount
                print("Withdrew {} from {}'s account. Remaining balance: {}".format(amount, self.name, self.balance))
            else:
                print("Insufficient funds in {}'s account.".format(self.name))
    
    # Creating an account using the class method
    account1 = BankAccount.create_account("Alice", 2000)
    account2 = BankAccount.create_account("Bob", 500)  # Minimum deposit requirement not met
    
    # Using instance methods
    if account1:
        account1.deposit(500)  # Deposited 500 into Alice's account. New balance: 2500
        account1.withdraw(1000)  # Withdrew 1000 from Alice's account. Remaining balance: 1500
    
    
  11. What are static methods in Python, and when would you use them?

    Static methods in Python are methods that are bound to the class itself, rather than any instance of the class. They are defined within a class but do not take the instance (self) or the class (cls) as the first argument. Static methods are used when you need a utility function that is logically related to the class but does not require access to the instance or class attributes.

    You would use static methods in the following scenarios:

    • Utility Functions: When you have a function that performs a specific operation related to the class but does not require access to the instance or class data. Static methods are useful for grouping related utility functions within a class for better code organization and readability.

    • Independent Operations: If you have a function that performs an operation independent of the instance or class state, it can be defined as a static method. For example, a function that performs mathematical calculations or data conversions.

    • Single Implementation: Static methods are useful when you want to ensure that a particular implementation of a method cannot be overridden by subclasses. This is because static methods are resolved at the class level, not the instance level.

    • Code Organization: Static methods can help organize code by grouping related utility functions within a class, even if they do not directly operate on the class or its instances. This can improve code readability and maintainability.

    Here’s an example to illustrate the use of a static method:

    import datetime as dt
    
    class Bond:
        def __init__(self, face_value, coupon_rate, maturity_date):
            self.face_value = face_value
            self.coupon_rate = coupon_rate
            self.maturity_date = maturity_date
    
        @staticmethod
        def present_value(future_value, discount_rate, years):
            return future_value / ((1 + discount_rate) ** years)
    
        @staticmethod
        def yield_to_maturity(bond_price, face_value, coupon_rate, years_to_maturity):
            # Implement yield to maturity calculation
            
    
    bond = Bond(1000, 0.05, dt.date(2025, 1, 1))
    pv = Bond.present_value(1000, 0.06, 5)            # Calculate present value
    ytm = Bond.yield_to_maturity(950, 1000, 0.05, 5)  # Calculate yield to maturity
    

    present_value and yield_to_maturity are called directly on the Bond class, without the need to create an instance of the class. They perform financial calculations that are independent of any specific bond instance.

  12. What is encapsulation in object-oriented programming, and how is it achieved in Python?

    Encapsulation is a fundamental principle of object-oriented programming (OOP) that involves bundling data (attributes) and methods (functions) that operate on that data within a single unit, called a class. The main goals of encapsulation are:

    1. Data Hiding: Encapsulation hides the internal implementation details of an object from the outside world, preventing direct access to the object’s data. This protects the data from accidental modification and ensures data integrity.

    2. Access Control: Encapsulation allows you to control access to the object’s data and methods by defining access modifiers (public, protected, and private) that determine the level of accessibility.

    However, Python does not implement these explicit access modifiers. Instead, the language relies upon naming conventions to indicate the intended accessibility:

    1. Public Members: By default, all attributes and methods in a class are public, meaning they can be accessed from anywhere. There is no special syntax for public members.

    2. Protected Members: A single leading underscore (_) before the name of an attribute or method indicates that it is protected. Protected members should be treated as private and accessed only from within the class and its subclasses.

    3. Private Members: A double leading underscore (__) before the name of an attribute or method indicates that it is private. Private members should only be accessible only within the class itself and not directly accessed from outside the class or its subclasses. Example:

    class BankAccount:
        def __init__(self, balance):
            self._balance = balance  # Protected attribute
    
        def deposit(self, amount):
            self._balance += amount  # Accessing protected attribute within the class
    
        def withdraw(self, amount):
            if self._balance >= amount:
                self._balance -= amount
            else:
                print("Insufficient funds")
    
        def get_balance(self):
            return self._balance  # Providing controlled access to the protected attribute
    
    # Creating an instance of the BankAccount class
    account = BankAccount(1000)
    account.deposit(500)  # Accessing public method
    print(account.get_balance())  # Output: 1500
    
    # accessing the protected attribute directly (not recommended)
    print(account._balance)  # Output: 1500
    

    In this example, the _balance attribute is protected, and the deposit, withdraw, and get_balance methods provide controlled access to modify and retrieve the balance.

  13. How do you define and use getters and setters in Python classes? Focus on the appropriate decorators.

    Python also supports the use of getter and setter methods to provide controlled access to attributes. These methods allow you to encapsulate the logic for getting and setting attribute values, respectively. While you can simplt use method names such as get_radius and `set_radius as below, the pythonic way is to use decorators.

    class Circle:
        def __init__(self, radius):
            self._radius = radius  # Protected attribute
    
        @property
        def radius(self):
            return self._radius
    
        @radius.setter
        def radius(self, new_radius):
        if new_radius >= 0:
            self._radius = new_radius
        else:
            raise ValueError("Radius cannot be a negative number")
    
    # Creating an instance of the Circle class
    circle = Circle(5)
    print(circle.radius)  # Output: 5
    circle.radius = 10
    print(circle.radius)  # Output: 10
    circle.radius = -10   # Raises ValueException
    
  14. Explain how it is not possible in Python to prevent code outside the class definition from directly accessing attributes. How do programmers signify to avoid doing this, though?

    In Python, it is not possible to completely prevent code outside the class definition from directly accessing attributes. Python does not have true private attributes like some other object-oriented programming languages. However, Python uses naming conventions and name mangling to signify that certain attributes should be treated as private and not accessed directly from outside the class.

  15. What name pattern is used in Python to override various operators such as == and <? What are these methods called?

    The methods used to override operators are called “special methods”, “dunder (double underscore) methods” or “magic methods”. These methods follow a specific naming pattern that begins and ends with double underscores (__).

    To override comparison operators such as == and <, you need to define the following special methods in your class:

    • __eq__(self, other) - To override the == operator for equality comparison.

    • __lt__(self, other) - To override the < operator for less than comparison.

    • __le__(self, other) - To override the <= operator for less than or equal to comparison.

    • __gt__(self, other) - To override the > operator for greater than comparison.

    • __ge__(self, other) - To override the >= operator for greater than or equal to comparison.

    • __ne__(self, other) - To override the != operator for inequality comparison.

    Here’s an example that overrides the < and == operators for a Person class:

    class Person:
        def __init__(self, name, age):
            self.name = name
            self.age = age
    
        def __eq__(self, other):
            return self.age == other.age
    
        def __lt__(self, other):
            return self.age < other.age
    
    person1 = Person("Alice", 25)
    person2 = Person("Bob", 30)
    
    print(person1 < person2)  # Output: True
    print(person1 == person2)  # Output: False
    
  16. What will be the output of the following Python code:

    class test:
        def __init__(self,a="Hello World"):
            self.a=a
    
        def display():
            print(self.a)
    
    obj=test()
    obj.display()
    

    The program contains an error and a stack trace appears - the self argument needs to be defined in the parameter list for display().

  17. What does built-in function type*() do in the context of classes?

    Note: This is an advanced topic and is not required for the program.

    In Python, the type() function is a built-in function that returns the type of an object. However, when used in the context of classes, it has a special behavior and serves as a way to create new class objects dynamically. When you call type(name, bases, dict), it returns a new class.

    • name (string): The name of the class.

    • bases (tuple): A tuple of base classes that the new class should inherit from.

    • dict (dictionary): A dictionary containing the class attributes, methods, and other definitions.

    Imagine you have a financial institution that offers different types of accounts, such as Savings, Checking, and Investment accounts. Instead of hardcoding the classes for each account type, you can dynamically create these classes using type().

    # Creating a Savings Account class dynamically
    SavingsAccount = type('SavingsAccount', (), {
        'interest_rate': 0.03,
        'deposit': lambda self, amount: setattr(self, 'balance', amount),
        'withdraw': lambda self, amount: setattr(self, 'balance', getattr(self, 'balance', 0) - amount)
    })
    
    # Creating a Checking Account class dynamically
    CheckingAccount = type('CheckingAccount', (), {
        'transaction_fee': 5.00,
        'deposit': lambda self, amount: setattr(self, 'balance', amount),
        'withdraw': lambda self, amount: setattr(self, 'balance', getattr(self, 'balance', 0) - amount - self.transaction_fee)
    })
    
    # Creating instances of the dynamically created classes
    savings_account = SavingsAccount()
    checking_account = CheckingAccount()
    
    # Using the instances
    savings_account.deposit(1000)
    print(savings_account.balance)  # Output: 1000
    
    checking_account.deposit(500)
    checking_account.withdraw(100)
    print(checking_account.balance)  # Output: 395.0 (after deducting the transaction fee)
    

    In this example, we dynamically create two classes: SavingsAccount and CheckingAccount. Each class has its own attributes (interest_rate and transaction_fee, respectively) and methods (deposit and withdraw). Note: Lambda functions will covered in the next section of this guide.

    The deposit method for both classes sets the balance attribute of the instance to the deposited amount. The withdraw method for SavingsAccount simply deducts the withdrawn amount from the balance, while the withdraw method for CheckingAccount also deducts a transaction fee.

    We then create instances of these dynamically created classes and use them like regular class instances.

    Using type() this way allows us to create classes on-the-fly based on certain conditions or configurations, without having to hardcode the class definitions. This can be useful in scenarios where the class structures need to be dynamic and flexible, such as in financial systems that offer different account types or features based on customer preferences or account configurations.

  18. How do you create an empty class in Python? Why is creating an empty class useful?

    In Python, you can create an empty class by defining a class without any attributes or methods. Here’s the syntax:

    class EmptyClass:
        pass
    

    The pass statement is used as a placeholder because Python requires at least one statement in the class definition.

    Creating an empty class can be useful in several situations:

    1. Placeholder for Future Implementation: Sometimes, you might want to define an empty class as a placeholder or a stub, with the intention of adding attributes and methods later. This can be helpful when you’re planning the structure of your code or working with a framework that requires certain class definitions.

    2. Base Class for Inheritance: An empty class can serve as a base class for other classes to inherit from. This can be useful when you want to create a hierarchy of classes and provide a common base for shared behavior or attributes. Inheritance is introduced in the next notebook.

    3. Namespace or Container: An empty class can be used as a namespace or a container to group related functions or variables together. This can make your code more organized and easier to maintain.

    4. Mixin Classes: In object-oriented programming, mixin classes are classes that provide additional methods or functionality to other classes through inheritance. Empty classes can be used as mixin classes to extend the functionality of existing classes.

    5. Testing and Experimentation: When learning or experimenting with object-oriented programming concepts, creating empty classes can be a good starting point for understanding class definitions and inheritance mechanisms.

    class BaseClass:
        pass
    
    class DerivedClass(BaseClass):
        def __init__(self, value):
            self.value = value
    
        def print_value(self):
            print(self.value)
    
    # Using the empty class as a namespace
    class Utils:
        pass
    
    Utils.double = lambda x: x * 2
    Utils.square = lambda x: x ** 2
    
    # Using the DerivedClass
    obj = DerivedClass(10)
    obj.print_value()  # Output: 10
    
    # Using the Utils namespace
    print(Utils.double(5))  # Output: 10
    print(Utils.square(3))  # Output: 9
    

    In this example, BaseClass is an empty class that serves as a base class for DerivedClass. The Utils class is an empty class used as a namespace to group utility functions (double and square).

    While empty classes may not be as common as classes with attributes and methods, they can be useful in certain scenarios, especially when working with object-oriented programming concepts or frameworks that require specific class structures.

  19. What is the output of the following code?

    class demo():
        def __repr__(self):
           return '__repr__ built-in function called'
        def __str__(self):
           return '__str__ built-in function called'
      
    s=demo()
    print(s)
    

    str built-in function called

  20. What is the __del__ method used for in Python? Explain when it is invoked. Can programmers rely upon it being invoked?

    The __del__ method in Python is a special method used for object destruction and resource cleanup. The garbage collecter calls this method on an object when it is about to be destroyed and its memory is reclaimed. However, programmers should not rely on __del__ for resource cleanup, as its invocation is not guaranteed and can be delayed indefinitely by Python’s garbage collector. Further, it is possible for the interpreter to exit before __del__ is called.

    It may be worth using __del__ as a fallback mechanism to release any resources that may have been missed or leaked due to programming errors or exceptional circumstances. However, it should not be the primary mechanism for resource cleanup, as its invocation is not guaranteed and can lead to resource leaks if not handled properly. Even here, the with statement (with the appropriate contet manager) is the preferred and correct solution.