How Python Properties Help With Refactoring

Once upon a time, Alice the Python developer had to create a class representing money. Her first implemented version looked like this:

  1. # First version of dollar-centric Money class.
  2. class Money:
  3. def __init__(self, dollars, cents):
  4. self.dollars = dollars
  5. self.cents = cents
  6. # Plus some other methods, which we
  7. # don't need to worry about here.

This class was packaged into a library, and over time, was used in many different pieces of code, in many different applications. For example, one developer on another team - Bob - used it this way in his code:

  1. money = Money(27, 12)
  2. message = "I have {:d} dollars and {:d} cents."
  3. print(message.format(money.dollars, money.cents))
  4. # "I have 27 dollars and 12 cents."
  5. money.dollars += 2
  6. money.cents += 20
  7. print(message.format(money.dollars, money.cents))
  8. # "I have 29 dollars and 32 cents."

This is all fine, but it creates a software maintainability problem. Can you spot it?

Fast forward a few months or years. Alice needs to refactor the internals of the Money class. Instead of keeping track of dollars and cents, she wants the class to just keep track of cents, because it will make certain operations much simpler. Here's the first change she might try to make:

  1. # Second version of Money class.
  2. class Money:
  3. def __init__(self, dollars, cents):
  4. self.total_cents = dollars * 100 + cents

This change has a consequence: every line of code referencing a Money object's dollars has to be changed. Sometimes when this happens, you're luckily the maintainer of all the code using this class, and you merely have a refactoring job on your hands. But Alice isn't so lucky here; many other teams are re-using her code. She needs to coordinate her changes with their code base... maybe even going through an excruciatingly long, formal deprecation process. About as fun as visiting the dentist, but it takes longer.

Fortunately, Alice knows a better way, which will let her avoid the whole more-fun-than-going-to-the-dentist thing: The built-in property decorator. @property is applied to a method, and effectively transforms an attribute access into a method call. Let me show you an example. Push that Money class onto your mental stack for a moment, and imagine instead a class representing a person:

  1. class Person:
  2. def __init__(self, first, last):
  3. self.first = first
  4. self.last = last
  5. @property
  6. def full_name(self):
  7. return '{} {}'.format(self.first, self.last)

Look at full_name. It's declared as a very normal method, except being decorated by @property on the line above. This changes how Person objects operate:

  1. >>> buddy = Person('Jonathan', 'Doe')
  2. >>> buddy.full_name
  3. 'Jonathan Doe'

Note that even though full_name is defined as a method, it is accessed like a member variable attribute. There are no parenthesis in that last line of code; I'm not invoking the method. What we've done is create a kind of dynamic attribute.

Popping back to the Money class, Alice makes the following change:

  1. # Final version of Money class!
  2. class Money:
  3. def __init__(self, dollars, cents):
  4. self.total_cents = dollars * 100 + cents
  5. # Getter and setter for dollars...
  6. @property
  7. def dollars(self):
  8. return self.total_cents // 100;
  9. @dollars.setter
  10. def dollars(self, new_dollars):
  11. self.total_cents = 100 * new_dollars + self.cents
  12. # And the getter and setter for cents.
  13. @property
  14. def cents(self):
  15. return self.total_cents % 100;
  16. @cents.setter
  17. def cents(self, new_cents):
  18. self.total_cents = 100 * self.dollars + new_cents

In addition to defining the getter for dollars using @property, Alice has also created a setter, using @dollars.setter. And likewise for cents.

What does Bob's code look like now? Exactly the same!

  1. # His code is COMPLETELY UNCHANGED, yet works
  2. # with the final Money class. High five!
  3. money = Money(27, 12)
  4. message = "I have {:d} dollars and {:d} cents."
  5. print(message.format(money.dollars, money.cents))
  6. # "I have 27 dollars and 12 cents."
  7. money.dollars += 2
  8. money.cents += 20
  9. print(message.format(money.dollars, money.cents))
  10. # "I have 29 dollars and 32 cents."
  11. # This works correctly, too.
  12. money.cents += 112
  13. print(message.format(money.dollars, money.cents))
  14. # "I have 30 dollars and 44 cents."

None of the code using the Money class has to change at all. Bob doesn't know, or care, that Alice got rid of the dollars and cents attributes: his code keeps working exactly the same as it did before. The only code that changed is in the Money class itself.

Because of how Python does properties, you can freely use simple attributes in your classes. If and when your class changes how it manages state, you can confidently modify that class, and only that class, by creating properties. Everybody wins! In languages like Java, in contrast, one must instead proactively define property access methods (e.g, getDollars or setCents).

Here's something interesting: this is most critical with code that is reused by other developers and teams. Imagine creating a class like Money inside your own application, where you are the only maintainer. Then if you change the class's interface, you can just refactor your code. You don't necessarily need to create properties as described above (though you might want to use them for other reasons.)

There's much more to Python properties, by the way. We've only focused only on how properties help in the natural evolution of code. All this and more is covered more deeply in my upcoming book on advanced Python programming. Get notified when it's available by subscribing to the newsletter.