Slide 1

Slide 1 text

Encapsulation with Descriptors Luciano Ramalho [email protected] 2013-03-12

Slide 2

Slide 2 text

@ramalhoorg Assumptions • You know the basics of how classes and objects work in Python • new-style versus old-style classes • how to use super • class versus instance attributes • attribute protection via name mangling (__x)

Slide 3

Slide 3 text

The scenario • Selling organic bulk foods • An order has several items • Each item has a description, weight (kg), unit price (per kg) and a sub-total

Slide 4

Slide 4 text

@ramalhoorg ➊

Slide 5

Slide 5 text

@ramalhoorg ➊ the first doctest ============== LineItem tests ============== A line item for a bulk food order has description, weight and price fields:: >>> from bulkfood import LineItem >>> raisins = LineItem('Golden raisins', 10, 6.95) >>> raisins.weight, raisins.description, raisins.price (10, 'Golden raisins', 6.95) A ``subtotal`` method gives the total price for that line item:: >>> raisins.subtotal() 69.5

Slide 6

Slide 6 text

➊ the simplest thing possible class LineItem(object): def __init__(self, description, weight, price): self.description = description self.weight = weight self.price = price def subtotal(self): return self.weight * self.price

Slide 7

Slide 7 text

@ramalhoorg ➊ perhaps too simple... >>> raisins = LineItem('Golden raisins', 10, 6.95) >>> raisins.weight = -20 >>> raisins.subtotal() -139.0 a negative amount is due?!

Slide 8

Slide 8 text

➊ perhaps too simple... >>> raisins = LineItem('Golden raisins', 10, 6.95) >>> raisins.weight = -20 >>> raisins.subtotal() -139.0 a negative amount is due?! Jeff Bezos of Amazon: Birth of a Salesman WSJ.com - http://j.mp/VZ5not “We found that customers could order a negative quantity of books! And we would credit their credit card with the price...” Jeff Bezos

Slide 9

Slide 9 text

➊ the classic solution class LineItem(object): def __init__(self, description, weight, price): self.description = description self.set_weight(weight) self.price = price def subtotal(self): return self.get_weight() * self.price def get_weight(self): return self.__weight def set_weight(self, value): if value > 0: self.__weight = value else: raise ValueError('value must be > 0') protected attribute changes in existing code

Slide 10

Slide 10 text

@ramalhoorg • Previously we could get the weight of a line item simply via item.weight, but not anymore... • This breaks code • Python offers another way... ➊ but it’s a breaking change >>> raisins.weight Traceback (most recent call last): ... AttributeError: 'LineItem' object has no attribute 'weight'

Slide 11

Slide 11 text

@ramalhoorg ➊ by the way... • Protected attributes in Python exist only for safety reasons • to avoid accidental assignment or overriding • not to prevent intentional use (or misuse)

Slide 12

Slide 12 text

➊ by the way... >>> raisins._LineItem__weight 10 • Protected attributes in Python exist only for safety reasons • to avoid accidental assignment or overriding • not to prevent intentional use (or misuse)

Slide 13

Slide 13 text

@ramalhoorg ➋

Slide 14

Slide 14 text

Assigning a negative ``weight`` is forbidden:: >>> raisins.weight = -20 Traceback (most recent call last): ... ValueError: value must be > 0 >>> raisins.weight 10 @ramalhoorg ➋ the second doctest looks like a violation of encapsulation but business logic is enforced weight is unchanged

Slide 15

Slide 15 text

Assigning a negative ``weight`` is forbidden:: >>> raisins.weight = -20 Traceback (most recent call last): ... ValueError: value must be > 0 >>> raisins.weight 10 @ramalhoorg ➋ validation with property to make this work, we implement weight as a property

Slide 16

Slide 16 text

➋ property implementation class LineItem(object): def __init__(self, description, weight, price): self.description = description self.weight = weight self.price = price def subtotal(self): return self.weight * self.price @property def weight(self): return self.__weight @weight.setter def weight(self, value): if value > 0: self.__weight = value else: raise ValueError('value must be > 0') must be new-style class properties are only supported in new-style classes

Slide 17

Slide 17 text

@ramalhoorg ➋ property implementation class LineItem(object): def __init__(self, description, weight, price): self.description = description self.weight = weight self.price = price def subtotal(self): return self.weight * self.price @property def weight(self): return self.__weight @weight.setter def weight(self, value): if value > 0: self.__weight = value else: raise ValueError('value must be > 0') within __init__ the property is already in use

Slide 18

Slide 18 text

@ramalhoorg ➋ property implementation class LineItem(object): def __init__(self, description, weight, price): self.description = description self.weight = weight self.price = price def subtotal(self): return self.weight * self.price @property def weight(self): return self.__weight @weight.setter def weight(self, value): if value > 0: self.__weight = value else: raise ValueError('value must be > 0') the protected __weight attribute is touched only within the property methods

Slide 19

Slide 19 text

@ramalhoorg ➋ property implementation class LineItem(object): def __init__(self, description, weight, price): self.description = description self.weight = weight self.price = price def subtotal(self): return self.weight * self.price @property def weight(self): return self.__weight @weight.setter def weight(self, value): if value > 0: self.__weight = value else: raise ValueError('value must be > 0') what if we need the same logic applied to the price attribute? duplicate the setter and getter?!?

Slide 20

Slide 20 text

@ramalhoorg ➌

Slide 21

Slide 21 text

__init__ subtotal description weight {descriptor} price {descriptor} LineItem @ramalhoorg ➌ the managed attributes we will use descriptors to manage acess to the weight and price attributes, enforcing the business logic

Slide 22

Slide 22 text

➌ descriptor implementation class Quantity(object): __counter = 0 def __init__(self): prefix = '_' + self.__class__.__name__ key = self.__class__.__counter self.target_name = '%s_%s' % (prefix, key) self.__class__.__counter += 1 def __get__(self, instance, owner): return getattr(instance, self.target_name) def __set__(self, instance, value): if value > 0: setattr(instance, self.target_name, value) else: raise ValueError('value must be > 0') class LineItem(object): weight = Quantity() price = Quantity() def __init__(self, description, weight, price): self.description = description self.weight = weight self.price = price def subtotal(self): return self.weight * self.price new-style class

Slide 23

Slide 23 text

➌ descriptor details weight and price are attributes of the LineItem class the get/set logic is moved to the Quantity descriptor class, where it can be reused via composition

Slide 24

Slide 24 text

@ramalhoorg ➌ a class builds instances class instances

Slide 25

Slide 25 text

@ramalhoorg class instances ➌ a class builds instances

Slide 26

Slide 26 text

@ramalhoorg ➌ descriptor instances attach to managed class the LineItem class has 2 instances of Quantity bound to it

Slide 27

Slide 27 text

class Quantity(object): __counter = 0 def __init__(self): prefix = '_' + self.__class__.__name__ key = self.__class__.__counter self.target_name = '%s_%s' % (prefix, key) self.__class__.__counter += 1 def __get__(self, instance, owner): return getattr(instance, self.target_name) def __set__(self, instance, value): if value > 0: setattr(instance, self.target_name, value) else: raise ValueError('value must be > 0') class LineItem(object): weight = Quantity() price = Quantity() def __init__(self, description, weight, price): self.description = description self.weight = weight self.price = price def subtotal(self): return self.weight * self.price ➌ descriptor managed class managed attributes

Slide 28

Slide 28 text

class LineItem(object): weight = Quantity() price = Quantity() def __init__(self, description, weight, price): self.description = description self.weight = weight self.price = price def subtotal(self): return self.weight * self.price ➌ use of the descriptor the Quantity instances are created at import time import time: when a module is loaded and its classes and functions are defined

Slide 29

Slide 29 text

class LineItem(object): weight = Quantity() price = Quantity() def __init__(self, description, weight, price): self.description = description self.weight = weight self.price = price def subtotal(self): return self.weight * self.price ➌ use of the descriptor each Quantity instance manages one LineItem attribute

Slide 30

Slide 30 text

class LineItem(object): weight = Quantity() price = Quantity() def __init__(self, description, weight, price): self.description = description self.weight = weight self.price = price def subtotal(self): return self.weight * self.price ➌ use of the descriptor every access to weight and price goes through their descriptors

Slide 31

Slide 31 text

class Quantity(object): __counter = 0 def __init__(self): prefix = '_' + self.__class__.__name__ key = self.__class__.__counter self.target_name = '%s_%s' % (prefix, key) self.__class__.__counter += 1 def __get__(self, instance, owner): return getattr(instance, self.target_name) def __set__(self, instance, value): if value > 0: setattr(instance, self.target_name, value) else: raise ValueError('value must be > 0') @ramalhoorg ➌ descriptor implementation a descriptor is a class that defines __get__ , __set__ or __delete__

Slide 32

Slide 32 text

class Quantity(object): __counter = 0 def __init__(self): prefix = '_' + self.__class__.__name__ key = self.__class__.__counter self.target_name = '%s_%s' % (prefix, key) self.__class__.__counter += 1 def __get__(self, instance, owner): return getattr(instance, self.target_name) def __set__(self, instance, value): if value > 0: setattr(instance, self.target_name, value) else: raise ValueError('value must be > 0') @ramalhoorg ➌ descriptor implementation self is the descriptor instance, bound to weight or price

Slide 33

Slide 33 text

class Quantity(object): __counter = 0 def __init__(self): prefix = '_' + self.__class__.__name__ key = self.__class__.__counter self.target_name = '%s_%s' % (prefix, key) self.__class__.__counter += 1 def __get__(self, instance, owner): return getattr(instance, self.target_name) def __set__(self, instance, value): if value > 0: setattr(instance, self.target_name, value) else: raise ValueError('value must be > 0') ➌ descriptor implementation self is the descriptor instance, bound to weight or price instance is the LineItem instance being handled

Slide 34

Slide 34 text

class Quantity(object): __counter = 0 def __init__(self): prefix = '_' + self.__class__.__name__ key = self.__class__.__counter self.target_name = '%s_%s' % (prefix, key) self.__class__.__counter += 1 def __get__(self, instance, owner): return getattr(instance, self.target_name) def __set__(self, instance, value): if value > 0: setattr(instance, self.target_name, value) else: raise ValueError('value must be > 0') ➌ descriptor implementation self is the descriptor instance, bound to weight or price instance is the LineItem instance being handled owner is LineItem, the class to which instance belongs

Slide 35

Slide 35 text

➌ descriptor implementation __get__ and __set__ read and write the target attribute in each LineItem instance

Slide 36

Slide 36 text

class Quantity(object): __counter = 0 def __init__(self): prefix = '_' + self.__class__.__name__ key = self.__class__.__counter self.target_name = '%s_%s' % (prefix, key) self.__class__.__counter += 1 def __get__(self, instance, owner): return getattr(instance, self.target_name) def __set__(self, instance, value): if value > 0: setattr(instance, self.target_name, value) else: raise ValueError('value must be > 0') ➌ descriptor implementation target_name is the name of the LineItem instance attribute where where we store the value of the attribute managed by this descriptor instance (self)

Slide 37

Slide 37 text

class Quantity(object): __counter = 0 def __init__(self): prefix = '_' + self.__class__.__name__ key = self.__class__.__counter self.target_name = '%s_%s' % (prefix, key) self.__class__.__counter += 1 def __get__(self, instance, owner): return getattr(instance, self.target_name) def __set__(self, instance, value): if value > 0: setattr(instance, self.target_name, value) else: raise ValueError('value must be > 0') ➌ descriptor implementation __get__ and __set__ use the getattr and setattr built-in functions to read and write the target attribute in the managed instance

Slide 38

Slide 38 text

➌ descriptor implementation each descriptor instance manages a specific LineItem instance attribute, and needs a specific target_name

Slide 39

Slide 39 text

class Quantity(object): __counter = 0 def __init__(self): prefix = '_' + self.__class__.__name__ key = self.__class__.__counter self.target_name = '%s_%s' % (prefix, key) self.__class__.__counter += 1 def __get__(self, instance, owner): return getattr(instance, self.target_name) def __set__(self, instance, value): if value > 0: setattr(instance, self.target_name, value) else: raise ValueError('value must be > 0') @ramalhoorg ➌ descriptor implementation each Quantity instance must create and use a unique target attribute name

Slide 40

Slide 40 text

➌ the target attributes >>> raisins = LineItem('Golden raisins', 10, 6.95) >>> dir(raisins) ['_Quantity_0', '_Quantity_1', '__class__', '__delattr__' ... 'description', 'price', 'subtotal', 'weight'] >>> raisins.weight 10 >>> raisins._Quantity_0 10 >>> raisins.price 6.95 >>> raisins._Quantity_1 6.95 _Quantity_0 and _Quantity_1 are the instance attributes where the weight and price values of each LineItem instance are stored

Slide 41

Slide 41 text

__init__ __get__ __set__ target_name «descriptor» Quantity __init__ subtotal description _Quantity_0 _Quantity_1 LineItem «weight» «price» «get/set target attribute» @ramalhoorg ➌ the target attributes _Quantity_0 stores the value of weight _Quantity_1 stores the value of price

Slide 42

Slide 42 text

__init__ subtotal description weight {descriptor} price {descriptor} LineItem @ramalhoorg ➌ the managed attributes clients of the LineItem class don’t need to know how (or if) weight and price are managed and they don’t need to know that _Quantity_0 and _Quantity_1 exist!

Slide 43

Slide 43 text

@ramalhoorg ➌ room for improvement • It would be better if the target attributes were proper protected attributes • _LineItem__weight instead of _Quantity_0 • To do so, we must find out the name of the managed attribute (eg. weight) • this is not as easy at it sounds • the extra complication may not be worthwhile

Slide 44

Slide 44 text

class LineItem(object): weight = Quantity() price = Quantity() def __init__(self, description, weight, price): self.description = description self.weight = weight self.price = price def subtotal(self): return self.weight * self.price ➌ the challenge when each descriptor is instantiated, the LineItem class does not exist, and the managed attributes don’t exist either

Slide 45

Slide 45 text

class LineItem(object): weight = Quantity() price = Quantity() def __init__(self, description, weight, price): self.description = description self.weight = weight self.price = price def subtotal(self): return self.weight * self.price ➌ the challenge for example, the weight attribute is only created after Quantity() is evaluated

Slide 46

Slide 46 text

@ramalhoorg What now? • If a descriptor needs to know the name of the managed class attribute... (perhaps to persist to a database using descriptive column names) • ...then you need to control the construction of the managed class with a

Slide 47

Slide 47 text

@ramalhoorg References • Raymond Hettinger’s Descriptor HowTo Guide (part of the official Python docs.) • Alex Martelli’s Python in a Nutshell, 2e. (Python 2.5 but still excellent, includes the complete attribute access algorithm)

Slide 48

Slide 48 text

@ramalhoorg References • David Beazley’s Python Essential Reference, 4th edition (covers Python 2.6) • Source code and doctests for this talk: https://github.com/oturing/encapy

Slide 49

Slide 49 text

By the way... • Python functions are descriptors! • they implement __get__ • That’s how a Python function becomes a bound method when acessed through an instance • fn.__get__ returns a partial function application of fn, fixing the first arg (self) to the target instance Isn’t this elegant?