Module 6.4

Encapsulation and Properties

Encapsulation hides internal details and protects data integrity. It creates a clean interface between the object's internal implementation and the outside world. Python uses naming conventions (underscores) and property decorators to achieve controlled access to object data.

45 min
Intermediate
Hands-on
What You'll Learn
  • Public, protected, private naming
  • Single underscore (_) convention
  • Double underscore (__) name mangling
  • @property decorator for getters
  • Setters with validation
Contents
01

Naming Conventions

Python does not have true private members like Java or C++. Instead, it uses naming conventions to signal access levels. Developers follow these conventions to indicate which parts of a class are internal implementation details and which are the public interface.

Key Concept

Convention Over Enforcement

Python trusts developers to follow conventions. The underscore prefixes are signals to other programmers, not security barriers. "We are all consenting adults here" is a common Python philosophy.

Why it matters: Understanding these conventions helps you write clear APIs and avoid accidentally breaking code when using third-party libraries.

Access Level Naming Conventions
PUBLIC
name
No prefix
Free to access and modify
PROTECTED
_name
Single underscore
"Don't use unless you know why"
PRIVATE
__name
Double underscore
Name mangled to _ClassName__name
BankAccount Example
self.owner = "Alice"
Public: anyone can access
self._balance = 1000
Protected: internal use
self.__pin = "1234"
Private: name mangled
More underscores = more "please don't touch this"

Public Attributes

Attributes without underscores are part of the public API. Users of your class are expected to access and modify them freely.

class Person:
    def __init__(self, name, age):
        self.name = name  # Public
        self.age = age    # Public

person = Person("Alice", 30)
print(person.name)  # Output: Alice
person.age = 31     # Direct modification is OK
print(person.age)   # Output: 31

Public attributes are the intended interface. They can be read and written directly without any restrictions.

Protected Attributes (Single Underscore)

A single leading underscore signals "internal use only." It is a convention that tells other developers to not access this directly from outside the class or subclasses.

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self._salary = salary  # Protected: internal

    def get_annual_salary(self):
        return self._salary * 12

emp = Employee("Bob", 5000)
print(emp._salary)  # Works, but not recommended!
print(emp.get_annual_salary())  # Use public methods instead

Python allows access to _salary, but the underscore says "this is an implementation detail that may change." Use the public method instead.

Private Attributes (Double Underscore)

Double leading underscores trigger name mangling. Python renames __attr to _ClassName__attr, making accidental access harder.

class BankAccount:
    def __init__(self, owner, pin):
        self.owner = owner
        self.__pin = pin  # Private: name mangled

    def verify_pin(self, pin):
        return self.__pin == pin

acc = BankAccount("Alice", "1234")
# print(acc.__pin)  # AttributeError!
print(acc._BankAccount__pin)  # Works but DO NOT do this!
print(acc.verify_pin("1234"))  # Correct way: True

Name mangling prevents accidental access and name clashes in inheritance. It is not true security since you can still access it with the mangled name.

Practice: Naming Conventions

Task: Create a User class with public username, protected _email, and private __password attributes.

Show Solution
class User:
    def __init__(self, username, email, password):
        self.username = username    # Public
        self._email = email         # Protected
        self.__password = password  # Private
    
    def check_password(self, pwd):
        return self.__password == pwd

user = User("alice", "alice@mail.com", "secret123")
print(user.username)       # alice
print(user.check_password("secret123"))  # True

Task: Create a Secret class with __code attribute. Try accessing it directly (will fail) then using the mangled name.

Show Solution
class Secret:
    def __init__(self, code):
        self.__code = code

s = Secret("ABC123")

# This will raise AttributeError:
# print(s.__code)

# This works (but don't do this in real code!):
print(s._Secret__code)  # Output: ABC123

Task: Create Vehicle with protected _speed. Create Car that has a method using _speed. Show that subclass can access protected attributes.

Show Solution
class Vehicle:
    def __init__(self, speed):
        self._speed = speed  # Protected

class Car(Vehicle):
    def describe(self):
        return f"Car moving at {self._speed} mph"

car = Car(60)
print(car.describe())  # Car moving at 60 mph
# Protected is accessible to subclasses
02

Property Decorator

The @property decorator lets you define methods that behave like attributes. You can add logic to getting, setting, or deleting values while maintaining a clean attribute-like syntax. This is Python's way of creating getters and setters.

Property Decorator Flow
user.name
@property
def name(self)
return self._name
user.name = "Bob"
@name.setter
def name(self, value)
self._name = value
del user.name
@name.deleter
def name(self)
del self._name
Syntax looks like attribute access, but methods run behind the scenes!

Basic Property (Getter)

Use @property to create a read-only computed attribute. The method runs when you access the attribute but looks like a simple attribute access.

class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def area(self):
        return 3.14159 * self._radius ** 2
    
    @property
    def radius(self):
        return self._radius

c = Circle(5)
print(c.radius)  # 5 (no parentheses!)
print(c.area)    # 78.53975 (computed on access)

Properties are accessed without parentheses, just like attributes. The area is computed fresh each time you access it.

Setter Property

Use @property_name.setter to allow assignment. The setter method receives the new value and can process or validate it before storing.

class Person:
    def __init__(self, name):
        self._name = name
    
    @property
    def name(self):
        return self._name
    
    @name.setter
    def name(self, value):
        self._name = value.strip().title()

p = Person("  alice  ")
print(p.name)  # Output: Alice
p.name = "bob smith"
print(p.name)  # Output: Bob Smith

The setter automatically cleans and formats the name. Users assign values normally while your processing happens behind the scenes.

Read-Only Properties

Create properties without setters to make computed or protected values read-only. Attempting to assign will raise an AttributeError.

class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height
    
    @property
    def area(self):
        return self._width * self._height

rect = Rectangle(4, 5)
print(rect.area)  # 20
# rect.area = 100  # AttributeError: can't set attribute

Without a setter, the property is read-only. This prevents users from directly modifying computed values that should derive from other attributes.

Practice: Property Decorator

Task: Create a Temperature class with _celsius attribute. Add a fahrenheit property that computes and returns the Fahrenheit value.

Show Solution
class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius
    
    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32

temp = Temperature(25)
print(temp.fahrenheit)  # Output: 77.0

Task: Extend the Temperature class so you can set fahrenheit and it updates _celsius internally.

Show Solution
class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius
    
    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        self._celsius = (value - 32) * 5/9

temp = Temperature(0)
temp.fahrenheit = 212
print(temp._celsius)  # Output: 100.0

Task: Create Person with first_name and last_name. Add a full_name property (getter) and setter that splits the full name into first and last.

Show Solution
class Person:
    def __init__(self, first, last):
        self.first_name = first
        self.last_name = last
    
    @property
    def full_name(self):
        return f"{self.first_name} {self.last_name}"
    
    @full_name.setter
    def full_name(self, value):
        parts = value.split()
        self.first_name = parts[0]
        self.last_name = parts[-1]

p = Person("John", "Doe")
p.full_name = "Jane Smith"
print(p.first_name, p.last_name)  # Jane Smith
03

Data Validation

Properties shine when you need to validate or constrain data. The setter can check values before storing them, raising exceptions or correcting invalid input. This ensures your objects always remain in a valid state.

Validating with Exceptions

Raise ValueError in the setter when the input is invalid. This immediately signals the problem to the caller.

class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price  # Uses the setter
    
    @property
    def price(self):
        return self._price
    
    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError("Price cannot be negative")
        self._price = value

p = Product("Phone", 999)
# p.price = -50  # Raises ValueError!

Note that __init__ uses self.price (not self._price), so the setter validation runs during object creation too.

Type Checking

Validate that the value is the expected type before storing. This catches type errors early.

class Student:
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade
    
    @property
    def grade(self):
        return self._grade
    
    @grade.setter
    def grade(self, value):
        if not isinstance(value, int):
            raise TypeError("Grade must be an integer")
        if not 0 <= value <= 100:
            raise ValueError("Grade must be 0-100")
        self._grade = value

s = Student("Alice", 95)
# s.grade = "A"  # Raises TypeError!

Multiple validation checks ensure the value meets all requirements before being stored.

Auto-Correction in Setters

Instead of raising errors, you can silently correct or constrain values. Use this when correction is safe and expected.

class Volume:
    def __init__(self, level):
        self.level = level
    
    @property
    def level(self):
        return self._level
    
    @level.setter
    def level(self, value):
        # Clamp value between 0 and 100
        self._level = max(0, min(100, value))

v = Volume(50)
v.level = 150
print(v.level)  # Output: 100 (clamped)
v.level = -20
print(v.level)  # Output: 0 (clamped)

This pattern is common for UI controls like sliders where exceeding bounds should just clamp to the limit, not crash.

Best Practice: Prefer exceptions for clearly invalid data (negative prices). Use auto-correction for boundary cases where the intent is clear (volume > 100 means max volume).

Practice: Data Validation

Task: Create a Circle class with radius property. The setter should raise ValueError if radius is not positive.

Show Solution
class Circle:
    def __init__(self, radius):
        self.radius = radius
    
    @property
    def radius(self):
        return self._radius
    
    @radius.setter
    def radius(self, value):
        if value <= 0:
            raise ValueError("Radius must be positive")
        self._radius = value

c = Circle(5)
# c.radius = -1  # ValueError: Radius must be positive

Task: Create a Username class with a name property. Validate that name is between 3 and 20 characters.

Show Solution
class Username:
    def __init__(self, name):
        self.name = name
    
    @property
    def name(self):
        return self._name
    
    @name.setter
    def name(self, value):
        if not 3 <= len(value) <= 20:
            raise ValueError("Name must be 3-20 chars")
        self._name = value

u = Username("alice")
# u.name = "ab"  # ValueError!

Task: Create a Contact class with email property. Validate that email contains "@" and "." characters.

Show Solution
class Contact:
    def __init__(self, email):
        self.email = email
    
    @property
    def email(self):
        return self._email
    
    @email.setter
    def email(self, value):
        if "@" not in value or "." not in value:
            raise ValueError("Invalid email format")
        self._email = value.lower()

c = Contact("Alice@Mail.COM")
print(c.email)  # Output: alice@mail.com

Task: Create a Progress class with percent property. Auto-clamp values to 0-100 range instead of raising errors.

Show Solution
class Progress:
    def __init__(self, percent=0):
        self.percent = percent
    
    @property
    def percent(self):
        return self._percent
    
    @percent.setter
    def percent(self, value):
        self._percent = max(0, min(100, value))

p = Progress()
p.percent = 150
print(p.percent)  # 100 (clamped)

Task: Create BankAccount with balance property. Validate non-negative on set. Add deposit() and withdraw() methods that use the property.

Show Solution
class BankAccount:
    def __init__(self, balance=0):
        self.balance = balance
    
    @property
    def balance(self):
        return self._balance
    
    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("Balance cannot be negative")
        self._balance = value
    
    def deposit(self, amount):
        self.balance += amount
    
    def withdraw(self, amount):
        self.balance -= amount  # Validation happens here!

acc = BankAccount(100)
acc.withdraw(50)
# acc.withdraw(100)  # ValueError!

Task: Create User with password property. Validate: min 8 chars, has digit, has uppercase. Store hashed (just use len for demo).

Show Solution
class User:
    def __init__(self, username, password):
        self.username = username
        self.password = password
    
    @property
    def password(self):
        return self._hashed
    
    @password.setter
    def password(self, value):
        if len(value) < 8:
            raise ValueError("Min 8 characters")
        if not any(c.isdigit() for c in value):
            raise ValueError("Must contain a digit")
        if not any(c.isupper() for c in value):
            raise ValueError("Must contain uppercase")
        self._hashed = f"hashed_{len(value)}"

u = User("alice", "Secret123")
print(u.password)  # hashed_9

Key Takeaways

Public by Default

Python attributes without underscores are public. This is your class's intended interface for external use.

Single Underscore = Internal

Prefix with _ to signal "internal use only." It is a convention that other developers should respect.

Double Underscore = Mangled

Prefix with __ to trigger name mangling. Python renames it to _ClassName__attr to prevent accidental access.

@property for Getters

Use @property to create computed attributes or controlled access with attribute-like syntax.

@name.setter for Setters

Add a setter to enable assignment with validation or transformation logic behind the scenes.

Validate Early

Use setters to validate data at the point of assignment. Keep objects in a valid state at all times.

Knowledge Check

Quick Quiz

Test what you've learned about Python encapsulation and properties

1 What does a single underscore prefix indicate?
2 What is name mangling in Python?
3 What decorator creates a getter property?
4 Why validate data in a setter?
5 How do you create a read-only property?
6 What is the main purpose of encapsulation?
Answer all questions to check your score