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
- What encapsulating means and why it matters
- The underscore convention:
_protected - Double underscore and name mangling:
__private - The problem with Java-style getters/setters
@property: computed and validated attributes- The setter: validating without changing the interface
Product, armor-plated: the final version- Common mistakes and tips
- 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-NNNformat. 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 uponThe 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
Productuses__counterand a subclass defines its own__counter, mangling keeps them apart (_Product__countervs_Book__counter). - Day to day, the Python community prefers the single underscore. Reserve
__privatefor 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 PythonIt 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 setterWithout 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 consistentThe 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 = valueNow 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: -5raise 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 toself.price, notself._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,MagazineandEBookdon't change a single line, and their prices and stocks are protected, because theirsuper().__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) # 3Common Mistakes and Tips
- Infinite recursion in the property: writing
return self.priceinside the getter ofprice(orself.price = valuein its setter) calls the property over and over untilRecursionError. Inside the getter/setter, always use the internal attributeself._price. - Validating in the setter but assigning to
self._pricein__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
__privateprotects sensitive data: name mangling is bypassed withobj._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
@propertyabove the getter and leaving only@price.setter:NameError, because the setter is defined based on the existing property. The order is:@propertyfirst,@name.setterafter. - 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 emptyWithout 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()) # JuliaThe _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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
