Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix a regression introduced in gh-727 and gh-723, caused by a misunderstanding of how __new__ works #733

Merged
merged 2 commits into from
Jan 8, 2019

Conversation

eric-wieser
Copy link
Member

@eric-wieser eric-wieser commented Dec 20, 2018

Alternative: #734

In those patches (gh-727, gh-723), I declared classes that were roughly

class SomeClass:
    def __new__(cls, arg):
        try:
            return existing[arg]
        except KeyError:
            self = super(SomeClass, cls).__new__(cls, arg)
            existing[arg] = self
            return self

    def __init__(self, arg):
        self.arg = arg
        self.state = 0

This approach has a fatal flaw (gh-729), with function calls shown in the following code:

A = SomeClass(1)
# SomeClass.__new__(SomeClass, 1) -> A
# A.__init__(1)
B = SomeClass(1)
# SomeClass.__new__(SomeClass, 1) -> A   # reusing the existing instance
# A.__init__(1)  # uh oh, we reset A.state

We need to override class-construction without allowing __init__ to run a second time.

One option would be to just remove __init__ entirely, and move the contents into __new__.
The other option, which I take in this patch, is to introduce a metaclass overriding the __call__ operator on class types.


I'm not convinced this is the best approach, and is possibly over-engineered - but it does fix the problem.

def test_edge_identity(dut):
"""
Test that Edge triggers returns the same object each time
"""
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test passed before this patch too, but I figured it was worth adding anyway

@eric-wieser
Copy link
Member Author

eric-wieser commented Dec 20, 2018

Reiterating the goals of the original patches, I wanted to:

  1. Ensure that trigger objects are garbage-collectable
  2. Make type(RisingEdge(signal)) == RisingEdge
  3. Make isinstance(trig, RisingEdge) not throw an exception
  4. Continue to keep RisingEdge(sig) == RisingEdge(sig) (needed by the scheduler)
  5. Continue to keep RisingEdge(sig) is RisingEdge(sig)

There are some compromises we could make here;

  • Reject 2 and 3, go back to something more similar to the original:

    class _RisingEdge:
        def __init__(self, signal):
            self.signal = signal
                self.hdl = 0
    
    def RisingEdge(signal):
        try:
            return existing[signal]
        except KeyError:
            return _RisingEdge(signal)
  • Reject 5 (and possibly 1), and use non-unique objects that compare equal. Any global state goes in a class dictionary

        #namedtuple implements __eq__, __hash__, __neq__, and __repr__ for us, which makes things easier
    class RisingEdge(collections.namedtuple('RisingEdge', 'signal')):
             _existing_hdl = {}  # re 1: not sure if this can be weak
            
            @property
            def hdl(self):
                return existing_hdl.set_default(self, 0)
  • Reject 1, 2, and 3, and go back to the status quo (but without the dangerous garbage collection of Join objects)

  • Reject nothing, move all of our work to __new__, in all of the trigger classes (edit: this opens a can of worms, and behaves poorly with inheritance):

    class RisingEdge:
        def __new__(cls, signal):
            try:
                return existing[arg]
            except KeyError:
                self = super(RisingEdge, cls).__new__(cls)
                existing[signal] = self
                self.signal = signal  # this is a little hard to see
                return self
  • Take this patch, which achieves all the goals but is over engineered

  • Reject nothing, use a lighter-weight metaclass (edit: done in Alternative to 733 #734):

    class CallableMeta(type):
        """ Allows instances to override the class call operator """
        def __call__(cls, *args, **kwargs):
            return cls.__class_call__(*args, **kwargs)
    
    CallableBase = CallableMeta('CallableBase', (), {})
    
    class RisingEdge(CallableBase):
        @classmethod
        def __class_call__(cls, signal):
        try:
            return existing[signal]
        except KeyError:
            return _RisingEdge(signal)
    
    def __init__(self, signal):
        self.signal = signal
            self.hdl = 0

@eric-wieser
Copy link
Member Author

@imphil: Would be great to hear your opinions on the above approaches.

@imphil
Copy link
Member

imphil commented Dec 20, 2018

@eric-wieser thanks for following up on the issue so quickly. I hope to find time tomorrow during a flight to have a closer look.

@imphil
Copy link
Member

imphil commented Dec 21, 2018

OK, I took a closer look and I'm not sure I fully understand all the side effects and requirements, but wouldn't the following work as well?

diff --git a/cocotb/triggers.py b/cocotb/triggers.py
index a489294..5472b17 100644
--- a/cocotb/triggers.py
+++ b/cocotb/triggers.py
@@ -230,11 +230,15 @@ class _EdgeBase(GPITrigger):
         except KeyError:
             instance = super(_EdgeBase, cls).__new__(cls)
             cls._instances[key] = instance
+            instance._initialized = False
             return instance
 
     def __init__(self, signal):
+        if self._initialized:
+            return
         super(_EdgeBase, self).__init__()
         self.signal = signal
+        self._initialized = True
 
     def prime(self, callback):
         """Register notification of a value change via a callback"""

Isn't the construct we're looking for a simple singleton? It seems too obvious so I probably missed something?

@eric-wieser
Copy link
Member Author

eric-wieser commented Dec 21, 2018

We're looking for a parameterized Singleton, which is essentially the same thing, yes.

Your approach would work, but I think it would leave most readers questioning "how could this ever happen", since having init called twice is very unusual.

I think I'm leaning towards the approach in my second patch - python will have a built-in __class_getitem__ thanks to a recent PEP, so injecting a __class_call__ with similar semantics is not too great a stretch.

@imphil
Copy link
Member

imphil commented Jan 3, 2019

I'm back from my winter vacation, so let's get back to this one.

I looked through the implementations in this PR #733 and in #734. I prefer the option presented in this PR (#733) as it is easier understandable to me. The approach in #734 is pretty generic, which makes it hard to understand what practical purpose all this "meta base class stuff" has. The approach in the PR here has a clearly defined and documented purpose, and even reduces code duplication by moving the list of instances into the base.

To make it even more obvious what is going on I'd suggest to add a bit of documentation that we're creating a (parametrized) singleton. This clearly defines for probably most readers what's going on.

@stuarthodgson
Copy link
Collaborator

I vote #733 too.

@eric-wieser
Copy link
Member Author

eric-wieser commented Jan 4, 2019

what practical purpose all this "meta base class stuff" has

Unfortunately most of it is a work around for:

  • Metaclasses syntax being different in Python 2 and 3, leaving neither available
  • Not having six available to patch over this difference.

Once the setup.py stuff is in place, it might be a good idea to add a six dependency.

I'll take another look at this in the next few days, adding some more docs.

@imphil
Copy link
Member

imphil commented Jan 4, 2019

Once the setup.py stuff is in place, it might be a good idea to add a six dependency.

yeah, I'm looking forward to that as well. Shouldn't be far away, though.

@stuarthodgson
Copy link
Collaborator

I'm really not keen on dependencies in the core code.

@eric-wieser
Copy link
Member Author

eric-wieser commented Jan 7, 2019

Updated with a little more documentation, and better naming. Updated the competitor PR too, but I think with the improved naming, this one now wins.

I took the liberty of vendoring six.with_metaclass in both branches - it's cryptic, but when cocotb eventually drops python 2, it translates directly to idiomatic python 3.

…by a misunderstanding of how __new__ works

In those patches, I declared classes that were roughly
```python
class SomeClass:
    def __new__(cls, arg):
        try:
            return existing[arg]
        except KeyError:
            return super(SomeClass, cls).__new__(cls, arg)

    def __init__(self, arg):
        self.arg = arg
        self.state = 0
```

This approach has a fatal flaw (cocotbgh-729), with function calls shown in the following code:
```python
A = SomeClass(1)
B = SomeClass(1)
```

We need to override class-construction without allowing `__init__` to run a second time.

One option would be to just remove `__init__` entirely, and move the contents into `__new__`.
The other option, which I take in this patch, is to introduce a metaclass overriding the `__call__` operator on class types.

I'm not convinced this is the best approach, but it does fix the problem.
@eric-wieser eric-wieser force-pushed the trigger-metaclass branch 2 times, most recently from 16c3e22 to 1ebf47a Compare January 7, 2019 01:51
@eric-wieser
Copy link
Member Author

eric-wieser commented Jan 7, 2019

Finally passing. I rolled back my attempt to make NextTimeStep and ReadWrite use this too. It turns out that the scheduler deliberately creates a second instance of these objects (but not ReadOnly), and it seems to rely on them being distinct.

I'll take another look at that once this goes in, assuming it looks ready to you.

@stuarthodgson
Copy link
Collaborator

Increasingly concerned with all of this. It's started losing sight of what was originally the issue and adding a lot of complexity. Right now I'm more in favour of backing out the original regression and starting afresh. Seems that people want the package work released and this is now the blocker as we have a regression. I'd like @chiggs to comment.

@eric-wieser
Copy link
Member Author

eric-wieser commented Jan 7, 2019

In the interest of unlocking a release, I'm happy to implement the alternative above I label "Reject 2 and 3, go back to something more similar to the original" (edit: Done in gh-742). I think this patch is still worth considering, but I have no qualms with merging the simpler fix first, and leaving this on the back burner

eric-wieser added a commit to eric-wieser/cocotb that referenced this pull request Jan 7, 2019
…by a misunderstanding of how __new__ works

In those patches, I declared classes that were roughly
```python
class SomeClass:
    def __new__(cls, arg):
        try:
            return existing[arg]
        except KeyError:
            return super(SomeClass, cls).__new__(cls, arg)

    def __init__(self, arg):
        self.arg = arg
        self.state = 0
```

This approach has a fatal flaw (cocotbgh-729), with function calls shown in the following code:
```python
A = SomeClass(1)
# SomeClass.__new__(SomeClass, 1) -> A
# A.__init__(1)
B = SomeClass(1)
# SomeClass.__new__(SomeClass, 1) -> A   # reusing the existing instance
# A.__init__(1)  # uh oh, we reset A.state
```

For the sake of simplicity, this commit undoes the merger between the factory functions and the classes themselves, while keeping the weakref behavior.

In future, it would be good to explore the more complex approach taken in cocotbgh-733, which ensures that `isinstance(Join(), Join)`, which this patch does not.
Since this property was not true before cocotbgh-723 and cocotbgh-727 anyway, we can afford to revert that improvement in order to get a release out the door.
eric-wieser added a commit to eric-wieser/cocotb that referenced this pull request Jan 8, 2019
…by a misunderstanding of how __new__ works

In those patches, I declared classes that were roughly
```python
class SomeClass:
    def __new__(cls, arg):
        try:
            return existing[arg]
        except KeyError:
            return super(SomeClass, cls).__new__(cls, arg)

    def __init__(self, arg):
        self.arg = arg
        self.state = 0
```

This approach has a fatal flaw (cocotbgh-729), with function calls shown in the following code:
```python
A = SomeClass(1)
# SomeClass.__new__(SomeClass, 1) -> A
# A.__init__(1)
B = SomeClass(1)
# SomeClass.__new__(SomeClass, 1) -> A   # reusing the existing instance
# A.__init__(1)  # uh oh, we reset A.state
```

For the sake of simplicity, this commit undoes the merger between the factory functions and the classes themselves, while keeping the weakref behavior.

In future, it would be good to explore the more complex approach taken in cocotbgh-733, which ensures that `isinstance(Join(), Join)`, which this patch does not.
Since this property was not true before cocotbgh-723 and cocotbgh-727 anyway, we can afford to revert that improvement in order to get a release out the door.
eric-wieser added a commit to eric-wieser/cocotb that referenced this pull request Jan 8, 2019
…by a misunderstanding of how __new__ works

In those patches, I declared classes that were roughly
```python
class SomeClass:
    def __new__(cls, arg):
        try:
            return existing[arg]
        except KeyError:
            return super(SomeClass, cls).__new__(cls, arg)

    def __init__(self, arg):
        self.arg = arg
        self.state = 0
```

This approach has a fatal flaw (cocotbgh-729), with function calls shown in the following code:
```python
A = SomeClass(1)
# SomeClass.__new__(SomeClass, 1) -> A
# A.__init__(1)
B = SomeClass(1)
# SomeClass.__new__(SomeClass, 1) -> A   # reusing the existing instance
# A.__init__(1)  # uh oh, we reset A.state
```

For the sake of simplicity, this commit undoes the merger between the factory functions and the classes themselves, while keeping the weakref behavior.

In future, it would be good to explore the more complex approach taken in cocotbgh-733, which ensures that `isinstance(Join(), Join)`, which this patch does not.
Since this property was not true before cocotbgh-723 and cocotbgh-727 anyway, we can afford to revert that improvement in order to get a release out the door.
@imphil
Copy link
Member

imphil commented Jan 8, 2019

I must say I like the implementation proposed in this PR. Singletons in Python are not overly nice to implement, and the implementation we have here makes sense to me: it's understandable and not overly meta'y.

If no-one objects I'll merge this one by Friday.

@chiggs
Copy link
Contributor

chiggs commented Jan 8, 2019

Apologies for the tardy response on this.

I like where we’ve ended up here, the final diff is more elegant and understandable than the alternatives so this gets my vote.

@eric-wieser thanks for working through possible alternatives as well, it’s very useful to see the goals and reasoning and see how this has evolved with discussion.

@imphil
Copy link
Member

imphil commented Jan 8, 2019

If @chiggs is happy I'm happy as well! Merging this to enable wider testing.

@imphil imphil merged commit 3bf4c92 into cocotb:master Jan 8, 2019
ktbarrett pushed a commit to ktbarrett/cocotb that referenced this pull request Mar 6, 2020
Fix a regression introduced in cocotbgh-727 and cocotbgh-723, caused by a misunderstanding of how __new__ works
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants