9

I have the following python3 code:

class BaseTypeClass(type):
    def __new__(cls, name, bases, namespace, **kwd):
        result = type.__new__(cls, name, bases, namespace)
        print("creating class '{}'".format(name))
        return result

    def __instancecheck__(self, other):
        print("doing instance check")
        print(self)
        print(other)
        return False


class A(metaclass=BaseTypeClass):
    pass

print(type(A))
print(isinstance(A(), A))

and when I run it on Python 3.6.3 (v3.6.3:2c5fed8, Oct 3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)] on win32 I get the following output

creating class 'A'
<class '__main__.BaseTypeClass'>
True

Why isn't it outputting doing instance check? The documentation says the __instancecheck__ method needs to be defined on the metaclass and not the class itself, which I have done here. I even verify the metaclass is being used since creating class 'A' is printed. However, when I call isinstance it appears to be using the default implementation and not the one I defined in the metaclass.

I'm probably not using metaclasses correctly, but I can't figure out where I made my mistake.

drdrez
  • 921
  • 1
  • 7
  • 16
  • let's try to workaround this "implementation detail"... – jsbueno Dec 11 '17 at 14:57
  • 2
    just as an odd side note, if you define `__instancecheck__` as a method on a normal class, instances of the class can be used as the second argument of `isinstance` – drdrez Dec 14 '17 at 17:56

2 Answers2

10

The isinstance function makes a quick check to see if the type of the instance supplied as an argument is the same as that of the class. If so, it returns early and doesn't invoke your custom __instancecheck__.

This is an optimization used in order to avoid an expensive call to __instancecheck__ (it's Pythonland code) when it isn't required.

You can see the specific test in PyObject_IsInstance, the function that handles the isinstance call in the CPython implementation:

/* Quick test for an exact match */
if (Py_TYPE(inst) == (PyTypeObject *)cls)
    return 1;

Of course, your __instancecheck__ fires correctly when that test isn't True:

>>> isinstance(2, A)
doing instance check
<class '__main__.A'>
2
False

I am not certain if this is implementation specific, I would of thought so, though, since there's no reference to this in the corresponding PEP section nor in the documentation on isinstance.


Interesting aside: issubclass actually doesn't behave this way. Due to its implementation it always calls __subclasscheck__. I had opened an issue on this a while back which is still pending.

Dimitris Fasarakis Hilliard
  • 150,925
  • 31
  • 268
  • 253
  • 1
    Shoot, I should have figured this was the case and tried testing it with a few other cases. Thanks! – drdrez Dec 10 '17 at 16:47
  • 1
    Huh. That does seem to contradict the documentation. The [data model docs](https://docs.python.org/3/reference/datamodel.html#customizing-instance-and-subclass-checks) on `__instancecheck__` and `__subclasscheck__` don't mention this case either. I feel like the `if (Py_TYPE(inst) == (PyTypeObject *)cls)` check should be moved into the `if (PyType_CheckExact(cls))` block; it'd match the docs better, and it probably wouldn't slow things down significantly, though I haven't benchmarked it. – user2357112 Dec 10 '17 at 20:11
  • It's a weird situation because there's also a discrepancy between `__instancecheck__` and `__subclasscheck__` @user2357112 . I had opened an [issue](https://bugs.python.org/issue30230) a while back where I talked about their difference (which I haven't gotten around to post on python-dev yet). – Dimitris Fasarakis Hilliard Dec 10 '17 at 20:17
4

Jim's answer seems to nail it.

But for whoever needs for some weid reason a fully customized instancheck (ok, now that I am writing this, there seems to be no correct reason for one to want that, let s hope I am wrong), a metaclass can get away with it, but it is tricky.

This one dynamically replaces the actual class of the object being instantiated by a "shadow class", that is a clone of the original. This way, the native "instancheck" always fail, and the metaclass one is called.

def sub__new__(cls, *args, **kw):
    metacls = cls.__class__
    new_cls = metacls(cls.__name__, cls.__bases__, dict(cls.__dict__), clonning=cls)
    return new_cls(*args, **kw)

class M(type):
    shadows = {}
    rev_shadows = {}
    def __new__(metacls, name, bases, namespace, **kwd):
        clonning = kwd.pop("clonning", None)
        if not clonning:
            cls = super().__new__(metacls, name, bases, namespace)
            # Assumes classes don't have  a `__new__` of them own.
            # if they do, it is needed to wrap it.
            cls.__new__ = sub__new__
        else:
            cls = clonning
            if cls not in metacls.shadows:
                clone = super().__new__(metacls, name, bases, namespace)
                # The same - replace for unwrapped new.
                del clone.__new__
                metacls.shadows[cls] = clone
                metacls.rev_shadows[clone] = cls
            return metacls.shadows[cls]

        return cls

    def __setattr__(cls, attr, value):

        # Keep class attributes in sync with shadoclass
        # This could be done with 'super', but we'd need a thread lock
        # and check for re-entering.
        type.__setattr__(cls, attr, value)
        metacls = type(cls)
        if cls in metacls.shadows:
            type.__setattr__(metacls.shadows[cls], attr, value)
        elif cls in metacls.rev_shadows:
            type.__setattr__(metacls.rev_shadows[cls], attr, value)    

    def call(cls, *args, **kw):
        # When __new__ don't return an instance of its class,
        # __init__ is not called by type's __call__
        instance = cls.__new__(*args, **kw)
        instance.__init__(*args, **kw)
        return instance

    def __instancecheck__(cls, other):
        print("doing instance check")
        print(cls)
        print(other)
        return False


class A(metaclass=M):
    pass

print(type(A))
print(isinstance(A(), A))

It even has a mechanism do sync attributes in the shadow class and actual class. The one thing it does not support is if classes handled in this way do implement a custom __new__. If such a __new__ makes use of parameterless super, it starts to become tricky, as the parameter to super would not be the shadow class.

jsbueno
  • 99,910
  • 10
  • 151
  • 209