# Understanding Object-Oriented Programming (OOP) in Python

During the second week of my internship at **DataraFlow**, I had the opportunity to deepen my understanding of **object-oriented programming (OOP)**, **iterators**, and **scope management** in Python. While I had previously encountered these concepts, applying them in structured tasks revealed more about how they work.

In this article, I will share my experience from the second week, detailing the key lessons learned, the challenges encountered, and the strategies I employed to overcome them.

## Key Lessons Learned

### 1\. Overview of Object-Oriented Programming (OOP)

OOP is the practice of creating objects in programs to simplify the code and ensure easier program maintenance. An object in Python is an instance of a class, which bundles attributes and methods together. That is, once you instantiate a class, you have automatically created an object of that class. You can think of a class as a recipe. It describes the ingredients (attributes) and steps (methods or functions), but the cake (object) is made when you follow the recipe.

In the code example below, the `Calculator` class acts as a blueprint for creating objects that can perform basic arithmetic operations. It contains multiple **functions (methods) -** `add()`, `subtract()`, `multiply()`, and `divide()` - each handling a different task. Instead of writing separate functions outside, grouping them inside a class keeps related operations organized and reusable.

```python
class Calculator:
    def add(self, x, y):
        return x + y

    def subtract(self, x, y):
        return x - y

    def multiply(self, x, y):
        return x * y

    def divide(self, x, y):
        if y == 0:
            return "Error! Division by zero not allowed."
        return x / y
```

When we create an object `calc = Calculator()`, we can call any of these functions through that object, like `calc.add(5, 3)`. This demonstrates how a class can bundle attributes and multiple functions (methods) into a single unit, making code simpler and easier to maintain.

```python
# Create an object
calc = Calculator()

# Call the add() function of the object
print(calc.add(10, 5))       
# Call the subtract() function of the object
print(calc.subtract(10, 5))
# Call the multiply() function of the object
print(calc.multiply(10, 5))  
# Call the divide() function of the object
print(calc.divide(10, 5))
```

Output:

```plaintext
15
5
50
2
```

We can also create multiple objects of the same Calculator class (`calc1`, `calc2,` `calc3`, etc):

```python
# First object
calc1 = Calculator()
# Second object
calc2 = Calculator()
# Third object
calc3 = Calculator()
```

### 2\. The **\_\_init\_\_() Method**

The Python `__init__()` method is used to set up and initialize the attributes of a class when the class is instantiated. In the code example below, the `__init__()` method of the `Person` class tells us that the class must contain two attributes (`name` and `age`) before it can work, and it also initializes those attributes once the class is instantiated as an object. In initializing the attributes, it attaches the attributes to the class itself using the `self` label. This enables us to access the attributes using the syntax `class_object.attribute` (Like in the code below, we can access the name attribute of our created object `p1` by running `p1.name`).

```python
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

# An object of Person class
p1 = Person("Franklin", 16)
# Access the name attribute
print(p1.name)
```

Output:

```plaintext
Franklin
```

Note that the `self` label is used to create a reference to a class and it can be any word. Different functions of a class can have different words for `self`, but it must be the first parameter in the functions. In the code example below, the `__init__()` method has `abc` in place of `self`, while the `my_function()` method has `mine` in place of `self`. They are different but they are both references to the same class since they are the first parameter in their respective methods.

```python
class Person:
    def __init__(abc, name, age):
        abc.name = name
        abc.age = age
    def my_function(mine):
        return f"My name is {mine.name}"

# An object of Person class
p1 = Person("Franklin", 16)
# Call my_function() in the object
print(p1.my_function())
```

Output:

```plaintext
My name is Franklin
```

### 3\. The \_\_str\_\_() Method

When we print an object, we will get a ‘strange-looking’ output `<__main__.Person object at 0x7dd914e361e0>` as seen in the code example below. This ‘non-human-friendly’ output tells us the class name and its memory address.

```python
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

# An object of the Person class
p1 = Person("Franklin", 16)
# Print the object
print(p1)
```

```plaintext
<__main__.Person object at 0x7dd914e361e0>
```

For us to get a readable, human-friendly output whenever we try to print an object, we make use of the `__str__()` method. By adding the method as shown below, we will get the `name` and `age` attributes of the object when we try to print the object.

```python
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age
    def __str__(self):
        return f"{self.name}({self.age})"

# An object of the Person class
p1 = Person("Franklin", 16)
# Print the object
print(p1)
```

Output:

```markdown
Franklin(16)
```

### 4\. Class Inheritance

This is the practice of making a class inherit the attributes and methods of another class. In the code example below, the class `Vehicle` is the **parent class** (also known as the **base class or superclass)**. It defines the basic attributes that any vehicle should have, namely `brand` and `model`. By itself, this class acts as a blueprint for all types of vehicles, ensuring that whenever we create an object from it or extend it into another class, those objects will carry the same basic attributes. This reduces redundancy, since attributes common to many vehicle types only need to be defined once in the parent class. The class `Car` is a **child class** (also known as **derived class or subclass)** that inherits from `Vehicle`. This means it automatically has access to all the properties and methods of `Vehicle`, such as `brand` and `model`, without needing to rewrite them. However, `Car` extends the parent class by adding a new attribute, `doors`, which is specific to cars but not necessarily to all vehicles. In this way, inheritance allows us to build on existing attributes while introducing specialized attributes.

```python
# A class named 'Vehicle' that takes two parameters: 'brand' and 'model'
class Vehicle:
    def __init__(self, brand, model):
        # Attribute to store brand
        self.brand = brand
        # Attribute to store model
        self.model = model

# A subclass named 'Car' that inherits from 'Vehicle' class
class Car(Vehicle):
    # Add the 'doors' parameter
    def __init__(self, brand, model, doors):
        # Keep the inheritance from the parent class 'Vehicle'
        super().__init__(brand, model)
        # Attribute to store doors
        self.doors = doors
```

Note that when introducing specialised attributes in a child class, the inherited basic attributes from the parent class gets overriden. To prevent this and maintain the basic attributes, we use the syntax `parent_classname.__init__(self, basic_attributes)` or `super().__init__(basic_attributes)`. It is recommended to use the latter syntax `super().__init__(basic_attributes)`. When you use `Parent_classname.__init__(self, basic_attributes)`, you are hard-coding the parent’s name into the child class. If later on you decide to rename the parent class or change the inheritance structure, you must go through all child classes and manually update the parent’s name. This makes maintenance tedious and error-prone. By contrast, `super().__init__(basic_attributes)` does not require you to reference the parent explicitly, as it follows Python’s **Method Resolution Order (MRO)**. That means if you rename or restructure inherited classes, you do not need to touch the child class code, which makes the program easier to maintain.

### 5\. Python’s Method Resolution Order (MRO)

The Method Resolution Order (MRO) in Python is the set of rules that is followed when searching for a method or attribute in a class hierarchy. Whenever you call a method on an object, Python first looks inside the class of that object. If the method is not found there, it begins searching through the parent classes in a specific sequence until it finds a match. This search order is what we call the MRO.

In the case of single inheritance, the MRO is straightforward. For example, in the code below, we have a class `Bicycle` that inherits from `Car`, which in turn inherits from `Vehicle`. If we call the `move()` method on an object of class `Bicycle`, Python will check class `Bicycle` first, then move to `Car`, and finally to `Vehicle`. When it finds the `move()` method in class `Vehicle`, it prints `‘Moving’`.

```python
class Vehicle:
    def move(self):
        return "Moving"

class Car(Vehicle):
    pass

class Bicycle(Car):
    pass

# Create an object instance of Bicycle class
bicycle = Bicycle()
# Call the move() method
print(bicycle.move())
```

Output:

```markdown
 Moving
```

The importance of MRO becomes even clearer in the case of multiple inheritance, where a class inherits from more than one parent. Without a defined order, it could be confusing to know which parent’s method should take precedence when the same method exists in multiple parents. Python resolves this by following a consistent order in which parent classes are checked. This prevents conflicts and ensures fairness across complex inheritance hierarchies. For example, in the code below, class `Bicycle` inherits from both `Vehicle` and `Car`, and both parents define the same `move()` method. Python resolves this by checking the MRO of `Bicycle`, which is `[Bicycle -> Vehicle -> Car -> object]`. This means `Bicycle` will use `Car`’s version of the method first, avoiding ambiguity.

```python
class Vehicle:
    def move(self):
        return "Moving"

class Car:
    def move(self):
        return "Car is moving"

class Bicycle(Vehicle, Car):
    pass

bicycle = Bicycle()
print(bicycle.move())
```

Output:

```markdown

Moving
```

Another practical aspect of MRO is its relationship with the `super()` function, as discussed earlier. When `super()` is used inside a class with multiple inheritance, Python follows the MRO to determine which class’s method to call next. This means that instead of directly hard-coding the parent class name, you can rely on `super()` to navigate the hierarchy in the correct order automatically. For example, in the code below, class `Bike` inherits from both `Car` and `Bicycle`, and both parent classes inherit from a common parent class `Vehicle`. Since each class has its own version of the `move()` method, then calling `super().move()` inside one of the classes will not simply call the immediate parent’s method, but instead will follow the Method Resolution Order (MRO), which is `[Bike → Car → Bicycle → Vehicle]`. This shows that `super().move()` does not necessarily mean “call the direct parent,” but instead “call the next method in the MRO.”

```python
class Vehicle:
    def move(self):
        print("Moving")

class Car(Vehicle):
    def move(self):
        super().move()
        print("Car is moving")

class Bicycle(Vehicle):
    def move(self):
        super().move()
        print("Bicycle is moving")

class Bike(Car, Bicycle):
    def move(self):
        super().move()
        print("Bike is moving")

bike = Bike()
bike.move()

# Output:
# Moving
# Bicycle is moving
# Car is moving
# Bike is moving
```

### 6\. Polymorphism

This is the ability of a single method to be used by multiple classes, and that single method behaves differently with each class. For instance, in the code below, both the `Car` and `Bicycle` classes define a method named `move()`, but each provides its own implementation. In `Car`, the method prints `‘Car drives’`, while in `Bicycle` it prints `‘Bicycle pedals’`. In the `for` loop, the same method call `vehicle.move()` is used on different object types. Even though the loop does not know in advance whether the object is a `Car` or a `Bicycle`, Python automatically determines the correct version of `move()` to execute. Polymorphism allows the code to be reusable, since new vehicle types could be added with their own `move()` method without modifying the loop.

```python
class Car:
    def move(self):
        return "Car drives"

class Bicycle:
    def move(self):
        return "Bicycle pedals"

for vehicle in [Car(), Bicycle()]:
    print(vehicle.move())
```

Output:

```plaintext
Car drives
Bicycle pedals
```

### 7\. Iterators

An iterator in Python is an object that lets you go through a sequence of items one at a time, without needing to know all the items in advance. It has two special methods: `__iter__()` (which returns the iterator itself) and `__next__()` (which returns the next item in the sequence). Each time you call `next()` on an iterator, it gives you the next value, and when there are no more items, it raises a `StopIteration` error to signal the end. This is the mechanism that makes Python’s `for` loops. It is important to note that iterators are different from **iterables**. Iterables are containers of data (like a list, tuple, string, or dictionary), while iterators are tools that fetch data from those containers step by step. For example, in the code below, the list (`[1, 2, 3]`) is an iterable, but calling `iter([1, 2, 3])` produces an iterator that knows how to move through the list one element at a time until it’s exhausted.

```python
mylist = [1,2,3]
myiterator = iter(mylist)
print(myiterator)
print(next(myiterator))
print(next(myiterator))
print(next(myiterator))
print(next(myiterator))
```

Output:

```plaintext
<list_iterator object at 0x7c1cbc943730>
1
2
3
StopIteration:
```

The code example below shows how a custom iterator works by creating a `CountDown` class. The class starts with an initial number `n`, and by defining the special methods `__iter__()` and `__next__()`, it becomes an iterator that can be used in a `for` loop. Each time `__next__()` is called, it returns the current value of `n` and then decreases it by one, so the numbers are produced in descending order. Once `n` reaches zero, a `StopIteration` error is raised, which tells Python that the loop has finished. When we run the `for` loop with `CountDown(10)`, Python automatically calls these iterator methods behind the scenes, printing the numbers from 10 down to 1 one step at a time.

```python
# A class named 'CountDown' that takes a parameter 'n'
class CountDown:
    def __init__(self, n):
        # Attribute to store 'n'
        self.n = n
    # Make the 'CountDown' class an iterator
    def __iter__(self):
        return self
    def __next__(self):
        if self.n <= 0:
            raise StopIteration
        current = self.n
        self.n -= 1
        return current
# Instantiate the 'CountDown' iterator
for number in CountDown(10):
    print(number)
```

Output:

```markdown
10
9
8
7
6
5
4
3
2
1
```

### 8\. Scope and the Importance of Naming

In Python, scope simply means the part of the program where a variable can be seen and used. Think of it like the boundaries of a room: a variable created inside one room (like inside a function) can only be used in that room unless it is specifically passed out. Python follows the **LEGB rule**. It looks for variables in the **Local scope** first (inside the function), then in the **Enclosing** **scope** (outer functions), then in the **Global** **scope** (outside the function), and finally in the **Built-in** **scope** (Python’s own reserved names, like `len` or `print`). For instance, in the code below, when the `inner()` function runs, it prints `"local"` because Python finds the variable `x` defined in the local scope of `inner()`. When control returns to the `outer()` function, it prints `"enclosing"`, since the closest definition of `x` is in the enclosing scope of `outer()`. Outside both functions, Python prints `"global"`, because it now uses the global scope where `x` was first defined. Finally, when calling `len([1, 2, 3])`, Python doesn’t find `len` in any of the local, enclosing, or global scopes, so it looks into the built-in scope and uses Python’s built-in `len()` function.

```python
# Global variable
x = "global"

def outer():
    # Enclosing variable
    x = "enclosing"
    
    def inner():
        # Local variable
        x = "local"
        print("Value inside inner():", x) 
    
    inner()
    print("Value inside outer():", x)     

outer()
print("Value in global scope:", x)      

# Built-in scope example
print("Length of list:", len([1, 2, 3]))
```

Output:

```markdown
Value inside inner(): local
Value inside outer(): enclosing
Value in global scope: global
Length of list: 3
```

The `global` and `nonlocal` keywords are used to change how variables are accessed and modified across different scopes. Normally, if you assign a value to a variable inside a function, Python treats it as a **local variable**. However, if you want to modify a variable defined in the global scope (outside any function), you must declare it with `global` inside the function; otherwise, Python will assume you are creating a new local variable. Similarly, `nonlocal` is used inside nested functions to tell Python that a variable should not be treated as local to the inner function, but rather refer to a variable in the enclosing scope (the nearest outer function).

For example, the code below shows how `global` and `nonlocal` work with variable scopes. At the start, `x = 10` is defined in the global scope, and `y = 20` is defined in the enclosing scope inside `outer()`. When `inner()` is called, declaring `global x` tells Python to use and update the `x` in the global scope, changing it from `10` to `100`. Similarly, `nonlocal y` tells Python to use the `y` defined in the enclosing function `outer()`, updating it from `20` to `200`. Without these keywords, Python would treat `x` and `y` as new local variables inside `inner()`.

```python
# Global variable
x = 10  

def outer():
    # Enclosing variable
    y = 20  

    def inner():
        global x       
        nonlocal y 
        # Updates the global variable
        x = 100 
        # Updates the enclosing variable       
        y = 200        
        print(f"Inside inner(): x = {x}, y = {y}")

    inner()
    print(f"Inside outer(): x = {x}, y = {y}")

outer()
print(f"In global scope: x = {x}")
```

We can see that scope makes sure variables don’t clash with each other and keeps our code organized, but it also means you have to be careful with naming to avoid conflicts with Python’s built-ins. For example, the code below demonstrates how scope affects variable behavior in Python. At the start of the function, calling `len(my_list)` correctly uses Python’s built-in `len()` function to return the length of the list. However, once a local variable named `len` is created and assigned the value `100`, it shadows the built-in `len` within that local scope. From that point onward, `len` no longer refers to the built-in function but to the integer `100`. As a result, the next attempt to call `len(my_list)` fails, because Python tries to treat the integer `100` as a function, which is not possible.

```python
def show_len_behavior():
    # Call the built-in len() function on a list
    my_list = [1, 2, 3]
    print("Length of list:", len(my_list))

    # A local variable named 'len'
    len = 100
    print("Local variable len:", len)

    # Call the built-in len() function on a list, again
    print("Length of list:", len(my_list))

# Call the function
show_len_behavior()
```

Output:

```markdown
UnboundLocalError: cannot access local variable 'len' where it is not associated with a value
```

### 9\. Building a Library Management System

I built a simple **Library Management System (LMS)** to demonstrate the programming concepts discussed: **Object-Oriented Programming (OOP)**, **scope**, and **iterators**.

```python
# A program for a Library Management System
# A class named 'Book' that takes in three parameters: 'title', 'author', and 'year'
class Book:
    def __init__(self, title, author, year):
        self.title = title
        self.author = author
        self.year = year
        # Define the initial state of the book as not borrowed
        self.borrowed = False

    # Method to get the book description
    def get_description(self):
        return f"'{self.title}' by {self.author} ({self.year})"

# A subclass named 'Ebook' that inherits from 'Book' class
class EBook(Book):
    def __init__(self, title, author, year, file_size, file_format):
        super().__init__(title, author, year)
        # Attributes specific to the 'Ebook' class
        self.file_size = file_size
        self.file_format = file_format

    # Override the get_description() method in parent class 'Book'
    def get_description(self):
        return f"E-Book: {self.title} by {self.author}, {self.year}. Size: {self.file_size}MB, Format: {self.file_format}"

# A subclass named 'PrintedBook' that inherits from 'Book' class
class PrintedBook(Book):
    def __init__(self, title, author, year, weight, dimensions):
        super().__init__(title, author, year)
        # Attributes specific to the 'PrintedBook' class
        self.weight = weight
        self.dimensions = dimensions

    # Override the get_description() method in parent class 'Book'
    def get_description(self):
        return f"Printed Book: {self.title} by {self.author}, {self.year}. Weight: {self.weight}g, Dimensions: {self.dimensions}"

# A class named 'Member' that takes in two parameters: 'name' and 'member_id'
class Member:
    # Class variable to keep track of total borrowed books across members
    total_borrowed_books = 0

    def __init__(self, name, member_id):
        self.name = name
        self.member_id = member_id
        # Protected attribute to store the list of borrowed books
        self._borrowed_books = []

    def borrow_book(self, book, borrow_limit):
        # Check if the borrowing limit has been reached
        if len(self._borrowed_books) >= borrow_limit:
            print(f"{self.name} cannot borrow more books. Borrowing limit of {borrow_limit} reached.")
        # Check if the book is already borrowed
        elif book.borrowed:
            print(f"'{book.title}' has already been borrowed by another member.")
        else:
            # Borrow the book successfully
            self._borrowed_books.append(book)
            book.borrowed = True
            Member.total_borrowed_books += 1
            print(f"{self.name} borrowed '{book.title}' successfully.")

    # Make the class an iterator over borrowed books
    def __iter__(self):
        # Initialize an index for iteration.
        self.index = 0
        return self

    # Generate the borrowed books
    def __next__(self):
        if self.index < len(self._borrowed_books):
            book = self._borrowed_books[self.index]
            self.index += 1
            return book
        else:
            if not self._borrowed_books and self.index == 0:
                print(f"{self.name} has no borrowed books.")
            raise StopIteration

# A subclass StudentMember that inherits from Member.
class StudentMember(Member):
    def __init__(self, name, member_id, grade_level):
        super().__init__(name, member_id)
        # Initialize grade_level specific to StudentMember
        self.grade_level = grade_level

    # Students can borrow up to 10 books
    def borrow_book(self, book):
        super().borrow_book(book, borrow_limit=10)

# A subclass TeacherMember that inherits from Member.
class TeacherMember(Member):
    def __init__(self, name, member_id, department):
        super().__init__(name, member_id)
        # Initialize department specific to TeacherMember
        self.department = department

    # Teachers can borrow up to 20 books
    def borrow_book(self, book):
        super().borrow_book(book, borrow_limit=20)


# Main Library class to manage books and members
class Library:
    def __init__(self, library_name):
        # Initialize a private attribute to store library name
        self.__library_name = library_name
        # Initialize an empty list to store all books in the library.
        self.books = []
        # Initialize an empty list to store all members in the library.
        self.members = []

    # Make the Library class iterable.
    def __iter__(self):
        # Initialize an index for iteration.
        self._index = 0
        return self

    # Method to return the next book in the library.
    def __next__(self):
        if self._index < len(self.books):
            book = self.books[self._index]
            self._index += 1
            return book
        else:
            raise StopIteration

    # Add a new book to the library
    def add_book(self, book):
        self.books.append(book)
        print(f"Added book: {book.get_description()}")

    # Add a new member to the library
    def add_member(self, member):
        self.members.append(member)
        print(f"Added member: {member.name}")

    # Getter method to safely access the private library name
    def get_library_name(self):
        return self.__library_name

    # Setter method to modify private library name
    def set_library_name(self, name):
        self.__library_name = name

    # Method to search for books by title or author in lower case
    def search_books(self, keyword):
        return [book.get_description() for book in self.books
                if keyword.lower() in book.title.lower()
                or keyword.lower() in book.author.lower()]
```

From an OOP perspective, the system models real-world entities such as `Book`, `EBook`, `PrintedBook`, `Member`, `StudentMember`, `TeacherMember`, and `Library` as classes. Each class encapsulates its data (attributes) and behaviors (methods). Inheritance is also used. `EBook` and `PrintedBook` extend the `Book` class with specific attributes and override the `get_description()` method, while `StudentMember` and `TeacherMember` extend `Member` with different borrowing rules. This structure showcases OOP principles like encapsulation, inheritance, and polymorphism.

In terms of scope, the program makes use of different levels of variable accessibility. For instance, `self.__library_name` is defined as a **private attribute** within the `Library` class, only accessible through getter and setter methods. The `_borrowed_books` list in `Member` is a **protected attribute**, meant to be accessed only within the class and its subclasses. At the class level, [`Member.total`](http://Member.total)`_borrowed_books` is a **class variable** that keeps track of all borrowed books across members, showing how variables can exist in different scopes - local to methods, shared across objects, or private to a class.

The program also implements iterators to make the system more Pythonic. The `Member` class defines `__iter__()` and `__next__()`, allowing iteration over the books borrowed by a member using a simple `for` loop. Similarly, the `Library` class is made iterable so that all books in the library can be looped through without manually handling indices. By implementing these special methods, the classes behave like built-in collections, simplifying how books and borrowed lists are accessed.

Demonstration of the Library Management System:

```python
# Demonstration of the Library Management System

# Create an instance of the Library class.
library = Library("The Chosen Library")

# Call the getter method to retrieve and print the library's private name.
print(f"Library Name: {library.get_library_name()}\n")

# Instances of different book types 
book1 = PrintedBook("Mere Christianity", "C.S. Lewis", 1952, 250, "5x8")
book2 = PrintedBook("The Case for Christ", "Lee Strobel", 1998, 300, "5.5x8.5")
book3 = EBook("The Pilgrim's Progress", "John Bunyan", 1678, 1.2, "EPUB")
book4 = EBook("The Alchemist", "Paulo Coelho", 1988, 10, "PDF")


# Add the created book objects to the library.
library.add_book(book1)
library.add_book(book2)
library.add_book(book3)
library.add_book(book4)

# Instances of different member types.
student1 = StudentMember("Franklin", "S001", 100)
student2 = StudentMember("Jonathan", "S002", 200)
teacher1 = TeacherMember("Mr. David", "T001", "Theology")
teacher2 = TeacherMember("Mr. Acosta", "T002", "Theology")

# Add the created member objects to the library.
library.add_member(student1)
library.add_member(student2)
library.add_member(teacher1)
library.add_member(teacher2)

# Demonstrate polymorphism by having different member types borrow books with their specific limits.
student1.borrow_book(book1)
teacher1.borrow_book(book2)

# Show total borrowed books
print(f"\nTotal borrowed books across all members: {Member.total_borrowed_books}\n")

# Attempt to borrow the same book1 again to show the availability check.
student2.borrow_book(book1)

# Search for books using author's name
keyword = 'Lewis'
found_books = library.search_books(keyword)
print(f"\nSearching for '{keyword}':")
for book in found_books:
    print(f"Found: {book}")

# Iterate over library books
print("\nIterating over all books in the library:")
for book in library:
    print(f"- {book.get_description()}")

# Iterate over borrowed books of a member
member = teacher2
print(f"\nIterating over {member.name}'s borrowed books:")
for book in member:
    print(f"- {book.get_description()}")

# Modify the privte library name
library.set_library_name("The Kings Library")
# Get the new private library name
print(f"\nNew Library Name: {library.get_library_name()}")
```

Output:

```plaintext
Library Name: The Chosen Library

Added book: Printed Book: Mere Christianity by C.S. Lewis, 1952. Weight: 250g, Dimensions: 5x8
Added book: Printed Book: The Case for Christ by Lee Strobel, 1998. Weight: 300g, Dimensions: 5.5x8.5
Added book: E-Book: The Pilgrim's Progress by John Bunyan, 1678. Size: 1.2MB, Format: EPUB
Added book: E-Book: The Alchemist by Paulo Coelho, 1988. Size: 10MB, Format: PDF
Added member: Franklin
Added member: Jonathan
Added member: Mr. David
Added member: Mr. Acosta
Franklin borrowed 'Mere Christianity' successfully.
Mr. David borrowed 'The Case for Christ' successfully.

Total borrowed books across all members: 2

'Mere Christianity' has already been borrowed by another member.

Searching for 'Lewis':
Found: Printed Book: Mere Christianity by C.S. Lewis, 1952. Weight: 250g, Dimensions: 5x8

Iterating over all books in the library:
- Printed Book: Mere Christianity by C.S. Lewis, 1952. Weight: 250g, Dimensions: 5x8
- Printed Book: The Case for Christ by Lee Strobel, 1998. Weight: 300g, Dimensions: 5.5x8.5
- E-Book: The Pilgrim's Progress by John Bunyan, 1678. Size: 1.2MB, Format: EPUB
- E-Book: The Alchemist by Paulo Coelho, 1988. Size: 10MB, Format: PDF

Iterating over Mr. Acosta's borrowed books:
Mr. Acosta has no borrowed books.

New Library Name: The Kings Library
```

## Challenges Encountered

1. I would mix up a class with its object and get an error. For example, running `Car.move` instead of `car.move()`.
    
2. I had difficulty understanding the role of the `super()` method, but I was able to grasp it with extensive study and practice.
    
3. In the LMS, I encoutered a challenge when trying to get the list of borrowed books by a Member who actually didn’t borrow any books. I would get an empty string. To solve this, I implemented an `if` condition to display that the member didn’t borrow any book instead of an empty string. For example, in the LMS demonstration above, I tried accessing the list of books borrowed by Mr. Acosta and received the output “Mr. Acosta has no borrowed books.'“
    

## Conclusion

Mastering **Object-Oriented Programming (OOP)**, **scope**, and **iterators** is essential for writing clean, efficient, and maintainable Python code. OOP provides the blueprint for modeling real-world entities through classes and objects, while principles like inheritance, encapsulation, and polymorphism make programs more modular and reusable. Scope, governed by the LEGB rule, ensures variables are accessed and updated in the right context, preventing conflicts and promoting clarity. Iterators, meanwhile, add a powerful way to handle sequences of data, allowing us to traverse collections lazily and efficiently. Together, these concepts form the backbone of Python’s expressiveness, helping developers move beyond basic scripting into building robust systems. By understanding how these ideas connect - classes to structure logic, scope to manage variable visibility, and iterators to streamline data access - you gain the tools to not only solve problems effectively but also design solutions that scale gracefully with complexity.
