Thursday, 25 September 2025

Kotlin Unit

When I wrote this post and mentioned the Kotlin use function for Automatic Resource Management I had some doubts about the signature. It's a generic function that returns R:


inline fun  T.use(block: (T) -> R): R

which fits so nice in the more functional style code I was discussing in that post, but what if we want to do something on that closable resource without returning any value? Well, the thing is that the function signature is also valid for that case, as in Kotlin what I mean by "not returning anything" (I'll be careful not to say "returning nothing" as in Kotlin Nothing has a particular meaning) means indeed returning Unit, a singleton object/class descending from Any, so that perfectly fits with the T generic type.

Having said this, it feels interesting to me to review how this "not retuning anything" works in different languages and compare it to the Kotlin approach and its advanced type system.

In Java and C# functions that do not return anything are marked as returning void. void is indeed a keyword representing "no return value" - it's not part of the normal type hierarchy.

In Python a function that does not have an explicit return statement or just does "return" without providing a value is indeed returning None (that is the single instance of the NoneType singleton class). If using type hints, mypy will consider correct that a function defined as returning Any returns None (as None is just a normal objec). In JavaScript the lack of an explicit return statement (or a simple "return;") will return the undefined value (JavaScript has this messy distinction between undefined and null that feels more accidental than intentional). For dynamic languages where type checking has come as an after thought (either directly in Python via typing or indirectly in JavaScript via its TypeScript "friend") this seems OK, but static languages things have to be more strict.

Kotlin elegantly establishes a subtle difference between functions that can return a value or the absence of that value, and functions that never return anything useful. For the former we use as return a nullable type. A getUser(id) function returns User? cause it returns a User if the id exists or null if that's not the case. On the other hand a writeLog() function returns Unit because it never returns anything useful. In Python we don't have that subtle difference, both write_log and get_user for missing id return None. With this, we can say that:

  • Kotlin uses Unit for "no meaningful value" and null for "the absence of an expected value"
  • Python lacks the semantic distinction between "no meaningful value" vs "absent value"

As I said at the start of this post I've been saying "does not return anything" (I should better say "does not return anything useful") rather than "returns nothing" because as I mention in this post Nothing has a special meaning in Kotlin. It's a bottom type, used for expressing that a function does not return (throws an exception, it's an infinite loop...). There's a good discussion here. I think Nothing is a confusing name, I prefer the naming used in Python typing for the same concept Never and NoReturn (both types are equivalent).

I was wondering, what if we want to declare a Kotlin function that always returns null? Well, probably that does not make much sense and we should just declare it as returning Unit. Googling around I've found this discussion where they propose returning Nothing?. They support the idea by copy-pasting a fragment of the documentation that does no longer seem to exist in the current documentation, so maybe the Kotlin designers just discarded that notion.

As a conclusion, in languages like Kotlin, Python or JavaScript we should not think about functions that do not return anything (save for functions that crash). All functions that finish naturally return something, but that something can be a not usable value (Unit) or an absent value(null, None, undefined).

Sunday, 14 September 2025

Python Currying

Last year I wrote a post about function currying in JavaScript, it corrected the (absolutely) wrong implementation that I had written some years ago. That last implementation did not feel particularly easy to understand to me. I was using three functions: curry, one for saving args and the curried function itself... I've recently implemented currying in Python, without looking into the JavaScript version, and it's interesting how thinking more in Python terms has made me better think about a JavaScript implementation

Currying a function means ending up with an invokable/callable object that references the original function and stores the provided parameters until all of them have been provided. In each "incomplete invokation" it has to return another invokable object trapping the expanded list of parameters. In Python an "invokable/callable object with state" is either a closure or an instance of a callable class (well, indeed normal functions are also instances of callables). And the creator of that invokable object is either a closure factory or a callable class. For my implementation I've used a Callable rather than a closure, somehow this time it felt more intutive:


class Curried:
    def __init__(self, fn, args: list[Any] | None = None):
        # in the initial call to create the initial curried function args is None
        self.fn = fn
        self.saved_args = args or []
        self.expected_args_len = len(inspect.signature(self.fn).parameters)
        # notice how for class based decorators we have to use update_wrapper here, rather than wraps
        functools.update_wrapper(self, fn)

    def __call__(self, *args):
        current_args = [*self.saved_args, *args]
        if len(current_args) > self.expected_args_len:
            raise Exception("too many arguments!!!")
        if len(current_args) == self.expected_args_len:
            return self.fn(*current_args)
        else:
            return Curried(self.fn, current_args)

# alias for better semantics when used as decorator        
curry = Curried


def test_curried(): 
    def format_city(planet: str, continent: str, country: str, region: str, city: str) -> str:
        """I'm the format_city docstring"""       
        return f"{planet}.{continent}.{country}.{region}_{city}"

    curried_format_city = Curried(format_city)
    
    # or if used as decorator:
    # @curry
    # def format_city(planet: str, continent: str, country: str, region: str, city: str) -> str:
    #     """I'm the format_city docstring"""       
    #     return f"{planet}.{continent}.{country}.{region}_{city}"

    print(curried_format_city("Earth")("Europe", "Spain")("Asturies", "Xixon"))

    format1 = curried_format_city("Earth", "Europe")
    format2 = curried_format_city("Earth", "Asia")
    # update_wrapper works nicely
    print(f"{format1.__name__=}, {format1.__doc__=}, ")

    print(format1("Spain")("Asturies", "Xixon"))
    print(format1("France")("Ile de France", "Paris"))

    print(format2("China")("Beijing", "Beijing"))
    print(format2("China")("Guandong", "Shenzen"))
    format3 = format2("Russia")("Northwestern")
    print(f"{format3.__name__=}, {format3.__doc__=}, ")
    print(format3("Saint Petersburg"))
    
# Earth.Europe.Spain.Asturies_Xixon
# format1.__name__='format_city', format1.__doc__="I'm the format_city docstring", 
# Earth.Europe.Spain.Asturies_Xixon
# Earth.Europe.France.Ile de France_Paris
# Earth.Asia.China.Beijing_Beijing
# Earth.Asia.China.Guandong_Shenzen
# format3.__name__='format_city', format3.__doc__="I'm the format_city docstring", 
# Earth.Asia.Russia.Northwestern_Saint Petersburg

As you see, a curried function is a callable object, an instance of the Curried class (that has a __call__ method). As I said, the curried function is equivalent to a closure, and the Curried class is equivalent to a closure factory. Notice that as I'm creating a callable object rather than a standard function I'm using functools.update_wrapper (rather than functools.wraps) to set the original __name__, __doc__, etc in the curried callable. I can invoke it directly (Curried(fn)) or use it as a decorator at function definition time.

In my previous JavaScript implementation I had 3 elements: a curry function, a saveArgs function and the closure itself. That's why it felt a bit strange to me, following the Python implementation, I only need 2 elements, the closure factory and the closure. So here it goes my new JavaScript implementation:


let curry = function createCurriedFn(fn, args) {
    // createCurriedFn is a closure factory, creates a closure that traps original fn and parameters
    let savedArgs = args ?? [];
    // return the curriedFn/closure
    return (...args) => {
        const curArgs = [...savedArgs, ...args];
        return curArgs.length >= fn.length 
            ? fn(...curArgs)
            : createCurriedFn(fn, curArgs);
    };
}

curriedFormat = curry(formatMessages);
curriedFormat("a")("b")("c");
curriedFormat("d")("e")("f");
curriedFormat("g", "h")("i");
curriedFormat("j", "k", "l");

// a-b-c
// d-e-f
// g-h-i
// j-k-l

As we know Python features named parameters (contrary to JavaScript), so we should contemplate that in our curry function. This is the improved version that does just that:


class Curried:
    def __init__(self, fn, args: list[Any] | None = None, kwargs: dict[str, Any] | None = None):
        # in the initial call to create the initial curried function args is None
        self.fn = fn
        self.saved_args = args or []
        self.saved_kwargs = kwargs or {}
        self.expected_args_len = len(inspect.signature(self.fn).parameters)
        # notice how for class based decorators we have to use update_wrapper here, rather than wraps
        functools.update_wrapper(self, fn)

    def __call__(self, *args, **kwargs):
        current_args = [*self.saved_args, *args]
        current_kwargs = {**self.saved_kwargs, **kwargs}
        
        if (cur_len := (len(current_args) + len(current_kwargs))) > self.expected_args_len:
            raise Exception("too many arguments!!!")
        if cur_len == self.expected_args_len:
            return self.fn(*current_args, **current_kwargs)
        else:
            #return wraps(self.fn)(Curried(self.fn, cur_arguments))
            return Curried(self.fn, current_args, current_kwargs)

# alias for better semantics when used as decorator        
curry = Curried


def test_curried(): 
    def format_city(planet: str, continent: str, country: str, region: str, city: str) -> str:
        """I'm the format_city docstring"""       
        return f"{planet}.{continent}.{country}.{region}_{city}"

    curried_format_city = Curried(format_city)
    #print(curried_format_city.__name__)
    print(curried_format_city("Earth")("Europe", "Spain")("Asturies", "Xixon"))

    format1 = curried_format_city("Earth", "Europe")
    format2 = curried_format_city("Earth", "Asia")
    # update_wrapper works nicely
    print(f"{format1.__name__=}, {format1.__doc__=}, ")

    print(format1("Spain")(region="Asturies", city="Xixon"))
    print(format1("France")("Ile de France", city="Paris"))

    print(format2(country="Chinaaa")(country="China")(city="Guangzhou", region="Guangdong"))
    print(format2("China")("Guandong", "Shenzen"))

    print(format2("China")("Guangdong", "Guangzhou"))
    print(format2("China")("Guandong", city="Shenzen"))
    format3 = format2("Russia")("Northwestern")
    print(f"{format3.__name__=}, {format3.__doc__=}, ")
    print(format3("Saint Petersburg"))


test_curried()
print("----------------------")

# Earth.Europe.Spain.Asturies_Xixon
# format1.__name__='format_city', format1.__doc__="I'm the format_city docstring", 
# Earth.Europe.Spain.Asturies_Xixon
# Earth.Europe.France.Ile de France_Paris
# Earth.Asia.China.Guangdong_Guangzhou
# Earth.Asia.China.Guandong_Shenzen
# Earth.Asia.China.Guangdong_Guangzhou
# Earth.Asia.China.Guandong_Shenzen
# format3.__name__='format_city', format3.__doc__="I'm the format_city docstring", 
# Earth.Asia.Russia.Northwestern_Saint Petersburg


Notice that contrary to what happens with standard functions, in the curried functions created by this implementation we can pass unnamed parameters after named ones, but the unnamed ones have to be provided in the same order as in the original function. Same as with functools.partial, we can provide the same named parameter multiple times, each new provided value overwrites the previous one.

Wednesday, 10 September 2025

Dans les Brumes de Capelans

I have to sadly admit that I'm not a great reader (I'm talking about literature, as for programming/technical stuff, political crap, history and so on I read tons of stuff). Just a few books per year (these last years a bit more, hopefully). Years ago (betwen 2009 and 2013 mainly) I was very much into Nordic Noir. I started with Stieg Larsson and continued with Asa Larsson (my favorite), Camilla Lackberg and Jo Nesbo. In Asa Larsson and Camilla Lackberg I terribly appreciated the "darkness" of many of the characters, that sadness, those difficoult existences... In recent years I've moved back into crime/thriller/police books, but this time into what I would call as "French blood noir", that is, crime-police-dark thriller novels where crimes are particularly bloody, violent, evil (involve torture, some sort of ritual, mutilations, BSDM...). It's what in Les Rivieres Pourpres (series) they call "crimes de sang". By the way, those series are really good, particularly Season 1 and 2 (season 3 and 4 felt a bit weaker to me, but have some excellent chapters also).

Since 2022 I've been reading the Sharko and Lucie Henebelle stories by Franck Thilliez. I can not recommend it enough. The crimes are horrible, bloody, sick, conducted by lonely psychopaths, organised elitist groups, pseudo-vampires... there are secret clubs that remind me of the 28 mms film, but above all I've come to love the main characters, particularly Sharko, and Nicolas Bellanger, whose nightmarish existence has become more and more important in the last books. They live a painful life, they fall, get up, fall again, overcome all sort of crap that leaves such deep scars... I should write several posts about them, but this one is not intended to that, but to a book from a different author, Dans les Brumes des Capelans by Olivier Norek.

I had previously read "Trilogy 93", that follows the misadventures of policeman Captain Victor Costa and his team tracking criminals in Seine-Saint-Denis. Pretty good, but it's more "standard crime-police literature" than the aforementiond "crimes de sang" stuff. In his real life Norek worked as a policeman in Seine-Saint-Denis, so one can imagine that there's much reality poured into those novels. "Dans les brumes de Capelans" is quite a different beast, much more of a dark thriller, of a "crimes de sang" story. Several years after the tragic end of the trilogy, Costa has managed to survive by running away from Paris and his previous life and living a lonely existence in such a secluded place as Saint Pierre et Miquelon, where works for the Witness Protection Service, managing a house by a cliff where he receives guests that have to remain hidden until they are provided with a new identity. We could say that Costa wants to remain as hidden as his guests.

This time he receives a young woman, Anna, that is the only survival of a maniac that has been seizing, torturing and murdering young girls for more than a decade, but that decided to keep her alive "for some reason". The girl is fucked up, Costa is fucked up, and strong bonds get woven between these 2 broken souls, 2 partners in pain and desolation. There are some very beautiful cathartic moments, there's the maniac killer that resurfaces, and there are many, many surprises. There's another interesting character, the policeman that dealt with Anna and the other girls disappearances and Anna's liberation. He appears only in the first and last chapters, playing an important role. Setting the story in this mysterious island adds darkness and loneliness to the story, a hard place for hard people.

I won't tell you more, go for the book and enjoy it.

Thursday, 4 September 2025

Automatic Resource Management as Expression

As a few weeks ago, going through the Python ideas forum has introduced me again to another interesting idea. As usual, someone proposes a syntax for a feature, and as it's clear that no syntax changes will be performed to provide that, people come up with interesting work arounds.

So someone proposed allowing to use with, the syntax for Automatic Resource Management with context managers, as an expression (so having a with expression along with the existing with statement. He was proposing something like this:
txt = do_something(json.load(f) with open('foo.json') as f)
That indeed reminds me of the syntax outlined in the rejected PEP for exception-catching expressions (aka try-expressions):
msg = (parse(txt) except ParsingError: None)

As I said, it's obvious that given how reluctant the Python leaders are to any syntax change, neither of those ideas will ever make it into the language. The good thing is that same as we can easily define a do_try function as the one we saw in this previous post, we can also define a using/with_do function, like this (taken from the discussion thread):


#def with_do(mgr, fn): 
def using(mgr, fn):
    with mgr as res:
        return fn(res)

# or maybe this is more semantic?
def do_with(fn, mgr):
    with mgr as res:
        return fn(res)

#config = tomllib.load(with open("file.toml", "rb") as f: f)
config = using(open("file.toml", "rb"), tomllib.load)
config = do_with(tomllib.load, open("file.toml", "rb"))

#data = with open("file.txt", "r") as f: f.read()
data = using(open("file.txt", "r"), lambda f: f.read())
data = do_with(lambda f: f.read(), open("file.txt", "r"), )

All the above examples are pretty contrived, as "with open() as" can be replaced by pathlib.Path.read_text, that takes care of managing any exception. I mean:


config = tomlib.loads(pathlib.Path('foo.json').read_text())

But there are other context manager use cases for which this kind of function would come handy.

The other Automatic Resource Management (ARM) mechanisms I'm familiar with are the C# using statement with IDisposables and Java Try-with-resources statement with Closables. So in Python, C# and Java ARM is provided via statements, not expressions, that's why I think I had never thought of using it as an expression. Given that in Kotlin almost everything is an expression, is easy to imagine that they have had this into account. Kotlin does not have a specific syntax construct for ARM, as given its rich and expressive syntax it can be nicely implemented with an extension function of the Closable interface, use. It Executes the given block function on this resource (a Closable object) and then closes it down correctly whether an exception is thrown or not:


inline fun  T.use(block: (T) -> R): R

As you can see in the signature, the block returns a value R, that in turn is returned by use, so what if we just want to execute a block that does not return anything? Well, that signature is also valid. In Kotlin a function that does not return anything does indeed return Unit (a singleton class), so when passing to use a block that does not return anything that generic type R becomes Unit, and everything is perfectly valid.

Monday, 25 August 2025

Python Adaptive Specializing Interpreter

It's clear that I have a fascination with the interaction between interpreters and JIT's, optmizations, deoptimizations and so on. I've written multiple posts about that, with this one being the most recent. I recently came across this interesting thesis where in the introduction it mentions:

For both variants, we can quicken certain operations. Quickening entails replacing one operation with another, which usually handles a subset of possible values or a more restrictive set of preconditions but performs the operation quicker. In tree-walk interpreters, this is performed by node replacement, and in bytecode-based interpreters, by changing the instruction stream, replacing one instruction with another. In both cases, the target of the dispatch changes. How this is implemented in Operation DSL is detailed in section 3.7.

The "replacing one instruction with another" suddenly reminded me of something I had read some months ago regarding Python performance improvements but that I had forgotten to dive into and had indeed forgotten. I'm talking about Adaptive Specializing Interpreter, that is something pretty surprising to me. I've talked in my previous posts about interpreters that find hotspots in your code and send those hot methods to a fast JIT compiler to turn them into native code. Then that native code continues to be monitored and if it's hot enough it's sent to a more aggressive JIT compiler that spends more time in producing a more optimized native code. Then we have cases where the code has to be deoptimized, returning the function to its interpreted form, and the cycle starts again. But the idea of monitoring the code to find specific bytecode instructions (opcodes) that can be replaced by an optimized (specialized/quickened) version of that bytecode instruction is something that was pretty new to me

As of today (Python 3.13) the main Python environment, CPython, does not come with a JIT compiler (an experimental one is due for 3.14 I think). CPython just uses a bytecode interpreter (historical note: The move from the initial tree-walk interpreter to a bytecode interpreter happened between Python 0.9.x and Python 1.0, likely around 1992–1993 during the pre-1.0 development phase). Python 3.11 implemented PEP-659 - Specializing Adaptive Interpreter as part of the Faster CPython project. It introduced some bytecode instructions that are generic (for example the one for adding 2 items, BINARY_OP_ADD (+)) and that if a constant execution pattern is found will be replaced (specialized/quickened) by an specialized version ('BINARY_OP_ADD_FLOAT', 'BINARY_OP_ADD_INT', 'BINARY_OP_ADD_UNICODE'). If that pattern changes the instruction will be replaced by the initial, generic bytecode. This discussion has some interesting information.

You probably know that (as I mention in this post) Python compiles functions to code objects, and then each function object points to a code object (via __code__ attribute). A code object has a co_code attribute pointing to a bytes object containing the bytecodes.

bytes objects are inmutable, so the bytecode specialization has to happen in another structure. I could not find much information about this, so ChatGPT came to the rescue. So yes, there's an additional structure that contains a mutable copy of the bytecodes. It's from that structure that the interpreter reads the bytecodes to execute for that given function, and applies adaptations/specializations/quickening as it sees fit.

  • The co_code itself remains immutable and is not rewritten at runtime. It continues to contain the canonical, "baseline" bytecode sequence as emitted by the compiler.
  • When a code object is executed, CPython creates an internal _PyCodeRuntime structure (not exposed to Python), which contains a mutable copy of the bytecode in a field called co_firstinstr (technically in co_warm → co_warm.instructions).
  • That runtime bytecode buffer is where the interpreter patches in "quickened" instructions (specialized opcodes). For example, a generic BINARY_OP might be replaced at runtime with BINARY_OP_ADD_INT if it sees enough hot integer additions.

That mutable copy of the bytecodes seem to be referenced from the code object via a private _co_code_adaptive attribute (but this is an internal, undocumented detail that can change from version to version). Python allows us to very easily check the bytecodes for a given function by using the standard dis module: dis.dis(my_function). By default dis.dis shows the immutable bytecodes in co_code, but since python3.12 we can use the adaptive=True flag, to see the adapted/quickened instruction. This is pretty amazing, cause we can so easily see how a function bytecodes evolve over time!


import sys, dis

def f(a, b):
    return a + b

print("before warming up")
dis.dis(f, adaptive=True)  # Only in 3.12+
# BINARY_OP                0 (+)

# Warm it up
for _ in range(10_000):
    f(1, 2)

# Disassemble with quickening shown
print("after warming up with ints")
dis.dis(f, adaptive=True)
#BINARY_OP_ADD_INT        0 (+)

# now let's try to break the quickening by passing strings rather than ints
print("first call with strings")
f("a", "b")
dis.dis(f, adaptive=True)
# it's still quickened
#BINARY_OP_ADD_INT        0 (+)

print("second call with strings")
f("c", "b")
dis.dis(f, adaptive=True)
# it's still quickened
#BINARY_OP_ADD_INT        0 (+)

print("Warm it up again, this time with strings")
for _ in range(10_000):
    f("a", "b")

print("after warming up again")
dis.dis(f, adaptive=True)
# BINARY_OP_ADD_UNICODE    0 (+)

So initially the bytecode for an addition of 2 values uses the BINARY_OP opcode (python is a dynamic language where a and b could be of any type, so BINARY_OP is a generic (and slower) instruction for summing up any value). Then we do a good bunch of additions all of them with int values, so that the interpreter decides to specialize the generic addition to a fast BINARY_OP_ADD_INT opcode. After that we do a couple of invokations using strings rather than ints. The specialized opcode checks if the operands are the expected types (here, two ints), as they are not it falls back to the generic implementation of the operation (the slow path), but for the moment it still keeps the specialized opcode. It takes note of these divergences so that if they continue it will revert the specialization. The thing is that in my tests I have not managed to find the number of failed executions that will make the interpreter to revert the specialization, what we can see is that after a good bunch of executions using strings, the int specialization is changed to a string specialization (BINARY_OP_ADD_UNICODE).

In some previous post I mentioned some crazy Python projects that manipulate functions by creating a new code object (an instance of types.CodeType) based on the original one, with a modified version (adding extra instructions, whatever) of its bytecodes, and assigns it to the function. How does this play with the adaptive version of the code? Well, thanks to ChatGPT we learn that the adaptation process starts again:

  • The quickened bytecode (co_code_adaptive) is built lazily, the first time the interpreter executes a CodeType.
  • It is not stored permanently in the CodeType; rather, it is in a per-runtime structure that references the original co_code.
  • If you assign a different code object to a function (func.__code__ = new_code), that’s a new CodeType with its own co_code_adaptive buffer, initially empty.
  • Therefore, execution will start again with baseline opcodes and caches, and the specializing interpreter will re-warm and re-specialize.

Thursday, 7 August 2025

Python Decorators Implementation

There's something in the inner workings of the amazing multimethod library that we saw in my previous post that has had me pretty confused. In that post I outline how the multidispatch decorator works internally, but for the multimethod decorator, things are more complex. From my previous post we know we use it like this:


class Formatter:
    def __init__(self, wrapper: str):
        self.wrapper = wrapper

    @multimethod
    def format(self, item: str, starter: str):
        return f"{starter}{self.wrapper}{item}{self.wrapper}"
    
    @multimethod
    def format(self, item: int, starter: str):
        return f"{starter}{self.wrapper * 2}{item}{self.wrapper * 2}"   

multimethod is a class based decorator. In the first invokation it returns an instance of the multimethod class, that will store the format function and its signature. For each new invokation of the decorator it has to store each of those additional overload functions and signatures in that already existing instance, rather than creating a new instance each time. For doing that, the decorator checks if in the current scope (the class declaration scope) already exists a variable with the name of the function being decorated that already points to an instance of multimethod. For that it inspects the previous frame in the stack, like this (taken from its source code):


    def __new__(cls, func):
        homonym = inspect.currentframe().f_back.f_locals.get(func.__name__)
        if isinstance(homonym, multimethod):
            return homonym
        ...

That's really, really nice code, but it's another thing what I could not grasp. We know that Python decorators are just callables (functions or classes) that are invoked receiving the function (or class) being decorated as parameter. So they are normally explained like this:


@my_deco
def fn(): 
   # whatever

Is (in principle) just equivalent (syntactic sugar) to this:


def fn():
   # whatever
fn = my_deco(fn)

The problem is that it would mean that our example above is indeed translated by the Python compiler into something like this:



    def format(self, item: str, starter: str):
        return f"{starter}{self.wrapper}{item}{self.wrapper}"
    format = multimethod(format)
    # here format is pointing to a multimethod instance, good
    
   
    def format(self, item: int, starter: str):
        return f"{starter}{self.wrapper * 2}{item}{self.wrapper * 2}" 
    # but here format is pointing the the function that we've just defined, so when we apply the decorator again and it does the isinstance(homonym, multimethod) check, it will be False! so this can not work
    format = multimethod(format)

I've explained the problem in the comments. When defining the second format function, the format variable in the current scope is set to that second function, so it's no longer pointing the the multimethod instance previously created, so the isinstance(homonym, multimethod) check will be False! and a new multimethod instance will be created, so the whole thing can not work!

Well, after discussing this with Claude AI we've found an explanation. When applying a decorator to a function, the function definition doesn't immediately overwrite the namespace (setting a variable with the name of the function to point to the function), what is really happening is this:

  1. def format(...) creates a temporary function object
  2. @multimethod is applied to that temporary function object
  3. The decorator returns an object (which could be the existing multimethod)
  4. Only then does format = assignment happen

So indeed a decorator works more like this:


format = my_deco(
	def format():
	    # whatever
)

But as Python lacks statement lambdas, the above code is not valid, and hence the different articles explaining decorators can not use that as the pseudo-code for what the runtime really does, and use an inaccurate approximation. Usually that's not a problem, but for this very specific case that approximation prevented me from envisioning how this particular decorator works.