Olivier Mehani<p><strong>I don’t often decorate classes, but when I did, I didn’t have to</strong></p><p>I have played with <a href="https://blog.narf.ssji.net/2022/11/22/fun-with-python-decorators/" rel="nofollow noopener" target="_blank">Python</a> <a href="https://blog.narf.ssji.net/2024/06/30/python-cli-backward-compatibility-decorator/" rel="nofollow noopener" target="_blank">decorators</a> before. They are useful to extend the behaviour of a function by composition, without having to change the function itself. But functions aren’t the only object that can be decorated: classes can, too.</p><p>I started investigating this when I had a number of different classes implementing a specific behaviour, and wanted to be able to find a specific one. A simple decorator can be written which will add each decorated class into a list. Finding the desired class is then a simple <code>for</code> loop away.</p><p>tl;dr:</p><ul><li>Classes can be decorated in the same way as functions: the decorator takes the class as an argument, does something to or with it, and returns a class.</li><li>For the stated purpose, it’s not necessary, as class inheritance is better suited, and parent classes natively have a <code>__subclasses__</code> method returning a list of their descendents.</li></ul><p></p><p>For a more concrete example, let’s say we want to have a system that support multiple markup languages (e.g, HTML and Markdown). We want independent classes to support each of the languages, and the ability to find the right class for a given language.</p><p><strong>A handful of markups</strong></p><p>Here’s our simple classes.</p><pre>class HtmlSupport: @classmethod def support(cls, lang): return lang == "html"class MarkdownSupport: @classmethod def support(cls, lang): return lang == "markdown"</pre><p>Thanks to the <code>support</code> method, each class can be interrogated about their support for the given <code>lang</code>.</p><pre>>>> MarkdownSupport.support('markdown')True>>> MarkdownSupport.support('html')False</pre><p>But how to find the right support class out of all the existing implementations? We don’t even know what all the implementations are! Now… If we had a list, it would be neater</p><pre>implementations = [ HtmlSupport, MarkdownSupport,]def find_class(lang): for imp in implementations: if imp.support(lang): return imp </pre><p>This is a good start.</p><pre>>>> find_class('html')<class 'decorate.HtmlSupport'>>>> find_class('json')>>></pre><p><strong>A few markups more</strong></p><p>One issue with this approach is that it needs the <code>implementations</code> list to be explicitely maintained: every new implementation needs to be added to that list, wherever it might be (other source file, other module, …). This is not very nice. Instead, we could offer a function to add new implementations to the list dynamically.</p><pre>implementations = []def add_to_implementation(klass): implementations.append(klass) return klass </pre><p>We can then use this function when we declare new classes.</p><pre>class RestructuredTextSupport: @classmethod def support(cls, lang): return lang == "restructuredtext"add_to_implementation(RestructuredTextSupport)</pre><p>And each class added in this way will be present in the <code>implementations</code> list.</p><pre>>>> implementations[<class '__main__.RestructuredTextSupport'>]</pre><p><strong>Decorators (at last)!</strong></p><p>But hold on. We are calling a method within a file which declare a class? Mixing declaration and instructions is not very nice. Could we do better? This is where decorators come into play. Like <a href="https://en.wikipedia.org/wiki/Le_Bourgeois_gentilhomme" rel="nofollow noopener" target="_blank">M. Jourdain</a>, it turns out we already had one, but we didn’t know about it.</p><pre>@add_to_implementationclass DokuWikiSupport: @classmethod def support(cls, lang): return lang == "dokuwiki"</pre><p>We called it more declaratively, but the outcome is the same.</p><pre>>>> implementations[<class '__main__.RestructuredTextSupport'>, <class '__main__.DokuWikiSupport'>]</pre><p>One important point of note is that our <code>add_to_implementations</code> function returns a class. If it didn’t, the class would still be added correctly to our list of implementations, but it would not be available in local namespace.</p><pre>>>> def half_decorate(cls):... implementations.append(cls)... >>> @half_decorate... class Bob:... pass... >>> implementations[<class '__main__.RestructuredTextSupport'>, <class '__main__.DokuWikiSupport'>, <class '__main__.Bob'>]>>> Bob()Traceback (most recent call last): File "<stdin>", line 1, in <module>TypeError: 'NoneType' object is not callable</pre><p>In any case, we are now able to declare all our implementations via our decorator. I renamed it to simply <code>implementation</code> for legibility. I’ve also expanded the decorator a bit so it would actually modify the returned class, by adding a <code>decorated</code> method, as a way to demonstrate that the main purpose of decorating still works.</p><pre># decorate.pyimplementations = []def implementation(klass): implementations.append(klass) def decorated(): return True klass.decorated = decorated return klass@implementationclass DokuWikiSupport: @classmethod def support(cls, lang): return lang == "dokuwiki"@implementationclass HtmlSupport: @classmethod def support(cls, lang): return lang == "html"@implementationclass MarkdownSupport: @classmethod def support(cls, lang): return lang == "markdown"class RestructuredTextSupport: @classmethod def support(cls, lang): return lang == "restructuredtext"def find_class(lang): for imp in implementations: if imp.support(lang): return imp </pre><p>And we can finally find our the right class for the right purpose.</p><pre>>>> <class 'decorate.DokuWikiSupport'>>>> find_class('html')<class 'decorate.HtmlSupport'>>>> find_class('txt')>>> >>> find_class('markdown').decorated()True</pre><p><strong>With better design, none of this is necessary</strong></p><p>So, at this point I was pretty happy with myself, and I had solved my problem. But something was bothering me. All those classes implement the same method, so this is screaming for some object orientation, with a nice abstract class.</p><pre># subclasses.pyfrom abc import abstractmethodclass AbstractSupport(): @classmethod @abstractmethod def support(cls, lang): """Return True if this class supports lang"""class HtmlSupport(AbstractSupport): # same body as beforeclass MarkdownSupport(AbstractSupport): # same body as beforeclass RestructuredTextSupport(AbstractSupport): # same body as beforeclass DokuWikiTextSupport(AbstractSupport): # same body as before</pre><p>This is nicer design, but this doesn’t help us find the right implementation just yet. We still need a list to search through. Fortunately, as part of the class hierarchy, Python maintains a list of all the subclasses of each class. It is available from the <code>__subclasses__</code> method.</p><pre>>>> AbstractSupport.__subclasses__()[<class 'subclasses.HtmlSupport'>, <class 'subclasses.MarkdownSupport'>, <class 'subclasses.RestructuredTextSupport'>, <class 'subclasses.DokuWikiSupport'>]</pre><p>So we can do the same dance as before, without having to do anything to maintain the list!</p><pre>def find_class(lang): for imp in AbstractSupport.__subclasses__(): if imp.support(lang): return imp </pre><p>And it all works as needed.</p><pre>>>> find_class("markdown")<class 'subclasses.MarkdownSupport'>>>> find_class("html")<class 'subclasses.HtmlSupport'>>>> find_class("json")>>> </pre><p>We have lost the silly addition of the <code>decorated</code> method along the way, but it was never a requirement in the first place.</p><p>So, here we are. Perhaps I now reach too readily for decorators when simpler and more straightforward solutions exist. They are still useful tools, including on classes, but when dealing with classes, it is probably best to start with what the normal class system offers before trying to reimplement the wheel (albeit a decorated wheel).</p> <p></p><p><strong>Related posts:</strong></p> <a class="" rel="nofollow noopener" href="https://blog.narf.ssji.net/2022/11/22/fun-with-python-decorators/" target="_blank"><span class="">Fun with Python decorators</span></a><a class="" rel="nofollow noopener" href="https://blog.narf.ssji.net/2024/06/30/python-cli-backward-compatibility-decorator/" target="_blank"><span class="">Python CLI backward compatibility decorator</span></a><a class="" rel="nofollow noopener" href="https://blog.narf.ssji.net/2020/04/12/locust-to-load-test-system-performance-under-pressure/" target="_blank"><span class="">How we manipulated Locust to test system performance under pressure</span></a><a class="" rel="nofollow noopener" href="https://blog.narf.ssji.net/2024/05/22/python-objects-as-kwargs/" target="_blank"><span class="">Python objects as kwargs</span></a> <p>Powered by <a href="https://yarpp.com" rel="nofollow noopener" target="_blank">YARPP</a>.</p> <p><a rel="nofollow noopener" class="hashtag u-tag u-category" href="https://blog.narf.ssji.net/tag/decorator/" target="_blank">#decorator</a> <a rel="nofollow noopener" class="hashtag u-tag u-category" href="https://blog.narf.ssji.net/tag/python/" target="_blank">#Python</a></p>