It seems that you expect A.NotFoundError
and B.NotFoundError
to be separate classes, since A
and B
are separate classes. However, that is not the case.
The code inside class Base:
is not some kind of template that is re-run for each derived class. There is an actual object that represents that class, and the code creates that object, and that object has another class, Base.NotFoundError
, as an attribute.
The derived classes don't define attributes named NotFoundError
themselves; thus, looking up NotFoundError
within them finds the one attached to the Base
- just like how e.g. A.__init__
implicitly calls Base.__init__
.
It doesn't really make sense to use subclassing for this example - instead, just use separate instances that each have their own .numbers
; the explicitly raised exception could include a reference to self
if necessary, making it clear which instance failed to contain the number.
However, sometimes there will be a legitimate use case for making a "parallel" structure of classes like this, wherein each derived class has its own associated class. Of course it works to just define NotFoundError
within each derived class, but there are ways to minimize the boilerplate.
In particular, we can use a metaclass (which actually can be implemented as a function), or else a class decorator.
Function-as-metaclass approach
A metaclass is a thing used to create class instances. It's simply the class, of which those classes are instances. By default, every class is an instance of the built-in type
. We can define our own metaclass that does something similar to what type
does at instantiation time, but also adds the corresponding exception class. Thus:
def TypeWithNotFound(name, bases, attributes):
attributes['NotFoundError'] = type('NotFoundError', (Exception,), {})
return type(name, bases, attributes)
class Base(metaclass=TypeWithNotFound):
def __init__(self, numbers):
self.numbers = numbers
def pop_by_val(self, number):
try:
self.numbers.remove(number)
except ValueError:
raise self.NotFoundError()
class A(Base, metaclass=TypeWithNotFound): pass
class B(Base, metaclass=TypeWithNotFound): pass
For the metaclass, we can use a function rather than a class, since we only need to replace what happens when it's called (our classes will actually still have type
as a metaclass, just the setup process is modified). We are passed the name of the new class as a string, a tuple of base classes, and a dict of attributes. (Python normally creates classes by computing these arguments from the class
body, and then calling type
with them.) So, we dynamically create an exception class, store it in the attributes dict, and then dynamically create the actual class.
From there, we don't need any code within each class to create corresponding exceptions - but we do need to use the metaclass for all of them.
Class decorator approach
This is arguably simpler. We simply define a function that accepts, modifies and returns a class, and then we can use that function as a decorator for classes. Specifically, we will modify the class by creating and adding a related exception type. Thus:
def addNotFound(cls):
cls.NotFoundError = type('NotFoundError', (Exception,), {})
return cls
@addNotFound
class Base:
# as before
@addNotFound
class A(Base): pass
@addNotFound
class B(Base): pass
Actual metaclass approach
This is a little harder conceptually, but it minimizes the boilerplate even more: derived classes will necessarily have the same metaclass as their base.
class TypeWithNotFound(type):
def __new__(cls, name, bases, attributes):
attributes['NotFoundError'] = type('NotFoundError', (Exception,), {})
return super().__new__(cls, name, bases, attributes)
We defined a subclass of the type
metaclass, and defined its __new__
. Now, when instances of TypeWithNotFound
(i.e.: classes that have this metaclass) are created, the overridden __new__
will modify the attributes dict before delegating back to the base type.__new__
. (Since we are overriding __new__
rather than __init__
, the first parameter will be the class TypeWithNotFound
itself, which needs to be forwarded to the base __new__
call. (This is just standard best practice, in case we later need to implement cooperative multiple inheritance between metaclasses.)
Anyway, now we can simply define Base
to use this metaclass, and the derived classes will also use it automatically:
class Base(metaclass=TypeWithNotFound):
# as before
class A(Base): pass
class B(Base): pass
For each of these approaches, the reader should be able to verify the result:
>>> a = A([1, 2])
>>> b = B([1, 2])
>>>
>>> try:
... a.pop_by_val(1)
... b.pop_by_val(3)
... except A.NotFoundError:
... print("no number found in class A")
... except B.NotFoundError:
... print("no number found in class B")
...
no number found in class B
References / See Also
What are metaclasses in Python?
How to decorate a class?