The problem
In Python there are three kind of method calls: regular methods, class methods and static methods.
Consider invoking method m on qualifier q in q.m(...).
If q is an object:
mis a regular method:mis called asm(q, ...)mis class method:mis called asm(type(q), ...)mstatic method:mis called asm(...)
In addition, if m is a regular attribute on object q, it is just called as m(...).
However, if q is a class:
mis a regular method:mis called asm(...)mis class method:mis called asm(q, ...)mstatic method:mis called asm(...)
CPython solves this through descriptors: most callables expose __get__ that allows them to act as a method:
def eq(self, arg):
return self == arg
class X(int):
pass
X.eq = eq
assert X(42).eq(42)Thus, we can also get a bound method by calling __get__ directly:
x = X(42)
bound_method_eq = eq.__get__(x, X)
assert bound_method_eq(42)Therefore, we can re-implement regular, class and static methods ourselves:
from functools import partial
class BaseMethod:
def __init__(self, fn):
self.fn = fn
class Method(BaseMethod):
def __get__(self, obj, cls):
if obj is None:
return self.fn
return partial(self.fn, obj)
class ClassMethod(BaseMethod):
def __get__(self, obj, cls):
if obj is not None:
cls = type(obj)
return partial(self.fn, cls)
class StaticMethod(BaseMethod):
def __get__(self, obj, cls):
return self.fn
class X(int):
@Method
def eq(self, other):
return self == other
@ClassMethod
def doubled(cls, value):
return cls(value + value)
@StaticMethod
def zero():
return 0
one = X(1)
assert one.eq(1)
assert X.eq(1, 1)
assert one.doubled(2) == 4
assert X.doubled(2) == 4
assert one.zero() == 0
assert X.zero() == 0Descriptor lookup works differently on objects and classes:
def get_attr(q, name):
if type(q) is type:
return type_get_attr(q, name)
return object_get_attr(q, name)
def object_get_attr(obj, name):
if name in obj.__dict__:
return obj.__dict__[name]
cls = obj.__class__
if name in cls.__dict__:
value = cls.__dict__[name]
if hasattr(value, '__get__'):
return value.__get__(obj, cls)
return value
raise AttributeError(name)
def type_get_attr(cls, name):
if name in cls.__dict__:
value = cls.__dict__[name]
if hasattr(value, '__get__'):
return value.__get__(None, cls)
return value
raise AttributeError(name)Now we can do:
def invoke(q, n, *args):
return get_attr(q, n)(*args)
assert invoke(one, "eq", 1)
assert invoke(X, "eq", 1, 1)
assert invoke(one, "doubled", 2) == 4
assert invoke(X, "doubled", 2) == 4
assert invoke(X, "zero") == 0
assert invoke(one, "zero") == 0The above works, but is costly. Implementing method calls as actual descriptor lookups introduces a lot of unneeded overhead. Instead, we can do:
def invoke_direct(q, n, *args):
# q is class
if type(q) is type:
m = q.__dict__[n]
if isinstance(m, ClassMethod):
return m.fn(q, *args)
if isinstance(m, (StaticMethod, Method)):
return m.fn(*args)
return m(*args)
# q is obj
if n in q.__dict__:
return q.__dict__[n](*args)
c = q.__class__
m = c.__dict__[n]
if isinstance(m, Method):
return m.fn(q, *args)
if isinstance(m, ClassMethod):
return m.fn(c, *args)
if isinstance(m, StaticMethod):
return m.fn(*args)
return m(*args)More importantly, we can actually divide the invocation into two steps:
def prepare(q, n):
# q is class
if type(q) is type:
m = q.__dict__[n]
if isinstance(m, ClassMethod):
return m.fn, "self"
if isinstance(m, (StaticMethod, Method)):
return m.fn, "none"
return m, "none"
# q is obj
if n in q.__dict__:
return q.__dict__[n], "none"
c = q.__class__
m = c.__dict__[n]
if isinstance(m, Method):
return m.fn, "self"
if isinstance(m, ClassMethod):
return m.fn, "cls"
if isinstance(m, StaticMethod):
return m.fn, "none"
return m, "none"
def execute(fn, how, q, *args):
if how == "none":
return fn(*args)
if how == "self":
return fn(q, *args)
if how == "cls":
return fn(q.__class__, *args)
def invoke_prepare(q, n, *args):
fn, how = prepare(q, n)
return execute(fn, how, q, *args)
assert invoke_prepare(one, "eq", 1)
assert invoke_prepare(X, "eq", 1, 1)
assert invoke_prepare(one, "doubled", 2) == 4
assert invoke_prepare(X, "doubled", 2) == 4
assert invoke_prepare(X, "zero") == 0
assert invoke_prepare(one, "zero") == 0For native types, this can even be simplified further. For example, we know that int objects don't have custom attributes, so there is no need to lookup (1).__dict__.
Bringing this to Monty.
Monty currently only has a CallAttr family of opcodes, which assumes that virtually all method calls are regular method calls.
There is also no support for descriptor, class or static methods.
Yet, for the first version, we might not need actual descriptors yet. #1089 introduces a notion of explicit regular and class methods.
However, we should address #976 and fix looking up the method before evaluating arguments.
This requires us to introduce new opcodes.
Given
q.m(...)it turns into opcodes:
Load 'q'
PrepareMethodCall 'm'
...
MethodCallPrepareMethodCall will look up m on q and return the instructions how to perform the actual method call. If m can't be found, an error is thrown before the arguments are evaluated.
PrepareMethodCall pushes two values on the stack: Callable and Receiver. If there is no receiver Value::Undefined is used.
Similarly, GetAttr can return a combined heap allocated tuple of callable using the existing BoundMethod.
Note, `Callable` is not guaranteed to be actually callable:
```py
class X:
x = classmethod(42)
X.x # this is fine
X.x() # TypeError: 'int' object is not callableActivity
0No activity yet.