In my previous post I discussed how the values of default parameters should be considered as an implementation detail, and not as part of the contract of the function/method (while the fact of having default parameters becomes part of the contract) . This has interesting implications regarding inheritance and method overriding.
- If a method in a base class (or interface/protocol) has a default parameter "timeout = 10", it should be fine that a derived class overrides this method with a different default value "timeout = 20".
- If a method in a base class (or interface/protocol) has no default parameters, it should be OK for a derived class to overwrite that method using some defaults. If the method in a derived instance is invoked from a reference typed as base, we will be forced to provide all the parameters, it's when it's invoked from a reference typed as derived that the defaults can be omitted.
- Of course, what is not OK is that a method that in the base class has defaults gets overriden in a derived class using a method that makes them compulsory.
In dynamic languages like Python (or Ruby or JavaScript) the runtime itself gives us total freedom to do whatever we want (more or less) with this kind of things, but maybe typecheckers decided to add some restriction on this for no reason. I've checked with mypy and this is perfectly fine:
class Formatter:
def format(self, text: str, wrapper: str = "|") -> str:
return f"{wrapper}{text}{wrapper}"
# NO error in mypy
class FormatterV2(Formatter):
def format(self, text: str, wrapper: str = "*") -> str:
return f"{wrapper}{text}{wrapper}"
# mypy:
# error: Signature of "format" incompatible with supertype "Formatter" [override]
class FormatterV3(Formatter):
def format(self, text: str, wrapper: str) -> str:
return f"{wrapper}{text}{wrapper}"
class GeoService:
def get_location(self, ip: str, country: str) -> str:
return f"Location for IP {ip} in country {country}"
# NO error in mypy
class GeoService2(GeoService):
def get_location(self, ip: str, country: str = "FR") -> str:
return f"Location for IP {ip} in country {country}"
Notice also that a mechanism that adds runtime restrictions, abstract classes/methods (abc.ABC, abc.abstractmethod) does not care about default parameters values (not even if we override a method making a default compulsory). Well, indeed abc's do not care about parameters at all (not even number of parameters or names), they only care about method names, not about method signatures.
Though from a design perspective what I've said above should be true for both dynamic and static languages, it seems that static languages like Kotlin or C# have decided to be quite restrictive regarding default parameters.
In Kotlin a derived class can not change the value of a default parameter in one method that it's overriding. Indeed when overriding a method with default parameter values, the default parameter values must be omitted from the signature. The reason for this is that Kotlin manages defaults at compile time. The compiler checks at the callsite that a function is being invoked with a missing parameter and adds to the call the value that was defined as default in the signature. Being done at compile time, if we have a variable typed as Base but that is indeed pointing to Child, it will choose the default defined in Base, not in Child (we can say that polymorphism does not work for defaults), so that's why to avoid confusion we can not redefine the default in the Child.
The other feature that I mentioned above, having a method in the Child that sets a default for a parameter that had no default in the Base should work OK, but Kotlin designers decided to forbid it, being the main reason for this that it would make method overloading confusing. As discussed with a GPT:
Defaults are syntactic sugar for overloads in many static languages.
Allowing derived classes to add defaults would blur the line between overriding and overloading, making method resolution harder to reason about.
I was wondering how Python manages default parameters at runtime. I know that when a function object is created default values are stored in the function object (which is a gotcha that I explained time ago). Diving a bit more:
When you define a function, any default expressions are evaluated immediately and stored on the function object: Positional/keyword defaults: func.__defaults__ → a tuple Keyword-only defaults: func.__kwdefaults__ → a dict
And regarding how defaults are used if necessary each time a function is invoked, we could think that maybe the compiler adds some checks at the start of the function, but no, it's not like that. These checks are performed by the call machinery itself. For each call to function (CALL bytecode instruction) the interpreter ends up calling a C function, and it's this C function who performs the defaults checking:
- Defaults are stored on the function object when the function is defined.
- Argument binding (including applying defaults) happens before any Python-level code runs, inside CPython’s C-level call machinery (vectorcall).
- No default checks are injected into the function body’s bytecode.
- The bytecode’s CALL ops trigger the C-level call path, which performs binding using __defaults__ and __kwdefaults__.
No comments:
Post a Comment