Metoda wywołania __init_subclass__ została wprowadzona w Pythonie 3.6 jako część PEP 487. Wcześniej każda klasa, która chciała wykonać jakieś działania podczas dziedziczenia — jak rejestracja, walidacja lub automatyczne zbieranie pól — musiała zadeklarować niestandardową metaklasę. Metaklasy, choć potężne, mogą powodować problemy w sytuacjach z wielokrotnym dziedziczeniem, ponieważ kolidują ze sobą, chyba że są starannie skoordynowane. Nowy hook pozwala klasom bazowym uczestniczyć w inicjalizacji podklas bez zmuszania całej hierarchii do przyswojenia określonej metaklasy, upraszczając takie ramy jak Django ORM i SQLAlchemy, które wcześniej polegały na skomplikowanej logice metaklas.
Kiedy klasa B dziedziczy po klasie bazowej A, deweloperzy ram często muszą wykonać logikę w momencie definiowania klasy B — przed utworzeniem jakichkolwiek instancji. Na przykład, ORM może potrzebować zebrać wszystkie definicje kolumn z B i przechować je w rejestrze. Użycie metaklasy wymaga, aby A miała type lub niestandardową metaklasę jako swoją metaklasę, co staje się problematyczne, kiedy B również potrzebuje użyć innej metaklasy (np. z ABC lub innego frameworka). To prowadzi do błędów konfliktu metaklas, które są trudne do rozwiązania. Dodatkowo, __new__ metaklasy działa przed pełnym wypełnieniem przestrzeni nazw klasy, co utrudnia inspekcję końcowych atrybutów klasy.
Python udostępnia metodę klasy __init_subclass__. Kiedy klasa definiuje tę metodę, jest ona wywoływana automatycznie za każdym razem, gdy tworzona jest klasa, która ma klasę definiującą jako bezpośredniego rodzica. Hook otrzymuje nowo utworzoną podklasę jako swój pierwszy argument, a następnie wszelkie argumenty słówkluczowych przekazane w linii definicji klasy (np. class B(A, keyword=value)).
class RegistryBase: _registry = {} def __init_subclass__(cls, category="default", **kwargs): super().__init_subclass__(**kwargs) print(f"Rejestracja {cls.__name__} w kategorii '{category}'") cls._registry[cls.__name__] = {"class": cls, "category": category} class Plugin(RegistryBase, category="audio"): pass class Effect(Plugin, category="reverb"): pass
W przeciwieństwie do __new__ metaklasy, które wykonuje się w trakcie tworzenia klasy przed istnieniem obiektu klasy, __init_subclass__ uruchamia się po pełnym skonstruowaniu obiektu klasy. To pozwala hookowi bezpiecznie przeglądać cls.__dict__, metody i adnotacje. Hook respektuje również MRO, zapewniając, że rejestracja klas rodziców następuje przed logiką klas dzieci, kiedy super() jest wywoływane.
W dużej platformie do przetwarzania dźwięku SaaS zespół inżynieryjny musiał wdrożyć system pluginów, w którym deweloperzy zewnętrzni mogą definiować efekty dźwiękowe, dziedzicząc po bazowej klasie AudioEffect. Każda podklasa musiała automatycznie rejestrować się w globalnym katalogu efektów z metadanymi takimi jak effect_name, latency_ms i category. Problem polegał na tym, że platforma wykorzystywała już deklaratywne bazy SQLAlchemy (które używają metaklas) do modeli baz danych, a niektóre efekty dźwiękowe musiały dziedziczyć zarówno z AudioEffect, jak i z modeli SQLAlchemy. Wprowadzenie niestandardowej metaklasy dla AudioEffect powodowało konflikty metaklas z DeclarativeMeta SQLAlchemy, co łamało uruchamianie aplikacji.
Pierwsze podejście polegało na ręcznej rejestracji za pomocą dekoratora. Deweloperzy musieli pisać @register_effect nad każdą definicją klasy. To działało, ale było podatne na błędy; deweloperzy często zapominali o dekoratorze, co prowadziło do brakujących efektów w produkcji. Wymagało to także powtarzania metadanych zarówno w argumentach dekoratora, jak i w definicji klasy, naruszając zasady DRY.
Drugie podejście próbowało użyć wspólnej metaklasy, która dziedziczyła z DeclarativeMeta i EffectMeta. To rozwiązało natychmiastowy konflikt, ale stworzyło kruchą zależność. Za każdym razem, gdy SQLAlchemy aktualizowało swoją wewnętrzną logikę metaklas, platforma przestawała działać. Zmuszało to także wszystkie klasy efektów do bycia modelami baz danych, co nie było odpowiednie dla lekkich efektów po stronie klienta.
Trzecie podejście wykorzystało __init_subclass__. Klasa bazowa AudioEffect zdefiniowała __init_subclass__, aby przechwycić argumenty słówkluczowych przekazywane podczas definicji klasy, takie jak effect_id i version. Kiedy deweloper napisał class Reverb(AudioEffect, effect_id="rvb-01", version=2), hook automatycznie walidował unikalność ID i rejestrował klasę w rejestrze WeakValueDictionary w bezpiecznym wątku. To całkowicie unikało konfliktów metaklasowych, ponieważ __init_subclass__ jest zwykłą metodą klasy, która współpracuje z każdą metaklasą.
Zespół wybrał trzecie rozwiązanie. Zachowało ono kompatybilność z SQLAlchemy, wyeliminowało potrzebę dekoratorów i zapewniło, że rejestracja odbywała się automatycznie w czasie importu. Rezultatem był system pluginów, który „po prostu działał” — deweloperzy musieli tylko dziedziczyć i zadeklarować parametry w linii. System skutecznie zarejestrował 150+ efektów bez jednego konfliktu metaklasowego, a czas uruchamiania poprawił się o 40% w porównaniu do podejścia metaklasowego dzięki zmniejszonej złożoności obliczeń MRO.
Dlaczego __init_subclass__ musi zawsze wywoływać super().__init_subclass__(), nawet jeśli rodzic tego nie definiuje?
Kandydaci często przyjmują, że ponieważ object nie definiuje __init_subclass__, wywołanie jest opcjonalne. Jednak w sytuacjach z wielokrotnym dziedziczeniem, brak wywołania super() może przerwać łańcuch dla klas rodzeństwa, które również implementują hook. Współpraca wielu dziedziczeń w Pythonie wymaga, aby każdy uczestnik w diamentowym wywołaniu wywołał super(), aby upewnić się, że wszystkie gałęzie hierarchii wykonują swoją logikę inicjalizacji. Jeśli A i B obie definiują __init_subclass__, a C(A, B) wywołuje tylko hook A, logika rejestracji B jest cicho pomijana, co prowadzi do subtelnych błędów w systemach pluginów.
Jak __init_subclass__ obsługuje argumenty słówkluczowych, które nie są konsumowane przez sygnaturę metody, i dlaczego **kwargs jest obowiązkowe?
Kiedy podklasa jest definiowana z argumentami słówkluczowych (np. class D(C, custom_arg=5)), te argumenty są przekazywane do __init_subclass__. Jeśli sygnatura metody nie zawiera **kwargs, aby przechwycić i przekazać nieużywane argumenty, a inna klasa w MRO też definiuje __init_subclass__, pojawia się TypeError, ponieważ Python próbuje przekazać argument słówkluczowy do następnego hooka, który go nie akceptuje. Dlatego solidne implementacje muszą zawsze zawierać **kwargs i przekazywać je do super().__init_subclass__(**kwargs), aby wspierać współpracujące dziedziczenie, gdzie różne poziomy konsumują różne parametry.
Czy __init_subclass__ może modyfikować przestrzeń nazw klasy lub dynamicznie dodawać metody, a jakie są tego implikacje dla __slots__?
Kandydaci często mylą __init_subclass__ z metaklasowym __new__. Ponieważ __init_subclass__ działa po pełnym utworzeniu klasy, nie może modyfikować słownika klasy przed utworzeniem (w przeciwieństwie do __prepare__ lub metaklasowego __new__). Jednak może dynamicznie dodawać atrybuty za pomocą setattr(cls, name, value). Niebezpieczeństwo pojawia się z __slots__: jeśli klasa rodzicielska korzysta z __slots__, klasa podklasa dziedziczy ten ograniczenie. Próba dodania nowego atrybutu do klasy z slotami za pomocą setattr w __init_subclass__ spowoduje błąd AttributeError, chyba że podklasa sama zdefiniowała __slots__ lub __dict__. To ograniczenie zmusza architektów do wyboru między używaniem __init_subclass__ do rejestracji/metadanych a używaniem metaklas do rzeczywistej modyfikacji strukturalnej ciała klasy.