When and why might I assign an instance of a descriptor class to a class attribute in Python rather than use a property?
Asked Answered
R

3

22

I'm aware that a property is a descriptor, but are there specific examples of when using a descriptor class might be more advantageous, pythonic, or provide some benefit over using @property on a method function?

Rustproof answered 30/4, 2011 at 15:3 Comment(0)
P
11

Better encapsulation and re-usability: A descriptor class can have custom attributes set on instantiating. Sometimes it's useful to keep data confined in this manner, instead of having to worry about it getting set or overwritten on the descriptor's owner.

Participate answered 30/4, 2011 at 15:29 Comment(6)
Thank you! So essentially you can split out responsibilities of the descriptor from the class that instantiates the descriptor?! Brilliant!Rustproof
The possibilities are literally infinite ;) Think things like deferred loading: set a string of something you want loaded later on when first accessing the descriptor and never have to worry about that again. Or remove clutter from the owner class and put it where it logically belongs.Participate
@möter set a string of something you want loaded later You can do the same using @property, don't you?Colatitude
@PiotrDobrogost sure you can. But it would take at least another statement to do that,right?Participate
Can we apply descriptor to an instance method?Spurious
A descriptor can return a callable which can then be called, including instance methods. docs.python.org/3/howto/descriptor.html#descriptor-protocolParticipate
G
6

Let me quote from the EuroPython 2012 great video "Discovering Descriptors":

How to choose between descriptors and properties:

  • Properties work best when they know about the class
  • Descriptors are more general, can often apply to any class
  • Use descriptors if behaviour is diferent for classes and instances
  • Properties are syntactic sugar

Also, please note, you can use __slots__ with Descriptors.

Greenhorn answered 17/3, 2013 at 20:48 Comment(0)
G
1

As far as Descriptor use-cases, you may find yourself wanting to re-use Properties in classes which are unrelated.

Please note that the Thermometer/Calculator analogy can be solved many other ways -- just an imperfect example.

Here is an example:

###################################
######## Using Descriptors ########
###################################

# Example:
#     Thermometer class wants to have two properties, celsius and farenheit.
#     Thermometer class tells the Celsius and Farenheit descriptors it has a '_celsius' var, which can be manipulated.
#     Celsius/Farenheit descriptor saves the name '_celsius' so it can manipulate it later.
#     Thermometer.celsius and Thermometer.farenheit both use the '_celsius' instance variable under the hood.
#     When one is set, the other is inherently up to date.
#
#     Now you want to make some Calculator class that also needs to do celsius/farenheit conversions.
#     A calculator is not a thermometer, so class inheritance does nothing for you.
#     Luckily, you can re-use these descriptors in the totally unrelated Calculator class.

# Descriptor base class without hard-coded instance variable names.
# Subclasses store the name of some variable in their owner, and modify it directly.
class TemperatureBase(object):
    __slots__ = ['name']

    def set_owner_var_name(self, var_name) -> None:
        setattr(self, TemperatureBase.__slots__[0], var_name)
    
    def get_owner_var_name(self) -> any:
        return getattr(self, TemperatureBase.__slots__[0])
    
    def set_instance_var_value(self, instance, value) -> None:
        setattr(instance, self.get_owner_var_name(), value)
    
    def get_instance_var_value(self, instance) -> any:
        return getattr(instance, self.get_owner_var_name())

# Descriptor. Notice there are no hard-coded instance variable names.
# Use the commented lines for faster performance, but with hard-coded owner class variables names.
class Celsius(TemperatureBase):
    __slots__ = []
    def __init__(self, var_name) -> None:
        super().set_owner_var_name(var_name)
        #self.name = var_name
    def __get__( self, instance, owner ) -> float:
        return super().get_instance_var_value(instance)
        #return instance._celsius
    def __set__( self, instance, value ) -> None:
        super().set_instance_var_value(instance, float(value))
        #instance._celsius = float(value)

# Descriptor. Notice there are no hard-coded instance variable names.
# Use the commented lines for faster performance, but with hard-coded owner class variables names.
class FarenheitFromCelsius(TemperatureBase):
    __slots__ = []
    def __init__(self, var_name) -> None:
        super().set_owner_var_name(var_name)
        #self.name = var_name
    def __get__( self, instance, owner ) -> float:
        return super().get_instance_var_value(instance) * 9 / 5 + 32
        #return instance._celsius * 9 / 5 + 32
    def __set__( self, instance, value ) -> None:
        super().set_instance_var_value(instance, (float(value)-32) * 5 / 9)
        #instance._celsius = (float(value)-32) * 5 / 9

# Descriptor. Notice we hard-coded self.name, but not owner variable names
class Celsius2(TemperatureBase):
    __slots__ = []
    def __init__(self, var_name) -> None:
        self.name = var_name
    def __get__( self, instance, type=None ) -> float:
        return getattr(instance, self.name)
    def __set__( self, instance, value ) -> None:
        setattr(instance, self.name, float(value))

# Descriptor. Notice we hard-coded self.name, but not owner variable names
class FarenheitFromCelsius2(TemperatureBase):
    __slots__ = []
    def __init__(self, var_name) -> None:
        self.name = var_name
    def __get__( self, instance, type=None ) -> float:
        return getattr(instance, self.name) * 9 / 5 + 32
    def __set__( self, instance, value ) -> None:
        setattr(instance, self.name, (float(value)-32) * 5 / 9)

# This class only has one instance variable allowed, _celsius
# The 'celsius' attribute is a descriptor which manipulates the '_celsius' instance variable
# The 'farenheit' attribute also manipulates the '_celsius' instance variable
class Thermometer(object):
    __slots__ = ['_celsius']
    def __init__(self, celsius=0.0) -> None:
        self._celsius= float(celsius)
    
    # Both descriptors are instantiated as attributes of this class
    # They will both manipulate a single instance variable, defined in __slots__
    celsius= Celsius(__slots__[0])
    farenheit= FarenheitFromCelsius(__slots__[0])

# This class also wants to have farenheit/celsius properties for some reason
class Calculator(object):
    __slots__ = ['_celsius', '_meters', 'grams']
    def __init__(self, value=0.0) -> None:
        self._celsius= float(value)
        self._meters = float(value)
        self._grams = float(value)
    
    # We can re-use descriptors!
    celsius= Celsius(__slots__[0])
    farenheit= FarenheitFromCelsius(__slots__[0])

##################################
######## Using Properties ########
##################################

# This class also only uses one instance variable, _celsius
class Thermometer_Properties_NoSlots( object ):
    # __slots__ = ['_celsius'] => Blows up the size, without slots
    def __init__(self, celsius=0.0) -> None:
        self._celsius= float(celsius)
        
    # farenheit property
    def fget( self ):
        return self.celsius * 9 / 5 + 32
    def fset( self, value ):
        self.celsius= (float(value)-32) * 5 / 9
    farenheit= property( fget, fset )

    # celsius property
    def cset( self, value ):
        self._celsius= float(value)
    def cget( self ):
        return self._celsius
    celsius= property( cget, cset, doc="Celsius temperature")

# performance testing
import random
def set_get_del_fn(thermometer):
    def set_get_del():
        thermometer.celsius = random.randint(0,100)
        thermometer.farenheit
        del thermometer._celsius
    return set_get_del

# main function
if __name__ == "__main__":
    thermometer0 = Thermometer()
    thermometer1 = Thermometer(50)
    thermometer2 = Thermometer(100)
    thermometerWithProperties = Thermometer_Properties_NoSlots()

    # performance: descriptors are better if you use the commented lines in the descriptor classes
    # however: Calculator and Thermometer MUST name their var _celsius if hard-coding, rather than using getattr/setattr
    import timeit
    print(min(timeit.repeat(set_get_del_fn(thermometer0), number=100000)))
    print(min(timeit.repeat(set_get_del_fn(thermometerWithProperties), number=100000)))
    
    # reset the thermometers (after testing performance)
    thermometer0.celsius = 0
    thermometerWithProperties.celsius = 0
    
    # memory: only 40 flat bytes since we use __slots__
    import pympler.asizeof as asizeof
    print(f'thermometer0: {asizeof.asizeof(thermometer0)} bytes')
    print(f'thermometerWithProperties: {asizeof.asizeof(thermometerWithProperties)} bytes')

    # print results    
    print(f'thermometer0: {thermometer0.celsius} Celsius = {thermometer0.farenheit} Farenheit')
    print(f'thermometer1: {thermometer1.celsius} Celsius = {thermometer1.farenheit} Farenheit')
    print(f'thermometer2: {thermometer2.celsius} Celsius = {thermometer2.farenheit} Farenheit')
    print(f'thermometerWithProperties: {thermometerWithProperties.celsius} Celsius = {thermometerWithProperties.farenheit} Farenheit')
Gare answered 1/9, 2023 at 21:8 Comment(2)
To avoid hardcoded names, you can use __set_name__ and a naming convention (e.g. attribute name with an underscore prepended)Undeniable
@Undeniable the __set_name__ only gets (self, owner, name) args, so two descriptors would not be able to share the same underlying variable (i.e. how Celsius and Farenheit both get/set the same Thermometer._celsius attribute above).Gare

© 2022 - 2024 — McMap. All rights reserved.