¿Entonces un método en realidad es un atributo?
La respuesta corta es si, para el nivel en el que la pregunta está enfocada a efectos prácticos lo es. Técnicamente cualquier cosa que se pueda referenciar a través de un objeto usando la notación .nombre
(dotted expression) es considerado un atributo, eso incluye a los métodos.
IMPORTANTE
Ésto puede parecer que contradice los conceptos de atributo y método que se tienen en el paradigma de la POO, en la que a grandes rasgos un atributo es un una característica que describe a determinado objeto mientras que un método es algo que ese objeto puede hacer. Ésta separación se mantiene de forma conceptual a alto nivel en Python, conceptualmente un método y un atributo no son por supuesto lo mismo, pero en lo que al lenguaje concierne y a cómo se implementa la POO en Python la separación no es tan clara, más bien todo lo contrario, y de eso trata la pregunta y es a lo que se intenta responder en ésta respuesta.
¿El atributo method
que defino en la clase A
(method = funcion
), es un método?
Si, lo es. Aunque el concepto de método normalmente se restringe a "función definida en el cuerpo de una clase", a efectos prácticos y no conceptuales, es un método. Se pueden definir los métodos fuera de la clase, incluso en otro módulo y luego enlazarlos así. O incluso después de la definición de la clase:
class Test:
def foo(self):
print(f"Hola, soy un método de {self} definido de forma 'normal'")
>>> inst = Test()
>>> inst.foo
<bound method Test.foo of <__main__.Test object at 0x7f13658fac70>>
>>> inst.foo()
Hola, soy un método de <__main__.Test object at 0x7f13658fac70> definido de forma 'normal'
>>> inst.bar()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Test' object has no attribute 'bar'
>>> def bar(self):
print(f"Soy un método de {self} definido a 'distancia'")
>>> Test.bar = bar
>>> inst.bar
<bound method bar of <__main__.Test object at 0x7f13658fac70>>
>>> inst.bar()
Soy un método de <__main__.Test object at 0x7f13658fac70> definido a 'distancia'
¡Toma! Lenguaje dinámico en todo su esplendor, para lo bueno y para lo malo... :)
Que se pueda hacer no significa que sea buena idea, Python deja hacer casi todo, muy buenas ideas y muy malas ideas. Cuando se suele restringir algo es porque pone en peligro al propio intérprete o por una muy buena razón de diseño, optimización, etc. En vez de hacer ésto tenemos la herencia por ejemplo, pero si podemos incluso agregar métodos a una clase con instancias creadas y encima llamarlos desde la instancias ya creadas sin ningún problema.
¿Entonces qué es exactamente un método en Python?
Si alguien tiene gana de leer un ratito con posibilidad de no entender nada al final por mi culpa queda avisado, pero ahí va.
En Python prácticamente todo es un objeto en memoria (incluidos los módulos importados, el propio script, funciones, clases, enteros, etc), que a su vez tienen métodos, funciones o cualquier cosa con un método __call __()
, como objetos que pueden ser llamados.
Al sistema de resolución de atributos (en el que __dict__
tiene una función primordial) no le importa en absoluto que el objeto sea callable o no. Solo trata de buscar ese nombre en la clase o las clases de las que deriva siguiendo el MRO. Luego tu lo intentas llamar, le intentas asignar o le pegas fuego, pero su misión termina cuanto te da una referencia al atributo o una excepción porque no lo ha podido encontrar.
En realidad los métodos son objetos que actúan de envoltorios para funciones manteniendo referencias a la instancia a la que pertenecen y enlazando así la función con su instancia. Además son creados sobre la marcha al acceder a la función como un atributo mediante la mencionada sintaxis .nombre
.
Si nos fijamos en como se define un método, no difiere en nada de definir una función, lo único que cambia es que se recibe una referencia a clase o a la instancia como primer argumento (cls
/self
por convención nada más).
Obviamente tiene que haber un mecanismo que una la función con la clase o instancia y que permita referenciar al método con .nombre
. Python resuelve ésto usando descriptores de no datos. Al final lo veremos por encima.
Hay que tener en cuenta que Python es un lenguaje dinámico, en la que una variable/atributo no deja de ser solo un nombre asociado en todo momento a una referencia a un objeto en memoria.
Esto quiere decir que en el fondo todas las variables/atributos son lo mismo, todas son solo caminos para encontrar un objeto en memoria, no importa que el objeto sea callable o no, o que en un momento dado la variable apunte a otro objeto o que varias apunten al mismo. El tipo y las propiedades pertenecen siempre al objeto, no a la variable. Realmente para el lenguaje no importa si instancia.nombre
es un entero, una cadena , una lista o una método, todo son objetos.
En los lenguajes como C++, ésto no pasa, los datos y los métodos están claramente separados, con los datos (atributos) fijados a un tipo en concreto y en el que los métodos no son objetos.
El atributo __dict__
El atributo __dict__
de un objeto (si también es un atributo) se comporta de forma parecida a un diccionario, pero no lo es. Realmente es una instancia de una clase DictProxy
cuyos objetos se comportan como diccionarios pero con algunas diferencias, por ejemplo, no podemos agregar atributos cómo hacemos con las claves de un diccionario, ésto no funciona:
Clase.__dict__["foo"] = 13
debemos usar:
Clase.foo = 13
o
setattr(Clase, "foo", 13)
Por ejemplo, si somos tan inconscientes de hacer ésto:
del instancia.__dict__
se crea de nuevo...
Las razones de usar DictProxy
son varias, en esencia se hace por optimización, forzando a que las claves siempre sean cadenas, lo cual permite que intérprete pueda realizar ciertas optimizaciones. También por seguridad, para evitar que cosas como la de arriba mutilen una clase o instancia y puedan mandar al intérprete mismo a tomar viento...
los "diccionarios" de clases como __dict__
simplemente almacenan métodos como funciones.
Los descriptores
Un descriptor es un objeto que tiene al menos uno de los siguientes métodos mágicos en sus atributos: __get__
, __set__
o __delete__
. Su implementación y uso se conoce como protocolo descriptor y que básicamente es cambiar un atributo por un objeto (el descriptor) que intermedia en el acceso a ese atributo, de ésta forma permiten definir y establecer el comportamiento del atributo de un objeto.
Un descriptor que solo implementa __get__
se llama descriptor de no datos y son usados para acceder a los métodos como veremos. Mientras que si implementa además __set__
son descriptores de datos, que no nos interesan mucho para éste caso.
Los descriptores se utilizan para muchas cosas relacionadas con los atributos y los métodos, están por todos lados en Python aunque no los veamos. Por ejemplo para los métodos estáticos (@staticmethod
), los métodos de clase (@classmethod
) y las propiedades (@property
) se usan descriptores, los decoradores no son nada más que una forma de implementarlos de manera simple sin que parezca que se están usando.
Las propiedades son quizás un ejemplo muy claro de ésto:
class Foo:
def __init__(self):
self._bar = None
@property
def bar(self):
return self._bar
@bar.setter
def bar(self, valor):
self._bar = valor
>>> inst = Foo()
>>> inst.bar = 5
>>> inst.bar
5
Pero todo ésto va mucho más lejos, si no no me hubiera metido en el "charco" de los descriptores. Como mencionaba antes, para permitir que una función sea llamada como métodos necesitan algo más. Las funciones incluyen el método __get__()
para vincular la función con el acceso a tributos mediante .nombre
, por lo tanto todas las funciones son descriptores de no datos que devuelven métodos vinculados cuando se invocan desde un objeto.
De hecho, si accedemos a un método a través de __dict__
, no se llama a __get__
, sino que se retorna una referencia a la función que hay debajo sin más:
class Foo:
def bar(self):
pass
>>> Foo.__dict__["bar"]
<function Foo.bar at 0x7f0345da3a60>
Si accedemos usando Clase.método
, si se invoca __get__
, dado que como se ha comentado es el enlace entre la función y la clase para permitir dicha sintaxis, pero se retorna también el objeto función subyacente sin más:
>>> Foo.bar
<function Foo.bar at 0x7f0345da3a60>
En cambio, cuando se accede mediante la instancia, la función se envuelve sobre la marcha en un método enlazado:
>>> Foo().bar
<bound method Foo.bar of <__main__.Foo object at 0x7f0345df0460>>
Dicho objeto almacena internamente la referencia a la función y a la instancia a la que está asociada, entre otras cosas:
>>> Foo().bar.__func__
<function Foo.bar at 0x7f0345da3a60>
>>> Foo().bar.__self__
<__main__.Foo object at 0x7f0345df0460>
Un método, por tanto, no es más que un atributo enlazado a una función a través de un descriptor.