0

I've got a relatively big Python project and in an effort to minimise debugging time I'm trying to emulate a few aspects of a lower-level language. Specifically

  1. Ability to type cast (Static Typing)
  2. Prevent dynamic attribute addition to classes.

I've been using mypy to catch type casting errors and I've been defining __slots__ in my class instances to prevent dynamic addition.

At one point I need a List filled with two different children class (they have the same parent) that have slightly different attributes. mypy didn't like the fact that there were calls to attributes for a list item that weren't present in ALL the list items. But then making the parent object too general meant that dynamic addition of variables present in the other child wasn't prevented.

To fix this I debugged/brute-forced myself to the following code example which seems to work:

from abc import ABCMeta
from typing import List

class parentclass(metaclass=ABCMeta):
    __slots__:List[str] = []
    name: None

class withb(parentclass):
    __slots__ = ['b','name']
    def __init__(self):
        self.b: int = 0 
        self.name: str = "john"

class withf(parentclass):
    __slots__ = ['f','name']
    def __init__(self):
        self.name: str = 'harry'
        self.f: int = 123


bar = withb()
foo = withf()

ls: List[parentclass] = [bar, foo]

ls[0].f = 12 ## Needs to fail either in Python or mypy

for i in range(1):
    print(ls[i].name)
    print(ls[i].b) ## This should NOT fail in mypy

This works. But I'm not sure why. If I don't initialise the variables in the parent (i.e. only set them to None or int) then they don't seem to be carried into the children. However if I give them a placeholder value e.g. f:int = 0 in the parent then they make it into the children and my checks don't work again.

Can anyone explain this behaviour to an idiot like me? I'd like to know just so that I don't mess up implementing something and introduce even more errors!

As an aside: I did try List[Union[withb, withf]] but that didn't work either!

m4p85r
  • 402
  • 2
  • 17

1 Answers1

0

Setting a name to a value in the parent creates a class attribute. Even though the instances are limited by __slots__, the class itself can have non-slotted names, and when an instance lacks an attribute, its class is always checked for a class-level attribute (this is how you can call methods on instances at all).

Attempting to assign to a class attribute via an instance doesn't replace the class attribute though. instance.attr = someval will always try to create the attribute on the instance if it doesn't exist (shadowing the class attribute). When all classes in the hierarchy use __slots__ (without a __dict__ slot), this will fail (because the slot doesn't exist).

When you just for f: None, you've annotated the name f, but not actually created a class attribute; it's the assignment of a default that actually creates it. Of course, in your example, it makes no sense to assign a default in the parent class, because not all children have f or b attributes. If all children must have a name though, that should be part of the parent class, e.g.:

class parentclass(metaclass=ABCMeta):
    # Slot for common attribute on parent
    __slots__:List[str] = ['name']
    def __init__(self, name: str):
        # And initializer for parent sets it (annotation on argument covers attribute type)
        self.name = name

class withb(parentclass):
    # Slot for unique attributes on child
    __slots__ = ['b']
    def __init__(self):
        super().__init__("john")  # Parent attribute initialized with super call
        self.b: int = 0  # Child attribute set directly

class withf(parentclass):
    __slots__ = ['f']
    def __init__(self):
        super().__init__('harry')
        self.f: int = 123

If the goal is to dynamically choose whether to use f or b based on the type of the child class, mypy understands isinstance checks, so you can change the code using it to:

if isinstance(ls[0], withf):  # Added to ensure `ls[0]` is withf before using it
    ls[0].f = 12 ## Needs to fail either in Python or mypy

for x in ls:
    print(x.name)
    if isinstance(x, withb):  # Added to only print b for withb instances in ls
        print(x.b) ## This should NOT fail in mypy

In cases where isinstance isn't necessary (you know the type, because certain indices are guaranteed to be withf or withb), you can explicitly cast the type, but be aware that this throws away mypy's ability to check; lists are intended as a homogeneous data structure, and making position important (a la tuple, intended as a heterogeneous container) is misusing them.

ShadowRanger
  • 143,180
  • 12
  • 188
  • 271
  • I can't run this right now because I'm on my phone but Im pretty sure I tested something very similar and mypy will bring up errors on something like the list construct that I had on my original example. – m4p85r Feb 13 '18 at 03:45
  • Yep just ran it. mypy raises `error: "parentclass" has no attribute "f"` – m4p85r Feb 13 '18 at 23:53
  • @ashgetstazered: Isn't that intended? The comments on your code specifically say you want assignment to and reads from `.f` to fail when using a `list` of mixed `parentclass` children. – ShadowRanger Feb 13 '18 at 23:56
  • Sorry! I've updated the question to better reflect what I was after. If you only access parts of the list that have `withb` classes mypy will still throw an error if `withf` classes are in the list as they don't have an attribute `b` even if they would never be called during runtime. – m4p85r Feb 14 '18 at 00:54
  • @ashgetstazered: This is feeling more and more like [an XY problem](https://meta.stackexchange.com/q/66377/322040). You've got a `list` where the types are effectively heterogeneous (they are used in ways that explicitly *don't* adhere to a common interface), so you need to track which indices are valid `withf` vs. `withb` and use them in completely different ways is precisely what `mypy` is *supposed* to prevent. `mypy` [does understand `isinstance` checks](https://mypy.readthedocs.io/en/latest/common_issues.html#complex-type-tests), so if you only use the attr after a type check, it's legal. – ShadowRanger Feb 14 '18 at 01:04
  • @ashgetstazered: I've added some info on type-checking and casting to the answer, but I strongly recommend you rethink your design if the scenario isn't one where `isinstance` checking applies; heterogeneous lists that actually *use* the non-homogeneous behaviors of their members violate [good design practices](https://stackoverflow.com/a/626871/364696); find a better way to do this. – ShadowRanger Feb 14 '18 at 01:20
  • Cheers! I didn't know about XY problems (which this definitely is) or the fact that mypy understood isinstance! I can use isinstance in my actual use case. In fact it will mean that the class differentiation will be even more useful as I can replace another attribute that I was using to check that! Thanks again. – m4p85r Feb 14 '18 at 01:23