0

I am building a typing.NamedTuple class (see typing.NamedTuple docs here, or the older collections.namedtuples docs it inherits from) that can accept different ways of being initialized.

Why NamedTuple in this case? I want it to be immutable and auto-hashable so it can be a dictionary key, and I don't have to write the hash function.

I understand that I need to use __new__ rather than __init__ due to NamedTuples being immutable (for example, see this Q&A. I've searched and there are some tidbits out there (e.g. answers to this question on setting up a custom hash for a namedtuple), but I can't get everything working, I'm getting an error about not being able to overwrite __new__.

Here's my current code:

from typing import NamedTuple

class TicTacToe(NamedTuple):
    """A tic-tac-toe board, each character is ' ', 'x', 'o'"""
    row1: str = '   '
    row2: str = '   '
    row3: str = '   '

    def __new__(cls, *args, **kwargs):
        print(f'Enter __new__ with {cls}, {args}, {kwargs}')
        if len(args) == 1 and args[0] == 0:
            new_args = ('   ', '   ', '   ')
        else:
            new_args = args
        self = super().__new__(cls, *new_args, *kwargs)
        return self

if __name__ == '__main__':
    a = TicTacToe(('xo ', 'x x', 'o o'))
    print(a)
    b = TicTacToe(0)
    print(b)

But I'm getting the following error:

Traceback (most recent call last):
  File "c:/Code/lightcc/OpenPegs/test_namedtuple.py", line 4, in <module>
    class TicTacToe(NamedTuple):
  File "C:\Dev\Python37\lib\typing.py", line 1384, 
in __new__
    raise AttributeError("Cannot overwrite NamedTuple attribute " + key)
AttributeError: Cannot overwrite NamedTuple attribute __new__

Am I not able to create a separate __new__ function for a child class that inherits from NamedTuple? It appears from the message that it's attempting to overwrite __new__ for NamedTuple directly, rather than the TicTacToe class.

What's going on here?

martineau
  • 119,623
  • 25
  • 170
  • 301
LightCC
  • 9,804
  • 5
  • 52
  • 92

1 Answers1

3

You can avoid needing to define __new__() by defining a classmethod. In the sample code below, I've simply named it make(). It's analogous to the class method named _make() that collections.namedtype subclasses have.

This is a common way to provide "alternative constructors" to any class.

Note that I also changed the first call to the function so it passes the arguments properly to the make() method.

from typing import NamedTuple

class TicTacToe(NamedTuple):
    """A tic-tac-toe board, each character is ' ', 'x', 'o'."""
    row1: str = '   '
    row2: str = '   '
    row3: str = '   '

    @classmethod
    def make(cls, *args, **kwargs):
        print(f'Enter make() with {cls}, {args}, {kwargs}')
        if len(args) == 1 and args[0] == 0:
            new_args = ('   ', '   ', '   ')
        else:
            new_args = args
        self = cls(*new_args, *kwargs)
        return self

if __name__ == '__main__':
#    a = TicTacToe.make(('xo ', 'x x', 'o o'))
    a = TicTacToe.make('xo ', 'x x', 'o o')
    print(a)
    b = TicTacToe.make(0)
    print(b)

Output:

Enter make() with <class '__main__.TicTacToe'>, ('xo ', 'x x', 'o o'), {}
TicTacToe(row1='xo ', row2='x x', row3='o o')
Enter make() with <class '__main__.TicTacToe'>, (0,), {}
TicTacToe(row1='   ', row2='   ', row3='   ')

Update

An alternative workaround to not being able to overload the NamedTuple subclass' __new__() method would be to split the derived class into two classes, one public and one private, so that the former is no longer a direct subclass of NamedTuple.

An advantage of doing it this way, is there's no longer a need to create instances using a special-purpose classmethod like make() above.

Here's what I mean:

from typing import NamedTuple

class _BaseBoard(NamedTuple):
    """Private base class for tic-tac-toe board."""
    row1: str = '   '
    row2: str = '   '
    row3: str = '   '


class TicTacToe(_BaseBoard):
    """A tic-tac-toe board, each character is ' ', 'x', 'o'."""
    __slots__ = ()  # Prevent creation of a __dict__.

    @classmethod
    def __new__(cls, *args, **kwargs):
        print(f'Enter __new__() with {cls}, {args}, {kwargs}')
        if len(args) == 1 and args[0] == 0:
            new_args = ('   ', '   ', '   ')
        else:
            new_args = args
        self = super().__new__(*new_args, *kwargs)
        return self


if __name__ == '__main__':

    a = TicTacToe('xo ', 'x x', 'o o')
    print(a)
    assert getattr(a, '__dict__', None) is None  # Verify not being created.
    b = TicTacToe(0)
    print(b)

Note that this approach is an example of applying Andrew Koenig's fundamental theorem of software engineering, namely: "We can solve any problem by introducing an extra level of indirection."

martineau
  • 119,623
  • 25
  • 170
  • 301
  • 1
    A good alternative - basically creating a factory through a classmethod (with `__new__` basically just being the default factory for the class..). This doesn't directly answer my original question though - is it correct that `__new__` is read-only for NamedTuple (I supposed it might not be for namedtuple). – LightCC May 17 '20 at 16:39
  • It _does_ answer the titular question "How to initialize a NamedTuple child class different ways based on input arguments?" — sao I think you should accept it. – martineau May 18 '20 at 23:18
  • 1
    The reason you can't override the `__new__()` method is because the code in the `typing.py` module explicitly checks for, and prohibits it — see the [source code](https://github.com/python/cpython/blob/master/Lib/typing.py#L1777). – martineau May 18 '20 at 23:38
  • I accepted because this works and does make a lot of sense. The issue I have is that I'm constantly forgetting to use the `Class.make(x)` method rather than just `Class(x)` (leading to not-immediately-obvious defects). The problem is disallowing the ability to override `__init__` and `__new__` for NamedTuples. This seems unpythonic, as those are the standard way to handle this situation. Too much trying to protect people from themselves. – LightCC May 23 '20 at 19:45
  • LightCC: You're assuming you know why the authors of the `typing` module prohibited overriding `__new__()`. It's possible it was for reasons other than protecting folks from themselves — such as it being incompatible with the implementation of one or more of the features `NamedTuple`s provides. As always, design decisions involve making trade-offs… – martineau May 23 '20 at 20:32