10

I've been subclassing tuple or using namedtuple blissfully for a few years, but now I have a use case where I need a class that can be used as a weak referent. And today I learned tuples don't support weak references.

Is there another way to create an immutable object in Python with a fixed set of attributes? I don't need the numeric indexing or variable width of a tuple.

class SimpleThingWithMethods(object):
    def __init__(self, n, x):
        # I just need to store n and x as read-only attributes 
    ... ??? ...

I guess this raises the obvious question of why immutable; "Pythonic" code usually just assumes we're all adults here and no one in their right mind would reach into a class and muck with its values if it risks ruining the class invariants. In my case I have a class in a library and I am worried about accidental modification of objects by end-users. The people I work with sometimes make incorrect assumptions about my code and start doing things I did not expect, so it's much cleaner if I can raise an error if they accidentally modify my code.

I'm not so worried about bulletproof immutability; if someone really nefarious wants to go and modify things, ok, fine, they're on their own. I just want to make it hard to accidentally modify my objects.

Jason S
  • 184,598
  • 164
  • 608
  • 970
  • What's wrong with the workaround suggested by Raymond (in the mail)? – Elazar Nov 04 '17 at 22:30
  • "to create a custom class with a has-a relationship instead of an is-a relationship." -- (1) I want to do it in pure Python, (2), has-a implies that the custom class contains my real class, but then the custom class isn't immutable because of chicken-and-egg -- if I could make an immutable class then I would just do it. – Jason S Nov 04 '17 at 22:32
  • I guess alternatively I could use a container class that overrides `__eq__` and `__hash__` and delegates to the contained object. Not sure that would work though for weakrefs. – Jason S Nov 04 '17 at 22:33
  • 1
    How about https://stackoverflow.com/a/4854045/2289509 (cython) – Elazar Nov 04 '17 at 22:36
  • Intriguing but that puts a dependency on Cython in my code and I would like to avoid it. – Jason S Nov 04 '17 at 22:37
  • @JasonS cython generates C code that depends only on Python. You don't need Cython to build it. – jfs Nov 06 '17 at 06:49
  • @jfs Python just works. How do I make my library available to others who don't have Cython or don't have a C compiler? I tried Cython a while back; I don't remember much other than that it was easy to use but I didn't want to force a dependency on my library customers. – Jason S Nov 06 '17 at 17:10
  • @JasonS In general, C is even *more* portable than Python. I can imagine a platform that supports C but not Python. I don't know of any examples of the reverse.¶ A deployment environment might not have a C compiler, you need to provide binaries for python and your library. The latter can be a pip-installable binary wheel. – jfs Nov 06 '17 at 18:33
  • C is not write-once run-anywhere, and I don't want to maintain binaries for Windows/OSX/Linux. Maybe I'm missing something here, it just seems like it's not an attractive path to take. – Jason S Nov 06 '17 at 19:58

3 Answers3

4

well, this isn't a great answer but it looks like I can modify the answer in https://stackoverflow.com/a/4828492/44330 --- essentially overriding __setattr__ and __delattr__ to meet my needs at least against accidental modification. (but not as nice as subclassing tuple)

class Point(object):
    __slots__ = ('x','y','__weakref__')
    def __init__(self, x, y):
        object.__setattr__(self, "x", x)
        object.__setattr__(self, "y", y)
    def __setattr__(self, *args):
        raise TypeError
    def __delattr__(self, *args):
        raise TypeError
    def __eq__(self, other):
        return self.x == other.x and self.y == other.y
    def __hash__(self):
        return self.x.__hash__() * 31 + self.y.__hash__()

Implementing @Elazar's idea:

class Point(object):
    __slots__ = ('x','y','__weakref__')
    def __new__(cls, x, y):
        thing = object.__new__(cls) 
        object.__setattr__(thing, "x", x)
        object.__setattr__(thing, "y", y)
        return thing
    def __setattr__(self, *args):
        raise TypeError
    def __delattr__(self, *args):
        raise TypeError
    def __eq__(self, other):
        return self.x == other.x and self.y == other.y
    def __hash__(self):
        return self.x.__hash__() * 31 + self.y.__hash__()    
Jason S
  • 184,598
  • 164
  • 608
  • 970
  • 2
    Maybe override `__new__` instead. To prevent calling `p.__init__(1, 2)`. – Elazar Nov 04 '17 at 22:50
  • Ooh... That's obscure, dark magic haha... A lot of people maybe want to burn you to the ashes for such heresy. I like it so much, that's what I mean with "parallel thinking", sometimes you have to be bold ;). It's a plus one BTW – developer_hatch Nov 04 '17 at 22:54
  • Elazar what's the way to do that? – Jason S Nov 04 '17 at 22:57
  • 2
    Just add `def __new__(cls, x, y): ` in which you create an empty object and initialize it. Then you don't need `__init__` to do anything. – Elazar Nov 04 '17 at 23:04
  • Won't tell me that's "pythonic"? :D – developer_hatch Nov 04 '17 at 23:10
  • 2
    Note: if you're defining `__eq__`, you should also define `__ne__` (on Python 2 -- and here the question had a python-2.7 tag). – Marius Gedminas Nov 07 '17 at 17:09
  • oh! It doesn't just use `not __eq__` by default? – Jason S Nov 07 '17 at 18:13
  • ah -- you are correct: https://docs.python.org/2/reference/datamodel.html#object.__eq__ There are no implied relationships among the comparison operators. The truth of `x==y` does not imply that `x!=y` is false. Accordingly, when defining `__eq__()`, one should also define `__ne__()` so that the operators will behave as expected. See the paragraph on `__hash__()` for some important notes on creating hashable objects which support custom comparison operations and are usable as dictionary keys. – Jason S Nov 07 '17 at 18:15
2

If you don't worry about isinstance checks, you can strengthen you answer:

def Point(x, y):
    class Point(object):
        __slots__ = ('x','y','__weakref__')
        def __setattr__(self, *args):
            raise TypeError
        def __delattr__(self, *args):
            raise TypeError
        def __eq__(self, other):
            return x == other.x and y == other.y
        def __hash__(self):
            return x.__hash__() * 31 + y.__hash__()
    p = Point()
    object.__setattr__(p, "x", x)
    object.__setattr__(p, "y", y)
    return p

I don't really recommend it (every invocation creates a class!), just wanted to note the possibility.

It is also possible to go javascript all the way, and supply __getattr__ that will access the local variables. But that will also slow down access, in addition to creation. Now we don't need these slots at all:

class MetaImmutable:
    def __setattr__(self, name, val):
        raise TypeError

def Point(x, y):
    class Point(object):
        __metaclass__ = MetaImmutable
        __slots__ = ('__weakref__',)
        def __getattr__(self, name):
            if name == 'x': return x
            if name == 'y': return y
            raise TypeError
        @property
        def x(self): return x
        @property
        def y(self): return y
        def __eq__(self, other):
            return x == other.x and y == other.y
        def __hash__(self):
            return x.__hash__() * 31 + y.__hash__()
    return Point()

Test it:

>>> p = Point(1, 2)
>>> p.y
2
>>> p.z
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 7, in __getattr__
TypeError
>>> p.z = 5
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Point' object has no attribute 'z'
>>> object.__setattr__(p, 'z', 5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Point' object has no attribute 'z'
>>> from weakref import ref
>>> ref(p)().x
1
>>> type(p).x = property(lambda self: 3)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 4, in __setattr__
TypeError

And finally, you can still break it:

>>> type.__setattr__(type(p), 'x', property(lambda self: 5))
>>> p.x
5

Again, nothing here is recommended. Use @Jasons implementation.

Elazar
  • 20,415
  • 4
  • 46
  • 67
  • I don't see what the point of the wrapper is. Maybe you were trying to block repeat `__init__` calls, but anyone who would think to do that would probably just call `object.__setattr__`. (To me, at least, `object.__setattr__` comes to mind first.) Also, you could have implemented `__new__` instead, like you commented on Jason's answer. – user2357112 Nov 04 '17 at 23:11
  • 1
    (1) calls to `__init__` are much more common than `object.__setattr__`. Some people don't consider it a hack. (2) The wrapper causes these call to make the object misbehave, which might teach 'em a lesson :P (3) seriously, if you add the `__getattr__` this become pretty bulletproof – Elazar Nov 04 '17 at 23:14
  • Note: if you're defining `__eq__`, you should also define `__ne__` (on Python 2 -- and here the question had a python-2.7 tag). – Marius Gedminas Nov 07 '17 at 17:09
1

What about using encapsulation and abstraction on the parameter (getter?):

class SimpleThingWithMethods(object):
    def __init__(self, n, x):
        self._n = n
        self._x = x

    def x(self):
      return self._x

    def n(self):
      return self._n

    SimpleThingWithMethods(2,3).x()

=> 3

developer_hatch
  • 15,898
  • 3
  • 42
  • 75
  • not immutable. Please don't tell me I have an XY problem and immutable classes aren't Pythonic. (p.s. you could use `@property` to make it a bit cleaner syntax) – Jason S Nov 04 '17 at 22:35
  • 1
    @JasonS I wouldn't say anything about XY, and sometimes you are tied to a tool (like you are to python), so, when that's the case, you have to open a little the mind and try something "not tool" or "not pyhonic", I mean, you are trying to achieve a goal, if you want that, you either don't use python, or you can try parallel thinking, the Cython option is a parallel way very interesting too – developer_hatch Nov 04 '17 at 22:44
  • ok, fair enough. I've been bitten on this site by nasty Soup Nazis who scream "XY problem!" too much, so unfortunately I'm quick to jump to conclusions when someone posts an answer that makes different assumptions than I stated. – Jason S Nov 04 '17 at 22:48
  • @JasonS haha don't worry at all, I'm not like most programmers or users here, I'm a little naive maybe, maybe to soft, maybe try to be to kind haha, anyway... I saw you own answer I will coment it – developer_hatch Nov 04 '17 at 22:51