Is there a way to return a previously defined object when initiating a new one?

Viewed 194

I am working on a grid-generator that takes x- and y-coordinates as input. When initiating, this node is stored using a @classmethod (storage is a set, see MWE) during which a unique index is provided. As part of __init__, there is a check if there is already a node with the same (x,y)-coordinates; if so, no new node should be created.

So far, so good. The problem arises when I want to return the previously defined node when the (x,y)-coordinates are already assigned to one. So when I initiate a node with previously defined (x,y)-coordinates, I want that previously defined node-object to be returned. As an example:

n1 = Node(x=0, y=0)
n2 = Node(x=0, y=0)

In this example, both n1 and n2 should contain exactly the same object, and not a copy with the same details. Thus, n1 == n2 should return True, as the objects are identical. This is necessary for further computations (left out for clarity reasons).

Below a MWE of my Node-class:

class Node:

    __nodes = set()  # Here, Node-objects are stored
    __node_idx = None

    def __init__(self, x, y):
        # This is the check if the (x,y)-coordinates are already assigned to a node.
        if (x, y) not in [n.coordinates for n in self.__nodes]:
            self.x = x
            self.y = y
            self.set_idx(self)
            self.store_node(self)
        # Should here be something like an 'else'-statement?

    @classmethod
    def set_idx(cls, node):
        """Function to set unique index based on the number of nodes."""
        # There is a procedure to determine this index, but that does not matter for the question.
        node.__node_idx = idx

    @classmethod
    def store_node(cls, node):
        cls.__nodes.add(node)

    @property
    def index(self):
        return self.__node_idx

    @property
    def coordinates(self):
        return self.x, self.y

Stating that self becomes this previously defined node is not working; I have considered it. Thus as else-statement in the __init__:

    ...
    else:
        self = [n for n in self.__nodes if n.coordinates == (x, y)][0]

I have looked at the __hash__-method, but I am not familiar with it and I do not know if this is where the solution to my problem lies.

I am using Python 3.7. Thank you very much for any help; really appreciated!

3 Answers

What about using the factory pattern?

class Node:
    def __init__(self, x, y):
        self.x = x
        self.y = y

class NodeFactory:
    def __init__(self):
        self.nodes = set()
    
    def create_node(self, x, y):
        for node in self.nodes:
            if node.x == x and node.y == y:
                return node
        node = Node(x, y)
        self.nodes.add(node)
        return node

factory = NodeFactory()

n1 = factory.create_node(1, 5)
n2 = factory.create_node(1, 5)
n3 = factory.create_node(2, 1)

print(n1)
print(n2)
print(n3)

Output:

<__main__.Node object at 0x7fa595aa6640>
<__main__.Node object at 0x7fa595aa6640>
<__main__.Node object at 0x7fa595aa6100>

As you can see n1 and n2 are the same object here, so:

>>> n1 == n2
True

You can override the __new__ method of the class to control the creation of the nodes.

We store the existing nodes in a class attribute, a dict with the coordinates as keys and the Node objects as values.

__new__ looks if there already is a node with these coordinates in this dict. If yes, it just returns the already existing node. If not, it creates and returns a new one.

Depending on what you do in it, it may be better not to run __init__ again (or maybe not all parts of it) for already existing nodes. So, we just set an _already_initialized flag on the instance, that will be False at creation time, and that will be changed to True by __init__. This way, you can completely control what to do the first time a node is initialized, and the following times.

The code, with a few prints included to show what's going on:

class Node:

    _nodes = {} 

    def __new__(cls, x, y):
        if (x, y) in cls._nodes:
            print('Reusing', cls._nodes[(x, y)])
            return cls._nodes[(x, y)]
       
        new_node = super().__new__(cls)
        new_node._already_initialized = False
        print(f'New node created for x={x}, y={y}')

        cls._nodes[(x, y)] = new_node
        
        return new_node
    
    def __init__(self, x, y):
        if not self._already_initialized:
            self.x = x
            self.y = y
            self.set_idx(self)
            self._already_initialized = True
            print(self, 'initialized')
        else:
            print(self, 'was already initialized, leaving as it is')

    @classmethod
    def set_idx(cls, node):
        """Function to set unique index based on the number of nodes."""
        # There is a procedure to determine this index, but that does not matter for the question.
        pass

    @property
    def index(self):
        return self.__node_idx

    @property
    def coordinates(self):
        return self.x, self.y
    
    def __repr__(self):
        return f'Node: x={self.x}, y={self.y}'

Demo:

print('Creating n1')    
n1 = Node(x=0, y=0)

print('\nCreating n2')
n2 = Node(x=0, y=0)

print('\nCreating n3')
n3 = Node(1, 1)

print(f'\nn1 == n2: {n1 == n2}')
print(f'n1 is n2: {n1 is n2}')

print(f'\nNode._nodes: {Node._nodes}')

Output:

Creating n1
New node created for x=0, y=0
Node: x=0, y=0 initialized

Creating n2
Reusing Node: x=0, y=0
Node: x=0, y=0 was already initialized, leaving as it is

Creating n3
New node created for x=1, y=1
Node: x=1, y=1 initialized

n1 == n2: True
n1 is n2: True

Node._nodes: {(0, 0): Node: x=0, y=0, (1, 1): Node: x=1, y=1}



There's a built-in function for this. Use the functools.cache decorator:

import functools

@functools.cache
class Node:
    def __init__(self, x, y):
        self.__slots = __x,__y
        self.x = x
        self.y = y

n1 = Node(1,2)
n2 = Node(1,2)
print(n1 == n2, n1 is n2)

Output:

True True

Note that functools.cache caches by the original parameters to create Node, so mutating the coordinates afterward n1.x = 2 will change both n1 and n2 to Node(2,2), but n3=Node(1,2) will still return the same Node created by those parameters, resulting in print(n3) giving Node(2,2). To prevent this, make sure the coordinates can't change via property:

import functools

@functools.cache
class Node:

    def __init__(self, x, y):
        self.__x = x            # use private notation
        self.__y = y

    def __repr__(self):
        return f'Node(x={self.__x}, y={self.__y})'

    @property                # read-only property x
    def x(self):
        return self.__x

    @property                # read-only property y
    def y(self):
        return self.__y

n1 = Node(1,2)
n2 = Node(1,2)
print(n1,n2)
print(n1 == n2, n1 is n2)

Output:

Node(x=1, y=2) Node(x=1, y=2)
True True

You won't be able to mutate the node's coordinates, but you can set other attributes if desired (or use __slots__ to limit attributes if you don't).

>>> n1.x = 2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute
>>> n1.__x = 2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Node' object has no attribute '__x'
Related