Polymorphism closed with a warning: nothing stops anyone from writing odyssey.price = -5 or faust.stock = -3, and from that moment on every Papyrus calculation lies. In 05-01 you wrote sell() precisely so the stock could never go negative... but anyone can bypass the method and touch the attribute directly. Encapsulation is the principle that fixes this: each object protects its internal data and only allows changes through controlled channels, so that its invariants — the rules that must always hold, such as "stock is never negative" — cannot be broken, even by accident. Python solves it in its own way: no real locks, just clear conventions and an elegant tool called a property.

Contents

  1. What encapsulating means and why it matters
  2. The underscore convention: _protected
  3. Double underscore and name mangling: __private
  4. The problem with Java-style getters/setters
  5. @property: computed and validated attributes
  6. The setter: validating without changing the interface
  7. Product, armor-plated: the final version
  8. Common mistakes and tips
  9. Exercises with solutions

What encapsulating means and why it matters

Encapsulating means separating the what (the public interface: final_price(), sell()) from the how (the internal details: which attribute the stock lives in, how it's validated). The benefits are concrete:

  • Business invariants guaranteed: at Papyrus, stock is never negative, price is never negative, a member code always follows the NAME-NNN format. If the object watches over its own data, no part of the program can corrupt it.
  • Freedom to change on the inside: if tomorrow stock goes from a single integer to a per-warehouse breakdown, the rest of the code never notices as long as in_stock() keeps answering the same way.
  • Less room for error: whoever uses the class only sees what they're supposed to use.

That said, an important cultural difference: Python has no truly private attributes (like private in Java or C#). Its philosophy is summed up in the phrase "we're all consenting adults": the language signals what is internal and trusts you to respect it.

The underscore convention: _protected

A leading underscore marks an attribute or method as internal: "don't touch this from the outside; it may change without notice". It's only a convention — Python doesn't enforce it — but the whole community respects it.

class Member:
    """A member of the Papyrus club."""

    def __init__(self, name, code):
        self.name = name
        self._code = code              # internal: don't manipulate from outside
        self._purchases = []           # internal: purchase history

    def record_purchase(self, amount):
        self._purchases.append(amount)  # the only legitimate way to modify it

    def total_spent(self):
        return round(sum(self._purchases), 2)

luis = Member("Luis", "LUIS-001")
luis.record_purchase(12.35)
luis._purchases.append(-999)   # Python does NOT stop this... but it's frowned upon

The last line works, and that's the whole point: _purchases is a traffic sign, not a wall. Good linters and code reviewers will flag it, and that social contract is surprisingly effective.

Double underscore and name mangling: __private

Two leading underscores (without two trailing ones) trigger name mangling: Python internally renames the attribute to _ClassName__attribute.

class Member:
    def __init__(self, name, code):
        self.name = name
        self.__code = code

luis = Member("Luis", "LUIS-001")
print(luis.__code)           # AttributeError: 'Member' object has no attribute '__code'
print(luis._Member__code)    # LUIS-001  → still accessible after all!

Points you need to be clear on:

  • It is not real security: anyone who knows the trick gets in with _Member__code. It's surface-level concealment, not encryption or protection.
  • Its genuine purpose is to avoid name collisions in inheritance: if Product uses __counter and a subclass defines its own __counter, mangling keeps them apart (_Product__counter vs _Book__counter).
  • Day to day, the Python community prefers the single underscore. Reserve __private for classes designed to be inherited by strangers.
Notation Actual name Meaning Does it block access?
price price Public: use freely No
_price _price Internal by convention No (social contract)
__price _Class__price Internal + collision-proof in inheritance No (only disguises it)
__price__ __price__ Reserved for magic methods (05-05) Don't invent your own

The problem with Java-style getters/setters

In languages with strict privacy, the classic recipe is: private attribute + get method + set method. Ported literally to Python it looks like this:

class JavaStyleBook:
    def __init__(self, title, price):
        self._title = title
        self._price = price

    def get_price(self):
        return self._price

    def set_price(self, value):
        if value < 0:
            raise ValueError("Price cannot be negative")
        self._price = value

book = JavaStyleBook("Hamlet", 9.95)
book.set_price(10.50)          # verbose
print(book.get_price())        # and unnatural in Python

It does validate, yes, but at the cost of an ugly interface and of breaking all existing code that did book.price. Python has something better.

@property: computed and validated attributes

A property is a method disguised as an attribute: you read it as book.price (no parentheses), but under the hood it runs code. It's a standard-library decorator (decorators in general are covered in module 8; for now it's enough to know how to use this one).

class Book:
    def __init__(self, title, price, stock=0):
        self.title = title
        self._price = price        # the actual data lives in the internal attribute
        self._stock = stock

    @property
    def price(self):
        """Base price of the book, in euros."""
        return self._price

odyssey = Book("The Odyssey", 12.50, 4)
print(odyssey.price)       # 12.50 — READ like an attribute, no ()
odyssey.price = 13.00      # AttributeError: property 'price' has no setter

Without a setter, the property creates a read-only attribute: nobody can slap a price on by brute force anymore. Properties are also useful for computed attributes that aren't stored anywhere:

    @property
    def price_with_vat(self):
        return round(self._price * 1.04, 2)

print(odyssey.price_with_vat)   # 13.00 — computed on the fly, always consistent

The setter: validating without changing the interface

To allow assignment with control, you add the setter with @name.setter:

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError(f"Negative price not allowed: {value}")
        self._price = value

Now validation is automatic and the syntax stays natural:

odyssey.price = 13.50     # OK: goes through the setter and validates
odyssey.price = -5        # ValueError: Negative price not allowed: -5

raise ValueError(...) stops the program with a clear message; you already used the idea when validating inputs, and module 7 will teach you to catch these errors with try/except. What matters today: the object rejects the invalid data at the exact moment it tries to get in.

Java style in Python Pythonic style (@property)
Read book.get_price() book.price
Write book.set_price(13.5) book.price = 13.5
Validation Yes, in set_price Yes, in the setter
Breaks code that used the attribute? Yes, it must be rewritten No: same syntax as always
Read-only Omit set_price (but _price stays exposed) Property without a setter
Verdict Avoid Prefer

The "breaks code?" row hides the big strategic advantage: you can start with a plain public attribute (self.price) and turn it into a property years later, when validation becomes necessary, without touching a single line of client code. That's why in Python nobody writes preventive getters/setters "just in case", as Java orthodoxy demands.

Product, armor-plated: the final version

Let's apply all of it to the base class of the hierarchy (05-02). Invariants to protect: price ≥ 0, stock a non-negative integer.

class Product:
    """A Papyrus item with protected invariants."""

    BOOK_VAT = 0.04
    MEMBER_DISCOUNT = 0.05

    def __init__(self, title, price, stock=0):
        self.title = title
        self.price = price     # goes through the setter, even from __init__!
        self.stock = stock     # same: validated from the very first moment

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError(f"Negative price not allowed: {value}")
        self._price = value

    @property
    def stock(self):
        return self._stock

    @stock.setter
    def stock(self, value):
        if not isinstance(value, int):
            raise ValueError(f"Stock must be an integer, not {type(value).__name__}")
        if value < 0:
            raise ValueError(f"Negative stock not allowed: {value}")
        self._stock = value

    def sell(self, units=1):
        """The one recommended way to deduct stock."""
        if units > self._stock:
            return False
        self._stock -= units
        return True

    def final_price(self, member=False):
        discount = Product.MEMBER_DISCOUNT if member else 0
        return round(self._price * (1 - discount) * (1 + Product.BOOK_VAT), 2)

Two golden subtleties in this code:

  • __init__ assigns to self.price, not self._price: that way validation also applies at construction time. Product("Ghost", -3, 1) fails immediately, which is exactly where it should fail.
  • Subclasses inherit the armor for free: Book, Magazine and EBook don't change a single line, and their prices and stocks are protected, because their super().__init__(...) calls flow into these setters.
faust = Book("Faust", "Goethe", 21.00, 0)
faust.stock = -3        # ValueError: Negative stock not allowed: -3
faust.stock = 2.5       # ValueError: Stock must be an integer, not float
faust.stock = 5         # OK: copies arrived — reservation queue, take note
faust.sell(2)           # True
print(faust.stock)      # 3

Common Mistakes and Tips

  • Infinite recursion in the property: writing return self.price inside the getter of price (or self.price = value in its setter) calls the property over and over until RecursionError. Inside the getter/setter, always use the internal attribute self._price.
  • Validating in the setter but assigning to self._price in __init__: the object is born unvalidated. In __init__, assign to the public name (self.price = price) so the setter kicks in from second one.
  • Believing that __private protects sensitive data: name mangling is bypassed with obj._Class__attribute; it's not a security mechanism, just name hygiene for inheritance.
  • Writing preventive get_x()/set_x() getters/setters for everything: it's noise inherited from Java. Public attribute first; property when (and only when) you need to validate or compute.
  • Forgetting the @property above the getter and leaving only @price.setter: NameError, because the setter is defined based on the existing property. The order is: @property first, @name.setter after.
  • Tip: decide what constitutes the public interface (what you document: title, price, stock, sell(), final_price()) and mark everything else with _. A class with few doors is a class that's easy to reason about and to maintain.

Exercises

Exercise 1: a read-only title

At Papyrus, a product's title must not change once created (it's its identity in the catalog). Turn title into a property without a setter that also validates in __init__ (via an internal method or the stored value itself) that it isn't an empty string after strip(). Verify that odyssey.title = "Something else" fails and that Product(" ", 5.0) raises ValueError.

Exercise 2: the armor-plated member code

Create the Member class with a public name and a code property with getter and setter. The setter must validate Papyrus's NAME-NNN format: the left part alphabetic and uppercase, a hyphen, and three digits (revisit split(), isalpha(), isdigit(), isupper() from 04-05). "LUIS-001" and "MARTA-002" must pass; "luis-001", "LUIS001" and "LUIS-1" must raise ValueError.

Exercise 3: encapsulated reservations

Add to Product an internal attribute _reservations (a list) and the methods reserve(customer) and serve_reservation(). reserve is only allowed when there is NO stock (if there is, it should return the notice "In stock: buy directly"); serve_reservation pops the first customer off the list (order of arrival) or returns None if there are no reservations. Simulate: Faust with stock 0, Julia and Omar reserve, stock arrives, Julia gets served.

Solutions

Solution 1:

class Product:
    def __init__(self, title, price, stock=0):
        title = title.strip()
        if not title:                        # truthiness (02-01): "" is falsy
            raise ValueError("Title cannot be empty")
        self._title = title
        self.price = price
        self.stock = stock

    @property
    def title(self):
        return self._title
    # ... rest of the properties as in the lesson ...

odyssey = Product("The Odyssey", 12.50, 4)
odyssey.title = "Something else"   # AttributeError: property 'title' has no setter
Product("   ", 5.0)                # ValueError: Title cannot be empty

Without a setter, the property is read-only: the product's identity is sealed at birth.

Solution 2:

class Member:
    def __init__(self, name, code):
        self.name = name
        self.code = code              # goes through the setter

    @property
    def code(self):
        return self._code

    @code.setter
    def code(self, value):
        parts = value.split("-")
        valid = (
            len(parts) == 2
            and parts[0].isalpha() and parts[0].isupper()
            and len(parts[1]) == 3 and parts[1].isdigit()
        )
        if not valid:
            raise ValueError(f"Invalid member code: {value!r} (expected format NAME-NNN)")
        self._code = value

Member("Luis", "LUIS-001")    # OK
Member("Luis", "luis-001")    # ValueError (lowercase)
Member("Luis", "LUIS-1")      # ValueError (missing digits)

It's the same rule as is_valid_member() from papyrus_utils.py, but now it lives where it belongs: in the object that stores the data, impossible to forget.

Solution 3:

class Product:
    def __init__(self, title, price, stock=0):
        self.title = title
        self.price = price
        self.stock = stock
        self._reservations = []       # internal: only touched via methods

    def reserve(self, customer):
        if self.stock > 0:
            return "In stock: buy directly"
        self._reservations.append(customer)
        return f"{customer} added to the list ({len(self._reservations)} in queue)"

    def serve_reservation(self):
        if self._reservations:
            return self._reservations.pop(0)   # first come, first served
        return None

faust = Product("Faust", 21.00, 0)
print(faust.reserve("Julia"))       # Julia added to the list (1 in queue)
print(faust.reserve("Omar"))        # Omar added to the list (2 in queue)
faust.stock = 3                     # copies arrive (validated by the setter)
print(faust.serve_reservation())    # Julia

The _reservations list is never manipulated from outside: the order of arrival is guaranteed by construction. (For long queues, remember that deque from 04-06 does popleft() in constant time; here pop(0) is enough.)

Conclusion

Encapsulation has closed the crack that polymorphism left exposed: _internal flags what shouldn't be touched, __private adds name mangling (name hygiene, never real security), and @property with its @setter delivers the best of both worlds — the natural syntax of an attribute with the validation of a method. Papyrus's Product class no longer accepts negative prices or impossible stocks, and all its subclasses inherit the armor without changing a line. With data joined to its behavior (05-01), organized into a hierarchy (05-02), polymorphic (05-03) and protected (05-04), the only thing missing is for your objects to speak Python's native language: for print(odyssey) to show something worthy, for sorted(catalog) to sort books without key=, for book in cart to just work. All of that is granted by the magic methods, and with them we'll finally settle the debt pending since 05-01: the "ugly" print. That's the next lesson.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved