Encapsulation: Public, Protected, and Private Members

by Christoph Schiessl on Python

Learning this may surprise or even shock you: Python doesn't support truly private or protected class members. Therefore, encapsulation in the OOP sense is, strictly speaking, not supported.

What is Encapsulation?

Encapsulation is one of the pillars of Object-Oriented Programming: hiding a class's internal state (i.e., instance variables) from the outside so that the state cannot be directly accessed and mutated. This is important to protect the state from arbitrary, uncontrolled mutations. Usually, this is accomplished by forcing mutations to go through the class's methods, which can implement safeguards to allow only specific mutations and reject others. The way to enforce the usage of the class's methods is to make the state itself private or protected from the outside.

Public members are accessible from outside the class, and private members are only accessible from within the class itself. So, what are protected members? They are the middle ground because they are directly accessible from within the class itself and its subclasses. This is not true for private members because they cannot be directly accessed from within subclasses.

Public Members

You don't have to do anything special to get public members because this is the default behavior. For instance, the method A.foo() is public, and therefore, it's accessible from every context.

Python 3.12.2 (main, Feb 17 2024, 11:13:07) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> class A:
...   def foo(self):
...     print("A.foo() has been called!")
...   def bar(self):
...     print("Calling A.foo() from within A ...")
...     self.foo()
...
>>> class B(A):
...   def baz(self):
...     print("Calling A.foo() from within subclass B ...")
...     self.foo()
...
>>> A().foo() # Calling A.foo() from the outside ...
A.foo() has been called!
>>> A().bar()
Calling A.foo() from within A ...
A.foo() has been called!
>>> B().baz()
Calling A.foo() from within subclass B ...
A.foo() has been called!

Protected Members

There's no mechanism in Python to make members protected, but there is a convention to prefix members with an _ to suggest that they are not supposed to be used from the outside. In the PEP8 section on naming styles, the underscore is referred to as a weak "internal use" indicator.

Python 3.12.2 (main, Feb 17 2024, 11:13:07) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> class A:
...   def _foo(self):
...     print("A._foo() has been called!")
...   def bar(self):
...     print("Calling A._foo() from within A ...")
...     self._foo()
...
>>> class B(A):
...   def baz(self):
...     print("Calling A._foo() from within subclass B ...")
...     self._foo()
...
>>> A()._foo() # Calling A.foo() from the outside ...
A._foo() has been called!
>>> A().bar()
Calling A._foo() from within A ...
A._foo() has been called!
>>> B().baz()
Calling A._foo() from within subclass B ...
A._foo() has been called!

As you can see, the leading _ doesn't affect privacy because, technically, the member is still public. It's just an agreed-upon convention without enforcement. The only effect that I'm aware of is for wildcard imports. Namely, statements such as from M import * would not import identifiers with a leading underscore.

Private Members

Last but not least, you can also prefix members with two underscores to suggest that they are private, which results in their names getting mangled. But even in this case, they are technically still public and can be accessed from the outside, but you have to know how exactly name mangling works.

Python 3.12.2 (main, Feb 17 2024, 11:13:07) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> class A:
...   def __foo(self):
...     print("A.__foo() has been called!")
...   def bar(self):
...     print("Calling A.__foo() from within A ...")
...     self.__foo()
...
>>> class B(A):
...   def baz(self):
...     print("Calling A.__foo() from within subclass B ...")
...     self.__foo()
...
>>> A().__foo() # Calling A.foo() from the outside ...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'A' object has no attribute '__foo'. Did you mean: '_A__foo'?
>>> A().bar()
Calling A.__foo() from within A ...
A.__foo() has been called!
>>> B().baz()
Calling A.__foo() from within subclass B ...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 4, in baz
AttributeError: 'B' object has no attribute '_B__foo'. Did you mean: '_A__foo'?

So, we can call A.__foo() from within A, but we get AttributeErrors if we try to call it from outside or a subclass of A. Also, the error messages already suggest how mangling works: A member __foo of a class A is renamed to _A__foo — knowing this, we can easily circumvent the effect of the leading __.

Python 3.12.2 (main, Feb 17 2024, 11:13:07) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> class A:
...   def __foo(self):
...     print("A.__foo() has been called!")
...   def bar(self):
...     print("Calling A.__foo() from within A ...")
...     self.__foo()
...
>>> class B(A):
...   def baz(self):
...     print("Calling A.__foo() from within subclass B ...")
...     self._A__foo()
...
>>> print("Calling A.__foo() from the outside ..."); A()._A__foo()
Calling A.__foo() from the outside ...
A.__foo() has been called!
>>> A().bar()
Calling A.__foo() from within A ...
A.__foo() has been called!
>>> B().baz()
Calling A.__foo() from within subclass B ...
A.__foo() has been called!

Please don't confuse this with magic methods that must start and end with __ (e.g., __init__).

Conclusion

As I said initially, strictly speaking, Python doesn't support private or protected members. Instead, there are conventions to express these concepts, but there is no enforcement. Also, as a developer, nobody forces you to follow the conventions. That said, I strongly advise that you follow the convention because not doing so will make it much harder for others to understand your code. Anyway, that's everything for today. Thank you for reading, and see you soon!

Ready to Learn More Web Development?

Join my Mailing List to receive two articles per week.


I send two weekly emails on building performant and resilient Web Applications with Python, JavaScript and PostgreSQL. No spam. Unscubscribe at any time.

Continue Reading?

Here are a few more Articles for you ...


Function Definition with Position-Only Parameters

Learn about positional parameters in Python and a special syntax that allows functions to declare certain parameters as position-only.

By Christoph Schiessl on Python

The Built-In sorted() Function

Python's sorted() function makes sorting easy and performant, guaranteeing stability and allowing descending order and custom mapping.

By Christoph Schiessl on Python

The Built-in bool() Class

Learn about boolean values in Python and the standard truth testing procedure. Understand how objects are converted to True or False.

By Christoph Schiessl on Python

Christoph Schiessl

Hi, I'm Christoph Schiessl.

I help you build robust and fast web applications.


I'm available for hire as a freelance web developer, so you can take advantage of the more than a decade of experience I have collected working on many projects across several industries. Most of my clients are building web-based SaaS applications in a B2B context and depend on my expertise in various capacities.

More often than not, my involvement includes hands-on development work using technologies like Python, JavaScript, and PostgreSQL. Furthermore, if you already have an established team, I can support you as a technical product manager with a passion for simplifying complex processes. Lastly, I'm also a skilled writer who takes pride in breaking down technical concepts into the simplest possible terms.