0

I know that Python is a dynamically typed language, and that I am likely trying to recreate Java behavior here. However, I have a team of people working on this code base, and my goal with the code is to ensure that they are doing things in a consistent manner. Let me give an example:

class Company:
    def __init__(self, j):
        self.locations = []

When they instantiate a Company object, an empty list that holds locations is created. Now, with Python anything can be added to the list. However, I would like for this list to only contain Location objects:

class Location:
    def __init__(self, j):
        self.address = None
        self.city = None
        self.state = None
        self.zip = None

I'm doing this with classes so that the code is self documenting. In other words, "location has only these attributes". My goal is that they do this:

c = Company()
l = Location()
l.city = "New York"
c.locations.append(l)

Unfortunately, nothing is stopping them from simply doing c.locations.append("foo"), and nothing indicates to them that c.locations should be a list of Location objects.

What is the Pythonic way to enforce consistency when working with a team of developers?

martineau
  • 119,623
  • 25
  • 170
  • 301
Franz Kafka
  • 780
  • 2
  • 13
  • 34
  • 2
    You can use type hints and run a static analyser over the code, e.g. `mypy`. But this will not prevent runtime type errors. You could also look into [`dataclasses`](https://docs.python.org/3/library/dataclasses.html) – AChampion Feb 13 '19 at 21:54
  • Did you look at e.g. this discussion : https://stackoverflow.com/questions/12569018/why-is-adding-attributes-to-an-already-instantiated-object-allowed ? – Demi-Lune Feb 13 '19 at 21:54
  • 1
    `c.locations.append` is bad. Provide a method on your object that checks the type before appending. Users of this class should only use that method and shouldn't be touching `self.locations` directly. Conventionally, attributes that aren't part of the public API are marked with a single-underscore, so you could use `._locations` to let [people know: "don't touch this" – juanpa.arrivillaga Feb 13 '19 at 21:55
  • @Demi-Lune The advantage that I get is that doing a `l.` in VS Code, PyCharm, etc, shows the end user what attributes I expect them to set. I did not realize that they could add any attribute they wanted, which is unfortunate. – Franz Kafka Feb 13 '19 at 21:57
  • Also note that the type enforcement prevents duck typing (https://stackoverflow.com/questions/4205130/what-is-duck-typing), which is a nice feature of python (imho). So the pythonic answer could be "make a nice developer's doc". – Demi-Lune Feb 13 '19 at 22:29

2 Answers2

4

An OOP solution is to make sure the users of your class' API do not have to interact directly with your instance attributes.

Methods

One approach is to implement methods which encapsulate the logic of adding a location.

Example

class Company:
    def __init__(self, j):
        self.locations = []

    def add_location(self, location):
        if isinstance(location, Location):
            self.locations.append(location)
        else:
            raise TypeError("argument 'location' should be a Location object")

Properties

Another OOP concept you can use is a property. Properties are a simple way to define getter and setters for your instance attributes.

Example

Suppose we want to enforce a certain format for a Location.zip attribute

class Location:
    def __init__(self):
        self._zip = None

    @property
    def zip(self):
        return self._zip

    @zip.setter
    def zip(self, value):
        if some_condition_on_value:
            self._zip = value
        else:
            raise ValueError('Incorrect format')

    @zip.deleter
    def zip(self):
        self._zip = None

Notice that the attribute Location()._zip is still accessible and writable. While the underscore denotes what should be a private attribute, nothing is really private in Python.

Final word

Due to Python's high introspection capabilities, nothing will ever be totally safe. You will have to sit down with your team and discuss the tools and practice you want to adopt.

Nothing is really private in python. No class or class instance can keep you away from all what's inside (this makes introspection possible and powerful). Python trusts you. It says "hey, if you want to go poking around in dark places, I'm gonna trust that you've got a good reason and you're not making trouble."

After all, we're all consenting adults here.

--- Karl Fast

Olivier Melançon
  • 21,584
  • 4
  • 41
  • 73
  • Ah, I thought it was not Pythonic to have setters/getters. I guess it is different if the attribute is a list rather than a string? – Franz Kafka Feb 13 '19 at 21:54
  • 4
    @FranzKafka It is perfectly pythonic. Python actually has a whole getter and setter protocol which you should definitely know about: https://stackoverflow.com/questions/17330160/how-does-the-property-decorator-work I believe it will greatly help you with the issues you are facing. – Olivier Melançon Feb 13 '19 at 21:56
  • 5
    @FranzKafka this is not a setter. A setter would be something like `def set_locations(self, loc): self.loctions = loc` You shouldn't do that. You should use a regular attribute and of you want to control access at some point, you can re-factor using a `property` – juanpa.arrivillaga Feb 13 '19 at 21:56
  • @juanpa.arrivillaga I realize that, I guess I meant that I thought the Pythonic way to interact with the instance variables was like so `obj.var = "foo"`, and `obj.var.append("foo")`, so this is illuminating for me. – Franz Kafka Feb 13 '19 at 21:58
  • 1
    @FranzKafka no, not at all. If you just want to *set or get* an instance attribute, then you just do it. Directly mutating the object referenced by an instance attribute is another matter. – juanpa.arrivillaga Feb 13 '19 at 21:59
  • 3
    `@property` means the setter/getter implementation details are not exposed to the user, e.g. `obj.var = "foo"` works in both cases (setter function or basic attribute). But `obj.var.append()` is not the same thing. – AChampion Feb 13 '19 at 21:59
1

You could also define a new class ListOfLocations that make the safety checks. Something like this

class ListOfLocations(list):
   def append(self,l):
      if not isinstance(l, Location): raise TypeError("Location required here")
      else: super().append(l)
Demi-Lune
  • 1,868
  • 2
  • 15
  • 26
  • This is a good step, but does not prevent from using `ListOfLocations.extend`, `ListOfLocations.insert`, `ListOfLocations.__setitem__`, etc. – Olivier Melançon Feb 13 '19 at 22:38
  • Yes, it'd require redefining any syntax that we want to protect. It's probably a matter of taste with your answer. The only advantage of this one, is that it doesn't require any modification of the initial test-case. – Demi-Lune Feb 14 '19 at 09:14