Search code examples
pythonsubclass

Random stealing calls to child initializer


There's a situation involving sub-classing I can't figure out.

I'm sub-classing Random (the reason is besides the point). Here's a basic example of what I have:

import random

class MyRandom(random.Random):
    def __init__(self, x):  # x isn't used here, but it's necessary to show the problem.
        print("Before")
        super().__init__()  # Nothing passed to parent
        print("After")

MyRandom([])

The above code, when run, gives the following error (and doesn't print "Before"):

>>> import test
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "C:\Users\_\PycharmProjects\first\test.py", line 11, in <module>
    MyRandom([])
TypeError: unhashable type: 'list'

To me, this doesn't make any sense. Somehow, the argument to MyRandom is apparently being passed directly to Random.__init__ even though I'm not passing it along, and the list is being treated as a seed. "Before" never prints, so apparently my initializer is never even being called.

I thought maybe this was somehow due to the parent of Random being implemented in C and this was causing weirdness, but a similar case with list sub-classing doesn't yield an error saying that ints aren't iterable:

class MyList(list):
    def __init__(self, y):
        print("Before")
        super().__init__() 
        print("After")


r = MyList(2)  # Prints "Before", "After"

I have no clue how to even approach this. I rarely ever sub-class, and even rarer is it that I sub-class a built-in, so I must have developed a hole in my knowledge. This is not how I expect sub-classing to work. If anyone can explain what's going on here, I'd appreciate it.


Python 3.9


Solution

  • Instantiating a class causes its __new__ method to be called. It is passed the name of the class and the arguments in the constructor call. So MyRandom([1, 2]) results in the call MyRandom.__new__(MyRandom, [1, 2]). (3.9.10 documentation).

    Because there isn't a MyRandom.__new__() method, the base classes are searched. random.Random does have a __new__() method (see random_new() in _randommodule.c). So we get a call something like this random_new(MyRandom, [1, 2]).

    Looking at the C code for random_new(), it calls random_seed(self, [1, 2]). Because the second argument isn't Null, or None, or an int, or a subclass of int, the code calls PyObject_Hash([1, 2]). But a list isn't hashable, hence the error.

    If __new__() returns a instance of the class, then the __init__() method is called with the arguments in the constuctor call.

    One possible fix is to define a MyRandom.__new__() method, which calls super().__new__() but only passes the appropriate args.

    class MyRandom(random.Random):
        def __new__(cls, *args, **kwargs):
            #print(f"In __new__: {args=}, {kwargs=}")
    
            # Random.__new__ expects an optional seed. We are going to 
            # implement out own RNG, so ignore args and kwargs. Pass in a 
            # junk integer value so that Random.__new__ doesn't waste time
            # trying to access urandom or calling time to initialize the MT RNG
            # since we aren't going to use it anyway.
            return super().__new__(cls, 123)
        
        def __init__(cls, *args, **kwargs):
            #print(f"In __init__: {args=}, {kwargs=}")
    
            # initialize your custom RNG here
            pass
    

    Also override the methods: random(), seed(), getstate(), setstate(), and optionally getrandbits().

    An alternative fix is to only use keyword arguments in the __init__() methods of the subclasses. The C code for random_new() checks to see if a an instance of random.Random is being created. If true, the code throws and error if there are any keyword arguments. However, if a subclass is being created, any keyword arguments are ignored by random_new(), but can be used in the subclass __init__().

    class MyRandom(random.Random):
        def __init__(self, *, x):  # make x a keyword only argument
            print("Before")
            super().__init__()  # Nothing passed to parent
            print("After")
    
    MyRandom(x=[])
    

    Interestingly, in Python 3.10, the code for random_new has been changed to raise an error if more that 1 positional argument is supplied.