Mahi
Mahi

Reputation: 21969

Prevent access to an instance variable from subclass, without affecting base class

Say I have a simple class Foo, which comes from an external library, thus I cannot change it directly:

class Foo(object):
    def __init__(self, x):
        self.x = x

I want to create a subclass Bar and prevent x from being change from an instance of Bar, but still use the x in Bar's methods.

Here's what I tried, and it will probably enlighten the basic idea, but unfortunately it doesn't work:

class Bar(Foo):

    @property
    def x(self):
        return super().x

    @x.setter
    def x(self, value):
        raise NotImplementedError('Do not change x directly, use "do_stuff()" instead')

    def do_stuff(self, value):
        if <something>:
            super().x = value

So basically I've created some wrapper functions (do_stuff()) around an attribute, and now I want to prevent the attribute from being changed directly, as it might mess up some functionality of the wrapper functions. Is this possible in a reasonable way?

Edited with a better example of what I want. I'm not trying to prevent them from seeing the variable x, but instead changing it from outside of do_stuff()

Upvotes: 9

Views: 2062

Answers (3)

Bi Rico
Bi Rico

Reputation: 25833

You're on the right track, you want to make x a property instead of having it be an attribute in the subclass. Where you went wrong was trying to store the raw data for x on super. What you want to do is exploit the fact that the parent class can use the new property of the subclass transparently and does not need to know that it is now a property and not a attribute. Something like this should work for you:

class Foo(object):
    def __init__(self, x):
        self.x = x

class Bar(Foo):

    _protected_x = None

    @property
    def x(self):
        return self._protected_x

    @x.setter
    def x(self, value):
        if self._protected_x is None:
            self._protected_x = value
        else:
            raise ValueError("Use set_x to change x.")

    def set_x(self, value):
        self._protected_x = value

b = Bar(12)
print b.x
b.set_x(5)
print b.x

Upvotes: 0

theB
theB

Reputation: 6738

The short answer is: No, this is not possible in a reasonable way.

Python's guiding principle here, to use the phrasing from the style guide is that we are all responsible users. Meaning that code is trusted not to do silly things, and people should generally avoid messing with members of other people's classes without a good reason.

The first and best way to prevent people from accidentally changing a value is to mark it using the single underscore (_variable). This however may not offer you the protection you want against accidental modification of your variables.

The next step up in protection is to use a double underscore. Quoting from PEP-8:

To avoid name clashes with subclasses, use two leading underscores to invoke Python's name mangling rules.

Python mangles these names with the class name: if class Foo has an attribute named __a , it cannot be accessed by Foo.__a . (An insistent user could still gain access by calling Foo._Foo__a .) Generally, double leading underscores should be used only to avoid name conflicts with attributes in classes designed to be subclassed.

The mangling makes it more difficult to accidentally overwrite a value.

I added emphasis to that last sentence because it is important. Using this mechanism for preventing accidental access to a member is not really the something that should be done for a lot of members.

In your specific case, the way that I'd solve the problem would be to not subclass at all. Consider:

class Foo(object):
    def __init__(self, x):
        self.x = x

class Bar():
    def __init__(self, x):
        self._foo = Foo(x)

    @property
    def x(self):
        return self._foo.x

    def do_stuff(self, value):
        # Validate the value, and the wrapped object's state
        if valid:
            self._foo.x = value

Of course this means that Bar has to wrap all of Foo's methods that you want to wrap. Yes, someone could still,

b = Bar(100)
b._foo.x = 127 # shame on them :)

or

b = Bar(100)
b._foo = EvilFoo(127)

but it's harder to unintentionally do.

Upvotes: 1

Noctis Skytower
Noctis Skytower

Reputation: 22041

This should be much simpler to accomplish if you are willing to avoid inheritance altogether:

def main():
    bar = Bar(123)
    bar.fizz()
    bar.buzz()
    bar.fizz()
    bar.set_x(456)
    print('bar.x =', bar.x)
    try:
        bar.x = 123
    except AttributeError:
        print('bar.x cannot be set directly')
    else:
        raise AssertionError('an AttributeError should have been raised')
    bar.mutate_x(789)
    bar.fizz()
    bar.set_x(0)
    bar.fizz()
    bar.mutate_x(1)
    bar.fizz()
    bar.set_x('Hello World')
    bar.fizz()


class Foo:

    def __init__(self, x):
        self.x = x

    def fizz(self):
        print(self.x)

    def buzz(self):
        self.x = None


class Bar:

    def __init__(self, x):
        self.__foo = foo = Foo(x)
        self.__copy_methods(foo)

    def __copy_methods(self, obj):
        for name in dir(obj):
            if name.startswith('__') or name.endswith('__'):
                continue
            attr = getattr(obj, name)
            if callable(attr):
                setattr(self, name, attr)

    @property
    def x(self):
        return self.__foo.x

    def set_x(self, value):
        if isinstance(value, int) and value > 0:
            self.__foo.x = value

    mutate_x = set_x

if __name__ == '__main__':
    main()

Upvotes: 1

Related Questions