Best practice for defining immutable classes is to use __new__ and other class methods for construction rather than __init__. This ensures that the object is never available partially initialised and would always be seen as fully constructed in an __init__ method of any subclass. Restricting initialisation of read-only attributes to __init__ is bad because __init__ should usually not be used for immutable classes. Restricting it to __new__ and not other class methods or functions is also bad because other constructor methods are needed in practice besides __new__. In fact one important reason for using __new__ rather than __init__ is because you can have multiple class method constructors and choose which one to call in context whereas there is no way to have multiple __init__ methods.
For an example see fractions.Fraction in the stdlib but there are many more outside of the stdlib. Here is a simplified version that shows how the class method constructors might typically look:
from __future__ import annotations
from math import gcd
class Fraction:
_numerator: int
_denominator: int
@property
def numerator(self) -> int:
return self._numerator
@property
def denominator(self) -> int:
return self._denominator
def __new__(cls, num: int | str | None = None, den: int | None = None, /) -> Fraction:
if den is not None:
if isinstance(num, int) and isinstance(den, int):
return cls._new(num, den)
else:
raise TypeError("Fraction() takes two integers")
elif num is not None:
if isinstance(num, int):
return cls._new_raw(num, 1)
elif isinstance(num, Fraction):
return num
elif isinstance(num, str):
return cls._from_str(num)
else:
return cls._zero()
@classmethod
def _new(cls, num: int, den: int) -> Fraction:
g = gcd(num, den)
num = num//g
den = den//g
if den < 0:
num, den = -num, -den
return cls._new_raw(num, den)
@classmethod
def _new_raw(cls, num: int, den: int) -> Fraction:
# Assumes num and den are already normaliased
obj = super().__new__(cls)
obj._numerator = num
obj._denominator = den
return obj
@classmethod
def _zero(cls) -> Fraction:
return cls._new_raw(0, 1)
@classmethod
def _from_str(cls, s: str) -> Fraction:
if "/" in s:
num, den = s.split("/")
return cls._new(int(num), int(den))
else:
return cls._new_raw(int(s), 1)
def __repr__(self) -> str:
return f"{self._numerator}/{self._denominator}"
def __mul__(self, other):
if not isinstance(other, Fraction):
return NotImplemented
return self._mul(other)
def _mul(self, other: Fraction) -> Fraction:
# Cancelling small gcd here is much more efficient
# than going through __new__ at large bitsizes
an, ad = self._numerator, self._denominator
bn, bd = other._numerator, other._denominator
g1 = gcd(an, bd)
g2 = gcd(ad, bn)
return self._new_raw(an//g1 * bn//g2, ad//g2 * bd//g1)
Note in this example that the __new__ method needs to provide the friendly public interface for users of the class and therefore has to accept many different types and check for them. Also __new__ cannot assume that input arguments are normalised so when given int arguments it always needs to cancel GCD and make the denominator positive (it should also check for zero denominator…). The same problems would apply if __init__ was used as well.
Internal calls to construct new instances should typically bypass __new__ and use a more specific class method constructor for known types. Note in particular that __mul__ computes normalised numerator and denominator and therefore needs to bypass the normalisation that is performed by __new__ which is why it uses the _new_raw class method instead of __new__.
The implicit rule understood by people who write such immutable classes is that if a class method or function actually creates a new Foo (by calling super.__new__()) then it should fully initialise the Foo before returning it. In the example shown _new_raw does this and is the only place that assigns the attributes: every other method just forwards an already initialised object that is received from elsewhere. Every method or function that returns a Foo therefore returns a fully initialised Foo that should be considered immutable once received by the caller.
Ideally a type checker could understand this and detect the error in this code:
class A:
_val: ReadOnly[int]
def __new__(cls, val: int) -> A:
obj = super().__new__(cls)
return obj # Uninitialised A: obj has no _val
Allowing assignment of read-only attributes in an __init__ method is contradictory because __init__ can be called on an already created object or may never be called. I can understand why you might want to allow it for __init__ given that most Python programmers don’t know __new__. Allowing this for __init__ but not for __new__ is backwards though.