Python

What is Encapsulation in Python? (With Examples)

What is Encapsulation in Python? (With Examples)
In: Python

In this blog post, we're diving into the concept of Encapsulation in Python. To make things easy to understand, we'll use the example of a Vending Machine. We'll explore what encapsulation is, when to use it, and why it's essential. We'll also learn about getters and setters, as well as the @property decorator.

What is Encapsulation in Python?

Encapsulation in Python refers to the practice of hiding the internal workings of an object. This means that you can bundle instance variables (often called attributes) and methods into a single entity called a Class.

Encapsulation allows us to control the access to these attributes and methods, making some public and some private. This way, we hide the inner workings of an object, exposing only what's necessary. Think of it like a vending machine, you don't need to know how it works on the inside to use it.

The Vending Machine Example

In this code snippet, we have a simple VendingMachine class that demonstrates how a vending machine operates. We've included methods for showing inventory, inserting money, making a purchase, and getting change. Please remember, as a user, we don't care about how the vending machine works. We just look at the items, insert the money, get our item, and receive any remaining balance.

class VendingMachine:
    def __init__(self):
        self.inventory = {'coke': 5, 'crisps': 3, 'chocolate': 10}
        self.item_price = 1
        self.balance = 0

    def show_inventory(self):
        print("Available items")
        print("---------------")
        for item, count in self.inventory.items():
            print(f"{item}: {count}")

    def insert_money(self, amount):
        self.balance += amount
        print(f"Inserted - £{amount}")

    def purchase(self, item_name):
        if item_name in self.inventory and self.inventory[item_name] > 0 and self.balance >= self.item_price:
            self.balance -= self.item_price
            self.inventory[item_name] -= 1
            print(f"Purchased '{item_name}' | Remaining balance: £{self.balance}")
        elif item_name in self.inventory and self.inventory[item_name] <= 0:
            print("Item not available")
        elif item_name not in self.inventory:
            print("Invalid item selected")
        elif self.balance < self.item_price:
            print("Not enough balance")

    def get_change(self):
        if self.balance != 0:
            print(f"Returning £{self.balance} change")
            self.balance = 0.0
        else:
            print(f"No Change")

# Example usage
vm = VendingMachine()
vm.show_inventory()
vm.insert_money(5)
vm.purchase('coke')
vm.get_change()
#output

Available items
---------------
coke: 5
crisps: 3
chocolate: 10
Inserted - £5
Purchased 'coke' | Remaining balance: £4
Returning £4 change

In our example VendingMachine class, we start by setting up the inventory with some items like 'coke', 'crisps', and 'chocolate', each with its quantity. We also set a fixed item price and initialize a balance.

Then we have four methods. The show_inventory method prints the available items. The insert_money method lets us add money to the balance. The purchase method allows us to buy an item if it's in stock and we have enough balance. Finally, the get_change method returns any remaining balance. With these methods, the class mimics the basic operations you would expect from a real-life vending machine.

The Problem with this Approach

While our VendingMachine class works well for basic operations, there's a glaring issue. Anyone using the class can directly change the instance variables like balance or item_price.

For example, you could easily set the balance to £50 just by using vm.balance = 50, without actually inserting any money. This violates the basic principle that internal details like balance should be controlled and managed within the class itself, to prevent unintended changes or misuse.

vm = VendingMachine()

vm.balance = 50
print(vm.balance)

#output
50

Sometimes the shop worker also might need to manually adjust the machine's settings. Imagine a customer's coin getting stuck. Right now, the only way to amend the balance is by accessing the instance variable directly, like vm.balance = 5. But according to strict encapsulation practices, you shouldn't really be accessing these variables directly. It's like opening up the vending machine with a crowbar instead of using a key—you can do it, but it's not the best approach.

Why You Should Avoid Direct Access?

In Python, encapsulation is often viewed as a form of "information hiding" where the inner workings of a class stay hidden from the outside world. This approach allows you to control how the data within the class can be accessed or modified. While it's tempting to access a class's internal variables or methods directly, doing so can lead to unpredictable behaviour and errors.

Direct access could accidentally modify variables in a way that makes the entire system unstable or insecure. Even though Python gives you the flexibility to access a class's attributes and methods directly, it's often best to use encapsulation to create some boundaries for safer and more predictable coding.

Getters and Setters

Getters and Setters are special methods that help us safely access and modify instance variables. A Getter allows you to 'get' or retrieve the value of a variable. A Setter, on the other hand, lets you 'set' or update the value. This way, instead of accessing or changing variables directly, you use these methods. It's like having a controlled mechanism for our vending machine's balance or inventory. With Getters and Setters, we can also add checks or additional logic. Here is the updated code.

💡
Please note that we've added a single underscore (_) in front of our instance variables which we will cover later in the post
class VendingMachine:
    def __init__(self):
        self._inventory = {'coke': 5, 'crisps': 3, 'chocolate': 10}
        self._item_price = 1
        self._balance = 0

    def show_inventory(self):
        print("Available items")
        print("---------------")
        for item, count in self._inventory.items():
            print(f"{item}: {count}")

    # Getter for balance
    def get_balance(self):
        return self._balance

    # Setter for balance
    def set_balance(self, amount):
        if amount >= 0:
            self._balance = amount
        else:
            print("Invalid amount")

    def insert_money(self, amount):
        self._balance += amount
        print(f"Inserted - £{amount} | Total Balance - £{self._balance}")

    def purchase(self, item_name):
        if item_name in self._inventory and self._inventory[item_name] > 0 and self._balance >= self._item_price:
            self._balance -= self._item_price
            self._inventory[item_name] -= 1
            print(f"Purchased '{item_name}' | Remaining balance: £{self._balance}")
        elif item_name in self._inventory and self._inventory[item_name] <= 0:
            print("Item not available")
        elif item_name not in self._inventory:
            print("Invalid item selected")
        elif self._balance < self._item_price:
            print("Not enough balance")

    def get_change(self):
        if self.get_balance() > 0:
            print(f"Returning £{self._balance} change")
            self._balance = 0
        else:
            print("No change")


# Example usage
vm = VendingMachine()
vm.set_balance(5)
print(vm.get_balance())

#output
5

In the updated version of our Vending Machine code, we've introduced getters and setters for managing the balance. Specifically, we added a get_balance method that lets us see the current balance and a set_balance method to update it. By using these methods, we add an extra layer of control. For example, in the set_balance method, we check if the amount is greater than or equal to zero before updating it.

Private (_ and __) Variables

If you try to directly access and modify the instance variable using vm.balance = 10, you'll actually end up creating a new instance variable named balance for that specific object, separate from the original _balance variable we intended to use. When you later call vm.get_balance(), it will still return the value of _balance, not the new balance you've just set.

vm = VendingMachine()
vm.balance = 10
print(vm.get_balance())

#output
0

In Python, prepending a single underscore _ to a variable name is more of a convention than a strict rule for making a variable private. This means that the variable can still be accessed and modified directly from outside the class, but the underscore indicates to the developer that it's meant for internal use within the class. As you can see below, I can still modify the instance variable by calling the _balance instance variable.

vm = VendingMachine()
vm._balance = 10
print(vm.get_balance())

#output
10

This is different from some other languages that have strict private and public designations. In Python, the single underscore is more like a "gentleman's agreement" that the variable should not be accessed directly, rather than a strict prohibition.

On the other hand, using double underscores __ before a variable name will name-mangle the attribute name. This is closer to what is considered "private" in many other programming languages. The interpreter changes the name of the variable in a way that makes it harder to create subclasses that accidentally override the private attributes and methods. For example, if you have __balance, it might become _VendingMachine__balance internally. However, it's worth noting that this doesn't make it completely private; it just makes it harder to access unintentionally.

Even with name-mangling, you can still access and modify the variable if you know the name the interpreter changes it to. For example, if you've used __balance in a class named VendingMachine, you can still access it from outside the class with vm._VendingMachine__balance. It's a way to help prevent accidental access but not a way to strictly enforce privacy.

@property Decorator

The @property decorator in Python allows you to manage instance variables in an object-oriented way. When you use @property, you can access a method as if it's a simple attribute, allowing you to replace what could have been direct attribute access with method-based access.

You can also define a corresponding setter method for it using @<method_name>.setter. This lets you run checks or operations before actually setting the attribute. So, with @property and its setter, you can both retrieve and modify an attribute's value while incorporating any needed logic or checks, all wrapped up in a clean and Pythonic way.

class VendingMachine:
    def __init__(self):
        self._inventory = {'coke': 5, 'crisps': 3, 'chocolate': 10}
        self._item_price = 1
        self._balance = 0

    def show_inventory(self):
        print("Available items")
        print("---------------")
        for item, count in self._inventory.items():
            print(f"{item}: {count}")

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, amount):
        if amount >= 0:
            self._balance = amount
        else:
            print("Invalid amount")

    def insert_money(self, amount):
        self.balance += amount
        print(f"Inserted - £{amount} | Total Balance - £{self.balance}")

    def purchase(self, item_name):
        if item_name in self._inventory and self._inventory[item_name] > 0 and self.balance >= self._item_price:
            self.balance -= self._item_price
            self._inventory[item_name] -= 1
            print(f"Purchased '{item_name}' | Remaining balance: £{self.balance}")
        else:
            print("Transaction failed for some reason")

    def get_change(self):
        if self.balance > 0:
            print(f"Returning £{self.balance} change")
            self.balance = 0
        else:
            print("No change")

vm = VendingMachine()
vm.balance = 10
print(vm.balance)

#output
10

As you can see above, we could replace the get_balance and set_balance methods with the @property decorator. This would allow us to access the _balance variable as if it were a public attribute, while still giving us control over its value. So when you do vm.balance, it would internally call the method decorated with @property, effectively serving as a "getter".

Similarly, when you set a value like vm.balance = 10, it would call the method decorated with @balance.setter, acting as a "setter". This way, you get a neat and clean approach to controlling access to the balance, without the need to explicitly call get_balance or set_balance. It's a cleaner and more Pythonic way to handle encapsulation.

Closing Up

Ah, that's a lot to cover, isn't it? You might be wondering which method is best for your project, using _ or __ for variable names, or maybe going for the @property and @<method_name>.setter decorators? Well, it really depends on what you need.

If you want a simple way to indicate that a variable shouldn't be accessed directly, then using a single underscore is quick and straightforward. But if you're looking for a way to control variable access in a more polished manner, @property is the way to go. It's a bit more work upfront, but it makes your code easier to read and maintain in the long run. So, choose the approach that fits your project's needs the best!

Written by
Suresh Vina
Tech enthusiast sharing Networking, Cloud & Automation insights. Join me in a welcoming space to learn & grow with simplicity and practicality.
Comments
More from Packetswitch
Table of Contents
Great! You’ve successfully signed up.
Welcome back! You've successfully signed in.
You've successfully subscribed to Packetswitch.
Your link has expired.
Success! Check your email for magic link to sign-in.
Success! Your billing info has been updated.
Your billing was not updated.