A metaclass is to a class what a class is to an instance; that is, a metaclass is used to create classes, just as classes are used to create instances. And just as we can ask whether an instance belongs to a class by using isinstance()
, we can ask whether a class object (such as dict
, int
, or SortedList
) inherits another class using issubclass()
.
The simplest use of metaclasses is to make custom classes fit into Python’s standard ABC hierarchy. For example, to make SortedList
a collections. Sequence
, instead of inheriting the ABC (as we showed earlier), we can simply register the SortedList
as a collections.Sequence
:
After the class is defined normally, we register it with the collections.Sequence
ABC. Registering a class like this makes it a virtual subclass.[*] A virtual subclass reports that it is a subclass of the class or classes it is registered with (e.g., using isinstance()
or issubclass()
), but does not inherit any data or methods from any of the classes it is registered with.
Registering a class like this provides a promise that the class provides the API of the classes it is registered with, but does not provide any guarantee that it will honor its promise. One use of metaclasses is to provide both a promise and a guarantee about a class’s API. Another use is to modify a class in some way (like a class decorator does). And of course, metaclasses can be used for both purposes at the same time.
Suppose we want to create a group of classes that all provide load()
and save()
methods. We can do this by creating a class that when used as a metaclass, checks that these methods are present:
class LoadableSaveable(type):
def __init__(cls, classname, bases, dictionary):
super().__init__(classname, bases, dictionary)
assert hasattr(cls, "load") and
isinstance(getattr(cls, "load"),
collections.Callable), ("class '" +
classname + "' must provide a load() method")
assert hasattr(cls, "save") and
isinstance(getattr(cls, "save"),
collections.Callable), ("class '" +
classname + "' must provide a save() method")
Classes that are to serve as metaclasses must inherit from the ultimate metaclass base class, type
, or one of its subclasses.
Note that this class is called when classes that use it are instantiated, in all probability not very often, so the runtime cost is extremely low. Notice also that we must perform the checks after the class has been created (using the super()
call), since only then will the class’s attributes be available in the class itself. (The attributes are in the dictionary, but we prefer to work on the actual initialized class when doing checks.)
We could have checked that the load
and save
attributes are callable using hasattr()
to check that they have the __call__
attribute, but we prefer to check whether they are instances of collections.Callable
instead. The collections.Callable
abstract base class provides the promise (but no guarantee) that instances of its subclasses (or virtual subclasses) are callable.
Once the class has been created (using type.__new__()
or a reimplementation of __new__()
), the metaclass is initialized by calling its __init__()
method. The arguments given to __init__()
are cls
, the class that’s just been created; classname
, the class’s name (also available from cls.__name__
); bases
, a list of the class’s base classes (excluding object
, and therefore possibly empty); and dictionary
that holds the attributes that became class attributes when the cls
class was created, unless we intervened in a reimplementation of the meta-class’s __new__()
method.
Here are a couple of interactive examples that show what happens when we create classes using the LoadableSaveable
metaclass:
>>> class Bad(metaclass=Meta.LoadableSaveable):
... def some_method(self): pass
Traceback (most recent call last):
...
AssertionError: class 'Bad' must provide a load() method
The metaclass specifies that classes using it must provide certain methods, and when they don’t, as in this case, an AssertionError
exception is raised.
>>> class Good(metaclass=Meta.LoadableSaveable):
... def load(self): pass
... def save(self): pass
>>> g = Good()
The Good
class honors the metaclass’s API requirements, even if it doesn’t meet our informal expectations of how it should behave.
We can also use metaclasses to change the classes that use them. If the change involves the name, base classes, or dictionary of the class being created (e.g., its slots), then we need to reimplement the metaclass’s __new__()
method; but for other changes, such as adding methods or data attributes, reimplemeting __init__()
is sufficient, although this can also be done in __new__()
. We will now look at a metaclass that modifies the classes it is used with purely through its __new__()
method.
As an alternative to using the @property
and @
name
.setter
decorators, we could create classes where we use a simple naming convention to identify properties. For example, if a class has methods of the form get_
name()
and set_
name()
, we would expect the class to have a private __name
property accessed using instance.name
for getting and setting. This can all be done using a metaclass. Here is an example of a class that uses this convention:
class Product(metaclass=AutoSlotProperties):
def __init__(self, barcode, description):
self.__barcode = barcode
self.description = description
def get_barcode(self):
return self.__barcode
def get_description(self):
return self.__description
def set_description(self, description):
if description is None or len(description) < 3:
self.__description = "<Invalid Description>"
else:
self.__description = description
We must assign to the private __barcode
property in the initializer since there is no setter for it; another consequence of this is that barcode
is a read-only property. On the other hand, description
is a readable/writable property. Here are some examples of interactive use:
>>> product = Product("101110110", "8mm Stapler")
>>> product.barcode, product.description
('101110110', '8mm Stapler')
>>> product.description = "8mm Stapler (long)"
>>> product.barcode, product.description
('101110110', '8mm Stapler (long)')
If we attempt to assign to the bar code an AttributeError
exception is raised with the error text “can’t set attribute”.
If we look at the Product
class’s attributes (e.g., using dir()
), the only public ones to be found are barcode
and description
. The get_
name()
and set_
name()
methods are no longer there—they have been replaced with the name
property. And the variables holding the bar code and description are also private (__bar-code
and __description
), and have been added as slots to minimize the class’s memory use. This is all done by the AutoSlotProperties
metaclass which is implemented in a single method:
class AutoSlotProperties(type):
def __new__(mcl, classname, bases, dictionary):
slots = list(dictionary.get("__slots__", []))
for getter_name in [key for key in dictionary
if key.startswith("get_")]:
if isinstance(dictionary[getter_name],
collections.Callable):
name = getter_name[4:]
slots.append("__" + name)
getter = dictionary.pop(getter_name)
setter_name = "set_" + name
setter = dictionary.get(setter_name, None)
if (setter is not None and
isinstance(setter, collections.Callable)):
del dictionary[setter_name]
dictionary[name] = property(getter, setter)
dictionary["__slots__"] = tuple(slots)
return super().__new__(mcl, classname, bases, dictionary)
A metaclass’s __new__()
class method is called with the metaclass, and the class name, base classes, and dictionary of the class that is to be created. We must use a reimplementation of __new__()
rather than __init__()
because we want to change the dictionary before the class is created.
We begin by copying the __slots__
collection, creating an empty one if none is present, and making sure we have a list rather than a tuple so that we can modify it. For every attribute in the dictionary we pick out those that begin with "get_"
and that are callable, that is, those that are getter methods. For each getter we add a private name to the slots to store the corresponding data; for example, given getter get_
name
()
we add __
name
to the slots. We then take a reference to the getter and delete it from the dictionary under its original name (this is done in one go using dict.pop()
). We do the same for the setter if one is present, and then we create a new dictionary item with the desired property name as its key; for example, if the getter is get_
name
()
the property name is name
. We set the item’s value to be a property with the getter and setter (which might be None
) that we have found and removed from the dictionary.
At the end we replace the original slots with the modified slots list which has a private slot for each property that was added, and call on the base class to actually create the class, but using our modified dictionary. Note that in this case we must pass the metaclass explicitly in the super()
call; this is always the case for calls to __new__()
because it is a class method and not an instance method.
For this example we didn’t need to write an __init__()
method because we have done all the work in __new__()
, but it is perfectly possible to reimplement both __new__()
and __init__()
doing different work in each.
If we consider hand-cranked drills to be analogous to aggregation and inheritance and electric drills the analog of decorators and descriptors, then meta-classes are at the laser beam end of the scale when it comes to power and versatility. Metaclasses are the last tool to reach for rather than the first, except perhaps for application framework developers who need to provide powerful facilities to their users without making the users go through hoops to realize the benefits on offer.