Skip to main content

Python Encapsulation Practice Problems & Exercises

Practice: Encapsulation and Data Hiding — Properties, Name Mangling, and Descriptors

11 problems4 Easy4 Medium3 Hard45-60 min
← Back to lesson

Easy

#1Predict the Output — Name ManglingEasy
name mangling__privateoutput prediction

Predict all three outputs. Understand that Python name mangling converts __attr to _ClassName__attr.

Python
class Safe:
    def __init__(self, value):
        self.__secret = value

    def reveal(self):
        return self.__secret

s = Safe(42)
print(s.reveal())
try:
    print(s.__secret)
except AttributeError as e:
    print(f"AttributeError: {e}")
print(s._Safe__secret)
Solution
42
AttributeError: 'Safe' object has no attribute '__secret'
42

Explanation: Python transforms any identifier with two leading underscores (and at most one trailing underscore) inside a class body by prepending _ClassName. So self.__secret inside Safe becomes self._Safe__secret in the compiled bytecode. Code inside the class uses the mangled name transparently. Code outside the class that uses s.__secret looks for the literal name __secret, which does not exist — hence AttributeError. The mangled name s._Safe__secret always works. This is a naming convention, not true access control.

class Safe:
  def __init__(self, value):
      self.__secret = value

  def reveal(self):
      return self.__secret

s = Safe(42)
print(s.reveal())
try:
  print(s.__secret)
except AttributeError as e:
  print(f"AttributeError: {e}")
print(s._Safe__secret)
Expected Output
42\nAttributeError: 'Safe' object has no attribute '__secret'\n42
Hints

Hint 1: Double-underscore prefix triggers name mangling: __secret becomes _Safe__secret.

Hint 2: Accessing s.__secret from outside the class raises AttributeError because the mangled name is used.

Hint 3: The mangled name _Safe__secret is always accessible — name mangling is not true privacy.


#2Add a Read-Only PropertyEasy
@propertyread-onlycomputed attribute

Implement the area and circumference properties. Both are derived from _radius and must be read-only.

Python
import math

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

    @property
    def area(self):
        return math.pi * self._radius ** 2

    @property
    def circumference(self):
        return 2 * math.pi * self._radius

c = Circle(5)
print(c.radius)
print(round(c.area, 4))
print(round(c.circumference, 4))
try:
    c.area = 99
except AttributeError as e:
    print("read-only")
Solution
@property
def area(self):
return math.pi * self._radius ** 2

@property
def circumference(self):
return 2 * math.pi * self._radius

Explanation: A @property with no corresponding @attr.setter is read-only. Attempting to assign to it raises AttributeError: can't set attribute. Properties are computed on every access — there is no cached value unless you implement caching explicitly. The backing attribute _radius uses a single underscore, signalling "internal use" without the name mangling that double underscores trigger. Read-only computed properties are preferable to storing derived values as attributes because they stay consistent when dependencies change.

class Circle:
  def __init__(self, radius):
      self._radius = radius

  @property
  def radius(self):
      return self._radius

  @property
  def area(self):
      # TODO: return pi * r^2, computed from self._radius
      pass

  @property
  def circumference(self):
      # TODO: return 2 * pi * r
      pass

import math
c = Circle(5)
print(c.radius)
print(round(c.area, 4))
print(round(c.circumference, 4))
try:
  c.area = 99
except AttributeError as e:
  print("read-only")
Expected Output
5\n78.5398\n31.4159\nread-only
Hints

Hint 1: import math at the top and use math.pi.

Hint 2: area = math.pi * self._radius ** 2.

Hint 3: Because there is no setter, assigning c.area = 99 raises AttributeError.


#3Property with Getter and SetterEasy
@property@settervalidation

Complete the temperature setter with an absolute-zero validation guard.

Python
class Celsius:
    def __init__(self, temperature):
        self.temperature = temperature

    @property
    def temperature(self):
        return self._temperature

    @temperature.setter
    def temperature(self, value):
        if value < -273.15:
            raise ValueError("Temperature below absolute zero")
        self._temperature = value

    @property
    def fahrenheit(self):
        return self._temperature * 9 / 5 + 32

c = Celsius(100)
print(c.temperature)
print(c.fahrenheit)
c.temperature = 0
print(c.fahrenheit)
try:
    c.temperature = -300
except ValueError as e:
    print(e)
Solution
@temperature.setter
def temperature(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero")
self._temperature = value

Explanation: The setter is the right place for validation because it intercepts every assignment — including the one in __init__ (self.temperature = temperature). The backing store is _temperature to avoid infinite recursion: if the setter stored to self.temperature, it would call itself. The fahrenheit property is read-only (no setter) and derives from _temperature, so it always reflects the current validated state. This is the canonical Python pattern for attribute validation — no need for explicit set_temperature() methods.

class Celsius:
  def __init__(self, temperature):
      self.temperature = temperature   # uses the setter

  @property
  def temperature(self):
      return self._temperature

  @temperature.setter
  def temperature(self, value):
      # TODO: raise ValueError if value < -273.15 (absolute zero)
      self._temperature = value

  @property
  def fahrenheit(self):
      return self._temperature * 9 / 5 + 32

c = Celsius(100)
print(c.temperature)
print(c.fahrenheit)
c.temperature = 0
print(c.fahrenheit)
try:
  c.temperature = -300
except ValueError as e:
  print(e)
Expected Output
100\n212.0\n32.0\nTemperature below absolute zero
Hints

Hint 1: In the setter, check if value < -273.15 and raise ValueError with a message.

Hint 2: Store the validated value in self._temperature (single underscore to avoid recursion).

Hint 3: The __init__ assignment self.temperature = temperature goes through the setter automatically.


#4Spot the Bug — Property RecursionEasy
@propertyinfinite recursionbug finding

The Box class has a common property recursion bug. Identify both bugs and fix the class.

Python
class Box:
    @property
    def width(self):
        return self._width   # fixed: use backing attribute

    @width.setter
    def width(self, value):
        self._width = value  # fixed: use backing attribute

b = Box()
b.width = 10
print(b.width)
Solution
class Box:
@property
def width(self):
return self._width

@width.setter
def width(self, value):
self._width = value

Explanation: In the buggy version, self.width inside the getter calls the getter again (infinite recursion: RecursionError), and self.width = value inside the setter calls the setter again (same problem). The fix is to store the actual value in a backing attribute — by convention _width (single underscore). The public property width provides the controlled access interface, while _width holds the raw value. This separation between the public name and the private backing store is the fundamental property pattern.

class Box:
  @property
  def width(self):
      return self.width   # BUG

  @width.setter
  def width(self, value):
      self.width = value  # BUG

b = Box()
b.width = 10
print(b.width)
Expected Output
10
Hints

Hint 1: self.width in the getter calls the getter again — infinite recursion.

Hint 2: self.width = value in the setter calls the setter again — infinite recursion.

Hint 3: Use a backing attribute with a single underscore: self._width.


Medium

#5Computed Property with CachingMedium
@propertycachinglazy evaluationcache invalidation

Implement the mean property with lazy caching and cache invalidation in the values setter.

Python
class DataSet:
    def __init__(self, values):
        self._values = list(values)
        self._mean_cache = None

    @property
    def values(self):
        return list(self._values)

    @values.setter
    def values(self, new_values):
        self._values = list(new_values)
        self._mean_cache = None  # invalidate cache

    @property
    def mean(self):
        if self._mean_cache is None:
            self._mean_cache = sum(self._values) / len(self._values)
        return self._mean_cache

ds = DataSet([1, 2, 3, 4, 5])
print(ds.mean)
print(ds.mean)
ds.values = [10, 20, 30]
print(ds.mean)
Solution
@values.setter
def values(self, new_values):
self._values = list(new_values)
self._mean_cache = None

@property
def mean(self):
if self._mean_cache is None:
self._mean_cache = sum(self._values) / len(self._values)
return self._mean_cache

Explanation: The caching pattern uses None as the sentinel for "not yet computed". The first call to mean computes the value and stores it. Subsequent calls return the cached result immediately. The values setter invalidates the cache by resetting it to None — the next mean access will recompute. The values getter returns a copy (list(self._values)) so external code cannot mutate the internal list without going through the setter, which would bypass cache invalidation. Python's functools.cached_property automates this pattern for properties that never need invalidation.

class DataSet:
  def __init__(self, values):
      self._values = list(values)
      self._mean_cache = None

  @property
  def values(self):
      return list(self._values)  # return a copy

  @values.setter
  def values(self, new_values):
      self._values = list(new_values)
      # TODO: invalidate the cache
      pass

  @property
  def mean(self):
      # TODO: compute mean only if cache is None, then cache it
      pass

ds = DataSet([1, 2, 3, 4, 5])
print(ds.mean)
print(ds.mean)       # should use cache
ds.values = [10, 20, 30]
print(ds.mean)       # should recompute
Expected Output
3.0\n3.0\n20.0
Hints

Hint 1: In the values setter, set self._mean_cache = None to invalidate.

Hint 2: In the mean property, check if self._mean_cache is None; if so, compute and store it.

Hint 3: Return self._mean_cache at the end of the mean property.


#6Name Mangling in InheritanceMedium
name manglinginheritanceoutput prediction

Predict all four outputs. This tests whether you understand that name mangling is class-scoped — the same __x in parent and child produces different mangled names.

Python
class Base:
    def __init__(self):
        self.__x = 10

    def get_x(self):
        return self.__x

class Child(Base):
    def __init__(self):
        super().__init__()
        self.__x = 20

    def get_child_x(self):
        return self.__x

c = Child()
print(c.get_x())
print(c.get_child_x())
print(c._Base__x)
print(c._Child__x)
Solution
10
20
10
20

Explanation: Name mangling uses the class in which the code is defined — not the runtime type of self. self.__x written inside Base becomes self._Base__x. The same spelling inside Child becomes self._Child__x. After construction, c.__dict__ contains both _Base__x = 10 and _Child__x = 20 as separate attributes. This is the primary purpose of name mangling: preventing subclass attribute collisions. get_x() defined in Base always reads _Base__x, and get_child_x() defined in Child always reads _Child__x.

class Base:
  def __init__(self):
      self.__x = 10

  def get_x(self):
      return self.__x

class Child(Base):
  def __init__(self):
      super().__init__()
      self.__x = 20   # different mangled name!

  def get_child_x(self):
      return self.__x

c = Child()
print(c.get_x())
print(c.get_child_x())
print(c._Base__x)
print(c._Child__x)
Expected Output
10\n20\n10\n20
Hints

Hint 1: self.__x in Base mangles to self._Base__x.

Hint 2: self.__x in Child mangles to self._Child__x.

Hint 3: These are two completely separate attributes on the same instance.

Hint 4: get_x() reads _Base__x (set in Base.__init__) = 10; get_child_x() reads _Child__x (set in Child.__init__) = 20.


#7__slots__ — Memory and Attribute RestrictionMedium
__slots__memoryattribute restriction

Predict all four outputs. Explain why __slots__ both saves memory and restricts attribute assignment.

Python
class Point:
    __slots__ = ("x", "y")

    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
print(p.x)
print(p.y)
print(hasattr(p, "__dict__"))
try:
    p.z = 3
except AttributeError as e:
    print(f"AttributeError: {e}")
Solution
1
2
False
AttributeError: 'Point' object has no attribute 'z'

Explanation: __slots__ declares the complete set of allowed instance attributes at the class level. Python replaces the per-instance __dict__ (a hash table) with a compact array of fixed slots — typically saving 40-50% memory per instance. Accessing slot values goes through descriptors generated by the metaclass, not through __dict__. Because there is no __dict__, you cannot add attributes not listed in __slots__. Use __slots__ when creating millions of lightweight instances (e.g., coordinate points, matrix entries, database rows) where memory and attribute access speed matter.

class Point:
  __slots__ = ("x", "y")

  def __init__(self, x, y):
      self.x = x
      self.y = y

p = Point(1, 2)
print(p.x)
print(p.y)

# Does Point have a __dict__?
print(hasattr(p, "__dict__"))

# Can we add arbitrary attributes?
try:
  p.z = 3
except AttributeError as e:
  print(f"AttributeError: {e}")
Expected Output
1\n2\nFalse\nAttributeError: 'Point' object has no attribute 'z'
Hints

Hint 1: __slots__ replaces __dict__ with fixed-size per-slot storage.

Hint 2: Instances of a __slots__ class have no __dict__ by default.

Hint 3: Assigning any attribute not in __slots__ raises AttributeError.


#8Property Chain — Derived Read-Write AttributeMedium
@property@setterderived attributesbidirectional conversion

Implement the aspect_ratio setter so it adjusts width and height while keeping the area constant.

Python
import math

class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, value):
        if value <= 0:
            raise ValueError("Width must be positive")
        self._width = value

    @property
    def height(self):
        return self._height

    @height.setter
    def height(self, value):
        if value <= 0:
            raise ValueError("Height must be positive")
        self._height = value

    @property
    def area(self):
        return self._width * self._height

    @property
    def aspect_ratio(self):
        return self._width / self._height

    @aspect_ratio.setter
    def aspect_ratio(self, ratio):
        area = self._width * self._height
        self._width = math.sqrt(area * ratio)
        self._height = math.sqrt(area / ratio)

r = Rectangle(4, 3)
print(r.area)
print(round(r.aspect_ratio, 4))
r.aspect_ratio = 1.0
print(r.area)
print(round(r.width, 4))
print(round(r.height, 4))
Solution
@aspect_ratio.setter
def aspect_ratio(self, ratio):
area = self._width * self._height
self._width = math.sqrt(area * ratio)
self._height = math.sqrt(area / ratio)

Explanation: Given area = w * h and ratio = w / h, solving simultaneously: w = sqrt(area * ratio) and h = sqrt(area / ratio). For a 4x3 rectangle: area = 12, aspect_ratio = 4/3. Setting ratio = 1.0: w = sqrt(12 * 1) = sqrt(12) ≈ 3.4641, h = sqrt(12 / 1) = sqrt(12) ≈ 3.4641. Verify: 3.4641 * 3.4641 ≈ 12. We write directly to _width and _height (bypassing their setters) inside this setter because we're performing a coordinated update — both values are always valid at the end of the operation.

class Rectangle:
  def __init__(self, width, height):
      self._width = width
      self._height = height

  @property
  def width(self):
      return self._width

  @width.setter
  def width(self, value):
      if value <= 0:
          raise ValueError("Width must be positive")
      self._width = value

  @property
  def height(self):
      return self._height

  @height.setter
  def height(self, value):
      if value <= 0:
          raise ValueError("Height must be positive")
      self._height = value

  @property
  def area(self):
      return self._width * self._height

  @property
  def aspect_ratio(self):
      return self._width / self._height

  @aspect_ratio.setter
  def aspect_ratio(self, ratio):
      # TODO: keep area constant, adjust width and height to achieve new ratio
      # new_w / new_h = ratio, new_w * new_h = current area
      pass

r = Rectangle(4, 3)
print(r.area)
print(round(r.aspect_ratio, 4))
r.aspect_ratio = 1.0   # square with same area
print(r.area)
print(round(r.width, 4))
print(round(r.height, 4))
Expected Output
12\n1.3333\n12.0\n3.4641\n3.4641
Hints

Hint 1: If area = A and ratio = r, then w = sqrt(A * r) and h = sqrt(A / r).

Hint 2: Compute current area = self._width * self._height before changing anything.

Hint 3: Set self._width and self._height directly (bypassing setters to skip positive check on intermediate values).


Hard

#9Reusable Validated Attribute DescriptorHard
descriptorsvalidationreusable__set_name__

Implement Positive.__set__ so that it validates and stores the value in a private backing attribute.

Python
class Positive:
    def __set_name__(self, owner, name):
        self.name = name
        self.storage = f"_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.storage, None)

    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError(f"{self.name} must be positive, got {value}")
        setattr(obj, self.storage, value)

class Product:
    price = Positive()
    quantity = Positive()

    def __init__(self, name, price, quantity):
        self.name = name
        self.price = price
        self.quantity = quantity

    @property
    def total(self):
        return self.price * self.quantity

p = Product("Widget", 9.99, 5)
print(p.total)
try:
    p.price = -1
except ValueError as e:
    print(e)
try:
    p.quantity = 0
except ValueError as e:
    print(e)
Solution
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f"{self.name} must be positive, got {value}")
setattr(obj, self.storage, value)

Explanation: The Positive descriptor is defined once and reused for multiple attributes. __set_name__ automatically captures the attribute name when the class is defined, computing the private backing name (_price, _quantity). The __get__ reads from the backing name using getattr. __set__ validates and stores to the backing name using setattr. Using setattr(obj, "_price", value) stores directly in obj.__dict__["_price"], bypassing the descriptor (which only intercepts the public name price). One Positive() descriptor object services all instances of Product — this is why we store data per-instance in obj, not in self (which is the descriptor instance).

class Positive:
  """Descriptor: enforces that the attribute value is a positive number."""

  def __set_name__(self, owner, name):
      self.name = name
      self.storage = f"_{name}"

  def __get__(self, obj, objtype=None):
      if obj is None:
          return self
      return getattr(obj, self.storage, None)

  def __set__(self, obj, value):
      # TODO: raise ValueError if value <= 0, else store in obj's private attr
      pass

class Product:
  price = Positive()
  quantity = Positive()

  def __init__(self, name, price, quantity):
      self.name = name
      self.price = price
      self.quantity = quantity

  @property
  def total(self):
      return self.price * self.quantity

p = Product("Widget", 9.99, 5)
print(p.total)
try:
  p.price = -1
except ValueError as e:
  print(e)
try:
  p.quantity = 0
except ValueError as e:
  print(e)
Expected Output
49.95\nprice must be positive, got -1\nquantity must be positive, got 0
Hints

Hint 1: In __set__, check value <= 0 and raise ValueError with f"{self.name} must be positive, got {value}".

Hint 2: Store the value with setattr(obj, self.storage, value) where self.storage is f"_{name}".

Hint 3: Using setattr bypasses the descriptor (since the storage name starts with _ not the public name) and avoids recursion.


#10Observable Property — Property That Notifies on ChangeHard
@propertyobserver patterncallbacksencapsulation

Implement the value setter to fire subscribed callbacks only when the value actually changes.

Python
class Observable:
    def __init__(self, initial):
        self._value = initial
        self._listeners = []

    def subscribe(self, callback):
        self._listeners.append(callback)

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, new_val):
        if new_val != self._value:
            old = self._value
            self._value = new_val
            for cb in self._listeners:
                cb(old, new_val)

events = []
obs = Observable(0)
obs.subscribe(lambda old, new: events.append(f"{old}->{new}"))
obs.value = 5
obs.value = 5
obs.value = 10
print(events)
Solution
@value.setter
def value(self, new_val):
if new_val != self._value:
old = self._value
self._value = new_val
for cb in self._listeners:
cb(old, new_val)

Explanation: The observable property pattern combines encapsulation with the Observer design pattern. The setter intercepts every assignment and fires callbacks only when the value genuinely changes (identity/equality check). Storing old before updating self._value ensures callbacks receive the correct before/after values. This pattern is the foundation of reactive UI frameworks, data binding systems, and configuration change detection. Using a property for this is preferable to explicit setter methods because callsites look like normal attribute assignment (obs.value = 5).

class Observable:
  def __init__(self, initial):
      self._value = initial
      self._listeners = []

  def subscribe(self, callback):
      self._listeners.append(callback)

  @property
  def value(self):
      return self._value

  @value.setter
  def value(self, new_val):
      # TODO: only notify listeners if the value actually changed
      pass

events = []
obs = Observable(0)
obs.subscribe(lambda old, new: events.append(f"{old}->{new}"))
obs.value = 5
obs.value = 5   # no change — should not fire
obs.value = 10
print(events)
Expected Output
['0->5', '5->10']
Hints

Hint 1: Store the old value before updating: old = self._value.

Hint 2: Only fire callbacks if new_val != old.

Hint 3: Call each listener with callback(old, new_val) after updating self._value.


#11Access Control Layer — Private, Protected, Public via PropertiesHard
encapsulationaccess controlpropertiesclass design

Implement set_protected, get_private, and set_private to enforce a three-tier access control model using Python encapsulation conventions.

Python
class SecureConfig:
    _ADMIN_TOKEN = "admin123"

    def __init__(self, public_val, protected_val, private_val):
        self._public = public_val
        self._protected = protected_val
        self.__private = private_val

    @property
    def public(self):
        return self._public

    @public.setter
    def public(self, value):
        self._public = value

    @property
    def protected(self):
        return self._protected

    def set_protected(self, value, token):
        if token != self._ADMIN_TOKEN:
            raise PermissionError("Access denied")
        self._protected = value

    def get_private(self, token):
        if token != self._ADMIN_TOKEN:
            raise PermissionError("Access denied")
        return self.__private

    def set_private(self, value, token):
        if token != self._ADMIN_TOKEN:
            raise PermissionError("Access denied")
        self.__private = value

cfg = SecureConfig("hello", "world", "secret")
print(cfg.public)
cfg.public = "updated"
print(cfg.public)
print(cfg.protected)
cfg.set_protected("new_world", "admin123")
print(cfg.protected)
print(cfg.get_private("admin123"))
try:
    cfg.get_private("wrong")
except PermissionError as e:
    print(e)
Solution
def set_protected(self, value, token):
if token != self._ADMIN_TOKEN:
raise PermissionError("Access denied")
self._protected = value

def get_private(self, token):
if token != self._ADMIN_TOKEN:
raise PermissionError("Access denied")
return self.__private

def set_private(self, value, token):
if token != self._ADMIN_TOKEN:
raise PermissionError("Access denied")
self.__private = value

Explanation: This models the three Python encapsulation conventions in one class. _public (no underscore convention — direct property), _protected (single underscore — readable directly, write via method that validates), and __private (double underscore — name-mangled, access only via validated methods). This is a teaching exercise; in production, access control would be enforced via authentication middleware, not Python naming conventions. The pattern demonstrates that Python's conventions are not security boundaries but design signals — the real enforcement here comes from the explicit token checks in the methods.

class SecureConfig:
  """
  Three-tier access:
  - public:    readable and writable by anyone
  - protected: readable by anyone, writable only via set_protected()
  - private:   readable and writable only through verify_token()
  """
  _ADMIN_TOKEN = "admin123"

  def __init__(self, public_val, protected_val, private_val):
      self._public = public_val
      self._protected = protected_val
      self.__private = private_val

  @property
  def public(self):
      return self._public

  @public.setter
  def public(self, value):
      self._public = value

  @property
  def protected(self):
      return self._protected

  def set_protected(self, value, token):
      # TODO: only update if token == _ADMIN_TOKEN
      pass

  def get_private(self, token):
      # TODO: return __private only if token is correct, else raise PermissionError
      pass

  def set_private(self, value, token):
      # TODO: update __private only if token is correct, else raise PermissionError
      pass

cfg = SecureConfig("hello", "world", "secret")
print(cfg.public)
cfg.public = "updated"
print(cfg.public)
print(cfg.protected)
cfg.set_protected("new_world", "admin123")
print(cfg.protected)
print(cfg.get_private("admin123"))
try:
  cfg.get_private("wrong")
except PermissionError as e:
  print(e)
Expected Output
hello\nupdated\nworld\nnew_world\nsecret\nAccess denied
Hints

Hint 1: In set_protected, check token == self._ADMIN_TOKEN; if not, raise PermissionError.

Hint 2: get_private and set_private should check the token; on failure raise PermissionError("Access denied").

Hint 3: Use self._SecureConfig__private (or just self.__private inside the class) to access the private attribute.

© 2026 EngineersOfAI. All rights reserved.