Why it matters
A class with public attributes is a class that anyone can break. Any line of code in the program can set a bank balance to a negative number, a score to 900, or a list of team members to None. When a bug shows up, you have to search the whole program to find the line that caused it. Encapsulation is the habit that stops this. It lets a class decide how its data may change, and it makes that decision in one place. Interviewers ask about encapsulation in most object-oriented rounds, because it shows that you can design code that other people can use safely.
Python handles this differently from Java and C#. Python does not stop code from reading or changing a hidden attribute. The protection comes from conventions, from properties, and from designing methods that enforce rules. This lesson shows what Python can enforce, what it cannot, and how to design a class that works either way.
The idea from first principles
Think about a bank. You cannot walk into the vault and move money yourself. You go to the teller, and the teller checks your identity, checks the amount, and then updates the ledger. The vault is hidden. The teller is the only door in.
Encapsulation applies the same rule to code. The data of an object is kept inside the object. Other code reaches the data through a small set of methods, and each method checks its input before it changes anything. The word means "capsule": the data is sealed inside, and the methods are the only opening.
Two parts make this work:
- Naming and access. In Python, a name that starts with one underscore, such as
_balance, means "internal, do not use from outside". Every Python programmer reads it that way. Python does not block the access. A name that starts with two underscores, such as__balance, is changed by Python so that outside code cannot reach it by accident. Both are explained below. - Methods that guard the data. You write methods such as
depositandwithdraw. Inside them you check the rules, and only then change the attribute.
Encapsulation is not about hiding things for secrecy. It is about protecting the rules. A balance has rules: it can be zero or more, deposits must be positive, withdrawals cannot take it below zero. Those rules belong next to the data, in the class. If they live in the caller's code, every caller has to remember them, and one of them will forget.
Naming conventions and what Python enforces
Java has four access levels, written as public, protected, package-private and private. Python has no keywords for these. Here is what Python uses instead.
- No underscore, such as
name, means public. Any code can read and change it. Most attributes in a Python class are public, and that is fine when the attribute has no rules. - One leading underscore, such as
_balance, means internal. It is a signal to other programmers, and tools such as linters warn when code outside the class uses it. Python does not stop the access. - Two leading underscores, such as
__balance, asks Python to rename the attribute. Inside the class, the name works as written. Outside the class, the plain name does not exist. This is called name mangling. It makes accidental use harder, but it is still not a lock: the renamed name is visible to anyone who looks for it.
The rule of thumb is simple. Start with attributes that have a leading underscore for the data that has rules. Expose a property or a method when other code needs the value. This is the principle of least exposure. Expose as little as you can, because anything public is something you cannot change safely later without breaking the code that uses it.
Worked example 1: a bank account that protects its balance
Here is a bank account with an internal balance. The only way to change the balance is through deposit and withdraw, and each one refuses bad input. The program reads commands from the input.
Type this into the program's input, one item per line:
Asha
4
deposit 100
withdraw 30
withdraw 500
deposit -5The first line is the owner's name. The second line is how many commands follow. Each command is a word and a number, separated by a space. The program reads each command and calls the matching method. The function input() reads one line as text, and int() turns text into a whole number.
class BankAccount:
def __init__(self, owner):
self._owner = owner
self._balance = 0
@property
def owner(self):
return self._owner
@property
def balance(self):
return self._balance
def deposit(self, amount):
if amount <= 0:
print(f"rejected deposit of {amount}")
return
self._balance = self._balance + amount
def withdraw(self, amount):
if amount <= 0 or amount > self._balance:
print(f"rejected withdraw of {amount}")
return False
self._balance = self._balance - amount
return True
owner = input()
acct = BankAccount(owner)
n = int(input())
for _ in range(n):
command, amount = input().split()
if command == "deposit":
acct.deposit(int(amount))
elif command == "withdraw":
acct.withdraw(int(amount))
print(f"{acct.owner} has {acct.balance}")The output is:
rejected withdraw of 500
rejected deposit of -5
Asha has 70Trace what happens with each command:
deposit 100: the amount is positive, so_balancebecomes100.withdraw 30: the amount is positive and not more than the balance, so_balancebecomes70. The method returnsTrue, but the caller ignores the result here.withdraw 500: the amount is more than the balance of 70. The method prints the rejection line and returnsFalsewithout touching the balance.deposit -5: the amount is not positive. The method prints the rejection line and returns early. Thereturnleaves the method at once, so the addition never runs.- The last line reads the owner and the balance through the properties. It prints
Asha has 70.
The part of the program outside the class does not write _balance anywhere. The rules about amounts are written once, in two methods, and every change to the money goes through them.
Properties: the Python way to write getters and setters
In Java you write a method called getBalance() to read a value and setBalance(...) to change it. Python code does not usually do this. Python uses a property. A property looks like an attribute to the caller, but Python runs a method behind it. The decorator @property marks the method that reads the value. The decorator @name.setter marks the method that changes the value.
In the bank example, acct.balance runs the method balance and returns its result. The caller writes no parentheses and does not know a method is running.
A property does not have to return the attribute directly. It can return a computed value, a copy, or a text form. A setter does not have to store the value. It can check it, convert it, or reject it. Here is a score that keeps its value between 0 and 100:
The output is:
45
100
0The line s.points = 250 looks like a plain assignment, but Python calls the setter. The setter uses min and max to limit the value. A value of 250 is not an error that stops the program. It is clamped, which means limited to the nearest allowed value. Whether you clamp, reject, or raise an error is a design choice. The important part is that the choice lives in the class, so every caller gets the same behaviour.
Prefer a named action such as deposit over a setter when the action has rules. A setter named balance would allow any value, while deposit says what the user wants to do and can refuse bad input with a clear meaning.
What Python checks at run time
Python does not check access at the moment the code is written, the way a compiler does. It checks when the line runs. Here is what happens when outside code reaches for hidden names:
The output is:
5
AttributeError: 'Secret' object has no attribute '__balance'
999The name __balance was stored as _Secret__balance. Outside code that writes a.__balance looks for a name that does not exist, so Python raises AttributeError. The line a._Secret__balance still works, because the real name is visible. The last lines show that a single underscore is only a convention: a._balance = 999 simply sets a new attribute, because nothing stops it.
Python's approach is sometimes summed up as "we are all consenting adults". Code that breaks an internal rule can do it, and the programmer who does so takes responsibility. This is why the convention matters. Write your classes so that the rules hold when everyone follows the convention, and review code for places where it is broken.
A property with no setter is a different case. Python does enforce it:
The output is:
AttributeError: property 'x' of 'Point' object has no setterA read-only property is the Python form of a getter with no setter. The caller can read p.x but cannot assign it.
Defensive copies
Encapsulation can fail in a quieter way. A property that returns a list directly can give away control of the data, even when the attribute is internal. The problem is that a list is returned as a reference. The caller then has the same list the object stores, and can change it.
Here is a team that stores its members in a list. The first version is safe. The second part shows how a leak happens and how to stop it.
The output is:
('Ana', 'Ben', 'Cy')
TypeError: 'tuple' object does not support item assignment
('Ana', 'Ben', 'Cy')There are two copies happening here, and each one protects against a different leak:
- The constructor calls
list(members). The caller still holds the listnames. Changingnames[0]to"Zed"would change the team if the team kept the same list. Because the team made its own copy, the team still showsAna. - The property returns
tuple(self._members). A tuple is a read-only sequence, so the caller cannot change the items. The caller receives a new tuple each time, so the team's real list is not exposed.
When the caller wants a list it can change, it makes its own copy with list(t.members). Changing that copy does not reach the team, as the last part shows.
A defensive copy is a copy made to protect the object from outside changes. You make one when you take an object in (in __init__) and when you give one out (in a property or method). The rule applies to any mutable object: lists, dictionaries, sets, dates and other classes you wrote yourself. A string does not need a copy, because a string cannot be changed after it is made.
The copy costs memory and time. Copying a large list on each call can be slow. For a team of three names, the cost is negligible. For a list of a million items that is read in a loop, you may prefer a read-only view, which the next section covers. A shallow copy such as list(...) copies the list but not the objects inside it. If the items are themselves mutable objects, they still need protection.
Read-only views
Python has a few ways to give out data that cannot be changed:
- A tuple, made with
tuple(...), is a read-only sequence. Its items cannot be replaced or added to. types.MappingProxyTypewraps a dictionary in a read-only view. The view shows the dictionary's current items, but writing to the view raises an error.- A frozen dataclass or a plain class with only read-only properties, covered in the next section, protects its own fields.
Here is a view of a dictionary:
The output is:
TypeError: 'mappingproxy' object does not support item assignment
15The view blocks writes, but it does not copy the dictionary. The line scores["ben"] = 15 changed the original dictionary, and the view showed the new item. So a view protects against writes through the view, not against writes to the original. If you need a snapshot that never changes, make a copy with dict(scores) instead.
Java's Collections.unmodifiableList and List.of have the same purpose as a tuple here. The Python tuple is simpler, because it is a built-in type with no extra wrapper.
Immutable objects
An immutable object cannot change after it is created. A Python string and a tuple are immutable. A user's bank account balance is mutable, because it has to change. Immutable objects are the strongest form of encapsulation, because there is no state to protect. You cannot break an object that never changes.
To make a class immutable in Python:
- Store each value in an internal attribute, set once in
__init__. - Expose the values through read-only properties. Write no setters.
- Do not store mutable objects, such as lists, inside the object. If you must, store a tuple instead, or copy the list in and out.
- Make changes by returning a new object.
The output is:
(3, 4)
(10, 4)
13with_x does not change p. It builds a new point with the same y and the new x. So p still prints (3, 4), and q prints (10, 4). The final line adds the two x values, 3 + 10, which is 13.
Writing read-only properties for every field is repetitive. Python has a shorter way: a frozen dataclass. A dataclass is a class whose __init__ and comparison methods are written for you. The module dataclasses covers this in the records lesson. Here is the same idea in a few lines:
The output is:
Point(x=3, y=4)
Point(x=10, y=4)
FrozenInstanceError: cannot assign to field 'x'The frozen=True argument makes any assignment to a field raise FrozenInstanceError. The function replace makes a copy with one field changed, which is the same as with_x in the earlier example.
Immutable objects are easier to reason about. You can pass one to any function without worrying that it will change. They are also safe to share between parts of a program. Python's own str, int and tuple are immutable for these reasons.
The cost is that each change creates a new object. In most programs this is fine, because Python frees the old ones quickly. For a loop that changes a value a million times, a mutable object can be faster. Choose by the needs of the code, not by a rule.
What final means in Python
Java uses the word final in several places, and each use means something slightly different. Python has no final keyword that the interpreter enforces. The closest tool is typing.final, a decorator that a type checker such as mypy reads. At run time it does nothing.
The output is:
5The class Fake subclasses Money, even though Money is marked @final. Python runs it without complaint. A type checker reports the subclass as an error, but the program itself is not stopped. This is the same honest limit as the underscore convention: Python trusts the programmer and the tools around the code.
The rule for attributes is the one that matters most in encapsulation. An internal attribute that you do not change after construction, plus no setter, is a cheap way to make a class safer.
What goes wrong without encapsulation
It helps to see the failure to understand the rule. Imagine a bank account with a public balance attribute. A developer writes a report that adds interest, then another developer writes a transfer function. Each one changes balance directly. One of them forgets to check that the amount is positive, and the account goes negative. A third developer writes account.balance = account.balance - fee in a place where the balance should never be touched. The bug appears only in production, after some customers have been charged twice.
With encapsulation, every change to the balance goes through deposit, withdraw and apply_interest. If one of them has a bug, you look in three places instead of thirty. You can add a print in one method and see every change. You can write one test that checks the rules, instead of a test for each caller.
This is the practical value. Encapsulation makes the program smaller to reason about, even though it adds a few methods.
Designing a class step by step
Encapsulation starts before any code is written. Write down what callers need to do with the object, then decide what the object must protect. A shopping cart is a good example. Callers need to add an item, remove an item, read the total and count the items. The cart must protect three rules: a price cannot be zero or negative, removing an item that is not in the cart changes nothing, and the total must always match the items in the cart.
Step one is to list the data. The cart keeps its items in an internal list. It keeps nothing else that can drift out of step with the list. The total is not stored as an attribute, because a stored total can go wrong when an item is added and the total is forgotten. Instead, the total property adds up the list each time it is read. This is a general rule: if a value can be computed from other attributes, compute it, and do not store a copy.
Step two is to write the methods that callers need. add_item(name, price) checks the price first. If the price is zero or less, the method raises an error or returns without changing the list. remove_item(name) removes the matching item if one exists. total returns the sum. count returns the number of items. Each method is short, because the rule lives in one place. Callers never see the list.
Step three is to decide what each property returns. The total property returns a number, which is a copy, so there is no leak. A property that returned the list itself would be a leak. The rule from the defensive copy section applies: return a copy or a tuple.
Step four is to test the rules. Try to add a price of zero, then a price of five, then remove an item that is not there. Each step should leave the cart in a state that follows the rules. If a step breaks a rule, the rule belongs in a method, not in the caller.
Step five is to review the public surface. Count the public names. Each one is a promise you must keep. If a method exists only for convenience inside the class, give it a leading underscore. A cart with four public names is easier to keep correct than a cart with twelve.
Encapsulation and testing
A test checks the behaviour of a class by calling its public names and reading the results. Because the attributes are internal, a test should not change them directly, so the test follows the same path as real code. That is a strength. If the cart test adds a price of zero and expects the total to stay the same, the test catches the rule even when the caller is new code written next year.
Encapsulation also changes how many tests you need. If the balance rule lives in one method, one group of tests covers every caller. If the rule is copied into five places, five groups are needed, and one of them is usually missing. This is why interviewers often ask how you would test a class. A good answer describes the public methods, the rules they enforce, and the edge cases at each boundary, such as zero, the largest allowed value, and the value just outside the range.
Helper methods with a leading underscore are tested through the public methods that call them. Some developers want to test a private helper directly, but that usually means the helper should be public or moved to a new class with its own public surface. The rule is simple: test what callers can see, and let the helpers be tested indirectly.
When a read-only property is enough
Not every value needs a setter. Many values are set once, in __init__, and never change. An account number, a creation date or a product code belongs to that category. For these, store the value in an internal attribute and provide only a read-only property. The object still reveals the value, but nobody can change it after creation.
A property with no setter is also a clear signal to other programmers. When they read order.number and find no setter, they know the number is fixed. If you add a setter later, the signal disappears, and any code that assumed a fixed number may break. So add a setter only when the value must truly change, and give the change a name that says what it means, such as rename or change_address, when a method reads better than a setter.
Choosing between a read-only property and a read-only view is a separate decision. A property returns one value. A view, such as a tuple, returns a collection that callers can read but cannot change. Use a tuple for lists, and a property for simple values.
Why this design holds up
Put together, these sections describe a design that a reader can follow. The cart keeps its rules, the total is computed from the items, and the public surface is small. A reader who wants to know whether a total can be wrong only needs to read add_item and remove_item, because those are the only places the list changes. That is the real benefit of encapsulation: the number of places to check stays small, even when the program grows.
Common mistakes
Making every attribute public and assuming it is safe. It works until the first change of rule. Any caller that set the attribute directly now has to be found and fixed. Start with an underscore on the data that has rules, and add properties and methods when you need them.
Writing a setter for every attribute automatically. A setter that does nothing but assign the value gives no protection at all. Write a setter only when it does something useful, such as checking a rule or updating a related value.
Returning a mutable attribute from a property. A property that returns the list itself lets callers change the team. Return a copy or a tuple when the attribute is mutable.
Forgetting the copy in __init__. The caller can keep changing its own list after the team was built. The team must copy the list when it receives it.
Checking the rule in one caller only. If main checks that a deposit is positive, but another function does not, the account can still go negative. Put the rule in the class.
Trying to set a read-only property. Assigning to a property with no setter raises AttributeError: property 'x' of 'Point' object has no setter. The fix is to use a method that expresses the change, such as with_x, or to add a setter if the value must change.
Thinking a leading underscore is a lock. Outside code can still read and write _balance. The underscore tells other programmers the attribute is internal, and linters can warn about it. It does not stop a determined caller.
Debugging walkthrough: a balance that is wrong
Suppose a program prints a balance that is too high and you cannot see why. Use the structure of encapsulation to narrow the search.
- Search the whole project for the attribute name, such as
_balance. List every line that changes it. In the bank example, these are the lines indepositandwithdraw. If you find a line outside the class that changes the balance, you have found a design problem, and that line is the first suspect. - Add a print line inside each method that changes the attribute. Print the old value, the amount and the new value. Run the program again.
- Read the printed values in order. Look for the first line where the new value is not what the arithmetic says.
- Check the condition. In
withdraw, the checkamount > self._balancedecides whether the subtraction happens. If the check uses>=where it should use>, a withdrawal of exactly the balance is refused. If the check is missing, a withdrawal can make the balance negative. - Fix the rule in the method, then remove the print lines.
The reason this works is the same reason encapsulation exists. There are only a few places to look.
Variations and edge cases
A getter for a boolean is often a property with a name that reads as a question, such as is_active. Many Python libraries use this style, so follow the convention your project uses.
A class can have a helper method with a leading underscore that the public methods share. The helper does the common check, so the rule lives in one place.
A class can store only what it needs and compute the rest. For example, a Circle can have an internal _radius, and a property area computes the area from the radius each time it is read. Only the radius is stored. The area is derived, so it can never disagree with the radius.
Edge case: a value that may be None. If __init__ sets an attribute to None, the object holds None until a setter runs. If your rule says the name must be present, check it in __init__ and in the setter.
Edge case: a class can read the internal attributes of another object of the same class. An __eq__ method can read other._x directly. The access is still limited to code inside the class, so it does not break the convention.
Interview-style questions
What is encapsulation? Hiding an object's data behind internal attributes, and allowing changes only through methods and properties that enforce the rules. It keeps the rules in the class that owns the data.
How does Python mark a value as private? With a leading underscore, which means internal by convention. With two leading underscores, Python renames the attribute, which makes accidental access fail. Neither one is enforced as strictly as a Java private field.
Why is returning a list from a property sometimes unsafe? A list is a mutable object, so the caller gets the same object and can change it. Return a copy, or a tuple, when the caller should only read it.
What makes a class immutable? Internal attributes set once in __init__, read-only properties, no setters, and no mutable objects stored inside. A frozen dataclass does this for you.
Is a property always a sign of good encapsulation? No. A class with a property and a setter for every attribute exposes its data just as much as a public attribute does. Good encapsulation is about rules, not about the decorators.
Real-world uses
Most Python codebases use encapsulation, usually without thinking about it. A payment library keeps the card number internal and exposes a charge method that checks the amount. A shopping cart keeps its item list internal and exposes add_item and total. A user record keeps the password hash internal and exposes a check_password method, so the raw hash never leaves the class.
Libraries are the strongest case. When a company publishes a library, every public name is a promise to every user of that library. Encapsulation keeps that promise small. Internal attributes can be redesigned later, because no outside code depends on them. This is why library authors are careful about adding public attributes.
Practice
Try each exercise before you open its answer. Type your code and run it when the exercise asks for a program.
Exercise 1: a read-only name (easy)
Write a class Person with an internal _name attribute, set by __init__. Add a read-only property name. In the main part of the program, create a person named Ada and print the name through the property.
Expected output:
AdaShow the answer
The property lets the caller read the name. The attribute _name is internal, and there is no setter, so the caller cannot change the name through p.name.
Exercise 2: a rule in a setter (easy)
Add an age property to Person with a setter. The setter stores the value only when it is between 0 and 130. Otherwise it leaves the age unchanged and prints invalid age. Test with p.age = 30, then p.age = 200, then print the age.
Expected output:
invalid age
30Show the answer
The rule is inside the setter. The bad value is refused, so the age stays at 30. The first line p.age = 30 runs the setter, not a plain assignment.
Exercise 3: find the leak (easy)
A class Playlist has an internal list _songs and a property songs that returns the list itself. Explain how a caller can change the playlist without calling any method of Playlist. Then fix the property.
Show the answer
The property returns the same list the playlist stores. A caller can write p.songs[0] = "something", which changes the list inside the playlist. The fix is to return a tuple: return tuple(self._songs). The caller then gets a read-only sequence and cannot change the playlist through it.
Exercise 4: what does it print? (easy)
Predict the output, then check.
class Box:
def __init__(self):
self._items = 3
def count(self):
return self._items
b = Box()
c = b.count()
c = c + 10
print(b.count())
print(c)Show the answer
It prints 3, then 13. The method returns the integer stored in _items. Integers cannot be changed in place. c + 10 makes a new number and points c at it, so the attribute stays 3.
Exercise 5: a counter with a limit (medium)
Write a class Tally with an internal _count that starts at 0. Add a method add() that increases the count by 1 but never past 5, and a method get() that returns the count. In the main part of the program, call add() seven times and print get().
Expected output:
5Show the answer
The limit is enforced in add, so the loop can call it as many times as it likes. The count stops at 5.
Exercise 6: the defensive copy in __init__ (medium)
Write a class Roster that stores a list of names passed to __init__. The constructor should copy the names it receives, so that changing the caller's list afterwards does not change the roster. Show it in the main part of the program: build a list of Ana and Ben, create a roster, add Cy to the original list, and print the roster's size.
Expected output:
2Show the answer
list(names) makes a new list with the same items. The roster keeps its own list, so adding Cy to names does not reach it.
Exercise 7: an immutable Money (medium)
Write a class Money with an internal _cents attribute and no setter. Add a method plus(other) that returns a new Money with the sum, and a read-only property value that returns the number of cents. In the main part of the program, make Money of 150, add 100 to it, and print both the original and the new one.
Expected output:
150
250Show the answer
plus returns a new object, so a keeps 150. No method changes _cents after __init__, so the object never changes.
Exercise 8: a read-only view (medium)
Make a list of three numbers. Write a function view() that returns the numbers as a tuple. Try to add a fourth item through the view inside a try block, catch AttributeError, and print blocked. Print the length of the original list at the end, which should still be 3.
Expected output:
blocked
3Show the answer
A tuple has no append method, so the attempt raises AttributeError. The tuple is a copy of the list, so the original data was never changed, and its length is still 3.
Exercise 9: a class with a hidden rule (medium)
Write a class Thermostat with an internal _target temperature, default 20. Add a method up() that adds 1 and a method down() that subtracts 1, but the target must stay between 15 and 25. Then call up() ten times and print the target.
Expected output:
25Show the answer
Starting at 20, five steps reach 25. The next five calls see 25 and do nothing. The caller never needs to know the limit.
Exercise 10: a private helper (hard)
A class Transfer has two amounts, _first and _second. Write a public method move(amount) that moves money from _first to _second. It must do nothing if _first has less than amount, or if amount is zero or negative. Put the check in a helper _can_move(amount) and call it from move. Test with first 50, second 10: move 20, then move 100, then print both values with a space between them.
Expected output:
30 30Show the answer
After moving 20, the values are 30 and 30. The second move asks for 100, which is more than 30, so _can_move returns False and nothing changes. The helper has a leading underscore, which tells other programmers not to call it from outside the class.
Exercise 11: fix a leaking class (hard)
The class below is supposed to hold a list of scores and report only what the class allows. A student says the code is "encapsulated because the attribute starts with an underscore". Explain why that claim is wrong for this class, and rewrite scores so the rule holds.
class Scores:
def __init__(self):
self._values = []
def add(self, v):
self._values.append(v)
@property
def scores(self):
return self._values
s = Scores()
s.add(10)
s.scores.clear()
print(len(s.scores))Show the answer
The underscore only marks _values as internal. The property scores returns the list itself, so any caller can call clear() on it and empty the real data. The program prints 0, even though the class offers only add. The fix is to return a tuple:
@property
def scores(self):
return tuple(self._values)With that change, s.scores.clear() raises AttributeError: 'tuple' object has no attribute 'clear', so the program stops at that line and the list stays as it was. A copy with list(self._values) also works, and it stops changes from reaching the object, but it costs more time when the list is large.
Exercise 12: an immutable Person (hard)
Write a class Person that stores a name and a tuple of tags, both internal and set once in __init__. The constructor must copy the tags into a tuple. The property tags returns the tuple. Add a method with_tag(tag) that returns a new Person with one more tag, and leaves the original unchanged. In the main part of the program, create Ana with the tag "admin", then make a copy with the tag "owner", and print both tag counts.
Expected output:
1
2Show the answer
The class copies the tags on the way in, and the tuple cannot change, so the object is safe to share. with_tag builds a new tuple, so the original person still has one tag.
Summary
- Encapsulation keeps an object's data behind internal names and changes it only through methods and properties that enforce the rules.
- Python uses naming conventions, not keywords. One leading underscore means internal. Two leading underscores make the name harder to reach. Neither one is a lock.
- A property lets a method run behind a plain-looking attribute. Use
@propertyfor a read-only value and@name.setterfor a checked change. - A property that returns a mutable list leaks control. Return a copy or a tuple.
- An immutable object stores its values once, has read-only properties, and makes changes by returning a new object. A frozen dataclass does this with less code.
- Python raises an error when outside code uses a property with no setter, but it does not stop access to an underscore name. The rules must live in the class, and the convention keeps everyone else out.