Subclassificação: É possível substituir uma propriedade por um atributo convencional?

14

Vamos supor que queremos criar uma família de classes que sejam diferentes implementações ou especializações de um conceito abrangente. Vamos supor que haja uma implementação padrão plausível para algumas propriedades derivadas. Queremos colocar isso em uma classe base

class Math_Set_Base:
    @property
    def size(self):
        return len(self.elements)

Assim, uma subclasse será capaz de contar automaticamente seus elementos neste exemplo bastante tolo

class Concrete_Math_Set(Math_Set_Base):
    def __init__(self,*elements):
        self.elements = elements

Concrete_Math_Set(1,2,3).size
# 3

Mas e se uma subclasse não quiser usar esse padrão? Isso não funciona:

import math

class Square_Integers_Below(Math_Set_Base):
    def __init__(self,cap):
        self.size = int(math.sqrt(cap))

Square_Integers_Below(7)
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
#   File "<stdin>", line 3, in __init__
# AttributeError: can't set attribute

Sei que existem maneiras de substituir uma propriedade por uma propriedade, mas eu gostaria de evitar isso. Como o objetivo da classe base é tornar a vida o mais fácil possível para o usuário, não adicionar inchaço impondo um método de acesso complicado e supérfluo (do ponto de vista restrito da subclasse).

Isso pode ser feito? Se não, qual é a próxima melhor solução?

Paul Panzer
fonte

Respostas:

6

Uma propriedade é um descritor de dados que tem precedência sobre um atributo de instância com o mesmo nome. Você pode definir um descritor que não seja de dados com um __get__()método exclusivo : um atributo de instância tem precedência sobre o descritor que não é de dados com o mesmo nome, consulte os documentos . O problema aqui é que o non_data_propertydefinido abaixo é apenas para fins de computação (você não pode definir um setter ou um deleter), mas parece ser o caso no seu exemplo.

import math

class non_data_property:
    def __init__(self, fget):
        self.__doc__ = fget.__doc__
        self.fget = fget

    def __get__(self, obj, cls):
        if obj is None:
            return self
        return self.fget(obj)

class Math_Set_Base:
    @non_data_property
    def size(self, *elements):
        return len(self.elements)

class Concrete_Math_Set(Math_Set_Base):
    def __init__(self, *elements):
        self.elements = elements


class Square_Integers_Below(Math_Set_Base):
    def __init__(self, cap):
        self.size = int(math.sqrt(cap))

print(Concrete_Math_Set(1, 2, 3).size) # 3
print(Square_Integers_Below(1).size) # 1
print(Square_Integers_Below(4).size) # 2
print(Square_Integers_Below(9).size) # 3

No entanto, isso pressupõe que você tenha acesso à classe base para fazer essas alterações.

Arkelis
fonte
Isso é quase perfeito. Portanto, isso significa que, mesmo se você definir apenas o getter, implicitamente adiciona um setter que não faz nada além de bloquear a atribuição? Interessante. Provavelmente vou aceitar esta resposta.
Paul Panzer
Sim, de acordo com os documentos, uma propriedade sempre define todos os três métodos descritores ( __get__(), __set__()e __delete__()) e eles AttributeErrorgeram um se você não fornecer nenhuma função para eles. Veja Python equivalente à implementação de propriedade .
Arkelis 29/04
11
Contanto que você não se importe com o setterou __set__da propriedade, isso funcionará. No entanto, aviso que isso também significa que você pode substituir facilmente a propriedade não apenas no nível da classe ( self.size = ...), mas também no nível da instância (por exemplo, Concrete_Math_Set(1, 2, 3).size = 10seria igualmente válido). Comida para pensamentos :)
r.ook 29/04
E Square_Integers_Below(9).size = 1também é válido, pois é um atributo simples. Nesse caso de uso "tamanho" específico, isso pode parecer estranho (use uma propriedade), mas no caso geral "substitui um objeto computado facilmente na subclasse", pode ser bom em alguns casos. Você também pode controlar o acesso aos atributos, __setattr__()mas isso pode ser esmagador.
Arkelis 29/04
11
Não estou discordando de que possa haver um caso de uso válido para isso, só queria mencionar a ressalva, pois isso pode complicar os esforços de depuração no futuro. Desde que você e o OP estejam cientes das implicações, tudo estará bem.
r.ook 29/04
10

Esta será uma resposta prolongada que pode servir apenas como cortesia ... mas sua pergunta me levou para um passeio pela toca do coelho, então eu gostaria de compartilhar minhas descobertas (e dor) também.

Você pode achar que essa resposta não é útil para o seu problema real. De fato, minha conclusão é que - eu não faria isso. Dito isto, o pano de fundo desta conclusão pode entretê-lo um pouco, pois você está procurando mais detalhes.


Abordando alguns equívocos

A primeira resposta, embora correta na maioria dos casos, nem sempre é o caso. Por exemplo, considere esta classe:

class Foo:
    def __init__(self):
        self.name = 'Foo!'
        @property
        def inst_prop():
            return f'Retrieving {self.name}'
        self.inst_prop = inst_prop

inst_prop, enquanto sendo um property, é irrevogavelmente um atributo de instância:

>>> Foo.inst_prop
Traceback (most recent call last):
  File "<pyshell#60>", line 1, in <module>
    Foo.inst_prop
AttributeError: type object 'Foo' has no attribute 'inst_prop'
>>> Foo().inst_prop
<property object at 0x032B93F0>
>>> Foo().inst_prop.fget()
'Retrieving Foo!'

Tudo depende de onde você propertyestá definido em primeiro lugar. Se seu @propertyé definido dentro da classe "escopo" (ou realmente, o namespace), ele se torna um atributo de classe. No meu exemplo, a classe em si não tem conhecimento inst_propaté ser instanciada. Obviamente, não é muito útil como propriedade aqui.


Mas primeiro, vamos abordar seu comentário sobre a resolução de herança ...

Então, como exatamente a herança influencia esse problema? Este artigo a seguir mergulha um pouco no tópico e a Ordem de resolução do método está um pouco relacionada, embora discuta principalmente a amplitude da herança em vez da profundidade.

Combinado com a nossa descoberta, dadas as configurações abaixo:

@property
def some_prop(self):
    return "Family property"

class Grandparent:
    culture = some_prop
    world_view = some_prop

class Parent(Grandparent):
    world_view = "Parent's new world_view"

class Child(Parent):
    def __init__(self):
        try:
            self.world_view = "Child's new world_view"
            self.culture = "Child's new culture"
        except AttributeError as exc:
            print(exc)
            self.__dict__['culture'] = "Child's desired new culture"

Imagine o que acontece quando essas linhas são executadas:

print("Instantiating Child class...")
c = Child()
print(f'c.__dict__ is: {c.__dict__}')
print(f'Child.__dict__ is: {Child.__dict__}')
print(f'c.world_view is: {c.world_view}')
print(f'Child.world_view is: {Child.world_view}')
print(f'c.culture is: {c.culture}')
print(f'Child.culture is: {Child.culture}')

O resultado é assim:

Instantiating Child class...
can't set attribute
c.__dict__ is: {'world_view': "Child's new world_view", 'culture': "Child's desired new culture"}
Child.__dict__ is: {'__module__': '__main__', '__init__': <function Child.__init__ at 0x0068ECD8>, '__doc__': None}
c.world_view is: Child's new world_view
Child.world_view is: Parent's new world_view
c.culture is: Family property
Child.culture is: <property object at 0x00694C00>

Note como:

  1. self.world_viewfoi capaz de ser aplicado, enquanto self.culturefalhou
  2. culturenão existe em Child.__dict__(o mappingproxyda classe, não deve ser confundido com a instância __dict__)
  3. Mesmo que cultureexista c.__dict__, não é referenciado.

Você pode adivinhar o porquê - world_viewfoi substituído pela Parentclasse como uma não propriedade, e também Childfoi possível substituí-lo. Enquanto isso, como cultureé herdado, ele existe apenas dentro mappingproxydeGrandparent :

Grandparent.__dict__ is: {
    '__module__': '__main__', 
    'culture': <property object at 0x00694C00>, 
    'world_view': <property object at 0x00694C00>, 
    ...
}

De fato, se você tentar remover Parent.culture:

>>> del Parent.culture
Traceback (most recent call last):
  File "<pyshell#67>", line 1, in <module>
    del Parent.culture
AttributeError: culture

Você notará que nem existe Parent. Porque o objeto está se referindo diretamente novamente Grandparent.culture.


Então, e a ordem de resolução?

Portanto, estamos interessados ​​em observar a ordem de resolução real, vamos tentar remover Parent.world_view:

del Parent.world_view
print(f'c.world_view is: {c.world_view}')
print(f'Child.world_view is: {Child.world_view}')

Gostaria de saber qual é o resultado?

c.world_view is: Family property
Child.world_view is: <property object at 0x00694C00>

Ele voltou ao Grandparent's world_view property, apesar de termos conseguido atribuir o self.world_viewantes! Mas e se mudarmos vigorosamente world_viewno nível de classe, como a outra resposta? E se a excluirmos? E se atribuirmos o atributo de classe atual a uma propriedade?

Child.world_view = "Child's independent world_view"
print(f'c.world_view is: {c.world_view}')
print(f'Child.world_view is: {Child.world_view}')

del c.world_view
print(f'c.world_view is: {c.world_view}')
print(f'Child.world_view is: {Child.world_view}')

Child.world_view = property(lambda self: "Child's own property")
print(f'c.world_view is: {c.world_view}')
print(f'Child.world_view is: {Child.world_view}')

O resultado é:

# Creating Child's own world view
c.world_view is: Child's new world_view
Child.world_view is: Child's independent world_view

# Deleting Child instance's world view
c.world_view is: Child's independent world_view
Child.world_view is: Child's independent world_view

# Changing Child's world view to the property
c.world_view is: Child's own property
Child.world_view is: <property object at 0x020071B0>

Isso é interessante porque c.world_viewé restaurado ao seu atributo de instância, enquanto Child.world_viewé o que atribuímos . Após remover o atributo de instância, ele reverte para o atributo de classe. E depois de reatribuir a Child.world_viewpropriedade à, perdemos instantaneamente o acesso ao atributo de instância.

Portanto, podemos supor a seguinte ordem de resolução :

  1. Se um atributo de classe existir e for a property, recupere seu valor via getterou fget(mais sobre isso mais adiante). Classe atual primeiro da classe Base por último.
  2. Caso contrário, se existir um atributo de instância, recupere o valor do atributo de instância.
  3. Senão, recupere o propertyatributo que não é de classe. Classe atual primeiro da classe Base por último.

Nesse caso, vamos remover a raiz property:

del Grandparent.culture
print(f'c.culture is: {c.culture}')
print(f'Child.culture is: {Child.culture}')

Que dá:

c.culture is: Child's desired new culture
Traceback (most recent call last):
  File "<pyshell#74>", line 1, in <module>
    print(f'Child.culture is: {Child.culture}')
AttributeError: type object 'Child' has no attribute 'culture'

Surpresa! Childagora tem seus próprios com culturebase na inserção forçada em c.__dict__. Child.culturenão existe, é claro, uma vez que nunca foi definido em Parentou Childatributo de classe e Grandparentfoi removido.


Essa é a causa raiz do meu problema?

Na verdade não . O erro que você está recebendo, que ainda estamos observando ao atribuir self.culture, é totalmente diferente . Mas a ordem de herança define o pano de fundo para a resposta - que é a propertyprópria.

Além do gettermétodo mencionado anteriormente , propertytambém tem alguns truques legais nas mangas. O mais relevante nesse caso é o método, setterou fset, que é acionado por self.culture = ...linha. Como você propertynão implementou nenhuma função setterou fget, python não sabe o que fazer e lança um AttributeError(ie can't set attribute).

Se, no entanto, você implementou um settermétodo:

@property
def some_prop(self):
    return "Family property"

@some_prop.setter
def some_prop(self, val):
    print(f"property setter is called!")
    # do something else...

Ao instanciar a Childclasse, você receberá:

Instantiating Child class...
property setter is called!

Em vez de receber um AttributeError, agora você está realmente chamando o some_prop.settermétodo O que lhe dá mais controle sobre seu objeto ... com nossas descobertas anteriores, sabemos que precisamos ter um atributo de classe substituído antes que ele atinja a propriedade. Isso pode ser implementado dentro da classe base como um gatilho. Aqui está um novo exemplo:

class Grandparent:
    @property
    def culture(self):
        return "Family property"

    # add a setter method
    @culture.setter
    def culture(self, val):
        print('Fine, have your own culture')
        # overwrite the child class attribute
        type(self).culture = None
        self.culture = val

class Parent(Grandparent):
    pass

class Child(Parent):
    def __init__(self):
        self.culture = "I'm a millennial!"

c = Child()
print(c.culture)

O que resulta em:

Fine, have your own culture
I'm a millennial!

SURPRESA! Agora você pode substituir seu próprio atributo de instância em uma propriedade herdada!


Então, problema resolvido?

... Na verdade não. O problema com essa abordagem é que agora você não pode ter um settermétodo adequado . Há casos em que você deseja definir valores no seu property. Mas agora, sempre que você definir self.culture = ..., sempre substituirá qualquer função que você definiu na getter(que neste caso é realmente apenas a @propertyparte envolvida. Você pode adicionar medidas mais sutis, mas de uma forma ou de outra sempre envolverá mais do que apenas self.culture = .... por exemplo:

class Grandparent:
    # ...
    @culture.setter
    def culture(self, val):
        if isinstance(val, tuple):
            if val[1]:
                print('Fine, have your own culture')
                type(self).culture = None
                self.culture = val[0]
        else:
            raise AttributeError("Oh no you don't")

# ...

class Child(Parent):
    def __init__(self):
        try:
            # Usual setter
            self.culture = "I'm a Gen X!"
        except AttributeError:
            # Trigger the overwrite condition
            self.culture = "I'm a Boomer!", True

É muito mais complicado que a outra resposta, size = Noneno nível da classe.

Você também pode escrever seu próprio descritor para manipular os métodos __get__e __set__ou adicionais. Mas no final do dia, quando self.cultureé referenciada, __get__sempre será acionada primeiro e, quando self.culture = ...é referenciada, __set__sempre será acionada primeiro. Não há como contornar isso, tanto quanto eu tentei.


O cerne da questão, IMO

O problema que vejo aqui é - você não pode comer o seu bolo e também. propertysignifica um descritor com acesso conveniente a partir de métodos como getattrou setattr. Se você também deseja que esses métodos atinjam um objetivo diferente, está apenas pedindo problemas. Talvez eu repensasse a abordagem:

  1. Eu realmente preciso de um propertypara isso?
  2. Um método poderia me servir de maneira diferente?
  3. Se eu precisar de property, existe algum motivo para substituí-lo?
  4. A subclasse realmente pertence à mesma família se propertynão se aplicar?
  5. Se eu precisar sobrescrever alguns / todos os propertys, um método separado me serviria melhor do que simplesmente reatribuir, pois a reatribuição pode acidentalmente anular os propertys?

Para o ponto 5, minha abordagem seria ter um overwrite_prop()método na classe base que substituísse o atributo de classe atual para que propertynão seja mais acionado:

class Grandparent:
    # ...
    def overwrite_props(self):
        # reassign class attributes
        type(self).size = None
        type(self).len = None
        # other properties, if necessary

# ...

# Usage
class Child(Parent):
    def __init__(self):
        self.overwrite_props()
        self.size = 5
        self.len = 10

Como você pode ver, embora ainda seja um pouco artificial, é pelo menos mais explícito que um enigmático size = None. Dito isso, em última análise, eu não sobrescreveria a propriedade e reconsideraria meu projeto da raiz.

Se você chegou até aqui - obrigado por percorrer essa jornada comigo. Foi um pequeno exercício divertido.

torre
fonte
2
Uau, muito obrigado! Vou levar um pouco de tempo para digerir isso, mas acho que pedi.
Paul Panzer
6

A @propertyé definido no nível da classe. A documentação entra em detalhes exaustivos sobre como funciona, mas basta dizer que definir ou obter a propriedade resolve chamar um método específico. No entanto, o propertyobjeto que gerencia esse processo é definido com a própria definição da classe. Ou seja, é definido como uma variável de classe, mas se comporta como uma variável de instância.

Uma conseqüência disso é que você pode reatribuí-lo livremente no nível da classe :

print(Math_Set_Base.size)
# <property object at 0x10776d6d0>

Math_Set_Base.size = 4
print(Math_Set_Base.size)
# 4

E, como qualquer outro nome no nível da classe (por exemplo, métodos), você pode substituí-lo em uma subclasse, definindo-o explicitamente de forma diferente:

class Square_Integers_Below(Math_Set_Base):
    # explicitly define size at the class level to be literally anything other than a @property
    size = None

    def __init__(self,cap):
        self.size = int(math.sqrt(cap))

print(Square_Integers_Below(4).size)  # 2
print(Square_Integers_Below.size)     # None

Quando criamos uma instância real, a variável de instância simplesmente oculta a variável de classe com o mesmo nome. O propertyobjeto normalmente usa algumas travessuras para manipular esse processo (por exemplo, aplicando getters e setters), mas quando o nome no nível da classe não é definido como uma propriedade, nada de especial acontece e, portanto, age como você esperaria de qualquer outra variável.

Manto Verde
fonte
Obrigado, isso é incrivelmente simples. Isso significa que, mesmo que nunca houvesse propriedades em nenhum lugar da minha classe e de seus ancestrais, ele ainda procuraria na árvore de herança inteira um nome de nível de classe antes mesmo de se preocupar em olhar para a __dict__? Além disso, existem maneiras de automatizar isso? Sim, é apenas uma linha, mas é o tipo de coisa que é muito enigmática de ler se você não estiver familiarizado com os detalhes sangrentos de propriedades etc.
Paul Panzer
@PaulPanzer Eu não analisei os procedimentos por trás disso, então não poderia dar a você uma resposta satisfatória. Você pode tentar decifrar o código-fonte cpython, se desejar. Quanto à automação do processo, acho que não há uma boa maneira de fazer isso, além de não torná-lo uma propriedade em primeiro lugar, ou apenas adicionar um comentário / doutrina para permitir que aqueles que leem seu código saibam o que estão fazendo. . Algo como# declare size to not be a @property
Green Cloak Guy
4

Você não precisa de atribuição (a size). sizeé uma propriedade na classe base, para que você possa substituir essa propriedade na classe filho:

class Math_Set_Base:
    @property
    def size(self):
        return len(self.elements)

    # size = property(lambda self: self.elements)


class Square_Integers_Below(Math_Set_Base):

    def __init__(self, cap):
        self._cap = cap

    @property
    def size(self):
        return int(math.sqrt(self._cap))

    # size = property(lambda self: int(math.sqrt(self._cap)))

Você pode (micro) otimizar isso pré-computando a raiz quadrada:

class Square_Integers_Below(Math_Set_Base):

    def __init__(self, cap):
        self._size = int(math.sqrt(self._cap))

    @property
    def size(self):
        return self._size
chepner
fonte
Esse é um ótimo ponto, basta substituir uma propriedade pai por uma propriedade filho! +1
r.ook 28/04
Isso é, de certa forma, algo simples de se fazer, mas eu estava interessado e pedi especificamente maneiras de permitir uma substituição de propriedade que não imponha o inconveniente de ter que escrever uma propriedade de memorização onde a atribuição simples faria o trabalho.
Paul Panzer
Não sei por que você gostaria de complicar seu código assim. Pelo pequeno preço de reconhecer que sizeé uma propriedade e não um atributo de instância, você não precisa fazer nada sofisticado.
chepner 29/04
Meu caso de uso são muitas classes filho com muitos atributos; não ter que escrever 5 linhas em vez de uma para todos justifica uma certa quantidade de fantasia, IMO.
Paul Panzer
2

Parece que você deseja definir sizeem sua classe:

class Square_Integers_Below(Math_Set_Base):
    size = None

    def __init__(self, cap):
        self.size = int(math.sqrt(cap))

Outra opção é armazenar capna sua classe e calculá-la com sizedefinida como uma propriedade (que substitui a propriedade da classe base size).

Uri
fonte
2

Sugiro adicionar um setter assim:

class Math_Set_Base:
    @property
    def size(self):
        try:
            return self._size
        except:
            return len(self.elements)

    @size.setter
    def size(self, value):
        self._size = value

Dessa forma, você pode substituir a .sizepropriedade padrão da seguinte maneira:

class Concrete_Math_Set(Math_Set_Base):
    def __init__(self,*elements):
        self.elements = elements

class Square_Integers_Below(Math_Set_Base):
    def __init__(self,cap):
        self.size = int(math.sqrt(cap))

print(Concrete_Math_Set(1,2,3).size) # 3
print(Square_Integers_Below(7).size) # 2
Amir
fonte
1

Além disso, você pode fazer a próxima coisa

class Math_Set_Base:
    _size = None

    def _size_call(self):
       return len(self.elements)

    @property
    def size(self):
        return  self._size if self._size is not None else self._size_call()

class Concrete_Math_Set(Math_Set_Base):
    def __init__(self, *elements):
        self.elements = elements


class Square_Integers_Below(Math_Set_Base):
    def __init__(self, cap):
        self._size = int(math.sqrt(cap))
Ryabchenko Alexander
fonte
Isso é legal, mas você realmente não precisa _sizeou está _size_calllá. Você poderia ter ativado a chamada de função dentro sizecomo uma condição e usar try... except... para testar em _sizevez de usar uma referência de classe extra que não será usada. Independentemente disso, acho que isso é ainda mais enigmático do que a simples size = Nonesubstituição em primeiro lugar.
r.ook 28/04
0

Eu acho que é possível definir uma propriedade de uma classe base para outra propriedade de uma classe derivada, dentro da classe derivada e, em seguida, usar a propriedade da classe base com um novo valor.

No código inicial, existe um tipo de conflito entre o nome sizeda classe base e o atributo self.sizeda classe derivada. Isso pode ser visível se substituirmos o nome self.sizeda classe derivada por self.length. Isso produzirá:

3
<__main__.Square_Integers_Below object at 0x000001BCD56B6080>

Então, se substituirmos o nome do método sizepor lengthtodas as ocorrências do programa, isso resultará na mesma exceção:

Traceback (most recent call last):
  File "C:/Users/Maria/Downloads/so_1.2.py", line 24, in <module>
    Square_Integers_Below(7)
  File "C:/Users/Maria/Downloads/so_1.2.py", line 21, in __init__
    self.length = int(math.sqrt(cap))
AttributeError: can't set attribute

O código fixo, ou de qualquer maneira, uma versão que de alguma forma funcione, é manter o código exatamente o mesmo, com exceção da classe Square_Integers_Below, que definirá o método sizeda classe base, para outro valor.

class Square_Integers_Below(Math_Set_Base):

    def __init__(self,cap):
        #Math_Set_Base.__init__(self)
        self.length = int(math.sqrt(cap))
        Math_Set_Base.size = self.length

    def __repr__(self):
        return str(self.size)

E então, quando executamos todo o programa, a saída é:

3
2

Espero que isso tenha sido útil de uma maneira ou de outra.

Sofia190
fonte