In this code:
class A():
def __init__(self, x):
self.x = x
def __str__(self):
return self.x
class B(A):
def __str__(self):
return super().__str__()
b = B("Hi")
print(b)
The output is: Hi
.
What is happening under the hood? How does the default constructor in the derived class invoke the super class constructor? How are the params passed to the derived class object get mapped to those of the super class?
1
How does the default constructor in the derived class invoke the super class constructor?
You didn’t override it in B
, so you inherited it from A
. That’s what inheritance is for.
>>> B.__init__ is A.__init__
True
In the same vein, you may as well not define B.__str__
at all here, since it doesn’t do anything (other than add a useless extra frame into the call stack).
How are the params passed to the derived class object get mapped to those of the super class?
You may be overthinking this. As shown above, B.__init__
and A.__init__
are identical. B.__init__
gets resolved in the namespace of A
, since it is not present in the namespace of B
.
>>> B.__mro__
(__main__.B, __main__.A, object)
>>> A.__dict__["__init__"]
<function __main__.A.__init__(self, x)>
>>> B.__dict__["__init__"]
...
# KeyError: '__init__'
What is happening under the hood?
Please note that __init__
methods are not constructors. If you’re looking for an analogy of constructors in other programming languages, the __new__
method may be more suitable than __init__
. First, an instance of B
will created (by __new__
), and then this instance will be passed as the first positional argument self
to A.__init__
, along with the string value “Hi” for the second positional argument x
.
3
First, what does it mean to call B
in the first place? B
is an instance of type
, and instances are callable when their classes define __call__
. type.__call__
can be thought of as being defined as
def __call__(self, *args, **kwargs):
obj = self.__new__(self, *args, **kawrgs)
if isinstance(obj, self):
obj.__init__(*args, **kwargs)
return obj
So B("hi")
starts as a call to type.__call__(B, "hi")
.
Inside __call__
, we first try to call B.__new__(B, "hi")
. Since B.__new__
is not defined, we need to look at B’s method resolution order to find a class that does define __new__
.
>>> B.__mro___
(<class '__main__.B'>, <class '__main__.A'>, <class 'object'>)
A
does not, but object
does, so we end up with
obj = object.__new__(B, "hi")
which effectively ignores "hi"
and returns a “bare” instance of B
(the first argument). *
Since obj
is indeed an instance of B
, isinstance
returns True
and we call the __init__
method. Once again, obj
has no __init__
attribute, so we fall back to the method resolution order of type(obj)
to find one, and we find A.__init__
. So we execute A.__init__(obj, "hi")
before finally returning obj
.
* There is a small amount of “magic” that I’m not aware of. object.__new__
can raise a TypeError
if it receives arguments that it is not expecting:
>>> object.__new__(object, "hi")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: object() takes no arguments
so I’m not sure exactly how object.__new__(B, "hi")
works. As far as I can tell, the existence of an __init__
method for the first argument is sufficient to make object.__new__
accept and ignore arbitrary arguments.