Skip to content

Latest commit

 

History

History
316 lines (241 loc) · 9.83 KB

py_ADV_OOP.md

File metadata and controls

316 lines (241 loc) · 9.83 KB

In Python, the self keyword is central to object-oriented programming (OOP). It represents the instance of a class and allows you to access its attributes and methods. When combined with special methods like __enter__ and __exit__, self enables powerful features like context management (used with the with statement). Let’s break down these concepts and the capabilities of self in Python OOP.


1. What is self in Python?

  • self is a conventional name (not a keyword) used to refer to the instance of a class.
  • It is the first parameter of instance methods and is automatically passed when the method is called.
  • Through self, you can:
    • Access instance attributes.
    • Call other methods of the class.
    • Modify the state of the instance.

Example:

class MyClass:
    def __init__(self, value):
        self.value = value  # Instance attribute

    def display(self):
        print(f"Value: {self.value}")  # Access instance attribute

obj = MyClass(10)
obj.display()  # Output: Value: 10

2. Context Managers: __enter__ and __exit__

Context managers are used to manage resources (e.g., files, database connections) efficiently. They ensure that resources are properly acquired and released, even if an error occurs. This is achieved using the with statement and two special methods:

__enter__(self)

  • Called when entering the with block.
  • Returns the resource to be managed.
  • Commonly returns self if the class itself is the resource.

__exit__(self, exc_type, exc_val, exc_tb)

  • Called when exiting the with block.
  • Handles cleanup (e.g., closing files, releasing locks).
  • Parameters:
    • exc_type: The exception type (if an exception occurred).
    • exc_val: The exception value (if an exception occurred).
    • exc_tb: The traceback (if an exception occurred).
  • If no exception occurs, all three parameters are None.

Example: Custom Context Manager

class FileManager:
    def __init__(self, filename, mode):
        self.filename = filename
        self.mode = mode
        self.file = None

    def __enter__(self):
        self.file = open(self.filename, self.mode)
        return self.file  # Return the resource

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.file:
            self.file.close()  # Clean up
        if exc_type:  # Handle exceptions
            print(f"Exception occurred: {exc_val}")
        return True  # Suppress exceptions

# Usage
with FileManager("example.txt", "w") as f:
    f.write("Hello, World!")

3. return self in __enter__

  • Returning self in __enter__ allows the instance itself to be used as the context manager.
  • This is useful when the class encapsulates the resource and provides methods to interact with it.

Example: Returning self

class DatabaseConnection:
    def __init__(self, db_name):
        self.db_name = db_name
        self.connection = None

    def __enter__(self):
        self.connection = self._connect()
        return self  # Return the instance

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.connection:
            self.connection.close()

    def _connect(self):
        print(f"Connecting to {self.db_name}...")
        return "Connection Object"  # Simulated connection

    def query(self, sql):
        print(f"Executing: {sql}")

# Usage
with DatabaseConnection("my_database") as db:
    db.query("SELECT * FROM users")

4. Capabilities of self in Python OOP

self is the backbone of Python OOP. Here’s what it enables:

Accessing Instance Attributes

  • You can define and access attributes specific to an instance.
  • Example:
    class Person:
        def __init__(self, name):
            self.name = name  # Instance attribute

Calling Instance Methods

  • You can call other methods of the class using self.
  • Example:
    class Calculator:
        def add(self, a, b):
            return a + b
    
        def multiply(self, a, b):
            return a * b
    
        def compute(self, a, b):
            return self.add(a, b) + self.multiply(a, b)

Modifying Instance State

  • You can change the state of the instance by modifying its attributes.
  • Example:
    class Counter:
        def __init__(self):
            self.count = 0
    
        def increment(self):
            self.count += 1

Implementing Special Methods

  • self is used in special methods (dunder methods) like __init__, __enter__, __exit__, __str__, etc.
  • Example:
    class MyClass:
        def __str__(self):
            return "MyClass instance"

Encapsulation and Abstraction

  • self allows you to hide implementation details and expose only necessary functionality.
  • Example:
    class BankAccount:
        def __init__(self, balance):
            self._balance = balance  # Private attribute
    
        def deposit(self, amount):
            self._balance += amount
    
        def get_balance(self):
            return self._balance

5. Key Takeaways

  • self: Represents the instance of a class and allows access to its attributes and methods.
  • __enter__ and __exit__: Enable context management for resource handling.
  • return self: Allows the instance itself to be used as the context manager.
  • Capabilities of self:
    • Access and modify instance attributes.
    • Call other methods.
    • Implement special methods.
    • Encapsulate and abstract implementation details.

By understanding these concepts, you can write more robust, reusable, and maintainable Python code.

Let’s break down the two concepts you’ve asked about:


1. The -> Syntax in Python Functions

The -> syntax in Python is used for type annotations. It specifies the expected return type of a function. This is part of Python's type hinting feature, introduced in PEP 484.

What Does -> Do?

  • It indicates the return type of a function.
  • It does not enforce the type (Python remains dynamically typed), but it helps with code readability, documentation, and static type checking (using tools like mypy).

Example:

def add(a: int, b: int) -> int:
    return a + b
  • Here, -> int indicates that the add function is expected to return an integer.
  • The type hints (a: int, b: int) specify that the arguments a and b should be integers.

Why Use Type Annotations?

  1. Improved Readability:
    • Makes it clear what types a function expects and returns.
  2. Static Type Checking:
    • Tools like mypy can catch type-related errors before runtime.
  3. Better IDE Support:
    • IDEs can provide better autocompletion, linting, and error detection.

Example with Complex Types:

from typing import List, Tuple

def process_data(data: List[str]) -> Tuple[int, str]:
    count = len(data)
    summary = ", ".join(data)
    return count, summary
  • This function takes a list of strings (List[str]) and returns a tuple containing an integer and a string (Tuple[int, str]).

2. The .get Method in Classes

The .get method is commonly associated with dictionaries in Python, but it can also be implemented in custom classes to provide similar functionality.

What Does .get Do in Dictionaries?

  • The .get method retrieves the value associated with a key in a dictionary.
  • If the key does not exist, it returns a default value (or None if no default is provided).

Example with Dictionaries:

my_dict = {"name": "Alice", "age": 30}

# Using .get
name = my_dict.get("name")  # Returns "Alice"
city = my_dict.get("city", "Unknown")  # Returns "Unknown" (default value)

Implementing .get in Custom Classes

You can define a .get method in your own classes to provide dictionary-like behavior.

Example: Custom Class with .get Method

class MyClass:
    def __init__(self):
        self.data = {"name": "Alice", "age": 30}

    def get(self, key, default=None):
        return self.data.get(key, default)

# Usage
obj = MyClass()
print(obj.get("name"))  # Output: Alice
print(obj.get("city", "Unknown"))  # Output: Unknown

Why Implement .get in Classes?

  1. Consistency:
    • Provides a familiar interface for users of your class.
  2. Flexibility:
    • Allows you to handle missing keys gracefully.
  3. Encapsulation:
    • Hides the internal data structure (e.g., a dictionary) and provides controlled access.

Combining Both Concepts

Here’s an example that combines type annotations and a .get method in a custom class:

from typing import Optional, Any

class MyClass:
    def __init__(self):
        self.data = {"name": "Alice", "age": 30}

    def get(self, key: str, default: Optional[Any] = None) -> Optional[Any]:
        """
        Retrieve the value associated with the key.
        If the key does not exist, return the default value.
        """
        return self.data.get(key, default)

# Usage
obj = MyClass()
print(obj.get("name"))  # Output: Alice
print(obj.get("city", "Unknown"))  # Output: Unknown
  • Type Annotations:
    • key: str: The key is expected to be a string.
    • default: Optional[Any] = None: The default value can be of any type or None.
    • -> Optional[Any]: The method returns a value of any type or None.
  • .get Method:
    • Provides dictionary-like access to the internal data attribute.

Key Takeaways

  1. -> in Functions:

    • Used for type annotations to specify the return type.
    • Improves code readability and enables static type checking.
  2. .get Method:

    • Commonly used in dictionaries to retrieve values with a default fallback.
    • Can be implemented in custom classes to provide similar functionality.

By understanding these concepts, you can write more expressive, maintainable, and robust Python code.